Initial commit for frontend
41
.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
221
Jenkinsfile
vendored
Normal file
@ -0,0 +1,221 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
environment {
|
||||
SSH_CREDENTIALS = 'cloudtopiaa'
|
||||
REMOTE_SERVER = 'ubuntu@160.187.166.39'
|
||||
REMOTE_WORKSPACE = '/home/ubuntu'
|
||||
PROJECT_NAME = 'codenuk-frontend-live'
|
||||
DEPLOY_PATH = '/var/www/html/codenuk-frontend-live'
|
||||
GIT_CREDENTIALS = 'git-cred'
|
||||
REPO_URL = 'https://git.tech4biz.wiki/Tech4Biz-Services/codenuk-frontend-live.git'
|
||||
NPM_PATH = '/home/ubuntu/.nvm/versions/node/v22.18.0/bin/npm'
|
||||
NODE_PATH = '/home/ubuntu/.nvm/versions/node/v22.18.0/bin/node'
|
||||
EMAIL_RECIPIENT = 'jassim.mohammed@tech4biz.io, chandini.pachigunta@tech4biz.org'
|
||||
}
|
||||
|
||||
options {
|
||||
timeout(time: 30, unit: 'MINUTES')
|
||||
retry(2)
|
||||
timestamps()
|
||||
buildDiscarder(logRotator(numToKeepStr: '10'))
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Preparation') {
|
||||
steps {
|
||||
script {
|
||||
echo "Starting ${PROJECT_NAME} deployment pipeline"
|
||||
echo "Server: ${REMOTE_SERVER}"
|
||||
echo "Deploy Path: ${DEPLOY_PATH}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Git Operations on Remote Server') {
|
||||
steps {
|
||||
script {
|
||||
sshagent(credentials: [SSH_CREDENTIALS]) {
|
||||
withCredentials([usernamePassword(credentialsId: GIT_CREDENTIALS, usernameVariable: 'GIT_USER', passwordVariable: 'GIT_PASS')]) {
|
||||
sh """
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} '
|
||||
set -e
|
||||
|
||||
echo "Checking Git repo..."
|
||||
if [ -d "${DEPLOY_PATH}/.git" ]; then
|
||||
echo "Pulling latest code..."
|
||||
cd ${DEPLOY_PATH}
|
||||
|
||||
# Fix ownership issues
|
||||
sudo chown -R ubuntu:ubuntu ${DEPLOY_PATH}
|
||||
git config --global --add safe.directory ${DEPLOY_PATH}
|
||||
|
||||
git reset --hard
|
||||
git clean -fd
|
||||
git config pull.rebase false
|
||||
git pull https://${GIT_USER}:${GIT_PASS}@git.tech4biz.wiki/Tech4Biz-Services/codenuk-frontend-live.git main
|
||||
else
|
||||
echo "Cloning fresh repo..."
|
||||
sudo rm -rf ${DEPLOY_PATH}
|
||||
sudo mkdir -p /var/www/html
|
||||
sudo git clone https://${GIT_USER}:${GIT_PASS}@git.tech4biz.wiki/Tech4Biz-Services/codenuk-frontend-live.git ${DEPLOY_PATH}
|
||||
sudo chown -R ubuntu:ubuntu ${DEPLOY_PATH}
|
||||
git config --global --add safe.directory ${DEPLOY_PATH}
|
||||
fi
|
||||
|
||||
cd ${DEPLOY_PATH}
|
||||
echo "Commit: \$(git rev-parse HEAD)"
|
||||
'
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Verify Node.js Environment') {
|
||||
steps {
|
||||
script {
|
||||
sshagent(credentials: [SSH_CREDENTIALS]) {
|
||||
sh """
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} '
|
||||
set -e
|
||||
export PATH="/home/ubuntu/.nvm/versions/node/v22.18.0/bin:\$PATH"
|
||||
cd ${DEPLOY_PATH}
|
||||
|
||||
echo "Node: \$(${NODE_PATH} -v)"
|
||||
echo "NPM: \$(${NPM_PATH} -v)"
|
||||
${NPM_PATH} cache clean --force
|
||||
'
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Install Dependencies') {
|
||||
steps {
|
||||
sshagent(credentials: [SSH_CREDENTIALS]) {
|
||||
sh """
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} '
|
||||
set -e
|
||||
export PATH="/home/ubuntu/.nvm/versions/node/v22.18.0/bin:\$PATH"
|
||||
cd ${DEPLOY_PATH}
|
||||
echo "Installing dependencies..."
|
||||
rm -rf node_modules package-lock.json
|
||||
${NPM_PATH} install --force
|
||||
'
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build Application') {
|
||||
steps {
|
||||
sshagent(credentials: [SSH_CREDENTIALS]) {
|
||||
sh """
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} '
|
||||
set -e
|
||||
export PATH="/home/ubuntu/.nvm/versions/node/v22.18.0/bin:\$PATH"
|
||||
cd ${DEPLOY_PATH}
|
||||
echo "Building..."
|
||||
${NPM_PATH} run build
|
||||
echo "Build directory contents:"
|
||||
ls -la dist || ls -la build || echo "No build/dist directory found"
|
||||
'
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Deploy Static Files') {
|
||||
steps {
|
||||
sshagent(credentials: [SSH_CREDENTIALS]) {
|
||||
sh """
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} '
|
||||
set -e
|
||||
cd ${DEPLOY_PATH}
|
||||
echo "Setting up static files for nginx..."
|
||||
|
||||
# Ensure proper ownership and permissions
|
||||
sudo chown -R www-data:www-data ${DEPLOY_PATH}
|
||||
sudo find ${DEPLOY_PATH} -type d -exec chmod 755 {} \\;
|
||||
sudo find ${DEPLOY_PATH} -type f -exec chmod 644 {} \\;
|
||||
|
||||
echo "Static files prepared for nginx serving"
|
||||
'
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Restart Nginx') {
|
||||
steps {
|
||||
sshagent(credentials: [SSH_CREDENTIALS]) {
|
||||
sh """
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} '
|
||||
set -e
|
||||
echo "Testing nginx configuration..."
|
||||
sudo nginx -t
|
||||
|
||||
echo "Restarting nginx..."
|
||||
sudo systemctl restart nginx
|
||||
|
||||
echo "Checking nginx status..."
|
||||
sudo systemctl status nginx --no-pager -l
|
||||
'
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Health Check') {
|
||||
steps {
|
||||
sshagent(credentials: [SSH_CREDENTIALS]) {
|
||||
sh """
|
||||
ssh -o StrictHostKeyChecking=no ${REMOTE_SERVER} '
|
||||
set -e
|
||||
echo "Verifying deployment..."
|
||||
echo "Directory structure:"
|
||||
ls -la ${DEPLOY_PATH}
|
||||
|
||||
echo "Build files:"
|
||||
ls -la ${DEPLOY_PATH}/dist || ls -la ${DEPLOY_PATH}/build || echo "No build directory found"
|
||||
|
||||
echo "Nginx status:"
|
||||
sudo systemctl is-active nginx
|
||||
'
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
cleanWs()
|
||||
}
|
||||
success {
|
||||
mail to: "${EMAIL_RECIPIENT}",
|
||||
subject: "✅ Jenkins - ${PROJECT_NAME} Deployment Successful",
|
||||
body: """The deployment of '${PROJECT_NAME}' to ${REMOTE_SERVER} was successful.
|
||||
|
||||
Build Number: ${BUILD_NUMBER}
|
||||
URL: ${BUILD_URL}
|
||||
Time: ${new Date()}
|
||||
|
||||
The static files have been deployed and nginx has been restarted.
|
||||
"""
|
||||
}
|
||||
failure {
|
||||
mail to: "${EMAIL_RECIPIENT}",
|
||||
subject: "❌ Jenkins - ${PROJECT_NAME} Deployment Failed",
|
||||
body: """Deployment failed. Please review logs at:
|
||||
|
||||
${BUILD_URL}console
|
||||
|
||||
Time: ${new Date()}
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
156
README.md
Normal file
@ -0,0 +1,156 @@
|
||||
# CodeNuk Frontend
|
||||
|
||||
A modern web application built with Next.js 14, TypeScript, and shadcn/ui. This project serves as the frontend for the CodeNuk platform, providing a user interface for generating and managing code projects.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- **Project Generation**: Create new projects with various templates
|
||||
- **Authentication**: Secure user authentication flows
|
||||
- **UI Components**: Built with shadcn/ui for a consistent design system
|
||||
- **Responsive Design**: Works on all device sizes
|
||||
- **Modern Stack**: Next.js 14, TypeScript, and Tailwind CSS
|
||||
|
||||
## 🛠️ Prerequisites
|
||||
|
||||
- Node.js 18.0.0 or later
|
||||
- npm or yarn package manager
|
||||
- Git
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/codenuk-frontend.git
|
||||
cd codenuk-frontend
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
yarn install
|
||||
# or
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 3. Environment Setup
|
||||
|
||||
Create a `.env.local` file in the root directory and add the necessary environment variables:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=your_api_url_here
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
# Add other environment variables as needed
|
||||
```
|
||||
|
||||
### 4. Run the Development Server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser to see the application running.
|
||||
|
||||
## 🎨 Adding shadcn/ui Components
|
||||
|
||||
This project uses shadcn/ui for UI components. Here's how to add new components:
|
||||
|
||||
1. Install the shadcn CLI:
|
||||
```bash
|
||||
npx shadcn-ui@latest init
|
||||
```
|
||||
|
||||
2. Add a new component:
|
||||
```bash
|
||||
npx shadcn-ui@latest add button
|
||||
```
|
||||
|
||||
3. Import and use the component in your code:
|
||||
```tsx
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export function MyComponent() {
|
||||
return <Button>Click me</Button>
|
||||
}
|
||||
```
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # App router
|
||||
│ ├── auth/ # Authentication pages
|
||||
│ ├── components/ # Page-specific components
|
||||
│ ├── layout.tsx # Root layout
|
||||
│ └── page.tsx # Home page
|
||||
├── components/ # Reusable components
|
||||
│ ├── ui/ # shadcn/ui components
|
||||
│ └── ...
|
||||
├── lib/ # Utility functions
|
||||
└── styles/ # Global styles
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
Run the test suite:
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
# or
|
||||
yarn test
|
||||
```
|
||||
|
||||
## 🏗️ Building for Production
|
||||
|
||||
1. Build the application:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Start the production server:
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- [Next.js](https://nextjs.org/)
|
||||
- [shadcn/ui](https://ui.shadcn.com/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
|
||||
---
|
||||
|
||||
Made with ❤️ by Tech4Biz
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
21
components.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
16
eslint.config.mjs
Normal file
@ -0,0 +1,16 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
24
next.config.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['@tldraw/tldraw'],
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
webpack: (config, { isServer }) => {
|
||||
// Fix tldraw duplication issues
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'@tldraw/utils': require.resolve('@tldraw/utils'),
|
||||
'@tldraw/state': require.resolve('@tldraw/state'),
|
||||
'@tldraw/state-react': require.resolve('@tldraw/state-react'),
|
||||
'@tldraw/store': require.resolve('@tldraw/store'),
|
||||
'@tldraw/validate': require.resolve('@tldraw/validate'),
|
||||
'@tldraw/tlschema': require.resolve('@tldraw/tlschema'),
|
||||
'@tldraw/editor': require.resolve('@tldraw/editor'),
|
||||
};
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
9897
package-lock.json
generated
Normal file
65
package.json
Normal file
@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "codenuk-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3001",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.57.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@tldraw/tldraw": "^3.15.4",
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.23.22",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next": "^15.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.9.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-resizable-panels": "^3.0.5",
|
||||
"recharts": "^3.2.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"svg-path-parser": "^1.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/svg-path-parser": "^1.1.6",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.4.6",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
5
postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
public/analytics-dashboard-preview.png
Normal file
|
After Width: | Height: | Size: 543 KiB |
BIN
public/blog-platform-preview.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
1
public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/landing-page-preview.png
Normal file
|
After Width: | Height: | Size: 669 KiB |
BIN
public/marketing-website-preview.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
1
public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/saas-platform-preview.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/seo-blog-preview.png
Normal file
|
After Width: | Height: | Size: 609 KiB |
1
public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
88
src/app/admin/analytics/page.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
|
||||
import { AdminSidebarLayout } from '@/components/layout/admin-sidebar-layout'
|
||||
import { AdminNotificationProvider } from '@/contexts/AdminNotificationContext'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { BarChart3, TrendingUp, Activity, Eye } from 'lucide-react'
|
||||
|
||||
export default function AdminAnalyticsPage() {
|
||||
return (
|
||||
<AdminNotificationProvider>
|
||||
<AdminSidebarLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Analytics Dashboard</h1>
|
||||
<p className="text-white/70">Monitor system performance and user engagement</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Page Views</CardTitle>
|
||||
<Eye className="h-4 w-4 text-white/60" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">45,231</div>
|
||||
<p className="text-xs text-white/60">+20.1% from last month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Active Sessions</CardTitle>
|
||||
<Activity className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">2,350</div>
|
||||
<p className="text-xs text-white/60">+180.1% from last month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Template Usage</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">12,234</div>
|
||||
<p className="text-xs text-white/60">+19% from last month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Growth Rate</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">+12.5%</div>
|
||||
<p className="text-xs text-white/60">+2.1% from last month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Analytics Content */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Analytics Features</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-white/70">
|
||||
<p>Advanced analytics functionality will be implemented here.</p>
|
||||
<p className="mt-2">Features will include:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Real-time user activity tracking</li>
|
||||
<li>Template usage statistics</li>
|
||||
<li>Performance metrics</li>
|
||||
<li>Custom reports generation</li>
|
||||
<li>Data visualization charts</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AdminSidebarLayout>
|
||||
</AdminNotificationProvider>
|
||||
)
|
||||
}
|
||||
15
src/app/admin/page.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { AdminDashboard } from '@/components/admin/admin-dashboard'
|
||||
import { AdminSidebarLayout } from '@/components/layout/admin-sidebar-layout'
|
||||
import { AdminNotificationProvider } from '@/contexts/AdminNotificationContext'
|
||||
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<AdminNotificationProvider>
|
||||
<AdminSidebarLayout>
|
||||
<AdminDashboard />
|
||||
</AdminSidebarLayout>
|
||||
</AdminNotificationProvider>
|
||||
)
|
||||
}
|
||||
186
src/app/admin/settings/page.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
"use client"
|
||||
|
||||
import { AdminSidebarLayout } from '@/components/layout/admin-sidebar-layout'
|
||||
import { AdminNotificationProvider } from '@/contexts/AdminNotificationContext'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Settings, Save, Database, Mail, Shield } from 'lucide-react'
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
return (
|
||||
<AdminNotificationProvider>
|
||||
<AdminSidebarLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">System Settings</h1>
|
||||
<p className="text-white/70">Configure system preferences and security settings</p>
|
||||
</div>
|
||||
<Button className="bg-orange-500 text-black hover:bg-orange-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* General Settings */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
General Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="site-name" className="text-white">Site Name</Label>
|
||||
<Input
|
||||
id="site-name"
|
||||
defaultValue="Codenuk"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="site-description" className="text-white">Site Description</Label>
|
||||
<Input
|
||||
id="site-description"
|
||||
defaultValue="AI-powered code generation platform"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="maintenance-mode" className="text-white">Maintenance Mode</Label>
|
||||
<Switch id="maintenance-mode" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="user-registration" className="text-white">Allow User Registration</Label>
|
||||
<Switch id="user-registration" defaultChecked />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security Settings */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<Shield className="h-5 w-5 mr-2" />
|
||||
Security Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="two-factor" className="text-white">Require 2FA for Admins</Label>
|
||||
<Switch id="two-factor" defaultChecked />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="session-timeout" className="text-white">Auto Session Timeout</Label>
|
||||
<Switch id="session-timeout" defaultChecked />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-login-attempts" className="text-white">Max Login Attempts</Label>
|
||||
<Input
|
||||
id="max-login-attempts"
|
||||
type="number"
|
||||
defaultValue="5"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password-min-length" className="text-white">Min Password Length</Label>
|
||||
<Input
|
||||
id="password-min-length"
|
||||
type="number"
|
||||
defaultValue="8"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Settings */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<Mail className="h-5 w-5 mr-2" />
|
||||
Email Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtp-host" className="text-white">SMTP Host</Label>
|
||||
<Input
|
||||
id="smtp-host"
|
||||
placeholder="smtp.example.com"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtp-port" className="text-white">SMTP Port</Label>
|
||||
<Input
|
||||
id="smtp-port"
|
||||
type="number"
|
||||
defaultValue="587"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="from-email" className="text-white">From Email</Label>
|
||||
<Input
|
||||
id="from-email"
|
||||
placeholder="noreply@codenuk.com"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="email-notifications" className="text-white">Email Notifications</Label>
|
||||
<Switch id="email-notifications" defaultChecked />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Database Settings */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<Database className="h-5 w-5 mr-2" />
|
||||
Database Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="auto-backup" className="text-white">Auto Backup</Label>
|
||||
<Switch id="auto-backup" defaultChecked />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-frequency" className="text-white">Backup Frequency (hours)</Label>
|
||||
<Input
|
||||
id="backup-frequency"
|
||||
type="number"
|
||||
defaultValue="24"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="retention-days" className="text-white">Backup Retention (days)</Label>
|
||||
<Input
|
||||
id="retention-days"
|
||||
type="number"
|
||||
defaultValue="30"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full border-white/20 text-white hover:bg-white/10">
|
||||
<Database className="h-4 w-4 mr-2" />
|
||||
Run Manual Backup
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSidebarLayout>
|
||||
</AdminNotificationProvider>
|
||||
)
|
||||
}
|
||||
15
src/app/admin/templates/page.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { AdminSidebarLayout } from '@/components/layout/admin-sidebar-layout'
|
||||
import { AdminNotificationProvider } from '@/contexts/AdminNotificationContext'
|
||||
import { AdminTemplatesManager } from '@/components/admin/admin-templates-manager'
|
||||
|
||||
export default function AdminTemplatesPage() {
|
||||
return (
|
||||
<AdminNotificationProvider>
|
||||
<AdminSidebarLayout>
|
||||
<AdminTemplatesManager />
|
||||
</AdminSidebarLayout>
|
||||
</AdminNotificationProvider>
|
||||
)
|
||||
}
|
||||
88
src/app/admin/users/page.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
|
||||
import { AdminSidebarLayout } from '@/components/layout/admin-sidebar-layout'
|
||||
import { AdminNotificationProvider } from '@/contexts/AdminNotificationContext'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Users, UserPlus, UserCheck, UserX } from 'lucide-react'
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
return (
|
||||
<AdminNotificationProvider>
|
||||
<AdminSidebarLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">User Management</h1>
|
||||
<p className="text-white/70">Manage user accounts and permissions</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Total Users</CardTitle>
|
||||
<Users className="h-4 w-4 text-white/60" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">1,234</div>
|
||||
<p className="text-xs text-white/60">+12% from last month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">New Users</CardTitle>
|
||||
<UserPlus className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">89</div>
|
||||
<p className="text-xs text-white/60">This month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Active Users</CardTitle>
|
||||
<UserCheck className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">987</div>
|
||||
<p className="text-xs text-white/60">80% of total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Inactive Users</CardTitle>
|
||||
<UserX className="h-4 w-4 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">247</div>
|
||||
<p className="text-xs text-white/60">20% of total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* User Management Content */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">User Management Features</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-white/70">
|
||||
<p>User management functionality will be implemented here.</p>
|
||||
<p className="mt-2">Features will include:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>View all users</li>
|
||||
<li>Search and filter users</li>
|
||||
<li>Edit user permissions</li>
|
||||
<li>Activate/deactivate accounts</li>
|
||||
<li>View user activity logs</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AdminSidebarLayout>
|
||||
</AdminNotificationProvider>
|
||||
)
|
||||
}
|
||||
92
src/app/api/ai/analyze/route.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const featureName: string = body.featureName || 'Custom Feature'
|
||||
const description: string = body.description || ''
|
||||
const requirements: string[] = Array.isArray(body.requirements) ? body.requirements : []
|
||||
const projectType: string | undefined = body.projectType
|
||||
|
||||
const apiKey =
|
||||
process.env.ANTHROPIC_API_KEY ||
|
||||
process.env.REACT_APP_ANTHROPIC_API_KEY ||
|
||||
process.env.NEXT_PUBLIC_ANTHROPIC_API_KEY ||
|
||||
''
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Missing Anthropic API key in env' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const anthropic = new Anthropic({ apiKey })
|
||||
|
||||
const requirementsText = (requirements || [])
|
||||
.filter((r) => r && r.trim())
|
||||
.map((r) => `- ${r}`)
|
||||
.join('\n')
|
||||
|
||||
const prompt = `
|
||||
Analyze this feature and provide complexity assessment and business logic rules:
|
||||
|
||||
Project Type: ${projectType || 'Generic'}
|
||||
Feature Name: ${featureName}
|
||||
Description: ${description}
|
||||
|
||||
Requirements:
|
||||
${requirementsText}
|
||||
|
||||
Based on these requirements, provide a JSON response with:
|
||||
1. Complexity level (low/medium/high)
|
||||
2. Business logic rules that should be implemented
|
||||
|
||||
Complexity Guidelines:
|
||||
- LOW: Simple CRUD operations, basic display features
|
||||
- MEDIUM: Moderate business logic, some validations, basic integrations
|
||||
- HIGH: Complex business rules, security requirements, external integrations, compliance needs
|
||||
|
||||
Return ONLY a JSON object in this exact format:
|
||||
{
|
||||
"complexity": "low|medium|high",
|
||||
"logicRules": [
|
||||
"Business rule 1 based on requirements",
|
||||
"Business rule 2 based on requirements",
|
||||
"Business rule 3 based on requirements"
|
||||
]
|
||||
}`
|
||||
|
||||
const message = await anthropic.messages.create({
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
max_tokens: 1000,
|
||||
temperature: 0.2,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
})
|
||||
|
||||
const responseText = (message as any).content?.[0]?.text?.trim?.() || ''
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/)
|
||||
if (!jsonMatch) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Invalid AI response format' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0])
|
||||
// Use the user-selected complexity if provided, otherwise use AI's suggestion
|
||||
const complexity = (body.complexity || parsed.complexity as 'low' | 'medium' | 'high') || 'medium'
|
||||
const logicRules = Array.isArray(parsed.logicRules) ? parsed.logicRules : []
|
||||
|
||||
return NextResponse.json({ success: true, data: { complexity, logicRules } })
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: error?.message || 'AI analysis failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
116
src/app/api/ai/tech-recommendations/route.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
console.log('🚀 Tech recommendations API called - redirecting to unified service');
|
||||
|
||||
// Parse request body
|
||||
const body = await request.json();
|
||||
console.log('📊 Request body:', {
|
||||
template: body.template?.title,
|
||||
featuresCount: body.features?.length,
|
||||
businessQuestionsCount: body.businessContext?.questions?.length,
|
||||
});
|
||||
|
||||
// Validate required fields
|
||||
if (!body.template || !body.features || !body.businessContext) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Missing required fields: template, features, or businessContext',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate template structure
|
||||
if (!body.template.title || !body.template.category) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Template must have title and category',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate features array
|
||||
if (!Array.isArray(body.features) || body.features.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Features must be a non-empty array',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate business context
|
||||
if (!body.businessContext.questions || !Array.isArray(body.businessContext.questions)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Business context must have questions array',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to unified service through API Gateway
|
||||
const apiGatewayUrl = process.env.BACKEND_URL || 'http://localhost:8000';
|
||||
|
||||
const response = await fetch(`${apiGatewayUrl}/api/unified/comprehensive-recommendations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
// Add optional parameters for template-based and domain-based recommendations
|
||||
templateId: body.template.id,
|
||||
budget: 15000, // Default budget - could be made configurable
|
||||
domain: body.template.category?.toLowerCase() || 'general',
|
||||
includeClaude: true,
|
||||
includeTemplateBased: true,
|
||||
includeDomainBased: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unified service error: ${response.status}`);
|
||||
}
|
||||
|
||||
const recommendations = await response.json();
|
||||
console.log('✅ Comprehensive recommendations received from unified service:', {
|
||||
success: recommendations.success,
|
||||
hasClaudeRecommendations: !!recommendations.data?.claude?.success,
|
||||
hasTemplateRecommendations: !!recommendations.data?.templateBased?.success,
|
||||
hasDomainRecommendations: !!recommendations.data?.domainBased?.success,
|
||||
});
|
||||
|
||||
// Return the recommendations
|
||||
return NextResponse.json(recommendations);
|
||||
} catch (error) {
|
||||
console.error('❌ Error in tech recommendations API:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle OPTIONS request for CORS
|
||||
export async function OPTIONS(request: NextRequest) {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
});
|
||||
}
|
||||
78
src/app/api/diffs/[...path]/route.ts
Normal file
@ -0,0 +1,78 @@
|
||||
// app/api/diffs/[...path]/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const GIT_INTEGRATION_URL = process.env.GIT_INTEGRATION_URL || 'http://localhost:8012';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
try {
|
||||
const path = params.path.join('/');
|
||||
const url = new URL(request.url);
|
||||
const searchParams = url.searchParams.toString();
|
||||
const fullUrl = `${GIT_INTEGRATION_URL}/api/diffs/${path}${searchParams ? `?${searchParams}` : ''}`;
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Git integration service responded with status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Error proxying diff request:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Failed to fetch diff data',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
try {
|
||||
const path = params.path.join('/');
|
||||
const body = await request.json();
|
||||
const url = new URL(request.url);
|
||||
const searchParams = url.searchParams.toString();
|
||||
const fullUrl = `${GIT_INTEGRATION_URL}/api/diffs/${path}${searchParams ? `?${searchParams}` : ''}`;
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Git integration service responded with status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Error proxying diff request:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Failed to process diff request',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
32
src/app/api/diffs/repositories/route.ts
Normal file
@ -0,0 +1,32 @@
|
||||
// app/api/diffs/repositories/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const GIT_INTEGRATION_URL = process.env.GIT_INTEGRATION_URL || 'http://localhost:8012';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const response = await fetch(`${GIT_INTEGRATION_URL}/api/diffs/repositories`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Git integration service responded with status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching repositories:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Failed to fetch repositories',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
5
src/app/architecture/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import ArchitectureGenerator from "@/components/architecture/architecture-generator"
|
||||
|
||||
export default function ArchitecturePage() {
|
||||
return <ArchitectureGenerator />
|
||||
}
|
||||
190
src/app/auth/emailVerification.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { BACKEND_URL } from "@/config/backend";
|
||||
|
||||
type VerifyState = "idle" | "loading" | "success" | "error";
|
||||
|
||||
interface VerificationResponse {
|
||||
success: boolean;
|
||||
data: { message: string; user: { email: string; username: string } };
|
||||
message: string;
|
||||
redirect?: string;
|
||||
}
|
||||
// Removed unused ErrorResponse interface
|
||||
|
||||
|
||||
|
||||
const EmailVerification: React.FC = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const [status, setStatus] = useState<VerifyState>("idle");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const token = searchParams.get("token");
|
||||
const didRun = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus("error");
|
||||
setError("No verification token found.");
|
||||
return;
|
||||
}
|
||||
if (didRun.current) return;
|
||||
didRun.current = true;
|
||||
|
||||
void verifyEmail(token);
|
||||
}, [token]);
|
||||
|
||||
const verifyEmail = async (verificationToken: string) => {
|
||||
setStatus("loading");
|
||||
setMessage("Verifying your email...");
|
||||
setError("");
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const timeout = setTimeout(() => ctrl.abort(), 10000);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BACKEND_URL}/api/auth/verify-email?token=${verificationToken}&format=json`,
|
||||
{ method: "GET", headers: { "Accept": "application/json" }, signal: ctrl.signal }
|
||||
);
|
||||
|
||||
const txt = await res.text();
|
||||
console.log("Raw response text:", txt);
|
||||
console.log("Response status:", res.status);
|
||||
console.log("Response headers:", Object.fromEntries(res.headers.entries()));
|
||||
|
||||
let data: Record<string, unknown> = {};
|
||||
try { data = JSON.parse(txt || "{}"); } catch { /* ignore non-JSON */ }
|
||||
console.log("Parsed response data:", data);
|
||||
|
||||
if (res.ok && (data as unknown as VerificationResponse)?.success) {
|
||||
setStatus("success");
|
||||
setMessage("Email verified successfully! Redirecting to login...");
|
||||
// Use window.location.href for a hard redirect to ensure it works
|
||||
setTimeout(() => {
|
||||
window.location.href = "/signin?verified=true";
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = String(data?.message || "").toLowerCase();
|
||||
if (msg.includes("already verified")) {
|
||||
setStatus("success");
|
||||
setMessage("Email already verified! Redirecting to login...");
|
||||
// Use window.location.href for a hard redirect to ensure it works
|
||||
setTimeout(() => {
|
||||
window.location.href = "/signin?verified=true";
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("error");
|
||||
setError(String(data?.message) || `Verification failed (HTTP ${res.status}).`);
|
||||
} catch (e: unknown) {
|
||||
setStatus("error");
|
||||
const error = e as { name?: string };
|
||||
setError(error?.name === "AbortError" ? "Request timed out. Please try again." : "Network error. Please try again.");
|
||||
console.error("Email verification error:", e);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
setStatus("loading");
|
||||
setMessage("Sending verification email...");
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const email = prompt("Enter your email to resend the verification link:");
|
||||
if (!email) {
|
||||
setStatus("error");
|
||||
setError("Email is required to resend verification.");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${BACKEND_URL}/api/auth/resend-verification`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data?.success) {
|
||||
setStatus("success");
|
||||
setMessage("Verification email sent! Please check your inbox.");
|
||||
} else {
|
||||
setStatus("error");
|
||||
setError(data?.message || "Failed to resend verification email.");
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
setError("Network error. Please try again.");
|
||||
console.error("Resend verification error:", e);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-1">Verifying Email</h2>
|
||||
<p className="text-gray-600">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
|
||||
<div className="max-w-md w-full space-y-6">
|
||||
{status === "success" && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-md p-4 text-center">
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-green-800 font-medium">{message || "Email verified. Redirecting..."}</p>
|
||||
<p className="text-green-600 text-sm mt-1">You will be redirected to the login page shortly.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<p className="text-red-800 font-medium text-center">{error}</p>
|
||||
<button
|
||||
onClick={handleResendVerification}
|
||||
className="mt-4 w-full py-2 rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Resend Verification Email
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => (window.location.href = "/auth")}
|
||||
className="w-1/2 py-2 rounded-md border bg-white text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Go to Login
|
||||
</button>
|
||||
<button
|
||||
onClick={() => (window.location.href = "/")}
|
||||
className="w-1/2 py-2 rounded-md border bg-white text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Go to Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailVerification;
|
||||
37
src/app/auth/page.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
|
||||
function AuthPageInner() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
const mode = searchParams.get('mode')
|
||||
if (mode === 'signup') {
|
||||
router.replace('/signup')
|
||||
} else if (mode === 'signin') {
|
||||
router.replace('/signin')
|
||||
} else {
|
||||
router.replace('/signin')
|
||||
}
|
||||
}, [router, searchParams])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500 mx-auto mb-4"></div>
|
||||
<p className="text-white/60">Redirecting...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AuthPageRoute() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AuthPageInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
5
src/app/business-context/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import BusinessContextGenerator from "@/components/business-context/business-context-generator"
|
||||
|
||||
export default function BusinessContextPage() {
|
||||
return <BusinessContextGenerator />
|
||||
}
|
||||
324
src/app/diff-viewer/page.tsx
Normal file
@ -0,0 +1,324 @@
|
||||
// app/diff-viewer/page.tsx
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
GitCommit,
|
||||
FolderOpen,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ExternalLink
|
||||
} from 'lucide-react';
|
||||
import DiffViewer from '@/components/diff-viewer/DiffViewer';
|
||||
|
||||
interface Repository {
|
||||
id: string;
|
||||
repository_name: string;
|
||||
owner_name: string;
|
||||
sync_status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Commit {
|
||||
id: string;
|
||||
commit_sha: string;
|
||||
author_name: string;
|
||||
message: string;
|
||||
committed_at: string;
|
||||
files_changed: number;
|
||||
diffs_processed: number;
|
||||
total_diff_size: number;
|
||||
}
|
||||
|
||||
const DiffViewerPage: React.FC = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||
const [commits, setCommits] = useState<Commit[]>([]);
|
||||
const [selectedRepository, setSelectedRepository] = useState<string>('');
|
||||
const [selectedCommit, setSelectedCommit] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Handle URL parameters
|
||||
useEffect(() => {
|
||||
const repoId = searchParams.get('repo');
|
||||
if (repoId) {
|
||||
setSelectedRepository(repoId);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Load repositories
|
||||
useEffect(() => {
|
||||
const loadRepositories = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch('/api/diffs/repositories');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setRepositories(data.data.repositories);
|
||||
} else {
|
||||
setError(data.message || 'Failed to load repositories');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load repositories');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRepositories();
|
||||
}, []);
|
||||
|
||||
// Load commits when repository is selected
|
||||
useEffect(() => {
|
||||
if (selectedRepository) {
|
||||
const loadCommits = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(`/api/diffs/repositories/${selectedRepository}/commits`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setCommits(data.data.commits);
|
||||
// Auto-select first commit
|
||||
if (data.data.commits.length > 0) {
|
||||
setSelectedCommit(data.data.commits[0].id);
|
||||
}
|
||||
} else {
|
||||
setError(data.message || 'Failed to load commits');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load commits');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCommits();
|
||||
}
|
||||
}, [selectedRepository]);
|
||||
|
||||
const handleRepositoryChange = (repositoryId: string) => {
|
||||
setSelectedRepository(repositoryId);
|
||||
setSelectedCommit('');
|
||||
};
|
||||
|
||||
const handleCommitChange = (commitId: string) => {
|
||||
setSelectedCommit(commitId);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (selectedRepository) {
|
||||
const loadCommits = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(`/api/diffs/repositories/${selectedRepository}/commits`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setCommits(data.data.commits);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to refresh commits');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCommits();
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCommits = commits.filter(commit =>
|
||||
commit.message.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
commit.author_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
commit.commit_sha.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Git Diff Viewer</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
View and analyze git diffs from your repositories
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading || !selectedRepository}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Repository and Commit Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<FolderOpen className="h-5 w-5" />
|
||||
<span>Select Repository & Commit</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Repository Selection */}
|
||||
<div>
|
||||
<Label htmlFor="repository">Repository</Label>
|
||||
<select
|
||||
id="repository"
|
||||
value={selectedRepository}
|
||||
onChange={(e) => handleRepositoryChange(e.target.value)}
|
||||
className="w-full mt-1 px-3 py-2 border border-input rounded-md bg-background"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">Select a repository...</option>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.owner_name}/{repo.repository_name} ({repo.sync_status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Commit Selection */}
|
||||
{selectedRepository && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label htmlFor="commit">Commit</Label>
|
||||
<Badge variant="outline">
|
||||
{commits.length} commits
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search commits..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
id="commit"
|
||||
value={selectedCommit}
|
||||
onChange={(e) => handleCommitChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-input rounded-md bg-background"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">Select a commit...</option>
|
||||
{filteredCommits.map((commit) => (
|
||||
<option key={commit.id} value={commit.id}>
|
||||
{commit.commit_sha.substring(0, 8)} - {commit.message.substring(0, 50)}
|
||||
{commit.message.length > 50 ? '...' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commit Info */}
|
||||
{selectedCommit && (
|
||||
<div className="bg-muted/50 p-4 rounded-lg">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<GitCommit className="h-4 w-4" />
|
||||
<span className="font-medium">Selected Commit</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const commit = commits.find(c => c.id === selectedCommit);
|
||||
return commit ? (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-mono text-xs bg-muted px-2 py-1 rounded">
|
||||
{commit.commit_sha.substring(0, 8)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">by {commit.author_name}</span>
|
||||
</div>
|
||||
<p className="font-medium">{commit.message}</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-muted-foreground">
|
||||
<span>{commit.files_changed} files changed</span>
|
||||
<span>{commit.diffs_processed} diffs processed</span>
|
||||
<span>{(commit.total_diff_size / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="font-medium">Error</p>
|
||||
<p className="text-sm mt-2">{error}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Diff Viewer */}
|
||||
{selectedRepository && selectedCommit && (
|
||||
<DiffViewer
|
||||
repositoryId={selectedRepository}
|
||||
commitId={selectedCommit}
|
||||
initialView="side-by-side"
|
||||
className="min-h-[600px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No Selection State */}
|
||||
{!selectedRepository && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<FolderOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="font-medium">No Repository Selected</p>
|
||||
<p className="text-sm mt-2">
|
||||
Please select a repository to view its diffs
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{selectedRepository && !selectedCommit && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<GitCommit className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="font-medium">No Commit Selected</p>
|
||||
<p className="text-sm mt-2">
|
||||
Please select a commit to view its diffs
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffViewerPage;
|
||||
BIN
src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
5
src/app/features/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { FeaturesPage } from "@/components/features/features-page"
|
||||
|
||||
export default function FeaturesPageRoute() {
|
||||
return <FeaturesPage />
|
||||
}
|
||||
329
src/app/github/analyze/page.tsx
Normal file
@ -0,0 +1,329 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, Suspense } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Brain,
|
||||
Code,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Star,
|
||||
Shield,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Zap,
|
||||
Target,
|
||||
BarChart3,
|
||||
Layers,
|
||||
Cpu,
|
||||
Search,
|
||||
File,
|
||||
Folder,
|
||||
Eye,
|
||||
AlertCircle
|
||||
} from "lucide-react"
|
||||
|
||||
interface FileAnalysis {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'folder'
|
||||
status: 'scanning' | 'analyzing' | 'completed' | 'pending'
|
||||
size?: string
|
||||
language?: string
|
||||
issues?: number
|
||||
score?: number
|
||||
details?: string
|
||||
}
|
||||
|
||||
// Component that uses useSearchParams - needs to be wrapped in Suspense
|
||||
function AIAnalysisContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const repoId = searchParams.get('repoId')
|
||||
const repoName = searchParams.get('repoName') || 'Repository'
|
||||
|
||||
const [analysisProgress, setAnalysisProgress] = useState(0)
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(true)
|
||||
const [currentFile, setCurrentFile] = useState('Initializing analysis...')
|
||||
|
||||
const [files, setFiles] = useState<FileAnalysis[]>([
|
||||
{ id: '1', name: 'package.json', path: '/package.json', type: 'file', status: 'pending', language: 'JSON', size: '2.1 KB' },
|
||||
{ id: '2', name: 'src', path: '/src', type: 'folder', status: 'pending' },
|
||||
{ id: '3', name: 'App.js', path: '/src/App.js', type: 'file', status: 'pending', language: 'JavaScript', size: '5.2 KB' },
|
||||
{ id: '4', name: 'components', path: '/src/components', type: 'folder', status: 'pending' },
|
||||
{ id: '5', name: 'Header.jsx', path: '/src/components/Header.jsx', type: 'file', status: 'pending', language: 'JavaScript', size: '3.8 KB' },
|
||||
{ id: '6', name: 'Footer.jsx', path: '/src/components/Footer.jsx', type: 'file', status: 'pending', language: 'JavaScript', size: '2.5 KB' },
|
||||
{ id: '7', name: 'utils', path: '/src/utils', type: 'folder', status: 'pending' },
|
||||
{ id: '8', name: 'helpers.js', path: '/src/utils/helpers.js', type: 'file', status: 'pending', language: 'JavaScript', size: '4.1 KB' },
|
||||
{ id: '9', name: 'README.md', path: '/README.md', type: 'file', status: 'pending', language: 'Markdown', size: '1.8 KB' },
|
||||
{ id: '10', name: 'styles', path: '/styles', type: 'folder', status: 'pending' },
|
||||
{ id: '11', name: 'main.css', path: '/styles/main.css', type: 'file', status: 'pending', language: 'CSS', size: '6.3 KB' },
|
||||
{ id: '12', name: 'tests', path: '/tests', type: 'folder', status: 'pending' },
|
||||
{ id: '13', name: 'App.test.js', path: '/tests/App.test.js', type: 'file', status: 'pending', language: 'JavaScript', size: '2.9 KB' }
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!repoId) {
|
||||
router.push('/github/repos')
|
||||
return
|
||||
}
|
||||
|
||||
let fileIndex = 0
|
||||
let progress = 0
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (fileIndex < files.length) {
|
||||
const currentFileData = files[fileIndex]
|
||||
|
||||
// Update current file being analyzed
|
||||
setCurrentFile(`Analyzing ${currentFileData.name}...`)
|
||||
|
||||
// Update file status to scanning
|
||||
setFiles(prev => prev.map((file, index) =>
|
||||
index === fileIndex ? { ...file, status: 'scanning' as const } : file
|
||||
))
|
||||
|
||||
// After a short delay, mark as analyzing
|
||||
setTimeout(() => {
|
||||
setFiles(prev => prev.map((file, index) =>
|
||||
index === fileIndex ? { ...file, status: 'analyzing' as const } : file
|
||||
))
|
||||
}, 500)
|
||||
|
||||
// After another delay, mark as completed with mock data
|
||||
setTimeout(() => {
|
||||
setFiles(prev => prev.map((file, index) =>
|
||||
index === fileIndex ? {
|
||||
...file,
|
||||
status: 'completed' as const,
|
||||
score: Math.floor(Math.random() * 30) + 70, // 70-100
|
||||
issues: Math.floor(Math.random() * 5),
|
||||
details: file.type === 'file' ? 'Analysis completed successfully' : 'Directory scanned'
|
||||
} : file
|
||||
))
|
||||
}, 1000)
|
||||
|
||||
progress = Math.min(100, ((fileIndex + 1) / files.length) * 100)
|
||||
setAnalysisProgress(progress)
|
||||
fileIndex++
|
||||
} else {
|
||||
// Complete analysis
|
||||
setIsAnalyzing(false)
|
||||
setCurrentFile('Analysis completed!')
|
||||
setAnalysisProgress(100)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 1500)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [repoId, router, files.length])
|
||||
|
||||
const getStatusIcon = (status: FileAnalysis['status'], type: FileAnalysis['type']) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-4 w-4 text-green-400" />
|
||||
case 'analyzing':
|
||||
return <div className="h-4 w-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
|
||||
case 'scanning':
|
||||
return <Search className="h-4 w-4 text-yellow-400 animate-pulse" />
|
||||
case 'pending':
|
||||
return <Clock className="h-4 w-4 text-white/40" />
|
||||
}
|
||||
}
|
||||
|
||||
const getFileIcon = (type: FileAnalysis['type'], language?: string) => {
|
||||
if (type === 'folder') {
|
||||
return <Folder className="h-4 w-4 text-blue-400" />
|
||||
}
|
||||
|
||||
switch (language) {
|
||||
case 'JavaScript':
|
||||
return <Code className="h-4 w-4 text-yellow-400" />
|
||||
case 'CSS':
|
||||
return <FileText className="h-4 w-4 text-blue-400" />
|
||||
case 'Markdown':
|
||||
return <FileText className="h-4 w-4 text-gray-400" />
|
||||
case 'JSON':
|
||||
return <FileText className="h-4 w-4 text-orange-400" />
|
||||
default:
|
||||
return <File className="h-4 w-4 text-white/60" />
|
||||
}
|
||||
}
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 90) return 'text-green-400'
|
||||
if (score >= 80) return 'text-yellow-400'
|
||||
if (score >= 70) return 'text-orange-400'
|
||||
return 'text-red-400'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-8 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/github/repos">
|
||||
<Button variant="ghost" className="text-white/80 hover:text-white">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Repositories
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white flex items-center gap-3">
|
||||
<Brain className="h-8 w-8 text-orange-400" />
|
||||
AI Code Analysis
|
||||
</h1>
|
||||
<p className="text-white/60 mt-1">Analyzing: <span className="text-orange-400 font-medium">{repoName}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analysis Progress */}
|
||||
{isAnalyzing && (
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white">Analysis Progress</h3>
|
||||
<span className="text-sm text-white/60">{Math.round(analysisProgress)}%</span>
|
||||
</div>
|
||||
<Progress value={analysisProgress} className="h-2" />
|
||||
<p className="text-white/80 text-sm flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-blue-400" />
|
||||
{currentFile}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* File Analysis List */}
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<FileText className="h-6 w-6 text-orange-400" />
|
||||
File Analysis Results
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-white/10">
|
||||
{files.map((file) => (
|
||||
<div key={file.id} className="p-4 hover:bg-white/5 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{getFileIcon(file.type, file.language)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white truncate">{file.name}</span>
|
||||
{file.language && (
|
||||
<Badge variant="outline" className="text-xs border-white/20 text-white/60">
|
||||
{file.language}
|
||||
</Badge>
|
||||
)}
|
||||
{file.size && (
|
||||
<span className="text-xs text-white/40">{file.size}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-white/60 truncate">{file.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{file.status === 'completed' && file.score && (
|
||||
<div className="flex items-center gap-2">
|
||||
{file.issues && file.issues > 0 && (
|
||||
<div className="flex items-center gap-1 text-red-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{file.issues}</span>
|
||||
</div>
|
||||
)}
|
||||
<Badge className={`${getScoreColor(file.score)} bg-transparent border`}>
|
||||
{file.score}/100
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{getStatusIcon(file.status, file.type)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{file.status === 'completed' && file.details && (
|
||||
<div className="mt-2 text-sm text-white/70">
|
||||
{file.details}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
{!isAnalyzing && (
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<BarChart3 className="h-6 w-6 text-orange-400" />
|
||||
Analysis Summary
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-400">
|
||||
{files.filter(f => f.status === 'completed').length}
|
||||
</div>
|
||||
<div className="text-white/60 text-sm">Files Analyzed</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-yellow-400">
|
||||
{files.filter(f => f.language).length}
|
||||
</div>
|
||||
<div className="text-white/60 text-sm">Languages Found</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-400">
|
||||
{files.reduce((sum, f) => sum + (f.issues || 0), 0)}
|
||||
</div>
|
||||
<div className="text-white/60 text-sm">Issues Found</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-400">
|
||||
{Math.round(files.filter(f => f.score).reduce((sum, f) => sum + (f.score || 0), 0) / files.filter(f => f.score).length) || 0}
|
||||
</div>
|
||||
<div className="text-white/60 text-sm">Avg Score</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{!isAnalyzing && (
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button className="bg-orange-600 hover:bg-orange-700 text-white">
|
||||
<Layers className="mr-2 h-4 w-4" />
|
||||
Generate Detailed Report
|
||||
</Button>
|
||||
<Button variant="outline" className="border-white/20 text-white hover:bg-white/10">
|
||||
<Cpu className="mr-2 h-4 w-4" />
|
||||
Export Analysis
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AIAnalysisPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="mx-auto max-w-7xl px-4 py-8 flex items-center justify-center min-h-screen"><div className="text-white">Loading analysis...</div></div>}>
|
||||
<AIAnalysisContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
76
src/app/github/repo/RepoTree.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { getRepositoryStructure, type RepoStructureEntry } from "@/lib/api/github"
|
||||
import { ChevronDown, ChevronRight, FileText, Folder } from "lucide-react"
|
||||
|
||||
type Node = {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'directory'
|
||||
}
|
||||
|
||||
export default function RepoTree({ repositoryId, rootPath = "", onSelectFile, onSelectDirectory }: { repositoryId: string, rootPath?: string, onSelectFile: (path: string) => void, onSelectDirectory?: (path: string) => void }) {
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
const [loading, setLoading] = useState<Record<string, boolean>>({})
|
||||
const [children, setChildren] = useState<Record<string, Node[]>>({})
|
||||
|
||||
const toggle = async (nodePath: string) => {
|
||||
const next = !expanded[nodePath]
|
||||
setExpanded(prev => ({ ...prev, [nodePath]: next }))
|
||||
if (next && !children[nodePath]) {
|
||||
setLoading(prev => ({ ...prev, [nodePath]: true }))
|
||||
try {
|
||||
const res = await getRepositoryStructure(repositoryId, nodePath)
|
||||
const list = (res?.structure || []) as RepoStructureEntry[]
|
||||
const mapped: Node[] = list.map(e => ({
|
||||
name: (e as any).name || (e as any).filename || (((e as any).path || (e as any).relative_path || '').split('/').slice(-1)[0]) || 'unknown',
|
||||
path: (e as any).path || (e as any).relative_path || '',
|
||||
type: (e as any).type === 'directory' || (e as any).type === 'dir' ? 'directory' : 'file'
|
||||
}))
|
||||
setChildren(prev => ({ ...prev, [nodePath]: mapped }))
|
||||
onSelectDirectory && onSelectDirectory(nodePath)
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, [nodePath]: false }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initial load for root
|
||||
useEffect(() => {
|
||||
toggle(rootPath)
|
||||
onSelectDirectory && onSelectDirectory(rootPath)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [repositoryId, rootPath])
|
||||
|
||||
const renderNode = (node: Node) => {
|
||||
const isDir = node.type === 'directory'
|
||||
const isOpen = !!expanded[node.path]
|
||||
return (
|
||||
<div key={node.path} className="select-none">
|
||||
<div className="flex items-center gap-1 py-1 px-1 hover:bg-white/5 rounded cursor-pointer"
|
||||
onClick={() => isDir ? toggle(node.path) : onSelectFile(node.path)}>
|
||||
{isDir ? (
|
||||
isOpen ? <ChevronDown className="h-4 w-4 text-white/70"/> : <ChevronRight className="h-4 w-4 text-white/70"/>
|
||||
) : (
|
||||
<span className="w-4"/>
|
||||
)}
|
||||
{isDir ? <Folder className="h-4 w-4 mr-1"/> : <FileText className="h-4 w-4 mr-1"/>}
|
||||
<span className="truncate text-sm text-white">{node.name}</span>
|
||||
</div>
|
||||
{isDir && isOpen && (
|
||||
<div className="pl-5 border-l border-white/10 ml-2">
|
||||
{loading[node.path] && <div className="text-xs text-white/60 py-1">Loading…</div>}
|
||||
{(children[node.path] || []).map(ch => renderNode(ch))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-white/90">
|
||||
{(children[rootPath] || []).map(n => renderNode(n))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
src/app/github/repo/page.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, Suspense } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import RepoByIdClient from "./repo-client"
|
||||
|
||||
// Component that uses useSearchParams - needs to be wrapped in Suspense
|
||||
function RepoPageContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [repositoryId, setRepositoryId] = useState<string>("")
|
||||
const [initialPath, setInitialPath] = useState<string>("")
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const id = searchParams.get('id')
|
||||
const path = searchParams.get('path') || ""
|
||||
|
||||
if (id) {
|
||||
setRepositoryId(id)
|
||||
setInitialPath(path)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}, [searchParams])
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="mx-auto max-w-3xl px-4 py-10 text-white/80">Loading...</div>
|
||||
}
|
||||
|
||||
if (!repositoryId) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-10 text-white/80">
|
||||
<h1 className="text-2xl font-semibold">Repository</h1>
|
||||
<p className="mt-2">Missing repository id. Go back to <a href="/github/repos" className="text-orange-400 underline">My GitHub Repositories</a>.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <RepoByIdClient repositoryId={repositoryId} initialPath={initialPath} />
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={<div className="mx-auto max-w-3xl px-4 py-10 text-white/80">Loading...</div>}>
|
||||
<RepoPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
200
src/app/github/repo/repo-client.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { getRepositoryStructure, getRepositoryFileContent, type RepoStructureEntry } from "@/lib/api/github"
|
||||
import { ArrowLeft, BookText, Clock, Code, FileText, Folder, GitBranch, Search } from "lucide-react"
|
||||
|
||||
export default function RepoByIdClient({ repositoryId, initialPath = "" }: { repositoryId: string, initialPath?: string }) {
|
||||
const [path, setPath] = useState(initialPath)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [entries, setEntries] = useState<RepoStructureEntry[]>([])
|
||||
const [fileQuery, setFileQuery] = useState("")
|
||||
const [readme, setReadme] = useState<string | null>(null)
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = useState<string | null>(null)
|
||||
const [fileLoading, setFileLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
;(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const struct = await getRepositoryStructure(repositoryId, path)
|
||||
if (!mounted) return
|
||||
setEntries(struct?.structure || [])
|
||||
// try README at this path
|
||||
const candidates = ["README.md", "readme.md", "README.MD"]
|
||||
for (const name of candidates) {
|
||||
try {
|
||||
const fp = path ? `${path}/${name}` : name
|
||||
const content = await getRepositoryFileContent(repositoryId, fp)
|
||||
if (content?.content) { setReadme(content.content); break }
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { mounted = false }
|
||||
}, [repositoryId, path])
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = fileQuery.toLowerCase()
|
||||
if (!q) return entries
|
||||
return entries.filter(e => e.name.toLowerCase().includes(q))
|
||||
}, [entries, fileQuery])
|
||||
|
||||
const navigateFolder = (name: string) => {
|
||||
const next = path ? `${path}/${name}` : name
|
||||
setPath(next)
|
||||
setSelectedFile(null)
|
||||
setFileContent(null)
|
||||
}
|
||||
|
||||
const goUp = () => {
|
||||
if (!path) return
|
||||
const parts = path.split("/")
|
||||
parts.pop()
|
||||
setPath(parts.join("/"))
|
||||
setSelectedFile(null)
|
||||
setFileContent(null)
|
||||
}
|
||||
|
||||
const handleFileClick = async (fileName: string) => {
|
||||
const filePath = path ? `${path}/${fileName}` : fileName
|
||||
setSelectedFile(filePath)
|
||||
setFileLoading(true)
|
||||
|
||||
try {
|
||||
const content = await getRepositoryFileContent(repositoryId, filePath)
|
||||
setFileContent(content?.content || null)
|
||||
} catch (error) {
|
||||
console.error('Failed to load file content:', error)
|
||||
setFileContent(null)
|
||||
} finally {
|
||||
setFileLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link href="/github/repos">
|
||||
<Button variant="ghost" className="text-white/80 hover:text-white">
|
||||
<ArrowLeft className="h-4 w-4 mr-2"/> Back to Repos
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-semibold">Repository #{repositoryId}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-green-500/20 text-green-400 border border-green-500/30">
|
||||
Attached
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button variant="outline" className="border-white/20 text-white hover:bg-white/10" onClick={goUp}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2"/> Up one level
|
||||
</Button>
|
||||
<div className="flex-1 flex justify-center">
|
||||
<div className="relative w-full max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40"/>
|
||||
<Input
|
||||
placeholder="Q Go to file"
|
||||
value={fileQuery}
|
||||
onChange={(e) => setFileQuery(e.target.value)}
|
||||
className="pl-10 bg-white/5 border-white/10 text-white placeholder-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="border-white/20 text-white hover:bg-white/10">
|
||||
<Code className="h-4 w-4 mr-2"/> Code
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* File Tree - Left Side */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="bg-white/5 border-white/10 overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-b border-white/10 px-4 py-3 text-sm font-semibold">
|
||||
Files {path && `- ${path}`}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="px-4 py-6 text-white/60">Loading...</div>
|
||||
)}
|
||||
{!loading && visible.length === 0 && (
|
||||
<div className="px-4 py-6 text-white/60">No entries found.</div>
|
||||
)}
|
||||
{visible.map((e, i) => (
|
||||
<div key={i} className={`flex items-center px-4 py-3 border-b border-white/10 hover:bg-white/5 cursor-pointer ${
|
||||
selectedFile === (path ? `${path}/${e.name}` : e.name) ? 'bg-white/10' : ''
|
||||
}`}
|
||||
onClick={() => e.type === 'directory' ? navigateFolder(e.name) : handleFileClick(e.name)}>
|
||||
<div className="w-7 flex justify-center">
|
||||
{e.type === 'directory' ? <Folder className="h-4 w-4"/> : <FileText className="h-4 w-4"/>}
|
||||
</div>
|
||||
<div className="flex-1 font-medium truncate">{e.name}</div>
|
||||
<div className="w-20 text-right text-sm text-white/60">
|
||||
{e.size && `${Math.round(Number(e.size) / 1024)}KB`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* File Content - Right Side */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="bg-white/5 border-white/10 h-[70vh]">
|
||||
<CardContent className="p-0 h-full flex flex-col">
|
||||
<div className="border-b border-white/10 px-4 py-3 text-sm font-semibold flex-shrink-0">
|
||||
{selectedFile ? `File: ${selectedFile}` : 'README'}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selectedFile ? (
|
||||
<div className="h-full p-4">
|
||||
{fileLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
<span className="ml-2 text-white/60">Loading file content...</span>
|
||||
</div>
|
||||
) : fileContent ? (
|
||||
<pre className="whitespace-pre-wrap text-sm text-white/90 bg-black/20 p-4 rounded overflow-auto h-full">{fileContent}</pre>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<FileText className="h-10 w-10 mx-auto text-white/60"/>
|
||||
<h3 className="mt-3 text-xl font-semibold">File content not available</h3>
|
||||
<p className="mt-1 text-white/60 text-sm">This file could not be loaded or is binary.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : !readme ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<BookText className="h-10 w-10 mx-auto text-white/60"/>
|
||||
<h3 className="mt-3 text-xl font-semibold">No README found</h3>
|
||||
<p className="mt-1 text-white/60 text-sm">Add a README.md to the repository to show it here.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full p-4">
|
||||
<pre className="whitespace-pre-wrap text-sm text-white/90 bg-black/20 p-4 rounded overflow-auto h-full">{readme}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
275
src/app/github/repos/page.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Github,
|
||||
FolderOpen,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Star,
|
||||
Eye,
|
||||
Code,
|
||||
Calendar,
|
||||
GitCompare
|
||||
} from 'lucide-react';
|
||||
import { getUserRepositories, type GitHubRepoSummary } from '@/lib/api/github';
|
||||
import Link from 'next/link';
|
||||
|
||||
const GitHubReposPage: React.FC = () => {
|
||||
const [repositories, setRepositories] = useState<GitHubRepoSummary[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filter, setFilter] = useState<'all' | 'public' | 'private'>('all');
|
||||
|
||||
// Load repositories
|
||||
useEffect(() => {
|
||||
const loadRepositories = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const repos = await getUserRepositories();
|
||||
setRepositories(repos);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load repositories');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRepositories();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const repos = await getUserRepositories(true); // Clear cache
|
||||
setRepositories(repos);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to refresh repositories');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredRepositories = repositories.filter(repo => {
|
||||
const matchesSearch = repo.full_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
repo.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
repo.language?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesFilter = filter === 'all' ||
|
||||
(filter === 'public' && repo.visibility === 'public') ||
|
||||
(filter === 'private' && repo.visibility === 'private');
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
|
||||
const formatDate = (dateString: string | undefined) => {
|
||||
if (!dateString) return 'Unknown';
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold flex items-center space-x-2">
|
||||
<Github className="h-8 w-8" />
|
||||
<span>My GitHub Repositories</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Browse and analyze your GitHub repositories
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/diff-viewer">
|
||||
<GitCompare className="h-4 w-4 mr-2" />
|
||||
Git Diff
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search repositories..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={filter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'public' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('public')}
|
||||
>
|
||||
Public
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'private' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('private')}
|
||||
>
|
||||
Private
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="font-medium">Error</p>
|
||||
<p className="text-sm mt-2">{error}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p>Loading repositories...</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Repositories Grid */}
|
||||
{!isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredRepositories.map((repo) => (
|
||||
<Card key={repo.id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FolderOpen className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{repo.name || 'Unknown Repository'}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{repo.full_name || 'Unknown Owner'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={repo.visibility === 'public' ? 'default' : 'secondary'}>
|
||||
{repo.visibility || 'unknown'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{repo.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Repository Stats */}
|
||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
{repo.language && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Code className="h-4 w-4" />
|
||||
<span>{repo.language}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-1">
|
||||
<Star className="h-4 w-4" />
|
||||
<span>{repo.stargazers_count || 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
<span>{repo.forks_count || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Updated Date */}
|
||||
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>Updated {formatDate(repo.updated_at)}</span>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button asChild size="sm" className="flex-1">
|
||||
<Link href={`/github/repo?id=${repo.id}`}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && filteredRepositories.length === 0 && !error && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Github className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="font-medium">
|
||||
{searchQuery || filter !== 'all' ? 'No repositories found' : 'No repositories available'}
|
||||
</p>
|
||||
<p className="text-sm mt-2">
|
||||
{searchQuery || filter !== 'all'
|
||||
? 'Try adjusting your search or filter criteria'
|
||||
: 'Make sure you have connected your GitHub account and have repositories'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitHubReposPage;
|
||||
233
src/app/globals.css
Normal file
@ -0,0 +1,233 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Added custom styles for drag-and-drop editor */
|
||||
.drag-overlay {
|
||||
@apply opacity-50 rotate-3 scale-105;
|
||||
}
|
||||
|
||||
.drop-zone-active {
|
||||
@apply ring-2 ring-accent bg-accent/5;
|
||||
}
|
||||
|
||||
.canvas-grid {
|
||||
background-image: linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.resizer {
|
||||
@apply bg-border hover:bg-accent transition-colors;
|
||||
}
|
||||
|
||||
.resizer:hover {
|
||||
@apply bg-accent;
|
||||
}
|
||||
|
||||
/* Enhanced scrolling for panels */
|
||||
[data-slot="scroll-area-viewport"] {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--border)) transparent;
|
||||
}
|
||||
|
||||
[data-slot="scroll-area-viewport"]::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
[data-slot="scroll-area-viewport"]::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-slot="scroll-area-viewport"]::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
[data-slot="scroll-area-viewport"]::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
/* Smooth scrolling for panels */
|
||||
.component-panel-scroll {
|
||||
scroll-behavior: smooth;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.properties-panel-scroll {
|
||||
scroll-behavior: smooth;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Compact properties cards */
|
||||
.properties-panel-scroll .max-w-sm {
|
||||
max-width: 280px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.properties-panel-scroll .space-y-4 > * + * {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.properties-panel-scroll .space-y-2 > * + * {
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
/* Reduce card header and content height */
|
||||
.properties-panel-scroll .max-w-sm .pb-2 {
|
||||
padding-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.properties-panel-scroll .max-w-sm .pt-3 {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.properties-panel-scroll .max-w-sm .px-3 {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.properties-panel-scroll .max-w-sm .pb-3 {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Additional compact styling */
|
||||
.properties-panel-scroll .space-y-3 > * + * {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.properties-panel-scroll .space-y-1\.5 > * + * {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Reduce text size for more compact look */
|
||||
.properties-panel-scroll .text-sm {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.properties-panel-scroll .text-xs {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
47
src/app/layout.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import type React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { AuthProvider } from "@/contexts/auth-context"
|
||||
import { AppLayout } from "@/components/layout/app-layout"
|
||||
import { ToastProvider } from "@/components/ui/toast"
|
||||
import "./globals.css"
|
||||
import "@tldraw/tldraw/tldraw.css"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Codenuk - AI-Powered Project Builder",
|
||||
description: "Build scalable applications with AI-generated architecture and code",
|
||||
generator: "v0.dev",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>{`
|
||||
html {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
--font-sans: 'Poppins', sans-serif;
|
||||
}
|
||||
`}</style>
|
||||
</head>
|
||||
<body className="font-sans antialiased dark bg-black text-white">
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<AppLayout>
|
||||
<main>{children}</main>
|
||||
</AppLayout>
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
5
src/app/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/project-builder")
|
||||
}
|
||||
78
src/app/project-builder/page.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { MainDashboard } from "@/components/main-dashboard"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
|
||||
// Component that uses useSearchParams - needs to be wrapped in Suspense
|
||||
function ProjectBuilderContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { user, isLoading } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
if (!user) {
|
||||
const returnUrl = encodeURIComponent("/project-builder")
|
||||
router.replace(`/signin?returnUrl=${returnUrl}`)
|
||||
}
|
||||
}, [user, isLoading, router])
|
||||
|
||||
// Handle GitHub OAuth callback parameters
|
||||
useEffect(() => {
|
||||
if (isLoading || !user) return
|
||||
|
||||
const githubConnected = searchParams.get('github_connected')
|
||||
const githubUser = searchParams.get('user')
|
||||
const processing = searchParams.get('processing')
|
||||
const repoAttached = searchParams.get('repo_attached')
|
||||
const repositoryId = searchParams.get('repository_id')
|
||||
const syncStatus = searchParams.get('sync_status')
|
||||
|
||||
if (githubConnected === '1') {
|
||||
console.log('🎉 GitHub OAuth callback successful!', {
|
||||
githubUser,
|
||||
processing,
|
||||
repoAttached,
|
||||
repositoryId,
|
||||
syncStatus
|
||||
})
|
||||
|
||||
// Clear any pending git attach from sessionStorage
|
||||
try {
|
||||
sessionStorage.removeItem('pending_git_attach')
|
||||
} catch (e) {
|
||||
console.warn('Failed to clear pending attach:', e)
|
||||
}
|
||||
|
||||
// Show success message
|
||||
if (processing === '1') {
|
||||
// Repository is being processed in background
|
||||
alert(`GitHub account connected successfully!\n\nGitHub User: ${githubUser}\n\nYour repository is being processed in the background. This may take a few moments.\n\nYou can start working, and the repository will be available shortly.`)
|
||||
} else if (repoAttached === '1' && repositoryId) {
|
||||
alert(`Repository attached successfully!\n\nGitHub User: ${githubUser}\nRepository ID: ${repositoryId}\nSync Status: ${syncStatus}`)
|
||||
} else {
|
||||
// Generic success message
|
||||
alert(`GitHub account connected successfully!\n\nGitHub User: ${githubUser}`)
|
||||
}
|
||||
|
||||
// Clean up URL parameters
|
||||
router.replace('/project-builder')
|
||||
}
|
||||
}, [isLoading, user, searchParams, router])
|
||||
|
||||
if (isLoading || !user) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
|
||||
}
|
||||
|
||||
return <MainDashboard />
|
||||
}
|
||||
|
||||
export default function ProjectBuilderPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Loading...</div>}>
|
||||
<ProjectBuilderContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
10
src/app/signin/page.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react"
|
||||
import { SignInPage } from "@/components/auth/signin-page"
|
||||
|
||||
export default function SignInPageRoute() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<SignInPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
5
src/app/signup/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { SignUpPage } from "@/components/auth/signup-page"
|
||||
|
||||
export default function SignUpPageRoute() {
|
||||
return <SignUpPage />
|
||||
}
|
||||
5
src/app/templates/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { TemplatesPage } from "@/components/templates/template-page"
|
||||
|
||||
export default function TemplatesPageRoute() {
|
||||
return <TemplatesPage />
|
||||
}
|
||||
10
src/app/verify-email/page.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import EmailVerification from "@/app/auth/emailVerification";
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<EmailVerification />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
132
src/components/DynamicSvg.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
|
||||
type DynamicSvgProps = {
|
||||
svg?: string | null
|
||||
// If provided, component will fetch the SVG string from this URL
|
||||
src?: string
|
||||
// Width/height can be CSS lengths or numbers (treated as px)
|
||||
width?: number | string
|
||||
height?: number | string
|
||||
className?: string
|
||||
// Optional role/title for accessibility
|
||||
role?: string
|
||||
title?: string
|
||||
// If true, preserves viewBox scaling inside a wrapper
|
||||
preserveAspectRatio?: boolean
|
||||
}
|
||||
|
||||
function normalizeSize(value?: number | string): string | undefined {
|
||||
if (value === undefined) return undefined
|
||||
return typeof value === "number" ? `${value}px` : value
|
||||
}
|
||||
|
||||
// Minimal sanitizer: strips script/style tags and event handlers
|
||||
function sanitizeSvg(svg: string): string {
|
||||
const withoutScripts = svg.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "")
|
||||
const withoutStyles = withoutScripts.replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, "")
|
||||
// Remove on* attributes (onload, onclick, etc.)
|
||||
const withoutEvents = withoutStyles.replace(/\son[a-z]+\s*=\s*"[^"]*"/gi, "")
|
||||
// Remove javascript: URLs
|
||||
const withoutJsUrls = withoutEvents.replace(/(href|xlink:href|src)\s*=\s*"javascript:[^"]*"/gi, "$1=\"\"")
|
||||
return withoutJsUrls
|
||||
}
|
||||
|
||||
export function DynamicSvg({
|
||||
svg,
|
||||
src,
|
||||
width,
|
||||
height,
|
||||
className,
|
||||
role = "img",
|
||||
title,
|
||||
preserveAspectRatio = true,
|
||||
}: DynamicSvgProps) {
|
||||
const [content, setContent] = React.useState<string | null>(svg ?? null)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const w = normalizeSize(width)
|
||||
const h = normalizeSize(height)
|
||||
|
||||
React.useEffect(() => {
|
||||
setContent(svg ?? null)
|
||||
}, [svg])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!src) return
|
||||
let isCancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const res = await fetch(src, { credentials: "include" })
|
||||
const text = await res.text()
|
||||
if (isCancelled) return
|
||||
// Try to pull out raw SVG if server wraps it in JSON
|
||||
let raw = text
|
||||
if (!text.trim().startsWith("<svg")) {
|
||||
try {
|
||||
const json = JSON.parse(text)
|
||||
raw = (json?.svg || json?.data?.svg || "").toString()
|
||||
} catch {
|
||||
// fallback to original text
|
||||
}
|
||||
}
|
||||
if (!raw || !raw.includes("<svg")) {
|
||||
throw new Error("No SVG content in response")
|
||||
}
|
||||
setContent(raw)
|
||||
} catch (e) {
|
||||
if (isCancelled) return
|
||||
setError(e instanceof Error ? e.message : "Failed to load SVG")
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [src])
|
||||
|
||||
if (error) {
|
||||
return <div className={className} role="img" aria-label={title || "SVG error"}>Failed to render SVG: {error}</div>
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return <div className={className} role="img" aria-label={title || "Loading SVG"} />
|
||||
}
|
||||
|
||||
const sanitized = sanitizeSvg(content)
|
||||
|
||||
// Wrap raw SVG in a container to control layout dimensions
|
||||
const style: React.CSSProperties = {
|
||||
width: w,
|
||||
height: h,
|
||||
display: "inline-block",
|
||||
}
|
||||
|
||||
// When preserving aspect ratio, let inner SVG own its viewBox sizing
|
||||
// Otherwise, we force width/height via a wrapper CSS
|
||||
return (
|
||||
<div className={className} style={style} role={role} aria-label={title} title={title}>
|
||||
<div
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: preserveAspectRatio
|
||||
? sanitized
|
||||
: sanitized.replace(
|
||||
/<svg(\s[^>]*)?>/i,
|
||||
(match) =>
|
||||
match.includes("width=") || match.includes("height=")
|
||||
? match
|
||||
: match.replace(
|
||||
/<svg/i,
|
||||
'<svg width="100%" height="100%"'
|
||||
)
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DynamicSvg
|
||||
|
||||
|
||||
619
src/components/admin/admin-dashboard.tsx
Normal file
@ -0,0 +1,619 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Copy,
|
||||
Filter,
|
||||
Search,
|
||||
Edit,
|
||||
Zap,
|
||||
Files,
|
||||
Bell,
|
||||
BarChart3
|
||||
} from 'lucide-react'
|
||||
import { adminApi, formatDate, getStatusColor, getComplexityColor } from '@/lib/api/admin'
|
||||
import { AdminFeature, AdminTemplate, AdminStats } from '@/types/admin.types'
|
||||
import { FeatureReviewDialog } from './feature-review-dialog'
|
||||
import { AdminNotificationsPanel } from './admin-notifications-panel'
|
||||
import { FeatureEditDialog } from './feature-edit-dialog'
|
||||
import { TemplateEditDialog } from './template-edit-dialog'
|
||||
import { RejectDialog } from './reject-dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { AdminNotificationProvider, useAdminNotifications } from '@/contexts/AdminNotificationContext'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
|
||||
function AdminDashboardContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const activeTab = searchParams.get('tab') || 'dashboard'
|
||||
const filterParam = searchParams.get('filter') || 'all'
|
||||
|
||||
const [pendingFeatures, setPendingFeatures] = useState<AdminFeature[]>([])
|
||||
const [customTemplates, setCustomTemplates] = useState<AdminTemplate[]>([])
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [templateStats, setTemplateStats] = useState<AdminStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedFeature, setSelectedFeature] = useState<AdminFeature | null>(null)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<AdminTemplate | null>(null)
|
||||
const [showReviewDialog, setShowReviewDialog] = useState(false)
|
||||
const [showFeatureEditDialog, setShowFeatureEditDialog] = useState(false)
|
||||
const [showTemplateEditDialog, setShowTemplateEditDialog] = useState(false)
|
||||
const [showRejectDialog, setShowRejectDialog] = useState(false)
|
||||
const [rejectItem, setRejectItem] = useState<{ id: string; name: string; type: 'feature' | 'template' } | null>(null)
|
||||
const [showNotifications, setShowNotifications] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>(filterParam)
|
||||
|
||||
const { unreadCount, removeByReference } = useAdminNotifications()
|
||||
|
||||
console.log("pendingFeatures__", pendingFeatures)
|
||||
|
||||
// Load dashboard data
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [featuresResponse, templatesResponse, featureStatsResponse, templateStatsResponse] = await Promise.all([
|
||||
adminApi.getCustomFeatures('pending'),
|
||||
adminApi.getCustomTemplates('pending'),
|
||||
adminApi.getCustomFeatureStats(),
|
||||
adminApi.getTemplateStats()
|
||||
])
|
||||
|
||||
setPendingFeatures(featuresResponse || [])
|
||||
setCustomTemplates(templatesResponse || [])
|
||||
setStats(featureStatsResponse)
|
||||
setTemplateStats(templateStatsResponse)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load dashboard data')
|
||||
console.error('Error loading dashboard data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData()
|
||||
}, [])
|
||||
|
||||
// Handle feature review
|
||||
const handleFeatureReview = async (featureId: string, reviewData: { status: 'approved' | 'rejected' | 'duplicate'; admin_notes?: string; admin_reviewed_by: string }) => {
|
||||
try {
|
||||
await adminApi.reviewFeature(featureId, reviewData)
|
||||
|
||||
// Update the feature in the list
|
||||
setPendingFeatures(prev => prev.map(f =>
|
||||
f.id === featureId ? { ...f, status: reviewData.status as AdminFeature['status'], admin_notes: reviewData.admin_notes } : f
|
||||
))
|
||||
|
||||
// Reload stats
|
||||
const newStats = await adminApi.getFeatureStats()
|
||||
setStats(newStats)
|
||||
|
||||
setShowReviewDialog(false)
|
||||
setSelectedFeature(null)
|
||||
} catch (err) {
|
||||
console.error('Error reviewing feature:', err)
|
||||
// Handle error (show toast notification, etc.)
|
||||
alert('Error reviewing feature')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle template review
|
||||
const handleTemplateReview = async (templateId: string, reviewData: { status: 'pending' | 'approved' | 'rejected' | 'duplicate'; admin_notes?: string }) => {
|
||||
try {
|
||||
await adminApi.reviewTemplate(templateId, reviewData)
|
||||
|
||||
// Update the template in the list
|
||||
setCustomTemplates(prev => prev.map(t =>
|
||||
t.id === templateId ? { ...t, status: reviewData.status as AdminTemplate['status'], admin_notes: reviewData.admin_notes } : t
|
||||
))
|
||||
|
||||
// Reload stats
|
||||
const newStats = await adminApi.getTemplateStats()
|
||||
setTemplateStats(newStats)
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error reviewing template:', err)
|
||||
// Handle error (show toast notification, etc.)
|
||||
alert('Error reviewing template')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle feature update
|
||||
const handleFeatureUpdate = (featureId: string, updatedFeature: AdminFeature) => {
|
||||
setPendingFeatures(prev => prev.map(f =>
|
||||
f.id === featureId ? updatedFeature : f
|
||||
))
|
||||
}
|
||||
|
||||
// Handle template update
|
||||
const handleTemplateUpdate = (templateId: string, updatedTemplate: AdminTemplate) => {
|
||||
// Hide the edited item from the pending list immediately
|
||||
setCustomTemplates(prev => prev.filter(t => t.id !== templateId))
|
||||
}
|
||||
|
||||
// Handle reject action
|
||||
const handleReject = async (adminNotes: string) => {
|
||||
if (!rejectItem) return
|
||||
|
||||
try {
|
||||
if (rejectItem.type === 'feature') {
|
||||
await adminApi.rejectCustomFeature(rejectItem.id, adminNotes)
|
||||
setPendingFeatures(prev => prev.map(f =>
|
||||
f.id === rejectItem.id ? { ...f, status: 'rejected', admin_notes: adminNotes } : f
|
||||
))
|
||||
// Reload feature stats
|
||||
const newStats = await adminApi.getCustomFeatureStats()
|
||||
setStats(newStats)
|
||||
} else {
|
||||
await adminApi.rejectTemplate(rejectItem.id, adminNotes)
|
||||
setCustomTemplates(prev => prev.map(t =>
|
||||
t.id === rejectItem.id ? { ...t, status: 'rejected', admin_notes: adminNotes } : t
|
||||
))
|
||||
// Reload template stats
|
||||
const newStats = await adminApi.getTemplateStats()
|
||||
setTemplateStats(newStats)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error rejecting ${rejectItem.type}:`, err)
|
||||
// Handle error (show toast notification, etc.)
|
||||
alert(`Error rejecting ${rejectItem.type}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle approve action
|
||||
const handleApprove = async (item: { id: string; type: 'feature' | 'template' }) => {
|
||||
// Optimistic UI: remove immediately
|
||||
if (item.type === 'feature') {
|
||||
setPendingFeatures(prev => prev.filter(f => f.id !== item.id))
|
||||
} else {
|
||||
setCustomTemplates(prev => prev.filter(t => t.id !== item.id))
|
||||
}
|
||||
try {
|
||||
if (item.type === 'feature') {
|
||||
// Get the feature details first
|
||||
const feature = pendingFeatures.find(f => f.id === item.id)
|
||||
if (feature) {
|
||||
// Only update the custom feature status to approved
|
||||
// The backend reviewFeature method will handle mirroring to template_features if needed
|
||||
await adminApi.reviewCustomFeature(item.id, { status: 'approved', admin_notes: 'Approved by admin' })
|
||||
|
||||
// already optimistically removed
|
||||
// Remove related notifications for this feature
|
||||
removeByReference('custom_feature', item.id)
|
||||
}
|
||||
|
||||
// Reload feature stats
|
||||
const newStats = await adminApi.getCustomFeatureStats()
|
||||
setStats(newStats)
|
||||
} else {
|
||||
// Get the template details first
|
||||
const template = customTemplates.find(t => t.id === item.id)
|
||||
if (template) {
|
||||
// Create new approved template in main templates table
|
||||
await adminApi.createApprovedTemplate(item.id, {
|
||||
title: template.title || '',
|
||||
description: template.description,
|
||||
category: template.category || '',
|
||||
type: template.type || '',
|
||||
icon: template.icon,
|
||||
gradient: template.gradient,
|
||||
border: template.border,
|
||||
text: template.text,
|
||||
subtext: template.subtext
|
||||
})
|
||||
|
||||
// Update the custom template status to approved
|
||||
await adminApi.reviewTemplate(item.id, { status: 'approved', admin_notes: 'Approved and created in main templates' })
|
||||
|
||||
// already optimistically removed
|
||||
// Remove related notifications for this template
|
||||
removeByReference('custom_template', item.id)
|
||||
}
|
||||
|
||||
// Reload template stats
|
||||
const newStats = await adminApi.getTemplateStats()
|
||||
setTemplateStats(newStats)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error approving ${item.type}:`, err)
|
||||
// Handle error (show toast notification, etc.)
|
||||
// Recover UI by reloading if optimistic removal was wrong
|
||||
await loadDashboardData()
|
||||
}
|
||||
}
|
||||
|
||||
// Filter features based on search and status
|
||||
const filteredFeatures = pendingFeatures.filter(feature => {
|
||||
const matchesSearch = feature.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
feature.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
feature.template_title?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || feature.status === statusFilter
|
||||
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
// Get status counts for features
|
||||
const getFeatureStatusCount = (status: string) => {
|
||||
return stats?.features?.find(s => s.status === status)?.count || 0
|
||||
}
|
||||
|
||||
// Get status counts for templates
|
||||
const getTemplateStatusCount = (status: string) => {
|
||||
return (templateStats as AdminStats & { templates?: Array<{ status: string; count: number }> })?.templates?.find((s) => s.status === status)?.count || 0
|
||||
}
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
<span>Loading admin dashboard...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2 text-red-600">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
<span>Error loading dashboard</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-600">{error}</p>
|
||||
<Button onClick={loadDashboardData} className="mt-4">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderDashboardOverview = () => (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Admin Dashboard</h1>
|
||||
<p className="text-white/70">Manage feature approvals and system notifications</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button onClick={loadDashboardData} className="bg-orange-500 text-black hover:bg-orange-600">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Features Stats */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-white">Custom Features</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-yellow-600" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Pending</p>
|
||||
<p className="text-xl font-bold text-white">{getFeatureStatusCount('pending')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Approved</p>
|
||||
<p className="text-xl font-bold text-white">{getFeatureStatusCount('approved')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Rejected</p>
|
||||
<p className="text-xl font-bold text-white">{getFeatureStatusCount('rejected')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Copy className="h-4 w-4 text-orange-500" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Duplicates</p>
|
||||
<p className="text-xl font-bold text-white">{getFeatureStatusCount('duplicate')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Templates Stats */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-white">Custom Templates</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-yellow-600" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Pending</p>
|
||||
<p className="text-xl font-bold text-white">{getTemplateStatusCount('pending')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Approved</p>
|
||||
<p className="text-xl font-bold text-white">{getTemplateStatusCount('approved')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Rejected</p>
|
||||
<p className="text-xl font-bold text-white">{getTemplateStatusCount('rejected')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Copy className="h-4 w-4 text-orange-500" />
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Duplicates</p>
|
||||
<p className="text-xl font-bold text-white">{getTemplateStatusCount('duplicate')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-white">Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-20 flex flex-col items-center justify-center space-y-2 border-white/20 text-white hover:bg-white/10"
|
||||
onClick={() => window.location.href = '/admin?tab=features'}
|
||||
>
|
||||
<Zap className="h-6 w-6" />
|
||||
<span className="text-sm">Review Features</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-20 flex flex-col items-center justify-center space-y-2 border-white/20 text-white hover:bg-white/10"
|
||||
onClick={() => window.location.href = '/admin?tab=templates'}
|
||||
>
|
||||
<Files className="h-6 w-6" />
|
||||
<span className="text-sm">Review Templates</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-20 flex flex-col items-center justify-center space-y-2 border-white/20 text-white hover:bg-white/10"
|
||||
onClick={() => setShowNotifications(true)}
|
||||
>
|
||||
<Bell className="h-6 w-6" />
|
||||
<span className="text-sm">Notifications</span>
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="bg-red-500 text-white text-xs">{unreadCount}</Badge>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-20 flex flex-col items-center justify-center space-y-2 border-white/20 text-white hover:bg-white/10"
|
||||
>
|
||||
<BarChart3 className="h-6 w-6" />
|
||||
<span className="text-sm">Analytics</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{activeTab === 'dashboard' && renderDashboardOverview()}
|
||||
|
||||
{(activeTab === 'features' || activeTab === 'templates') && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
{activeTab === 'features' ? 'Custom Features' : 'Custom Templates'}
|
||||
</h1>
|
||||
<p className="text-white/70">
|
||||
{activeTab === 'features'
|
||||
? 'Review and manage custom feature requests'
|
||||
: 'Review and manage custom template submissions'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button onClick={loadDashboardData} className="bg-orange-500 text-black hover:bg-orange-600">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Main Content - Only show for features/templates tabs */}
|
||||
{activeTab === 'features' && (
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search features..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
<SelectItem value="duplicate">Duplicate</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Features List */}
|
||||
<div className="space-y-4">
|
||||
{filteredFeatures.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-gray-500">
|
||||
<Clock className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>No features found</p>
|
||||
<p className="text-sm">All features have been reviewed or no features match your filters.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredFeatures.map((feature) => (
|
||||
<Card key={feature.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h3 className="text-lg font-semibold">{feature.name}</h3>
|
||||
<Badge className={getStatusColor(feature.status)}>
|
||||
{feature.status}
|
||||
</Badge>
|
||||
<Badge className={getComplexityColor(feature.complexity)}>
|
||||
{feature.complexity}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{feature.description && (
|
||||
<p className="text-gray-600 mb-2">{feature.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>Template Type: {feature.template_type || 'Unknown'}</span>
|
||||
<span>Submitted: {formatDate(feature.created_at)}</span>
|
||||
{feature.similarity_score && (
|
||||
<span>Similarity: {(feature.similarity_score * 100).toFixed(1)}%</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{feature.admin_notes && (
|
||||
<div className="mt-2 p-2 bg-gray-50 rounded">
|
||||
<p className="text-sm text-gray-600">
|
||||
<strong>Admin Notes:</strong> {feature.admin_notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleApprove({ id: feature.id, type: 'feature' })}
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRejectItem({ id: feature.id, name: feature.name, type: 'feature' })
|
||||
setShowRejectDialog(true)
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedFeature(feature)
|
||||
setShowFeatureEditDialog(true)
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feature Edit Dialog */}
|
||||
{selectedFeature && (
|
||||
<FeatureEditDialog
|
||||
feature={selectedFeature}
|
||||
open={showFeatureEditDialog}
|
||||
onOpenChange={setShowFeatureEditDialog}
|
||||
onUpdate={handleFeatureUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reject Dialog */}
|
||||
{rejectItem && (
|
||||
<RejectDialog
|
||||
open={showRejectDialog}
|
||||
onOpenChange={setShowRejectDialog}
|
||||
onReject={handleReject}
|
||||
title={`Reject ${rejectItem.type === 'feature' ? 'Feature' : 'Template'}`}
|
||||
itemName={rejectItem.name}
|
||||
itemType={rejectItem.type}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminDashboard() {
|
||||
return (
|
||||
<AdminNotificationProvider>
|
||||
<AdminDashboardContent />
|
||||
</AdminNotificationProvider>
|
||||
)
|
||||
}
|
||||
434
src/components/admin/admin-feature-selection.tsx
Normal file
@ -0,0 +1,434 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { ArrowLeft, Plus, Edit, Trash2 } from 'lucide-react'
|
||||
import { DatabaseTemplate, TemplateFeature } from '@/lib/template-service'
|
||||
import { AICustomFeatureCreator } from '@/components/ai/AICustomFeatureCreator'
|
||||
import { getApiUrl } from '@/config/backend'
|
||||
import { getAccessToken } from '@/components/apis/authApiClients'
|
||||
|
||||
interface AdminFeatureSelectionProps {
|
||||
template: {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
type: string
|
||||
category?: string
|
||||
icon?: string
|
||||
gradient?: string
|
||||
border?: string
|
||||
text?: string
|
||||
subtext?: string
|
||||
}
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function AdminFeatureSelection({ template, onBack }: AdminFeatureSelectionProps) {
|
||||
// Admin template service functions using admin API endpoints
|
||||
const fetchFeatures = async (templateId: string): Promise<TemplateFeature[]> => {
|
||||
const token = getAccessToken()
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(getApiUrl(`api/admin/templates/${templateId}/features`), {
|
||||
headers
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to fetch features')
|
||||
const data = await response.json()
|
||||
console.log('🔍 Raw API response for features:', data)
|
||||
// Handle different response structures
|
||||
const features = Array.isArray(data) ? data : (data.data || data.features || [])
|
||||
console.log('🔍 Processed features with rules:', features.map((f: any) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
business_rules: f.business_rules,
|
||||
technical_requirements: f.technical_requirements
|
||||
})))
|
||||
return features
|
||||
}
|
||||
|
||||
// The features list already includes business_rules and technical_requirements
|
||||
// No need for separate API call since the data is already available
|
||||
|
||||
const createFeature = async (templateId: string, feature: Partial<TemplateFeature>) => {
|
||||
const token = getAccessToken()
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(getApiUrl('api/features'), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ ...feature, template_id: templateId })
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to create feature')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const updateFeature = async (templateId: string, featureId: string, feature: Partial<TemplateFeature>) => {
|
||||
const token = getAccessToken()
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
console.log('🔄 Updating feature:', { templateId, featureId, feature })
|
||||
console.log('📤 Sending data to backend:', JSON.stringify(feature, null, 2))
|
||||
|
||||
const response = await fetch(getApiUrl(`api/admin/templates/${templateId}/features/${featureId}`), {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(feature)
|
||||
})
|
||||
|
||||
const responseText = await response.text()
|
||||
console.log('📥 Backend response:', responseText)
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('❌ Update failed:', response.status, responseText)
|
||||
throw new Error(`Failed to update feature: ${responseText}`)
|
||||
}
|
||||
|
||||
return JSON.parse(responseText)
|
||||
}
|
||||
|
||||
const deleteFeature = async (templateId: string, featureId: string) => {
|
||||
const token = getAccessToken()
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(getApiUrl(`api/admin/templates/${templateId}/features/${featureId}`), {
|
||||
method: 'DELETE',
|
||||
headers
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to delete feature')
|
||||
}
|
||||
|
||||
const bulkCreateFeatures = async (templateId: string, features: Partial<TemplateFeature>[]) => {
|
||||
const token = getAccessToken()
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(getApiUrl(`api/admin/templates/${templateId}/features/bulk`), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ features })
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to create features')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const [features, setFeatures] = useState<TemplateFeature[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [newFeature, setNewFeature] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
complexity: 'medium' as 'low' | 'medium' | 'high'
|
||||
})
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [showAIModal, setShowAIModal] = useState(false)
|
||||
const [editingFeature, setEditingFeature] = useState<TemplateFeature | null>(null)
|
||||
const [expandedDescriptions, setExpandedDescriptions] = useState<Set<string>>(new Set())
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await fetchFeatures(template.id)
|
||||
// Ensure we always have an array
|
||||
setFeatures(Array.isArray(data) ? data : [])
|
||||
} catch (err) {
|
||||
console.error('Error loading features:', err)
|
||||
const message = err instanceof Error ? err.message : 'Failed to load features'
|
||||
setError(message)
|
||||
setFeatures([]) // Reset to empty array on error
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [template.id])
|
||||
|
||||
const handleAddCustom = async () => {
|
||||
if (!newFeature.name.trim()) return
|
||||
|
||||
// Check if template ID is valid UUID format (database templates use UUIDs)
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
if (!uuidRegex.test(template.id)) {
|
||||
alert(`Cannot add features to demo templates. Template ID: ${template.id} is not a valid UUID. Please select a real template from your database.`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createFeature(template.id, {
|
||||
name: newFeature.name,
|
||||
description: newFeature.description,
|
||||
feature_type: 'essential',
|
||||
complexity: newFeature.complexity,
|
||||
is_default: true,
|
||||
created_by_user: true,
|
||||
})
|
||||
setNewFeature({ name: '', description: '', complexity: 'medium' })
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (created?.id) next.add(created.id)
|
||||
return next
|
||||
})
|
||||
await load()
|
||||
} catch (error) {
|
||||
console.error('Error creating custom feature:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Template ID not found in database'
|
||||
alert(`Failed to create custom feature: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddAIAnalyzed = async (payload: { name: string; description: string; complexity: 'low' | 'medium' | 'high'; logic_rules?: string[]; requirements?: Array<{ text: string; rules: string[] }>; business_rules?: Array<{ requirement: string; rules: string[] }> }) => {
|
||||
// Check if template ID is valid UUID format
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
if (!uuidRegex.test(template.id)) {
|
||||
alert("Cannot add features to demo templates. Please select a real template from your database.")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createFeature(template.id, {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
feature_type: 'essential',
|
||||
complexity: payload.complexity,
|
||||
is_default: true,
|
||||
created_by_user: true,
|
||||
// @ts-expect-error backend accepts additional fields
|
||||
logic_rules: payload.logic_rules,
|
||||
business_rules: payload.business_rules ?? (payload.requirements ? payload.requirements.map(r => ({ requirement: r.text, rules: r.rules || [] })) : undefined),
|
||||
})
|
||||
await load()
|
||||
} catch (error) {
|
||||
console.error('Error creating AI-analyzed feature:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Template ID not found in database'
|
||||
alert(`Failed to create AI-analyzed feature: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (f: TemplateFeature, updates: Partial<TemplateFeature>) => {
|
||||
try {
|
||||
await updateFeature(template.id, f.id, updates)
|
||||
await load()
|
||||
setEditingFeature(null)
|
||||
} catch (err) {
|
||||
console.error('Error updating feature:', err)
|
||||
const message = err instanceof Error ? err.message : 'Failed to update feature'
|
||||
alert(`Error updating feature: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (f: TemplateFeature) => {
|
||||
try {
|
||||
if (!confirm(`Delete feature "${f.name}"? This cannot be undone.`)) return
|
||||
await deleteFeature(template.id, f.id)
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(f.id)
|
||||
return next
|
||||
})
|
||||
await load()
|
||||
} catch (err) {
|
||||
console.error('Error deleting feature:', err)
|
||||
const message = err instanceof Error ? err.message : 'Failed to delete feature'
|
||||
alert(`Error deleting feature: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (f: TemplateFeature) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(f.id)) next.delete(f.id)
|
||||
else next.add(f.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleDescription = (featureId: string) => {
|
||||
setExpandedDescriptions((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(featureId)) next.delete(featureId)
|
||||
else next.add(featureId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const TruncatedDescription = ({ feature }: { feature: TemplateFeature }) => {
|
||||
const isExpanded = expandedDescriptions.has(feature.id)
|
||||
const description = feature.description || 'No description provided.'
|
||||
const maxLength = 150
|
||||
const shouldTruncate = description.length > maxLength
|
||||
|
||||
if (!shouldTruncate) {
|
||||
return <p>{description}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
{isExpanded ? description : `${description.substring(0, maxLength)}...`}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleDescription(feature.id)}
|
||||
className="p-0 h-auto text-orange-400 hover:text-orange-300 hover:bg-transparent mt-1"
|
||||
>
|
||||
{isExpanded ? 'Show less' : 'Show more'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-20 text-white/60">Loading features...</div>
|
||||
}
|
||||
if (error) {
|
||||
return <div className="text-center py-20 text-red-400">{error}</div>
|
||||
}
|
||||
|
||||
// Ensure features is always an array before filtering
|
||||
const safeFeatures = Array.isArray(features) ? features : []
|
||||
const essentialFeatures = safeFeatures.filter(f => f.feature_type === 'essential')
|
||||
const suggestedFeatures = safeFeatures.filter(f => f.feature_type === 'suggested')
|
||||
const customFeatures = safeFeatures.filter(f => f.feature_type === 'custom')
|
||||
|
||||
const section = (title: string, list: TemplateFeature[]) => (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-3">{title} ({list.length})</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{list.map((f) => (
|
||||
<Card key={f.id} className={`bg-white/5 ${selectedIds.has(f.id) ? 'border-orange-400' : 'border-white/10'}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-white flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(f.id)}
|
||||
onCheckedChange={() => toggleSelect(f)}
|
||||
className="border-white/20 data-[state=checked]:bg-orange-500 data-[state=checked]:border-orange-500"
|
||||
/>
|
||||
<span>{f.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-blue-500 text-blue-300 hover:bg-blue-500/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
console.log('🔍 Feature being edited:', f);
|
||||
console.log('🔍 Business rules:', f.business_rules);
|
||||
console.log('🔍 Technical requirements:', f.technical_requirements);
|
||||
setEditingFeature(f);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500 text-red-300 hover:bg-red-500/10"
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(f) }}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-white/80 text-sm space-y-2">
|
||||
<TruncatedDescription feature={f} />
|
||||
<div className="flex gap-2 text-xs">
|
||||
<Badge variant="outline" className="bg-white/5 border-white/10">{f.feature_type}</Badge>
|
||||
<Badge variant="outline" className="bg-white/5 border-white/10">{f.complexity}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-bold text-white">Select Features for {template.title}</h1>
|
||||
<p className="text-xl text-white/60 max-w-3xl mx-auto">add essential features.</p>
|
||||
</div>
|
||||
|
||||
{section('Essential Features', essentialFeatures)}
|
||||
{/* {section('Suggested Features', suggestedFeatures)} */}
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4 space-y-3">
|
||||
<h3 className="text-white font-semibold">Add Essential Feature</h3>
|
||||
<p className="text-white/60 text-sm">Use AI to analyze and create essential features for your project.</p>
|
||||
<div className="text-center">
|
||||
<Button variant="outline" onClick={() => setShowAIModal(true)} className="border-orange-500 text-orange-400 hover:bg-orange-500/10 cursor-pointer">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Analyze with AI
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-white/60 text-xs text-center">AI will analyze your requirements and create essential features</p>
|
||||
</div>
|
||||
|
||||
{section('Your Custom Features', customFeatures)}
|
||||
|
||||
{/* Edit Feature Modal reusing AI-Powered Feature Creator */}
|
||||
{editingFeature && (
|
||||
<AICustomFeatureCreator
|
||||
projectType={template.type || template.title}
|
||||
editingFeature={editingFeature as any}
|
||||
onAdd={async (payload) => {
|
||||
console.log('🔍 Payload from AICustomFeatureCreator:', payload);
|
||||
const updateData = {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
complexity: payload.complexity,
|
||||
// Map AI form structure to backend fields
|
||||
business_rules: payload.business_rules,
|
||||
technical_requirements: payload.logic_rules,
|
||||
// Also send logic_rules for backward compatibility
|
||||
logic_rules: payload.logic_rules,
|
||||
};
|
||||
console.log('🔍 Update data being passed to handleUpdate:', updateData);
|
||||
await handleUpdate(editingFeature, updateData)
|
||||
setEditingFeature(null)
|
||||
}}
|
||||
onClose={() => setEditingFeature(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAIModal && (
|
||||
<AICustomFeatureCreator
|
||||
projectType={template.type || template.title}
|
||||
onAdd={async (f) => { await handleAddAIAnalyzed(f); setShowAIModal(false) }}
|
||||
onClose={() => setShowAIModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-center py-4">
|
||||
<div className="space-x-4">
|
||||
<Button variant="outline" onClick={onBack} className="border-white/20 text-white hover:bg-white/10 cursor-pointer">Back</Button>
|
||||
</div>
|
||||
<div className="text-white/60 text-sm mt-2">Selected: {selectedIds.size} | Essential: {essentialFeatures.length} | Suggested: {suggestedFeatures.length} | Custom: {customFeatures.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
176
src/components/admin/admin-layout.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { AdminSidebar } from './admin-sidebar'
|
||||
import { AdminTemplatesManager } from './admin-templates-manager'
|
||||
import { AdminDashboard } from './admin-dashboard'
|
||||
import { TemplateFeaturesManager } from './template-features-manager'
|
||||
import { AdminNotificationsPanel } from './admin-notifications-panel'
|
||||
|
||||
type AdminView =
|
||||
| 'dashboard'
|
||||
| 'templates'
|
||||
| 'features'
|
||||
| 'ai-features'
|
||||
| 'users'
|
||||
| 'notifications'
|
||||
| 'analytics'
|
||||
| 'settings'
|
||||
| 'create-template'
|
||||
| 'add-feature'
|
||||
| 'ai-analyze'
|
||||
|
||||
export function AdminLayout() {
|
||||
const [currentView, setCurrentView] = useState<AdminView>('dashboard')
|
||||
const [featuresDialogOpen, setFeaturesDialogOpen] = useState(false)
|
||||
|
||||
const renderMainContent = () => {
|
||||
switch (currentView) {
|
||||
case 'dashboard':
|
||||
return <AdminDashboard />
|
||||
|
||||
case 'templates':
|
||||
case 'create-template':
|
||||
return <AdminTemplatesManager />
|
||||
|
||||
case 'features':
|
||||
case 'add-feature':
|
||||
const defaultTemplate = {
|
||||
id: "default",
|
||||
type: "default",
|
||||
title: "Default Template",
|
||||
description: "Default template for feature management",
|
||||
complexity: "medium" as const,
|
||||
approved: true,
|
||||
usage_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
is_custom: false,
|
||||
status: "approved" as const
|
||||
}
|
||||
return (
|
||||
<TemplateFeaturesManager
|
||||
template={defaultTemplate}
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCurrentView('dashboard')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'ai-features':
|
||||
case 'ai-analyze':
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-bold text-white">AI Feature Analysis</h1>
|
||||
<p className="text-xl text-white/60">AI-powered feature creation and optimization</p>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-8 text-center">
|
||||
<div className="text-6xl mb-4">🤖</div>
|
||||
<h3 className="text-2xl font-bold text-white mb-2">AI Feature Creator</h3>
|
||||
<p className="text-white/60 mb-6">Analyze project requirements and generate optimized features</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white/5 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-white mb-2">Smart Analysis</h4>
|
||||
<p className="text-white/60 text-sm">AI analyzes your project type and suggests relevant features</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-white mb-2">Essential Features</h4>
|
||||
<p className="text-white/60 text-sm">Creates essential features that integrate with your template</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-white mb-2">Auto-Optimization</h4>
|
||||
<p className="text-white/60 text-sm">Optimizes complexity and requirements automatically</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'users':
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-bold text-white">User Management</h1>
|
||||
<p className="text-xl text-white/60">Manage user accounts and permissions</p>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-8 text-center">
|
||||
<div className="text-6xl mb-4">👥</div>
|
||||
<h3 className="text-2xl font-bold text-white mb-2">User Administration</h3>
|
||||
<p className="text-white/60">User management features coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'notifications':
|
||||
return <AdminNotificationsPanel open={true} onOpenChange={() => {}} />
|
||||
|
||||
case 'analytics':
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-bold text-white">Analytics Dashboard</h1>
|
||||
<p className="text-xl text-white/60">Usage statistics and performance metrics</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Total Templates</h3>
|
||||
<div className="text-3xl font-bold text-orange-400">24</div>
|
||||
<p className="text-white/60 text-sm">+3 this week</p>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Active Users</h3>
|
||||
<div className="text-3xl font-bold text-emerald-400">156</div>
|
||||
<p className="text-white/60 text-sm">+12 this week</p>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Features Created</h3>
|
||||
<div className="text-3xl font-bold text-blue-400">89</div>
|
||||
<p className="text-white/60 text-sm">+7 this week</p>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">AI Analyses</h3>
|
||||
<div className="text-3xl font-bold text-purple-400">42</div>
|
||||
<p className="text-white/60 text-sm">+8 this week</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'settings':
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-bold text-white">System Settings</h1>
|
||||
<p className="text-xl text-white/60">Configure system preferences and options</p>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-8 text-center">
|
||||
<div className="text-6xl mb-4">⚙️</div>
|
||||
<h3 className="text-2xl font-bold text-white mb-2">Configuration Panel</h3>
|
||||
<p className="text-white/60">System settings panel coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return <AdminDashboard />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex">
|
||||
<AdminSidebar
|
||||
currentView={currentView}
|
||||
onViewChange={(view) => setCurrentView(view as AdminView)}
|
||||
/>
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="p-8">
|
||||
{renderMainContent()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
259
src/components/admin/admin-notifications-panel.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Bell,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Check,
|
||||
Trash2,
|
||||
Wifi,
|
||||
WifiOff
|
||||
} from 'lucide-react'
|
||||
import { formatDate } from '@/lib/api/admin'
|
||||
import { useAdminNotifications } from '@/contexts/AdminNotificationContext'
|
||||
|
||||
interface AdminNotificationsPanelProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function AdminNotificationsPanel({
|
||||
open,
|
||||
onOpenChange
|
||||
}: AdminNotificationsPanelProps) {
|
||||
const [markingAsRead, setMarkingAsRead] = useState<string | null>(null)
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isConnected,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
clearAll
|
||||
} = useAdminNotifications()
|
||||
|
||||
const handleMarkAsRead = async (id: string) => {
|
||||
try {
|
||||
setMarkingAsRead(id)
|
||||
await markAsRead(id)
|
||||
} catch (error) {
|
||||
console.error('Error marking notification as read:', error)
|
||||
} finally {
|
||||
setMarkingAsRead(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
try {
|
||||
await markAllAsRead()
|
||||
} catch (error) {
|
||||
console.error('Error marking all notifications as read:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearAll = async () => {
|
||||
try {
|
||||
await clearAll()
|
||||
} catch (error) {
|
||||
console.error('Error clearing all notifications:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'new_feature':
|
||||
return <Clock className="h-5 w-5 text-blue-600" />
|
||||
case 'feature_reviewed':
|
||||
return <CheckCircle className="h-5 w-5 text-green-600" />
|
||||
default:
|
||||
return <Bell className="h-5 w-5 text-gray-600" />
|
||||
}
|
||||
}
|
||||
|
||||
const getNotificationColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'new_feature':
|
||||
return 'border-l-blue-500'
|
||||
case 'feature_reviewed':
|
||||
return 'border-l-green-500'
|
||||
default:
|
||||
return 'border-l-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
const unreadNotifications = notifications.filter(n => !n.is_read)
|
||||
const readNotifications = notifications.filter(n => n.is_read)
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-96 sm:w-[540px] overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center space-x-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
<span>Admin Notifications</span>
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="bg-red-500 text-white">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center ml-auto">
|
||||
{isConnected ? (
|
||||
<div className="flex items-center text-green-600">
|
||||
<Wifi className="h-4 w-4 mr-1" />
|
||||
<span className="text-xs">Live</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-red-600">
|
||||
<WifiOff className="h-4 w-4 mr-1" />
|
||||
<span className="text-xs">Offline</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
System notifications and feature review updates • Real-time updates {isConnected ? 'enabled' : 'disabled'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-4 mt-6">
|
||||
{/* Unread Notifications */}
|
||||
{unreadNotifications.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-medium text-sm text-gray-600 mb-2">
|
||||
Unread ({unreadNotifications.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{unreadNotifications.map((notification) => (
|
||||
<Card
|
||||
key={notification.id}
|
||||
className={`border-l-4 ${getNotificationColor(notification.type)}`}
|
||||
>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
{getNotificationIcon(notification.type)}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{notification.message}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{formatDate(notification.created_at)}
|
||||
</p>
|
||||
{notification.reference_type && (
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
{notification.reference_type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleMarkAsRead(notification.id)}
|
||||
disabled={markingAsRead === notification.id}
|
||||
className="ml-2"
|
||||
>
|
||||
{markingAsRead === notification.id ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900"></div>
|
||||
) : (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Read Notifications */}
|
||||
{readNotifications.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-medium text-sm text-gray-600 mb-2">
|
||||
Read ({readNotifications.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{readNotifications.map((notification) => (
|
||||
<Card
|
||||
key={notification.id}
|
||||
className={`border-l-4 ${getNotificationColor(notification.type)} opacity-75`}
|
||||
>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
{getNotificationIcon(notification.type)}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm">{notification.message}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{formatDate(notification.created_at)}
|
||||
{notification.read_at && (
|
||||
<span className="ml-2">
|
||||
• Read {formatDate(notification.read_at)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{notification.reference_type && (
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
{notification.reference_type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{notifications.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<Bell className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500">No notifications</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
You're all caught up! New notifications will appear here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
{unreadNotifications.length} unread, {readNotifications.length} read
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleMarkAllAsRead}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Mark All Read
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
className="ml-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
246
src/components/admin/admin-sidebar.tsx
Normal file
@ -0,0 +1,246 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
Settings,
|
||||
Users,
|
||||
Bell,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Home,
|
||||
Database,
|
||||
Layers,
|
||||
Zap,
|
||||
BarChart3,
|
||||
Globe,
|
||||
Code
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AdminSidebarProps {
|
||||
currentView: string
|
||||
onViewChange: (view: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AdminSidebar({ currentView, onViewChange, className }: AdminSidebarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
icon: LayoutDashboard,
|
||||
description: 'Overview & Analytics',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
label: 'Templates',
|
||||
icon: FileText,
|
||||
description: 'Manage Templates',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
id: 'features',
|
||||
label: 'Features',
|
||||
icon: Layers,
|
||||
description: 'Feature Management',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
id: 'ai-features',
|
||||
label: 'AI Features',
|
||||
icon: Zap,
|
||||
description: 'AI-Powered Creation',
|
||||
badge: 'NEW'
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: 'Users',
|
||||
icon: Users,
|
||||
description: 'User Management',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
label: 'Notifications',
|
||||
icon: Bell,
|
||||
description: 'System Alerts',
|
||||
badge: '3'
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
label: 'Analytics',
|
||||
icon: BarChart3,
|
||||
description: 'Usage Statistics',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
icon: Settings,
|
||||
description: 'System Configuration',
|
||||
badge: null
|
||||
}
|
||||
]
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
id: 'create-template',
|
||||
label: 'New Template',
|
||||
icon: Plus,
|
||||
action: () => onViewChange('create-template')
|
||||
},
|
||||
{
|
||||
id: 'add-feature',
|
||||
label: 'Add Feature',
|
||||
icon: Layers,
|
||||
action: () => onViewChange('add-feature')
|
||||
},
|
||||
{
|
||||
id: 'ai-analyze',
|
||||
label: 'AI Analyze',
|
||||
icon: Zap,
|
||||
action: () => onViewChange('ai-analyze')
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"bg-gray-900/50 border-r border-white/10 flex flex-col transition-all duration-300",
|
||||
isCollapsed ? "w-16" : "w-64",
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
{!isCollapsed && (
|
||||
<div>
|
||||
<h2 className="text-white font-semibold text-lg">Admin Panel</h2>
|
||||
<p className="text-white/60 text-sm">CodeNuk Management</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-2">
|
||||
{!isCollapsed && (
|
||||
<div className="text-white/40 text-xs font-medium uppercase tracking-wider px-3 py-2">
|
||||
Navigation
|
||||
</div>
|
||||
)}
|
||||
<nav className="space-y-1">
|
||||
{navigationItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = currentView === item.id
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="ghost"
|
||||
onClick={() => onViewChange(item.id)}
|
||||
className={cn(
|
||||
"w-full justify-start text-left transition-colors",
|
||||
isActive
|
||||
? "bg-orange-500/20 text-orange-400 border-r-2 border-orange-400"
|
||||
: "text-white/70 hover:text-white hover:bg-white/10",
|
||||
isCollapsed ? "px-2" : "px-3"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-4 w-4", isCollapsed ? "" : "mr-3")} />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{item.label}</div>
|
||||
<div className="text-xs text-white/50">{item.description}</div>
|
||||
</div>
|
||||
{item.badge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
item.badge === 'NEW'
|
||||
? "bg-emerald-500/20 text-emerald-300 border-emerald-500/30"
|
||||
: "bg-orange-500/20 text-orange-300 border-orange-500/30"
|
||||
)}
|
||||
>
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{!isCollapsed && (
|
||||
<div className="p-2 border-t border-white/10 mt-4">
|
||||
<div className="text-white/40 text-xs font-medium uppercase tracking-wider px-3 py-2">
|
||||
Quick Actions
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{quickActions.map((action) => {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={action.action}
|
||||
className="w-full justify-start border-white/20 text-white/70 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<Icon className="h-4 w-4 mr-2" />
|
||||
{action.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-white/10">
|
||||
{!isCollapsed ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-white/60">System Status</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-emerald-400 rounded-full"></div>
|
||||
<span className="text-emerald-400 text-xs">Online</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-white/40">
|
||||
Backend: Connected • DB: Healthy
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-2 h-2 bg-emerald-400 rounded-full"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
953
src/components/admin/admin-templates-list.tsx
Normal file
@ -0,0 +1,953 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Tooltip } from '@/components/ui/tooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import {
|
||||
Search,
|
||||
Settings,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Zap,
|
||||
Globe,
|
||||
BarChart3,
|
||||
Code,
|
||||
ShoppingCart,
|
||||
Briefcase,
|
||||
GraduationCap,
|
||||
Plus,
|
||||
Save,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react'
|
||||
import { adminApi, formatDate, getComplexityColor } from '@/lib/api/admin'
|
||||
import { BACKEND_URL } from '@/config/backend'
|
||||
import { AdminTemplate, AdminStats } from '@/types/admin.types'
|
||||
import { AdminFeatureSelection } from './admin-feature-selection'
|
||||
|
||||
interface AdminTemplatesListProps {
|
||||
onTemplateSelect?: (template: AdminTemplate) => void
|
||||
}
|
||||
|
||||
export function AdminTemplatesList({ onTemplateSelect }: AdminTemplatesListProps) {
|
||||
const [templates, setTemplates] = useState<AdminTemplate[]>([])
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||||
const [showFeaturesManager, setShowFeaturesManager] = useState(false)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<any | null>(null)
|
||||
const [showFeatureSelection, setShowFeatureSelection] = useState(false)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [editingTemplate, setEditingTemplate] = useState<AdminTemplate | null>(null)
|
||||
// Pagination state
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(6)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [totalTemplates, setTotalTemplates] = useState<number | null>(null)
|
||||
|
||||
// Create template form state
|
||||
const [newTemplate, setNewTemplate] = useState({
|
||||
type: '',
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
icon: '',
|
||||
gradient: '',
|
||||
border: '',
|
||||
text: '',
|
||||
subtext: ''
|
||||
})
|
||||
|
||||
const categories = [
|
||||
"Food Delivery",
|
||||
"E-commerce",
|
||||
"SaaS Platform",
|
||||
"Mobile App",
|
||||
"Dashboard",
|
||||
"CRM System",
|
||||
"Learning Platform",
|
||||
"Healthcare",
|
||||
"Real Estate",
|
||||
"Travel",
|
||||
"Entertainment",
|
||||
"Finance",
|
||||
"Social Media",
|
||||
"Marketplace",
|
||||
"Other"
|
||||
]
|
||||
|
||||
// Load templates and stats
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
console.log('Loading admin templates data...')
|
||||
|
||||
const offset = (page - 1) * limit
|
||||
const effectiveCategory = categoryFilter === 'all' ? undefined : categoryFilter
|
||||
const [templatesResponse, statsResponse] = await Promise.all([
|
||||
adminApi.getAdminTemplates(limit, offset, effectiveCategory, searchQuery),
|
||||
adminApi.getAdminTemplateStats()
|
||||
])
|
||||
|
||||
console.log('Admin templates response:', templatesResponse)
|
||||
console.log('Admin template stats response:', statsResponse)
|
||||
|
||||
setTemplates((templatesResponse?.templates) || [])
|
||||
setHasMore(Boolean(templatesResponse?.pagination?.hasMore ?? ((templatesResponse?.templates?.length || 0) === limit)))
|
||||
setTotalTemplates(templatesResponse?.pagination?.total ?? (statsResponse as any)?.total_templates ?? null)
|
||||
setStats(statsResponse)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load admin templates')
|
||||
console.error('Error loading admin templates:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [page, categoryFilter, searchQuery])
|
||||
|
||||
// Handle template selection for features
|
||||
const handleManageFeatures = (template: AdminTemplate) => {
|
||||
// Convert AdminTemplate to Template format for feature management
|
||||
const templateForFeatures = {
|
||||
id: template.id,
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
type: template.type,
|
||||
category: template.category,
|
||||
icon: template.icon,
|
||||
gradient: template.gradient,
|
||||
border: template.border,
|
||||
text: template.text,
|
||||
subtext: template.subtext
|
||||
}
|
||||
setSelectedTemplate(templateForFeatures)
|
||||
setShowFeatureSelection(true)
|
||||
}
|
||||
|
||||
// Handle edit template
|
||||
const handleEditTemplate = (template: AdminTemplate) => {
|
||||
setEditingTemplate(template)
|
||||
setShowEditModal(true)
|
||||
}
|
||||
|
||||
// Handle delete template
|
||||
const handleDeleteTemplate = async (template: AdminTemplate) => {
|
||||
if (!confirm(`Delete template "${template.title}"? This cannot be undone.`)) return
|
||||
try {
|
||||
await adminApi.deleteAdminTemplate(template.id)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error('Delete failed', err)
|
||||
alert('Failed to delete template')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle create template form submission
|
||||
const handleCreateTemplate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// Create template payload
|
||||
const templateData = {
|
||||
...newTemplate,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
// Call API to create template (you'll need to implement this endpoint)
|
||||
const response = await fetch(`${BACKEND_URL}/api/templates`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(templateData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create template')
|
||||
}
|
||||
|
||||
// Reset form and hide it
|
||||
setNewTemplate({
|
||||
type: '',
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
icon: '',
|
||||
gradient: '',
|
||||
border: '',
|
||||
text: '',
|
||||
subtext: ''
|
||||
})
|
||||
setShowCreateModal(false)
|
||||
|
||||
// Reload data to show new template
|
||||
await loadData()
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating template:', error)
|
||||
setError(error instanceof Error ? error.message : 'Failed to create template')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Get category icon
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category.toLowerCase()) {
|
||||
case 'food delivery':
|
||||
return ShoppingCart
|
||||
case 'e-commerce':
|
||||
case 'ecommerce':
|
||||
return ShoppingCart
|
||||
case 'saas platform':
|
||||
return Code
|
||||
case 'mobile app':
|
||||
return Code
|
||||
case 'dashboard':
|
||||
return BarChart3
|
||||
case 'crm system':
|
||||
return Briefcase
|
||||
case 'learning platform':
|
||||
return GraduationCap
|
||||
case 'healthcare':
|
||||
return AlertCircle
|
||||
case 'real estate':
|
||||
return Globe
|
||||
case 'travel':
|
||||
return Globe
|
||||
case 'entertainment':
|
||||
return Zap
|
||||
case 'finance':
|
||||
return BarChart3
|
||||
case 'social media':
|
||||
return Zap
|
||||
case 'marketplace':
|
||||
return ShoppingCart
|
||||
default:
|
||||
return Globe
|
||||
}
|
||||
}
|
||||
|
||||
// Get category stats for filters
|
||||
const getCategoryStats = () => {
|
||||
// Start with "All Templates"
|
||||
const categoryStats = [
|
||||
{ id: 'all', name: 'All Templates', count: templates.length, icon: Globe }
|
||||
]
|
||||
|
||||
// Add categories based on your actual categories array
|
||||
categories.forEach(category => {
|
||||
if (category !== 'Other') { // Skip 'Other' as it will be handled separately
|
||||
categoryStats.push({
|
||||
id: category.toLowerCase().replace(/\s+/g, '-'),
|
||||
name: category,
|
||||
count: 0,
|
||||
icon: getCategoryIcon(category)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Add 'Other' category at the end
|
||||
categoryStats.push({
|
||||
id: 'other',
|
||||
name: 'Other',
|
||||
count: 0,
|
||||
icon: Globe
|
||||
})
|
||||
|
||||
// Count templates by category
|
||||
templates.forEach(template => {
|
||||
const templateCategory = template.category
|
||||
if (templateCategory) {
|
||||
const categoryItem = categoryStats.find(cat =>
|
||||
cat.name.toLowerCase() === templateCategory.toLowerCase() ||
|
||||
cat.id === templateCategory.toLowerCase().replace(/\s+/g, '-')
|
||||
)
|
||||
if (categoryItem) {
|
||||
categoryItem.count++
|
||||
} else {
|
||||
// If category doesn't match any predefined category, add to 'Other'
|
||||
const otherCategory = categoryStats.find(cat => cat.id === 'other')
|
||||
if (otherCategory) {
|
||||
otherCategory.count++
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return categoryStats
|
||||
}
|
||||
|
||||
// Filter templates based on search and category
|
||||
const filteredTemplates = templates.filter(template => {
|
||||
const matchesSearch = !searchQuery ||
|
||||
template.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.type?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
const matchesCategory = categoryFilter === 'all' ||
|
||||
template.category?.toLowerCase() === categoryFilter.toLowerCase() ||
|
||||
template.category?.toLowerCase().replace(/\s+/g, '-') === categoryFilter ||
|
||||
(categoryFilter === 'other' && !categories.some(cat =>
|
||||
cat.toLowerCase() === template.category?.toLowerCase()
|
||||
))
|
||||
|
||||
return matchesSearch && matchesCategory
|
||||
})
|
||||
|
||||
const MAX_TITLE_CHARS = 25
|
||||
const MAX_DESCRIPTION_PREVIEW_CHARS = 90
|
||||
|
||||
const TemplateCard = ({ template }: { template: AdminTemplate }) => {
|
||||
const [descExpanded, setDescExpanded] = useState(false)
|
||||
|
||||
const title = template.title || ''
|
||||
const truncatedTitle = title.length > MAX_TITLE_CHARS ? `${title.slice(0, MAX_TITLE_CHARS - 1)}…` : title
|
||||
|
||||
const fullDesc = template.description || ''
|
||||
const needsClamp = fullDesc.length > MAX_DESCRIPTION_PREVIEW_CHARS
|
||||
const shownDesc = descExpanded || !needsClamp
|
||||
? fullDesc
|
||||
: `${fullDesc.slice(0, MAX_DESCRIPTION_PREVIEW_CHARS)}…`
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="group hover:shadow-md transition-all bg-gray-900 border-gray-800 cursor-pointer"
|
||||
onClick={() => handleManageFeatures(template)}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleManageFeatures(template) } }}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-2 flex-1 min-w-0">
|
||||
<CardTitle className="text-lg text-white group-hover:text-orange-400 transition-colors">
|
||||
<Tooltip content={title}>
|
||||
<span title={undefined} className="block max-w-full overflow-hidden text-ellipsis whitespace-nowrap">{truncatedTitle}</span>
|
||||
</Tooltip>
|
||||
</CardTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs bg-white/5 border-white/10 text-white/80">
|
||||
{template.type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs bg-white/5 border-white/10 text-white/70">
|
||||
{template.category}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleManageFeatures(template)}
|
||||
className="text-green-400 hover:text-green-300 border-green-400/30 hover:border-green-300/50"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-1" />
|
||||
Features
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="border-white/10 text-white/80 hover:bg-white/10">⋮</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-gray-900 border-gray-800 text-white">
|
||||
<DropdownMenuItem onClick={() => handleEditTemplate(template)} className="cursor-pointer">Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDeleteTemplate(template)} className="text-red-400 focus:text-red-400 cursor-pointer">Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
{fullDesc && (
|
||||
<div className="text-white/70 text-sm break-words hyphens-auto">
|
||||
<span>{shownDesc}</span>
|
||||
{needsClamp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setDescExpanded(v => !v) }}
|
||||
className="ml-2 text-orange-400 hover:text-orange-300 underline underline-offset-4"
|
||||
>
|
||||
{descExpanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3 text-white/80">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Zap className="h-4 w-4 text-orange-400" />
|
||||
<span>{(template as any).feature_count || 0} features</span>
|
||||
</div>
|
||||
<div className="text-white/60">
|
||||
{template.created_at && formatDate(template.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{template.gradient && (
|
||||
<div className="text-xs text-white/60">
|
||||
<span className="font-medium">Style:</span> {template.gradient}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-orange-400" />
|
||||
<span className="text-white">Loading admin templates...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Card className="w-full max-w-md bg-gray-900 border-gray-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2 text-red-400">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
<span>Error loading admin templates</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-400">{error}</p>
|
||||
<Button onClick={loadData} className="mt-4 bg-orange-500 text-black hover:bg-orange-600">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show feature selection view if a template is selected
|
||||
if (showFeatureSelection && selectedTemplate) {
|
||||
return (
|
||||
<AdminFeatureSelection
|
||||
template={selectedTemplate}
|
||||
onBack={() => {
|
||||
setShowFeatureSelection(false)
|
||||
setSelectedTemplate(null)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Admin Templates</h1>
|
||||
<p className="text-white/70">Manage features for your templates</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="bg-green-500 text-black hover:bg-green-600"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Template
|
||||
</Button>
|
||||
<Button onClick={loadData} className="bg-orange-500 text-black hover:bg-orange-600">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Total Templates</CardTitle>
|
||||
<Globe className="h-4 w-4 text-white/60" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{totalTemplates ?? (stats as any).total_templates ?? templates.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Categories</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-white/60" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{(stats as any).total_categories || categories.length - 1}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Avg Features</CardTitle>
|
||||
<Zap className="h-4 w-4 text-orange-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{Math.round((stats as any).avg_features_per_template || 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">With Features</CardTitle>
|
||||
<Settings className="h-4 w-4 text-green-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{(stats as any).templates_with_features || 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search templates..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => { setSearchQuery(e.target.value); setPage(1) }}
|
||||
className="pl-10 bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={categoryFilter} onValueChange={(v) => { setCategoryFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-48 bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Filter by category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getCategoryStats().map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<category.icon className="h-4 w-4" />
|
||||
<span>{category.name} ({category.count})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Category Filters removed; using only dropdown above */}
|
||||
|
||||
{/* Create Template Modal */}
|
||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800 text-white max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Create New Template</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleCreateTemplate} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Template Type *</label>
|
||||
<Input
|
||||
placeholder="e.g., multi_restaurant_food_delivery"
|
||||
value={newTemplate.type}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, type: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-white/60">Unique identifier for the template</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Title *</label>
|
||||
<Input
|
||||
placeholder="e.g., Multi-Restaurant Food Delivery App"
|
||||
value={newTemplate.title}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, title: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Description *</label>
|
||||
<textarea
|
||||
placeholder="Describe your template and its key features..."
|
||||
value={newTemplate.description}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 text-white placeholder:text-white/40 rounded-md min-h-[100px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Category *</label>
|
||||
<Select value={newTemplate.category} onValueChange={(value) => setNewTemplate(prev => ({ ...prev, category: value }))}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 border-white/10">
|
||||
{categories.map((category, index) => (
|
||||
<SelectItem key={`category-${index}`} value={category} className="text-white">
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Icon (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., restaurant, shopping-cart, users"
|
||||
value={newTemplate.icon}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, icon: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Gradient (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., from-orange-400 to-red-500"
|
||||
value={newTemplate.gradient}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, gradient: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Border (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., border-orange-500"
|
||||
value={newTemplate.border}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, border: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Text Color (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., text-orange-500"
|
||||
value={newTemplate.text}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, text: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Subtext (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., Perfect for food delivery startups"
|
||||
value={newTemplate.subtext}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, subtext: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="border-white/20 text-white hover:bg-white/10 cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-orange-500 hover:bg-orange-400 text-black font-semibold cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{loading ? "Creating..." : "Create Template"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Template Modal */}
|
||||
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800 text-white max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Edit Template</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editingTemplate && (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget as HTMLFormElement
|
||||
const formData = new FormData(form)
|
||||
const payload = {
|
||||
title: String(formData.get('title') || ''),
|
||||
description: String(formData.get('description') || ''),
|
||||
category: String(formData.get('category') || ''),
|
||||
type: String(formData.get('type') || ''),
|
||||
icon: String(formData.get('icon') || ''),
|
||||
gradient: String(formData.get('gradient') || ''),
|
||||
border: String(formData.get('border') || ''),
|
||||
text: String(formData.get('text') || ''),
|
||||
subtext: String(formData.get('subtext') || ''),
|
||||
}
|
||||
try {
|
||||
await adminApi.updateAdminTemplate(editingTemplate.id, payload)
|
||||
setShowEditModal(false)
|
||||
setEditingTemplate(null)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error('Update failed', err)
|
||||
alert('Failed to update template')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Title *</label>
|
||||
<Input name="title" defaultValue={editingTemplate.title || ''} className="bg-white/5 border-white/10 text-white" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Type *</label>
|
||||
<Input name="type" defaultValue={editingTemplate.type || ''} className="bg-white/5 border-white/10 text-white" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Description *</label>
|
||||
<textarea name="description" defaultValue={editingTemplate.description || ''} className="w-full px-3 py-2 bg-white/5 border border-white/10 text-white rounded-md min-h-[100px]" required />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Category *</label>
|
||||
<Select defaultValue={editingTemplate.category || ''} onValueChange={(v) => {
|
||||
const input = document.querySelector<HTMLInputElement>('input[name=\'category\']')
|
||||
if (input) input.value = v
|
||||
}}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 border-white/10">
|
||||
{categories.map((category, index) => (
|
||||
<SelectItem key={`edit-category-${index}`} value={category} className="text-white">
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="category" defaultValue={editingTemplate.category || ''} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Icon</label>
|
||||
<Input name="icon" defaultValue={editingTemplate.icon || ''} className="bg-white/5 border-white/10 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Gradient</label>
|
||||
<Input name="gradient" defaultValue={editingTemplate.gradient || ''} className="bg-white/5 border-white/10 text-white" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Border</label>
|
||||
<Input name="border" defaultValue={editingTemplate.border || ''} className="bg-white/5 border-white/10 text-white" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Text Color</label>
|
||||
<Input name="text" defaultValue={editingTemplate.text || ''} className="bg-white/5 border-white/10 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Subtext</label>
|
||||
<Input name="subtext" defaultValue={editingTemplate.subtext || ''} className="bg-white/5 border-white/10 text-white" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setShowEditModal(false)} className="border-white/20 text-white">Cancel</Button>
|
||||
<Button type="submit" className="bg-orange-500 text-black hover:bg-orange-600">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Feature Selection View */}
|
||||
{showFeatureSelection && selectedTemplate && (
|
||||
<AdminFeatureSelection
|
||||
template={selectedTemplate}
|
||||
onBack={() => {
|
||||
setShowFeatureSelection(false)
|
||||
setSelectedTemplate(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Features Manager Modal */}
|
||||
{showFeaturesManager && selectedTemplate && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-gray-900 rounded-lg max-w-6xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-white">Manage Features - {selectedTemplate.title}</h2>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowFeaturesManager(false)
|
||||
setSelectedTemplate(null)
|
||||
}}
|
||||
variant="outline"
|
||||
className="text-white border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<AdminFeatureSelection
|
||||
template={selectedTemplate}
|
||||
onBack={() => {
|
||||
setShowFeaturesManager(false)
|
||||
setSelectedTemplate(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Templates Grid */}
|
||||
<div className="space-y-4">
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-gray-400">
|
||||
<Globe className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
||||
<p>No templates found</p>
|
||||
<p className="text-sm">Try adjusting your search or filters.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredTemplates.map((template) => (
|
||||
<TemplateCard key={template.id} template={template} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Pagination Controls */}
|
||||
<div className="flex items-center justify-center pt-4">
|
||||
{(() => {
|
||||
const totalFromStats = (totalTemplates ?? (stats as any)?.total_templates) as number | undefined
|
||||
const totalPages = totalFromStats
|
||||
? Math.max(1, Math.ceil(totalFromStats / limit))
|
||||
: (hasMore ? page + 1 : page)
|
||||
|
||||
const createRange = (current: number, total: number): (number | string)[] => {
|
||||
const range: (number | string)[] = []
|
||||
const siblingCount = 1
|
||||
const firstPage = 1
|
||||
const lastPage = total
|
||||
const startPage = Math.max(firstPage, current - siblingCount)
|
||||
const endPage = Math.min(lastPage, current + siblingCount)
|
||||
|
||||
if (startPage > firstPage + 1) {
|
||||
range.push(firstPage, '...')
|
||||
} else {
|
||||
for (let i = firstPage; i < startPage; i++) range.push(i)
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) range.push(i)
|
||||
|
||||
if (endPage < lastPage - 1) {
|
||||
range.push('...', lastPage)
|
||||
} else {
|
||||
for (let i = endPage + 1; i <= lastPage; i++) range.push(i)
|
||||
}
|
||||
return range
|
||||
}
|
||||
|
||||
const items = createRange(page, totalPages)
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Previous page"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
className="h-9 w-9 p-0 rounded-md bg-black/40 border-white/10 text-white hover:bg-white/10 disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{items.map((it, idx) =>
|
||||
typeof it === 'number' ? (
|
||||
<button
|
||||
key={`pg-${it}-${idx}`}
|
||||
aria-current={it === page ? 'page' : undefined}
|
||||
onClick={() => setPage(it)}
|
||||
className={
|
||||
`h-9 min-w-9 px-3 inline-flex items-center justify-center rounded-md border ${
|
||||
it === page
|
||||
? 'bg-orange-500 text-black font-semibold border-orange-400/60'
|
||||
: 'bg-black/40 text-white border-white/10 hover:bg-white/10'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{it}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
key={`ellipsis-${idx}`}
|
||||
className="h-9 min-w-9 px-3 inline-flex items-center justify-center rounded-md bg-black/30 text-white/70 border border-white/10"
|
||||
>
|
||||
…
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Next page"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
className="h-9 w-9 p-0 rounded-md bg-black/40 border-white/10 text-white hover:bg-white/10 disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
680
src/components/admin/admin-templates-manager.tsx
Normal file
@ -0,0 +1,680 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Copy,
|
||||
Filter,
|
||||
Search,
|
||||
Edit,
|
||||
Plus,
|
||||
Save,
|
||||
Files,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react'
|
||||
import { adminApi, formatDate, getStatusColor, getComplexityColor } from '@/lib/api/admin'
|
||||
import { AdminTemplate, AdminStats } from '@/types/admin.types'
|
||||
import { TemplateEditDialog } from './template-edit-dialog'
|
||||
import { RejectDialog } from './reject-dialog'
|
||||
import { TemplateFeaturesManager } from './template-features-manager'
|
||||
import { AdminTemplatesList } from './admin-templates-list'
|
||||
import { useAdminNotifications } from '@/contexts/AdminNotificationContext'
|
||||
|
||||
export function AdminTemplatesManager() {
|
||||
const [activeTab, setActiveTab] = useState("admin-templates")
|
||||
const [customTemplates, setCustomTemplates] = useState<AdminTemplate[]>([])
|
||||
const [_templateStats, setTemplateStats] = useState<AdminStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<AdminTemplate | null>(null)
|
||||
const [showTemplateEditDialog, setShowTemplateEditDialog] = useState(false)
|
||||
const [showRejectDialog, setShowRejectDialog] = useState(false)
|
||||
const [rejectItem, setRejectItem] = useState<{ id: string; name: string; type: 'template' } | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [showFeaturesManager, setShowFeaturesManager] = useState(false)
|
||||
const [selectedTemplateForFeatures, setSelectedTemplateForFeatures] = useState<AdminTemplate | null>(null)
|
||||
// Pagination state
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(6)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
|
||||
// Create template form state
|
||||
const [newTemplate, setNewTemplate] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
type: '',
|
||||
complexity: 'low',
|
||||
icon: '',
|
||||
gradient: '',
|
||||
border: '',
|
||||
text: '',
|
||||
subtext: ''
|
||||
})
|
||||
|
||||
const { removeByReference } = useAdminNotifications()
|
||||
|
||||
// Load templates data
|
||||
const loadTemplatesData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
console.log('Loading templates data...')
|
||||
|
||||
const offset = (page - 1) * limit
|
||||
const effectiveStatus = statusFilter === 'all' ? undefined : statusFilter
|
||||
const [templatesResponse, templateStatsResponse] = await Promise.all([
|
||||
adminApi.getCustomTemplates(effectiveStatus, limit, offset),
|
||||
adminApi.getTemplateStats()
|
||||
])
|
||||
|
||||
console.log('Templates response:', templatesResponse)
|
||||
console.log('Template stats response:', templateStatsResponse)
|
||||
|
||||
setCustomTemplates(templatesResponse || [])
|
||||
setHasMore((templatesResponse || []).length === limit)
|
||||
setTemplateStats(templateStatsResponse)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load templates data')
|
||||
console.error('Error loading templates data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplatesData()
|
||||
}, [page, statusFilter])
|
||||
|
||||
// Handle template review
|
||||
const handleTemplateReview = async (templateId: string, reviewData: { status: 'pending' | 'approved' | 'rejected' | 'duplicate'; admin_notes?: string }) => {
|
||||
try {
|
||||
await adminApi.reviewTemplate(templateId, reviewData)
|
||||
|
||||
// Update the template in the list
|
||||
setCustomTemplates(prev => prev.map(t =>
|
||||
t.id === templateId ? { ...t, status: reviewData.status as AdminTemplate['status'], admin_notes: reviewData.admin_notes } : t
|
||||
))
|
||||
|
||||
// Reload stats
|
||||
const newStats = await adminApi.getTemplateStats()
|
||||
setTemplateStats(newStats)
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error reviewing template:', err)
|
||||
alert('Error reviewing template')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle template update
|
||||
const handleTemplateUpdate = (templateId: string, updatedTemplate: AdminTemplate) => {
|
||||
setCustomTemplates(prev => prev.filter(t => t.id !== templateId))
|
||||
}
|
||||
|
||||
// Handle reject action
|
||||
const handleReject = async (adminNotes: string) => {
|
||||
if (!rejectItem) return
|
||||
|
||||
try {
|
||||
await adminApi.rejectTemplate(rejectItem.id, adminNotes)
|
||||
setCustomTemplates(prev => prev.map(t =>
|
||||
t.id === rejectItem.id ? { ...t, status: 'rejected', admin_notes: adminNotes } : t
|
||||
))
|
||||
// Reload template stats
|
||||
const newStats = await adminApi.getTemplateStats()
|
||||
setTemplateStats(newStats)
|
||||
} catch (err) {
|
||||
console.error('Error rejecting template:', err)
|
||||
alert('Error rejecting template')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle approve action
|
||||
const handleApprove = async (templateId: string) => {
|
||||
// Optimistic UI: remove immediately
|
||||
setCustomTemplates(prev => prev.filter(t => t.id !== templateId))
|
||||
|
||||
try {
|
||||
// Get the template details first
|
||||
const template = customTemplates.find(t => t.id === templateId)
|
||||
if (template) {
|
||||
// Create new approved template in main templates table
|
||||
await adminApi.createApprovedTemplate(templateId, {
|
||||
title: template.title || '',
|
||||
description: template.description,
|
||||
category: template.category || '',
|
||||
type: template.type || '',
|
||||
icon: template.icon,
|
||||
gradient: template.gradient,
|
||||
border: template.border,
|
||||
text: template.text,
|
||||
subtext: template.subtext
|
||||
})
|
||||
|
||||
// Update the custom template status to approved
|
||||
await adminApi.reviewTemplate(templateId, { status: 'approved', admin_notes: 'Approved and created in main templates' })
|
||||
|
||||
// Remove related notifications for this template
|
||||
removeByReference('custom_template', templateId)
|
||||
}
|
||||
|
||||
// Reload template stats
|
||||
const newStats = await adminApi.getTemplateStats()
|
||||
setTemplateStats(newStats)
|
||||
} catch (err) {
|
||||
console.error('Error approving template:', err)
|
||||
// Recover UI by reloading if optimistic removal was wrong
|
||||
await loadTemplatesData()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle create template
|
||||
const handleCreateTemplate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!newTemplate.title || !newTemplate.description || !newTemplate.category || !newTemplate.type) {
|
||||
alert('Please fill in all required fields (Title, Description, Category, Type)')
|
||||
return
|
||||
}
|
||||
|
||||
// Call API to create new custom template using the correct endpoint
|
||||
await adminApi.createCustomTemplate({
|
||||
title: newTemplate.title,
|
||||
description: newTemplate.description,
|
||||
category: newTemplate.category,
|
||||
type: newTemplate.type,
|
||||
complexity: newTemplate.complexity,
|
||||
icon: newTemplate.icon,
|
||||
gradient: newTemplate.gradient,
|
||||
border: newTemplate.border,
|
||||
text: newTemplate.text,
|
||||
subtext: newTemplate.subtext
|
||||
})
|
||||
|
||||
// Reset form
|
||||
setNewTemplate({
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
type: '',
|
||||
complexity: 'low',
|
||||
icon: '',
|
||||
gradient: '',
|
||||
border: '',
|
||||
text: '',
|
||||
subtext: ''
|
||||
})
|
||||
|
||||
// Reload templates
|
||||
await loadTemplatesData()
|
||||
|
||||
// Switch to manage tab
|
||||
setActiveTab("manage")
|
||||
|
||||
alert('Template created successfully!')
|
||||
} catch (err) {
|
||||
console.error('Error creating template:', err)
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to create template'
|
||||
alert(`Error creating template: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter templates based on search and status
|
||||
const filteredTemplates = customTemplates.filter(template => {
|
||||
const matchesSearch = template.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.category?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || template.status === statusFilter
|
||||
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
// Get status counts for CUSTOM templates only (computed on client)
|
||||
const getCustomStatusCount = (status: 'pending' | 'approved' | 'rejected' | 'duplicate') => {
|
||||
return customTemplates.filter((t) => t.status === status).length
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
<span>Loading templates...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2 text-red-600">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
<span>Error loading templates</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-600">{error}</p>
|
||||
<Button onClick={loadTemplatesData} className="mt-4">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
{/* <div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Templates Management</h1>
|
||||
<p className="text-white/70">Manage templates and create new ones</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button onClick={loadTemplatesData} className="bg-orange-500 text-black hover:bg-orange-600">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* Stats Cards - only for Custom Templates tab */}
|
||||
{activeTab === 'manage' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Total Templates</CardTitle>
|
||||
<Files className="h-4 w-4 text-white/60" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{customTemplates.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Pending</CardTitle>
|
||||
<Clock className="h-4 w-4 text-yellow-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{getCustomStatusCount('pending')}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Approved</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{getCustomStatusCount('approved')}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-white">Rejected</CardTitle>
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{getCustomStatusCount('rejected')}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||
<TabsList className="bg-gray-900 border-gray-800">
|
||||
<TabsTrigger value="admin-templates" className="flex items-center space-x-2 data-[state=active]:bg-orange-500 data-[state=active]:text-black text-white/70">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>Admin Templates</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="manage" className="flex items-center space-x-2 data-[state=active]:bg-orange-500 data-[state=active]:text-black text-white/70">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>Custom Templates ({customTemplates.length})</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="admin-templates" className="space-y-4">
|
||||
<AdminTemplatesList />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manage" className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search templates..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => { setSearchQuery(e.target.value); setPage(1) }}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
<SelectItem value="duplicate">Duplicate</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Templates List */}
|
||||
<div className="space-y-4">
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-gray-500">
|
||||
<Copy className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>No templates found</p>
|
||||
<p className="text-sm">All templates have been reviewed or no templates match your filters.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredTemplates.map((template) => (
|
||||
<Card key={template.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h3 className="text-lg font-semibold">{template.title}</h3>
|
||||
<Badge className={getStatusColor(template.status)}>
|
||||
{template.status}
|
||||
</Badge>
|
||||
<Badge className={getComplexityColor(template.complexity)}>
|
||||
{template.complexity}
|
||||
</Badge>
|
||||
{template.category && (
|
||||
<Badge variant="outline">{template.category}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{template.description && (
|
||||
<p className="text-gray-600 mb-2">{template.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>Type: {template.type || 'Unknown'}</span>
|
||||
<span>Submitted: {formatDate(template.created_at)}</span>
|
||||
<span>Usage: {template.usage_count || 0}</span>
|
||||
</div>
|
||||
|
||||
{template.admin_notes && (
|
||||
<div className="mt-2 p-2 bg-gray-50 rounded">
|
||||
<p className="text-sm text-gray-600">
|
||||
<strong>Admin Notes:</strong> {template.admin_notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleApprove(template.id)}
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRejectItem({ id: template.id, name: template.title, type: 'template' })
|
||||
setShowRejectDialog(true)
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedTemplateForFeatures(template)
|
||||
setShowFeaturesManager(true)
|
||||
}}
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-1" />
|
||||
Features
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedTemplate(template)
|
||||
setShowTemplateEditDialog(true)
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
{/* Pagination Controls */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="text-sm text-gray-500">Page {page}</div>
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<span className="flex items-center"><ChevronLeft className="h-4 w-4 mr-1" /> Prev</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasMore}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
<span className="flex items-center">Next <ChevronRight className="h-4 w-4 ml-1" /></span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="create" className="space-y-4">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Create New Template</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleCreateTemplate} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title" className="text-white">Template Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newTemplate.title}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, title: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
placeholder="Enter template title"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category" className="text-white">Category</Label>
|
||||
<Select value={newTemplate.category} onValueChange={(value) => setNewTemplate(prev => ({ ...prev, category: value }))}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="marketing">Marketing</SelectItem>
|
||||
<SelectItem value="software">Software</SelectItem>
|
||||
<SelectItem value="seo">SEO</SelectItem>
|
||||
<SelectItem value="ecommerce">E-commerce</SelectItem>
|
||||
<SelectItem value="portfolio">Portfolio</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-white">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={newTemplate.description}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
placeholder="Enter template description"
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type" className="text-white">Type</Label>
|
||||
<Select value={newTemplate.type} onValueChange={(value) => setNewTemplate(prev => ({ ...prev, type: value }))}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select template type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Business Website">Business Website</SelectItem>
|
||||
<SelectItem value="E-commerce Store">E-commerce Store</SelectItem>
|
||||
<SelectItem value="Landing Page">Landing Page</SelectItem>
|
||||
<SelectItem value="Blog Platform">Blog Platform</SelectItem>
|
||||
<SelectItem value="Portfolio Site">Portfolio Site</SelectItem>
|
||||
<SelectItem value="SaaS Platform">SaaS Platform</SelectItem>
|
||||
<SelectItem value="Mobile App">Mobile App</SelectItem>
|
||||
<SelectItem value="Web Application">Web Application</SelectItem>
|
||||
<SelectItem value="Marketing Site">Marketing Site</SelectItem>
|
||||
<SelectItem value="Corporate Website">Corporate Website</SelectItem>
|
||||
<SelectItem value="Educational Platform">Educational Platform</SelectItem>
|
||||
<SelectItem value="Social Media App">Social Media App</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="complexity" className="text-white">Complexity</Label>
|
||||
<Select value={newTemplate.complexity} onValueChange={(value) => setNewTemplate(prev => ({ ...prev, complexity: value }))}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select complexity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="icon" className="text-white">Icon</Label>
|
||||
<Input
|
||||
id="icon"
|
||||
value={newTemplate.icon}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, icon: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
placeholder="Icon name or URL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gradient" className="text-white">Gradient</Label>
|
||||
<Input
|
||||
id="gradient"
|
||||
value={newTemplate.gradient}
|
||||
onChange={(e) => setNewTemplate(prev => ({ ...prev, gradient: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
placeholder="CSS gradient value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="button" variant="outline" onClick={() => setActiveTab("manage")}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="bg-orange-500 text-black hover:bg-orange-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Create Template
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Template Edit Dialog */}
|
||||
{selectedTemplate && (
|
||||
<TemplateEditDialog
|
||||
template={selectedTemplate}
|
||||
open={showTemplateEditDialog}
|
||||
onOpenChange={setShowTemplateEditDialog}
|
||||
onUpdate={handleTemplateUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reject Dialog */}
|
||||
{rejectItem && (
|
||||
<RejectDialog
|
||||
open={showRejectDialog}
|
||||
onOpenChange={setShowRejectDialog}
|
||||
onReject={handleReject}
|
||||
title="Reject Template"
|
||||
itemName={rejectItem.name}
|
||||
itemType={rejectItem.type}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Template Features Manager */}
|
||||
{selectedTemplateForFeatures && (
|
||||
<TemplateFeaturesManager
|
||||
template={selectedTemplateForFeatures}
|
||||
open={showFeaturesManager}
|
||||
onOpenChange={setShowFeaturesManager}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
257
src/components/admin/feature-edit-dialog.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Loader2, Save, AlertTriangle } from 'lucide-react'
|
||||
import { adminApi } from '@/lib/api/admin'
|
||||
import { AdminFeature } from '@/types/admin.types'
|
||||
|
||||
interface FeatureEditDialogProps {
|
||||
feature: AdminFeature
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onUpdate: (featureId: string, updatedFeature: AdminFeature) => void
|
||||
}
|
||||
|
||||
export function FeatureEditDialog({
|
||||
feature,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdate
|
||||
}: FeatureEditDialogProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
complexity: 'medium' as 'low' | 'medium' | 'high',
|
||||
business_rules: '',
|
||||
technical_requirements: ''
|
||||
})
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Initialize form data when feature changes
|
||||
useEffect(() => {
|
||||
if (feature) {
|
||||
setFormData({
|
||||
name: feature.name || '',
|
||||
description: feature.description || '',
|
||||
complexity: feature.complexity || 'medium',
|
||||
business_rules: feature.business_rules ? JSON.stringify(feature.business_rules, null, 2) : '',
|
||||
technical_requirements: feature.technical_requirements ? JSON.stringify(feature.technical_requirements, null, 2) : ''
|
||||
})
|
||||
setError(null)
|
||||
}
|
||||
}, [feature])
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
setError('Feature name is required')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Prepare update data
|
||||
const updateData: Record<string, unknown> = {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
complexity: formData.complexity
|
||||
}
|
||||
|
||||
// Parse JSON fields if they have content
|
||||
if (formData.business_rules.trim()) {
|
||||
try {
|
||||
updateData.business_rules = JSON.parse(formData.business_rules)
|
||||
} catch (err) {
|
||||
setError('Invalid JSON format in business rules')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.technical_requirements.trim()) {
|
||||
try {
|
||||
updateData.technical_requirements = JSON.parse(formData.technical_requirements)
|
||||
} catch (err) {
|
||||
setError('Invalid JSON format in technical requirements')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const updatedFeature = await adminApi.updateCustomFeature(feature.id, updateData)
|
||||
|
||||
// Update the feature in the parent component
|
||||
onUpdate(feature.id, { ...feature, ...updatedFeature })
|
||||
|
||||
// Close dialog
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
console.error('Error updating feature:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getComplexityColor = (complexity: string) => {
|
||||
switch (complexity) {
|
||||
case 'low':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'medium':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
case 'high':
|
||||
return 'bg-red-100 text-red-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Custom Feature</DialogTitle>
|
||||
<p className="text-sm text-gray-600">
|
||||
Template: <strong>{feature.template_title || 'Unknown'}</strong>
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Feature Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Feature Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
placeholder="Enter feature name..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
placeholder="Describe what this feature does..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Complexity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="complexity">Complexity</Label>
|
||||
<Select value={formData.complexity} onValueChange={(value) => handleInputChange('complexity', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className="bg-green-100 text-green-800">Low</Badge>
|
||||
<span>Simple implementation</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="medium">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className="bg-yellow-100 text-yellow-800">Medium</Badge>
|
||||
<span>Moderate complexity</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="high">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className="bg-red-100 text-red-800">High</Badge>
|
||||
<span>Complex implementation</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Business Rules */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="business_rules">Business Rules (JSON)</Label>
|
||||
<Textarea
|
||||
id="business_rules"
|
||||
value={formData.business_rules}
|
||||
onChange={(e) => handleInputChange('business_rules', e.target.value)}
|
||||
placeholder='{"rule1": "description", "rule2": "description"}'
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Optional: Define business rules for this feature in JSON format
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Technical Requirements */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="technical_requirements">Technical Requirements (JSON)</Label>
|
||||
<Textarea
|
||||
id="technical_requirements"
|
||||
value={formData.technical_requirements}
|
||||
onChange={(e) => handleInputChange('technical_requirements', e.target.value)}
|
||||
placeholder='{"framework": "React", "database": "PostgreSQL"}'
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Optional: Define technical requirements in JSON format
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !formData.name.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Update Feature
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
311
src/components/admin/feature-review-dialog.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Copy,
|
||||
AlertTriangle,
|
||||
Search,
|
||||
ExternalLink
|
||||
} from 'lucide-react'
|
||||
import { AdminFeature, FeatureSimilarity, FeatureReviewData } from '@/types/admin.types'
|
||||
import { adminApi, formatDate, getStatusColor, getComplexityColor } from '@/lib/api/admin'
|
||||
|
||||
interface FeatureReviewDialogProps {
|
||||
feature: AdminFeature
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onReview: (featureId: string, reviewData: FeatureReviewData) => Promise<void>
|
||||
}
|
||||
|
||||
export function FeatureReviewDialog({
|
||||
feature,
|
||||
open,
|
||||
onOpenChange,
|
||||
onReview
|
||||
}: FeatureReviewDialogProps) {
|
||||
const [status, setStatus] = useState<'approved' | 'rejected' | 'duplicate'>('approved')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [canonicalFeatureId, setCanonicalFeatureId] = useState('')
|
||||
const [similarFeatures, setSimilarFeatures] = useState<FeatureSimilarity[]>([])
|
||||
const [loadingSimilar, setLoadingSimilar] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Load similar features when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && feature.name) {
|
||||
loadSimilarFeatures(feature.name)
|
||||
}
|
||||
}, [open, feature.name])
|
||||
|
||||
const loadSimilarFeatures = async (query: string) => {
|
||||
try {
|
||||
setLoadingSimilar(true)
|
||||
const features = await adminApi.findSimilarFeatures(query, 0.7, 5)
|
||||
setSimilarFeatures(features)
|
||||
} catch (error) {
|
||||
console.error('Error loading similar features:', error)
|
||||
} finally {
|
||||
setLoadingSimilar(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!status) return
|
||||
|
||||
const reviewData: FeatureReviewData = {
|
||||
status,
|
||||
notes: notes.trim() || undefined,
|
||||
canonical_feature_id: status === 'duplicate' ? canonicalFeatureId : undefined,
|
||||
admin_reviewed_by: 'admin' // TODO: Get from auth context
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
await onReview(feature.id, reviewData)
|
||||
} catch (error) {
|
||||
console.error('Error reviewing feature:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = (newStatus: string) => {
|
||||
setStatus(newStatus as 'approved' | 'rejected' | 'duplicate')
|
||||
if (newStatus !== 'duplicate') {
|
||||
setCanonicalFeatureId('')
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSimilarFeatures = similarFeatures.filter(f =>
|
||||
f.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review Feature: {feature.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review and approve, reject, or mark as duplicate this custom feature submission.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Feature Details */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="text-lg font-semibold">{feature.name}</h3>
|
||||
<Badge className={getStatusColor(feature.status)}>
|
||||
{feature.status}
|
||||
</Badge>
|
||||
<Badge className={getComplexityColor(feature.complexity)}>
|
||||
{feature.complexity}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{feature.description && (
|
||||
<p className="text-gray-600">{feature.description}</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Template:</span> {feature.template_title || 'Unknown'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Submitted:</span> {formatDate(feature.created_at)}
|
||||
</div>
|
||||
{feature.similarity_score && (
|
||||
<div>
|
||||
<span className="font-medium">Similarity Score:</span> {(feature.similarity_score * 100).toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">Usage Count:</span> {feature.usage_count}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feature.business_rules && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Business Rules:</h4>
|
||||
<pre className="text-sm bg-gray-50 p-2 rounded">
|
||||
{JSON.stringify(feature.business_rules, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feature.technical_requirements && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Technical Requirements:</h4>
|
||||
<pre className="text-sm bg-gray-50 p-2 rounded">
|
||||
{JSON.stringify(feature.technical_requirements, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Similar Features */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-medium">Similar Features</h4>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search similar features..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 border rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingSimilar ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900 mx-auto"></div>
|
||||
<p className="text-sm text-gray-500 mt-2">Loading similar features...</p>
|
||||
</div>
|
||||
) : filteredSimilarFeatures.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{filteredSimilarFeatures.map((similar) => (
|
||||
<div
|
||||
key={similar.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium">{similar.name}</span>
|
||||
<Badge className={getComplexityColor(similar.complexity)}>
|
||||
{similar.complexity}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{similar.match_type} ({(similar.score * 100).toFixed(1)}%)
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{similar.feature_type}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCanonicalFeatureId(similar.id)}
|
||||
disabled={status !== 'duplicate'}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
Select as Duplicate
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
<AlertTriangle className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
||||
<p>No similar features found</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Review Form */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="status">Review Decision</Label>
|
||||
<Select value={status} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select review decision" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="approved">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<span>Approve</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="rejected">
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
<span>Reject</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="duplicate">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Copy className="h-4 w-4 text-orange-600" />
|
||||
<span>Mark as Duplicate</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{status === 'duplicate' && (
|
||||
<div>
|
||||
<Label htmlFor="canonical">Canonical Feature ID</Label>
|
||||
<input
|
||||
id="canonical"
|
||||
type="text"
|
||||
value={canonicalFeatureId}
|
||||
onChange={(e) => setCanonicalFeatureId(e.target.value)}
|
||||
placeholder="Enter the ID of the canonical feature"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Select a similar feature above or enter the canonical feature ID manually
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="notes">Admin Notes (Optional)</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add notes about your decision..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || (status === 'duplicate' && !canonicalFeatureId)}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
`Submit ${status.charAt(0).toUpperCase() + status.slice(1)}`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
140
src/components/admin/reject-dialog.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Loader2, XCircle, AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface RejectDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onReject: (adminNotes: string) => Promise<void>
|
||||
title: string
|
||||
itemName: string
|
||||
itemType: 'feature' | 'template'
|
||||
}
|
||||
|
||||
export function RejectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onReject,
|
||||
title,
|
||||
itemName,
|
||||
itemType
|
||||
}: RejectDialogProps) {
|
||||
const [adminNotes, setAdminNotes] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!adminNotes.trim()) {
|
||||
setError('Please provide a reason for rejection')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
await onReject(adminNotes.trim())
|
||||
|
||||
// Reset form and close dialog
|
||||
setAdminNotes('')
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : `Failed to reject ${itemType}`)
|
||||
console.error(`Error rejecting ${itemType}:`, error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setAdminNotes('')
|
||||
setError(null)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center text-red-600">
|
||||
<XCircle className="h-5 w-5 mr-2" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-800">
|
||||
You are about to reject: <strong>{itemName}</strong>
|
||||
</p>
|
||||
<p className="text-xs text-red-600 mt-1">
|
||||
This action will mark the {itemType} as rejected and notify the submitter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adminNotes">Reason for Rejection *</Label>
|
||||
<Textarea
|
||||
id="adminNotes"
|
||||
value={adminNotes}
|
||||
onChange={(e) => setAdminNotes(e.target.value)}
|
||||
placeholder={`Explain why this ${itemType} is being rejected...`}
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
This message will be visible to the submitter
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={loading || !adminNotes.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Rejecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
Reject {itemType === 'feature' ? 'Feature' : 'Template'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
319
src/components/admin/template-edit-dialog.tsx
Normal file
@ -0,0 +1,319 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Loader2, Save, AlertTriangle } from 'lucide-react'
|
||||
import { adminApi, AdminApiError } from '@/lib/api/admin'
|
||||
import { AdminTemplate } from '@/types/admin.types'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
|
||||
interface TemplateEditDialogProps {
|
||||
template: AdminTemplate
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onUpdate: (templateId: string, updatedTemplate: AdminTemplate) => void
|
||||
}
|
||||
|
||||
export function TemplateEditDialog({
|
||||
template,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdate
|
||||
}: TemplateEditDialogProps) {
|
||||
const { show } = useToast()
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
type: '',
|
||||
icon: '',
|
||||
gradient: '',
|
||||
border: '',
|
||||
text: '',
|
||||
subtext: ''
|
||||
})
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [conflictInfo, setConflictInfo] = useState<{ title?: string; type?: string } | null>(null)
|
||||
|
||||
const categories = [
|
||||
"Food Delivery",
|
||||
"E-commerce",
|
||||
"SaaS Platform",
|
||||
"Mobile App",
|
||||
"Dashboard",
|
||||
"CRM System",
|
||||
"Learning Platform",
|
||||
"Healthcare",
|
||||
"Real Estate",
|
||||
"Travel",
|
||||
"Entertainment",
|
||||
"Finance",
|
||||
"Social Media",
|
||||
"Marketplace",
|
||||
"Other"
|
||||
]
|
||||
|
||||
// Initialize form data when template changes
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setFormData({
|
||||
title: template.title || '',
|
||||
description: template.description || '',
|
||||
category: template.category || '',
|
||||
type: template.type || '',
|
||||
icon: template.icon || '',
|
||||
gradient: template.gradient || '',
|
||||
border: template.border || '',
|
||||
text: template.text || '',
|
||||
subtext: template.subtext || ''
|
||||
})
|
||||
setError(null)
|
||||
setConflictInfo(null)
|
||||
}
|
||||
}, [template])
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
setError('Template title is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.type.trim()) {
|
||||
setError('Template type is required')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setConflictInfo(null)
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
title: formData.title.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
category: formData.category || undefined,
|
||||
type: formData.type.trim(),
|
||||
icon: formData.icon.trim() || undefined,
|
||||
gradient: formData.gradient.trim() || undefined,
|
||||
border: formData.border.trim() || undefined,
|
||||
text: formData.text.trim() || undefined,
|
||||
subtext: formData.subtext.trim() || undefined
|
||||
}
|
||||
|
||||
const newTemplate = await adminApi.createTemplateFromEdit(template.id, updateData)
|
||||
|
||||
// Update the template in the parent component with the new template data
|
||||
onUpdate(template.id, { ...template, ...newTemplate, status: 'pending' })
|
||||
|
||||
// Close dialog
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
if (error instanceof AdminApiError) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data: any = (error as any).data || {}
|
||||
if (data?.existing_template) {
|
||||
setConflictInfo({ title: data.existing_template.title, type: data.existing_template.type })
|
||||
}
|
||||
const message = data?.message || error.message || 'Failed to create template'
|
||||
setError(message)
|
||||
show({
|
||||
title: 'Template creation failed',
|
||||
description: data?.existing_template
|
||||
? `${message} — Existing: ${data.existing_template.title} (${data.existing_template.type}).`
|
||||
: message,
|
||||
variant: 'error',
|
||||
})
|
||||
} else {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create template'
|
||||
setError(message)
|
||||
show({ title: 'Template creation failed', description: message, variant: 'error' })
|
||||
}
|
||||
console.error('Error creating template:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Template from Edit</DialogTitle>
|
||||
<p className="text-sm text-gray-600">
|
||||
We'll help you create a comprehensive template. based on: <strong>{template.title}</strong>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
The new template will be created with 'pending' status for review.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Template Type *</Label>
|
||||
<Input
|
||||
id="type"
|
||||
placeholder="e.g., multi_restaurant_food_delivery"
|
||||
value={formData.type}
|
||||
onChange={(e) => handleInputChange('type', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500">Unique identifier for the template</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="e.g., Multi-Restaurant Food Delivery App"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleInputChange('title', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Describe your template and its key features..."
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
className="min-h-[100px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category *</Label>
|
||||
<Select value={formData.category} onValueChange={(value) => handleInputChange('category', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="icon">Icon (optional)</Label>
|
||||
<Input
|
||||
id="icon"
|
||||
placeholder="e.g., restaurant, shopping-cart, users"
|
||||
value={formData.icon}
|
||||
onChange={(e) => handleInputChange('icon', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gradient">Gradient (optional)</Label>
|
||||
<Input
|
||||
id="gradient"
|
||||
placeholder="e.g., from-orange-400 to-red-500"
|
||||
value={formData.gradient}
|
||||
onChange={(e) => handleInputChange('gradient', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="border">Border (optional)</Label>
|
||||
<Input
|
||||
id="border"
|
||||
placeholder="e.g., border-orange-500"
|
||||
value={formData.border}
|
||||
onChange={(e) => handleInputChange('border', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="text">Text Color (optional)</Label>
|
||||
<Input
|
||||
id="text"
|
||||
placeholder="e.g., text-orange-500"
|
||||
value={formData.text}
|
||||
onChange={(e) => handleInputChange('text', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subtext">Subtext (optional)</Label>
|
||||
<Input
|
||||
id="subtext"
|
||||
placeholder="e.g., Perfect for food delivery startups"
|
||||
value={formData.subtext}
|
||||
onChange={(e) => handleInputChange('subtext', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{error}
|
||||
{conflictInfo && (
|
||||
<span className="block mt-1 text-xs text-gray-600">
|
||||
Existing: {conflictInfo.title} ({conflictInfo.type}). Try a different title.
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !formData.title.trim() || !formData.type.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Create New Template
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
490
src/components/admin/template-features-manager.tsx
Normal file
@ -0,0 +1,490 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Save,
|
||||
X,
|
||||
Settings,
|
||||
Zap,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
RefreshCw
|
||||
} from 'lucide-react'
|
||||
import { adminApi, getComplexityColor } from '@/lib/api/admin'
|
||||
import { AdminFeature, AdminTemplate } from '@/types/admin.types'
|
||||
|
||||
interface TemplateFeature {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
complexity: 'low' | 'medium' | 'high'
|
||||
feature_type?: string
|
||||
business_rules?: Record<string, unknown>
|
||||
technical_requirements?: Record<string, unknown>
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
interface TemplateFeaturesManagerProps {
|
||||
template: AdminTemplate
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function TemplateFeaturesManager({ template, open, onOpenChange }: TemplateFeaturesManagerProps) {
|
||||
const [features, setFeatures] = useState<TemplateFeature[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showAddFeature, setShowAddFeature] = useState(false)
|
||||
const [editingFeature, setEditingFeature] = useState<TemplateFeature | null>(null)
|
||||
|
||||
// New feature form state
|
||||
const [newFeature, setNewFeature] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
complexity: 'low' as 'low' | 'medium' | 'high',
|
||||
feature_type: '',
|
||||
business_rules: '',
|
||||
technical_requirements: ''
|
||||
})
|
||||
|
||||
// Load template features
|
||||
const loadFeatures = async () => {
|
||||
if (!template.id) return
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const featuresData = await adminApi.getTemplateFeatures(template.id)
|
||||
setFeatures(featuresData as TemplateFeature[])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load features')
|
||||
console.error('Error loading template features:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open && template.id) {
|
||||
loadFeatures()
|
||||
}
|
||||
}, [open, template.id])
|
||||
|
||||
// Handle add feature
|
||||
const handleAddFeature = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!newFeature.name.trim()) {
|
||||
alert('Feature name is required')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const featureData = {
|
||||
name: newFeature.name,
|
||||
description: newFeature.description || undefined,
|
||||
complexity: newFeature.complexity,
|
||||
feature_type: newFeature.feature_type || undefined,
|
||||
business_rules: newFeature.business_rules ? JSON.parse(newFeature.business_rules) : undefined,
|
||||
technical_requirements: newFeature.technical_requirements ? JSON.parse(newFeature.technical_requirements) : undefined
|
||||
}
|
||||
|
||||
const addedFeature = await adminApi.addFeatureToTemplate(template.id, featureData)
|
||||
setFeatures(prev => [...prev, addedFeature as TemplateFeature])
|
||||
|
||||
// Reset form
|
||||
setNewFeature({
|
||||
name: '',
|
||||
description: '',
|
||||
complexity: 'low',
|
||||
feature_type: '',
|
||||
business_rules: '',
|
||||
technical_requirements: ''
|
||||
})
|
||||
setShowAddFeature(false)
|
||||
} catch (err) {
|
||||
console.error('Error adding feature:', err)
|
||||
alert('Failed to add feature: ' + (err instanceof Error ? err.message : 'Unknown error'))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle update feature
|
||||
const handleUpdateFeature = async (featureId: string, updateData: Partial<TemplateFeature>) => {
|
||||
try {
|
||||
const updatedFeature = await adminApi.updateTemplateFeature(template.id, featureId, updateData)
|
||||
setFeatures(prev => prev.map(f => f.id === featureId ? updatedFeature as TemplateFeature : f))
|
||||
setEditingFeature(null)
|
||||
} catch (err) {
|
||||
console.error('Error updating feature:', err)
|
||||
alert('Failed to update feature: ' + (err instanceof Error ? err.message : 'Unknown error'))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle delete feature
|
||||
const handleDeleteFeature = async (featureId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this feature?')) return
|
||||
|
||||
try {
|
||||
await adminApi.removeFeatureFromTemplate(template.id, featureId)
|
||||
setFeatures(prev => prev.filter(f => f.id !== featureId))
|
||||
} catch (err) {
|
||||
console.error('Error deleting feature:', err)
|
||||
alert('Failed to delete feature: ' + (err instanceof Error ? err.message : 'Unknown error'))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle bulk add common features
|
||||
const handleBulkAddCommonFeatures = async () => {
|
||||
const commonFeatures = [
|
||||
{ name: 'User Authentication', description: 'Login and registration system', complexity: 'medium' as const },
|
||||
{ name: 'Responsive Design', description: 'Mobile-friendly layout', complexity: 'low' as const },
|
||||
{ name: 'SEO Optimization', description: 'Search engine optimization features', complexity: 'low' as const },
|
||||
{ name: 'Analytics Integration', description: 'Google Analytics or similar tracking', complexity: 'low' as const },
|
||||
{ name: 'Contact Form', description: 'Contact form with validation', complexity: 'low' as const },
|
||||
{ name: 'Content Management', description: 'CMS functionality for content updates', complexity: 'high' as const }
|
||||
]
|
||||
|
||||
try {
|
||||
const addedFeatures = await adminApi.bulkAddFeaturesToTemplate(template.id, commonFeatures)
|
||||
setFeatures(prev => [...prev, ...addedFeatures as TemplateFeature[]])
|
||||
} catch (err) {
|
||||
console.error('Error bulk adding features:', err)
|
||||
alert('Failed to add common features: ' + (err instanceof Error ? err.message : 'Unknown error'))
|
||||
}
|
||||
}
|
||||
|
||||
const FeatureCard = ({ feature }: { feature: TemplateFeature }) => (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h4 className="font-medium text-white">{feature.name}</h4>
|
||||
<Badge className={getComplexityColor(feature.complexity)}>
|
||||
{feature.complexity}
|
||||
</Badge>
|
||||
{feature.feature_type && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{feature.feature_type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{feature.description && (
|
||||
<p className="text-gray-400 text-sm mb-2">{feature.description}</p>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{feature.created_at && `Added: ${new Date(feature.created_at).toLocaleDateString()}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingFeature(feature)}
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteFeature(feature.id)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto bg-black border-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center space-x-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
<span>Manage Features - {template.title}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Header Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Zap className="h-5 w-5 text-orange-400" />
|
||||
<span className="text-white font-medium">Template Features ({features.length})</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
onClick={handleBulkAddCommonFeatures}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-white border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Common Features
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowAddFeature(true)}
|
||||
className="bg-orange-500 text-black hover:bg-orange-600"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Feature
|
||||
</Button>
|
||||
<Button
|
||||
onClick={loadFeatures}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-white border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-orange-400" />
|
||||
<span className="ml-2 text-white">Loading features...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<Card className="bg-red-900/20 border-red-800">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center space-x-2 text-red-400">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span>Error: {error}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Features List */}
|
||||
{!loading && !error && (
|
||||
<div className="space-y-4">
|
||||
{features.length === 0 ? (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-gray-400">
|
||||
<Zap className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
||||
<p>No features added yet</p>
|
||||
<p className="text-sm">Add features to make this template more comprehensive.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{features.map((feature) => (
|
||||
<FeatureCard key={feature.id} feature={feature} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Feature Form */}
|
||||
{showAddFeature && (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center justify-between">
|
||||
<span>Add New Feature</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAddFeature(false)}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleAddFeature} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feature-name" className="text-white">Feature Name *</Label>
|
||||
<Input
|
||||
id="feature-name"
|
||||
value={newFeature.name}
|
||||
onChange={(e) => setNewFeature(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
placeholder="e.g., User Authentication"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feature-type" className="text-white">Feature Type</Label>
|
||||
<Input
|
||||
id="feature-type"
|
||||
value={newFeature.feature_type}
|
||||
onChange={(e) => setNewFeature(prev => ({ ...prev, feature_type: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
placeholder="e.g., Authentication, UI, Backend"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feature-description" className="text-white">Description</Label>
|
||||
<Textarea
|
||||
id="feature-description"
|
||||
value={newFeature.description}
|
||||
onChange={(e) => setNewFeature(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
placeholder="Describe what this feature does..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feature-complexity" className="text-white">Complexity</Label>
|
||||
<Select value={newFeature.complexity} onValueChange={(value) => setNewFeature(prev => ({ ...prev, complexity: value as 'low' | 'medium' | 'high' }))}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select complexity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowAddFeature(false)}
|
||||
className="text-white border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="bg-orange-500 text-black hover:bg-orange-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Add Feature
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Edit Feature Dialog */}
|
||||
{editingFeature && (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center justify-between">
|
||||
<span>Edit Feature</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingFeature(null)}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
const formData = new FormData(e.currentTarget)
|
||||
handleUpdateFeature(editingFeature.id, {
|
||||
name: formData.get('name') as string,
|
||||
description: formData.get('description') as string,
|
||||
complexity: formData.get('complexity') as 'low' | 'medium' | 'high',
|
||||
feature_type: formData.get('feature_type') as string
|
||||
})
|
||||
}} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-name" className="text-white">Feature Name</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
defaultValue={editingFeature.name}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-type" className="text-white">Feature Type</Label>
|
||||
<Input
|
||||
id="edit-type"
|
||||
name="feature_type"
|
||||
defaultValue={editingFeature.feature_type || ''}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-description" className="text-white">Description</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
name="description"
|
||||
defaultValue={editingFeature.description || ''}
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-complexity" className="text-white">Complexity</Label>
|
||||
<Select name="complexity" defaultValue={editingFeature.complexity}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEditingFeature(null)}
|
||||
className="text-white border-gray-600 hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="bg-orange-500 text-black hover:bg-orange-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Update Feature
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
215
src/components/ai-side-panel.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type Block = {
|
||||
id: string
|
||||
label: string
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export type WireframePlan = {
|
||||
version: number
|
||||
blocks: Block[]
|
||||
}
|
||||
|
||||
export function AISidePanel({
|
||||
onGenerate,
|
||||
onClear,
|
||||
className,
|
||||
}: {
|
||||
onGenerate: (plan: WireframePlan) => void
|
||||
onClear: () => void
|
||||
className?: string
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [prompt, setPrompt] = useState(
|
||||
"Dashboard with header, left sidebar, 3 stats cards, a line chart and a data table, plus footer.",
|
||||
)
|
||||
const [version, setVersion] = useState(1)
|
||||
|
||||
const examples = useMemo(
|
||||
() => [
|
||||
"Landing page with header, hero, 3x2 feature grid, and footer.",
|
||||
"Settings screen: header, list of toggles, and save button.",
|
||||
"E‑commerce product page: header, 2-column gallery/details, reviews, sticky add-to-cart.",
|
||||
"Dashboard: header, left sidebar, 3 stats cards, line chart, data table, footer.",
|
||||
"Signup page: header, 2-column form, callout, submit button.",
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
function parsePromptToPlan(input: string): WireframePlan {
|
||||
const W = 1200
|
||||
const H = 800
|
||||
const P = 24
|
||||
const blocks: Block[] = []
|
||||
let y = P
|
||||
let x = P
|
||||
let width = W - 2 * P
|
||||
|
||||
const lower = input.toLowerCase()
|
||||
const addBlock = (label: string, bx: number, by: number, bw: number, bh: number) => {
|
||||
blocks.push({
|
||||
id: `${label}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
label,
|
||||
x: Math.round(bx),
|
||||
y: Math.round(by),
|
||||
w: Math.round(bw),
|
||||
h: Math.round(bh),
|
||||
})
|
||||
}
|
||||
|
||||
if (/\bheader\b/.test(lower) || /\bnavbar\b/.test(lower)) {
|
||||
addBlock("Header", x, y, width, 72)
|
||||
y += 72 + P
|
||||
}
|
||||
|
||||
let hasSidebar = false
|
||||
if (/\bsidebar\b/.test(lower) || /\bleft sidebar\b/.test(lower)) {
|
||||
hasSidebar = true
|
||||
addBlock("Sidebar", x, y, 260, H - y - P)
|
||||
x += 260 + P
|
||||
width = W - x - P
|
||||
}
|
||||
|
||||
if (/\bhero\b/.test(lower)) {
|
||||
addBlock("Hero", x, y, width, 200)
|
||||
y += 200 + P
|
||||
}
|
||||
|
||||
if (/stats?\b/.test(lower) || /\bcards?\b/.test(lower)) {
|
||||
const cols = /4/.test(lower) ? 4 : 3
|
||||
const gap = P
|
||||
const cardW = (width - gap * (cols - 1)) / cols
|
||||
const cardH = 100
|
||||
for (let i = 0; i < cols; i++) {
|
||||
addBlock(`Card ${i + 1}`, x + i * (cardW + gap), y, cardW, cardH)
|
||||
}
|
||||
y += cardH + P
|
||||
}
|
||||
|
||||
const gridMatch = lower.match(/(\d)\s*x\s*(\d)/)
|
||||
if (gridMatch) {
|
||||
const cols = Number.parseInt(gridMatch[1], 10)
|
||||
const rows = Number.parseInt(gridMatch[2], 10)
|
||||
const gap = P
|
||||
const cellW = (width - gap * (cols - 1)) / cols
|
||||
const cellH = 120
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
addBlock(`Feature ${r * cols + c + 1}`, x + c * (cellW + gap), y + r * (cellH + gap), cellW, cellH)
|
||||
}
|
||||
}
|
||||
y += rows * cellH + (rows - 1) * gap + P
|
||||
}
|
||||
|
||||
if (/\b2[-\s]?column\b/.test(lower) || /gallery\/details/.test(lower) || /gallery/.test(lower)) {
|
||||
const gap = P
|
||||
const colW = (width - gap) / 2
|
||||
const colH = 260
|
||||
addBlock("Left Column", x, y, colW, colH)
|
||||
addBlock("Right Column", x + colW + gap, y, colW, colH)
|
||||
y += colH + P
|
||||
}
|
||||
|
||||
if (/\bchart\b/.test(lower) || /\bline chart\b/.test(lower)) {
|
||||
addBlock("Chart", x, y, width, 220)
|
||||
y += 220 + P
|
||||
}
|
||||
if (/\btable\b/.test(lower)) {
|
||||
addBlock("Data Table", x, y, width, 260)
|
||||
y += 260 + P
|
||||
}
|
||||
|
||||
if (/\bform\b/.test(lower) || /\bsignup\b/.test(lower) || /\blogin\b/.test(lower)) {
|
||||
const gap = P
|
||||
const twoCol = /\b2[-\s]?column\b/.test(lower)
|
||||
if (twoCol) {
|
||||
const colW = (width - gap) / 2
|
||||
addBlock("Form Left", x, y, colW, 260)
|
||||
addBlock("Form Right", x + colW + gap, y, colW, 260)
|
||||
y += 260 + P
|
||||
} else {
|
||||
addBlock("Form", x, y, width, 220)
|
||||
y += 220 + P
|
||||
}
|
||||
}
|
||||
|
||||
if (/\bfooter\b/.test(lower)) {
|
||||
addBlock("Footer", P, H - 80 - P, W - 2 * P, 80)
|
||||
}
|
||||
|
||||
return { version: Date.now() + version, blocks }
|
||||
}
|
||||
|
||||
const handleGenerate = () => {
|
||||
const plan = parsePromptToPlan(prompt)
|
||||
setVersion((v) => v + 1)
|
||||
onGenerate(plan)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"h-full border-l bg-white dark:bg-neutral-900 flex flex-col",
|
||||
collapsed ? "w-12" : "w-96",
|
||||
className,
|
||||
)}
|
||||
aria-label="AI prompt side panel"
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<h2 className={cn("text-sm font-medium text-balance", collapsed && "sr-only")}>AI Wireframe</h2>
|
||||
<Button variant="ghost" size="icon" onClick={() => setCollapsed((c) => !c)} aria-label="Toggle panel">
|
||||
{collapsed ? <span aria-hidden>›</span> : <span aria-hidden>‹</span>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<label className="text-xs font-medium">Prompt</label>
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Describe your screen: Landing with header, hero, 3x2 features, footer"
|
||||
className="min-h-28"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleGenerate} className="flex-1">
|
||||
Generate
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onClear}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-1">
|
||||
<p className="text-xs font-medium mb-2">Examples</p>
|
||||
<ScrollArea className="h-40 border rounded">
|
||||
<ul className="p-2 space-y-2">
|
||||
{examples.map((ex) => (
|
||||
<li key={ex}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPrompt(ex)}
|
||||
className="text-left text-xs w-full hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded px-2 py-1"
|
||||
>
|
||||
{ex}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
378
src/components/ai/AICustomFeatureCreator.tsx
Normal file
@ -0,0 +1,378 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { analyzeFeatureWithAI } from '@/services/aiAnalysis'
|
||||
|
||||
type Complexity = 'low' | 'medium' | 'high'
|
||||
|
||||
export interface AIAnalysisResult {
|
||||
suggested_name?: string
|
||||
complexity?: Complexity
|
||||
implementation_details?: string[]
|
||||
technical_requirements?: string[]
|
||||
estimated_effort?: string
|
||||
dependencies?: string[]
|
||||
api_endpoints?: string[]
|
||||
database_tables?: string[]
|
||||
confidence_score?: number
|
||||
}
|
||||
|
||||
export function AICustomFeatureCreator({
|
||||
projectType,
|
||||
onAdd,
|
||||
onClose,
|
||||
editingFeature,
|
||||
}: {
|
||||
projectType?: string
|
||||
onAdd: (feature: { name: string; description: string; complexity: Complexity; logic_rules?: string[]; requirements?: Array<{ text: string; rules: string[] }>; business_rules?: Array<{ requirement: string; rules: string[] }> }) => void
|
||||
onClose: () => void
|
||||
editingFeature?: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
complexity: Complexity;
|
||||
business_rules?: any;
|
||||
technical_requirements?: any;
|
||||
additional_business_rules?: any;
|
||||
}
|
||||
}) {
|
||||
const [featureName, setFeatureName] = useState(editingFeature?.name || '')
|
||||
const [featureDescription, setFeatureDescription] = useState(editingFeature?.description || '')
|
||||
const [selectedComplexity, setSelectedComplexity] = useState<Complexity | undefined>(editingFeature?.complexity || undefined)
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
||||
const [aiAnalysis, setAiAnalysis] = useState<AIAnalysisResult | null>(() => {
|
||||
if (editingFeature) {
|
||||
return {
|
||||
suggested_name: editingFeature.name,
|
||||
complexity: editingFeature.complexity,
|
||||
confidence_score: 1,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
const [analysisError, setAnalysisError] = useState<string | null>(null)
|
||||
const [requirements, setRequirements] = useState<Array<{ text: string; rules: string[] }>>(() => {
|
||||
// Initialize requirements from existing feature data
|
||||
if (editingFeature) {
|
||||
console.log('🔍 Editing feature data:', editingFeature)
|
||||
try {
|
||||
// Try to get business rules from multiple sources
|
||||
let businessRules = null;
|
||||
|
||||
// First try direct business_rules field
|
||||
if (editingFeature.business_rules) {
|
||||
console.log('📋 Found business_rules field:', editingFeature.business_rules)
|
||||
businessRules = Array.isArray(editingFeature.business_rules)
|
||||
? editingFeature.business_rules
|
||||
: (typeof editingFeature.business_rules === 'string' ? JSON.parse(editingFeature.business_rules) : editingFeature.business_rules)
|
||||
}
|
||||
// Then try additional_business_rules from feature_business_rules table
|
||||
else if ((editingFeature as any).additional_business_rules) {
|
||||
console.log('📋 Found additional_business_rules field:', (editingFeature as any).additional_business_rules)
|
||||
businessRules = Array.isArray((editingFeature as any).additional_business_rules)
|
||||
? (editingFeature as any).additional_business_rules
|
||||
: (typeof (editingFeature as any).additional_business_rules === 'string' ? JSON.parse((editingFeature as any).additional_business_rules) : (editingFeature as any).additional_business_rules)
|
||||
}
|
||||
// Also try technical_requirements field
|
||||
else if ((editingFeature as any).technical_requirements) {
|
||||
console.log('📋 Found technical_requirements field:', (editingFeature as any).technical_requirements)
|
||||
const techReqs = Array.isArray((editingFeature as any).technical_requirements)
|
||||
? (editingFeature as any).technical_requirements
|
||||
: (typeof (editingFeature as any).technical_requirements === 'string' ? JSON.parse((editingFeature as any).technical_requirements) : (editingFeature as any).technical_requirements)
|
||||
|
||||
// Convert technical requirements to business rules format
|
||||
if (Array.isArray(techReqs)) {
|
||||
businessRules = techReqs.map((req: string, index: number) => ({
|
||||
requirement: `Requirement ${index + 1}`,
|
||||
rules: [req]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📋 Parsed business rules:', businessRules)
|
||||
|
||||
if (businessRules && Array.isArray(businessRules) && businessRules.length > 0) {
|
||||
const requirements = businessRules.map((rule: any) => ({
|
||||
text: rule.requirement || rule.text || rule.name || `Requirement`,
|
||||
rules: Array.isArray(rule.rules) ? rule.rules : (rule.rules ? [rule.rules] : [])
|
||||
}))
|
||||
console.log('📋 Mapped requirements:', requirements)
|
||||
return requirements.length > 0 ? requirements : [{ text: '', rules: [] }]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing business rules:', error)
|
||||
}
|
||||
}
|
||||
return [{ text: '', rules: [] }]
|
||||
})
|
||||
const [analyzingIdx, setAnalyzingIdx] = useState<number | null>(null)
|
||||
const hasAnyAnalysis = !!aiAnalysis || requirements.some(r => (r.rules || []).length > 0)
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
// Allow analyze even if some analysis exists; we'll only analyze missing items
|
||||
if (!featureDescription.trim() && requirements.every(r => !r.text.trim())) return
|
||||
setIsAnalyzing(true)
|
||||
setAnalysisError(null)
|
||||
try {
|
||||
// Aggregate requirements texts for richer context
|
||||
const reqTexts = requirements.map(r => r.text).filter(t => t && t.trim())
|
||||
const overall = await analyzeFeatureWithAI(
|
||||
featureName,
|
||||
featureDescription,
|
||||
reqTexts,
|
||||
projectType
|
||||
)
|
||||
|
||||
setAiAnalysis({
|
||||
suggested_name: featureName,
|
||||
complexity: overall.complexity, // Using the complexity from the API response
|
||||
implementation_details: [],
|
||||
technical_requirements: [],
|
||||
estimated_effort: overall.complexity === 'high' ? 'High' : overall.complexity === 'low' ? 'Low' : 'Medium',
|
||||
dependencies: [],
|
||||
api_endpoints: [],
|
||||
database_tables: [],
|
||||
confidence_score: 0.9,
|
||||
})
|
||||
|
||||
|
||||
// Generate logic rules per requirement in parallel; analyze only those missing rules
|
||||
const perRequirementRules = await Promise.all(
|
||||
requirements.map(async (r) => {
|
||||
// Preserve existing rules if already analyzed
|
||||
if (Array.isArray(r.rules) && r.rules.length > 0) {
|
||||
return r.rules
|
||||
}
|
||||
try {
|
||||
const res = await analyzeFeatureWithAI(
|
||||
r.text || featureName,
|
||||
r.text || featureDescription,
|
||||
r.text ? [r.text] : [],
|
||||
projectType
|
||||
)
|
||||
return Array.isArray(res?.logicRules) ? res.logicRules : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
)
|
||||
setRequirements((prev) => prev.map((r, idx) => ({ ...r, rules: perRequirementRules[idx] || [] })))
|
||||
} catch (e: any) {
|
||||
setAnalysisError(e?.message || 'AI analysis failed')
|
||||
} finally {
|
||||
setIsAnalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAnalyzeRequirement = async (idx: number) => {
|
||||
const req = requirements[idx]
|
||||
if (!req?.text?.trim()) return
|
||||
if ((req.rules || []).length > 0) return
|
||||
setAnalyzingIdx(idx)
|
||||
setAnalysisError(null)
|
||||
try {
|
||||
const res = await analyzeFeatureWithAI(
|
||||
req.text || featureName,
|
||||
req.text || featureDescription,
|
||||
[req.text],
|
||||
projectType
|
||||
)
|
||||
const rules = Array.isArray(res?.logicRules) ? res.logicRules : []
|
||||
setRequirements(prev => {
|
||||
const next = [...prev]
|
||||
next[idx] = { ...next[idx], rules }
|
||||
return next
|
||||
})
|
||||
if (!aiAnalysis) {
|
||||
setAiAnalysis({
|
||||
suggested_name: featureName,
|
||||
complexity: res?.complexity || 'medium',
|
||||
implementation_details: [],
|
||||
technical_requirements: [],
|
||||
estimated_effort: res?.complexity === 'high' ? 'High' : res?.complexity === 'low' ? 'Low' : 'Medium',
|
||||
dependencies: [],
|
||||
api_endpoints: [],
|
||||
database_tables: [],
|
||||
confidence_score: 0.9,
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
setAnalysisError(e?.message || 'AI analysis failed')
|
||||
} finally {
|
||||
setAnalyzingIdx(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onAdd({
|
||||
name: aiAnalysis?.suggested_name || featureName.trim() || 'Custom Feature',
|
||||
description: featureDescription.trim(),
|
||||
complexity: aiAnalysis?.complexity || selectedComplexity || 'medium',
|
||||
logic_rules: requirements.flatMap(r => r.rules || []),
|
||||
requirements: requirements,
|
||||
business_rules: requirements.map(r => ({ requirement: r.text, rules: r.rules || [] })),
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl max-w-4xl w-full max-h-[90vh] backdrop-blur flex flex-col">
|
||||
<div className="p-6 border-b border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-white text-lg font-semibold">
|
||||
{editingFeature ? 'Edit Custom Feature' : 'AI-Powered Feature Creator'}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-white/60 hover:text-white">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">Feature Name</label>
|
||||
<Input value={featureName} onChange={(e) => setFeatureName(e.target.value)} placeholder="e.g., Subscriptions" className="bg-white/10 border-white/20 text-white placeholder:text-white/40" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">Describe Your Feature Requirements</label>
|
||||
<textarea value={featureDescription} onChange={(e) => setFeatureDescription(e.target.value)} rows={4} className="w-full bg-white/10 border border-white/20 text-white rounded-md p-3 placeholder:text-white/40" placeholder="Describe what this feature should do..." required />
|
||||
<p className="text-xs text-white/50 mt-1">Be as detailed as possible. The AI will analyze and break down your requirements.</p>
|
||||
</div>
|
||||
|
||||
{/* Complexity is determined by AI; manual selection removed */}
|
||||
|
||||
{/* Dynamic Requirements List */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-white font-medium">Detailed Requirements (Add one by one)</div>
|
||||
{requirements.map((r, idx) => (
|
||||
<div key={idx} className="rounded-lg border border-white/10 bg-white/5 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-white/60 text-sm w-6">{idx + 1}</div>
|
||||
<Input
|
||||
placeholder="Requirement..."
|
||||
value={r.text}
|
||||
onChange={(e) => {
|
||||
const next = [...requirements]
|
||||
next[idx] = { ...r, text: e.target.value }
|
||||
setRequirements(next)
|
||||
}}
|
||||
className="bg-white/10 border-white/20 text-white placeholder:text-white/40"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleAnalyzeRequirement(idx)}
|
||||
disabled={isAnalyzing || analyzingIdx === idx || !r.text.trim() || (r.rules || []).length > 0}
|
||||
className="border-white/20 text-white hover:bg-white/10"
|
||||
>
|
||||
{analyzingIdx === idx ? 'Analyzing…' : ((r.rules || []).length > 0 ? 'Analyzed' : 'Analyze With AI')}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRequirements(requirements.filter((_, i) => i !== idx))}
|
||||
className="text-white/50 hover:text-red-400"
|
||||
aria-label="Remove requirement"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="pl-8 space-y-2">
|
||||
<div className="text-white/70 text-sm">Logic Rules for this requirement:</div>
|
||||
<div className="space-y-1">
|
||||
{(r.rules || []).length === 0 && (
|
||||
<div className="text-white/40 text-xs">No rules yet. Click Analyze to generate.</div>
|
||||
)}
|
||||
{(r.rules || []).map((rule, ridx) => (
|
||||
<div key={ridx} className="flex items-center gap-2">
|
||||
<div className="text-white/50 text-xs w-8">R{ridx + 1}</div>
|
||||
<Input value={rule} onChange={(e) => {
|
||||
const next = [...requirements]
|
||||
const rr = [...(next[idx].rules || [])]
|
||||
rr[ridx] = e.target.value
|
||||
next[idx] = { ...next[idx], rules: rr }
|
||||
setRequirements(next)
|
||||
}} className="bg-white/10 border-white/20 text-white placeholder:text-white/40" />
|
||||
<button type="button" onClick={() => {
|
||||
const next = [...requirements]
|
||||
const rr = [...(next[idx].rules || [])]
|
||||
rr.splice(ridx, 1)
|
||||
next[idx] = { ...next[idx], rules: rr }
|
||||
setRequirements(next)
|
||||
}} className="text-white/50 hover:text-red-400">×</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => {
|
||||
const next = [...requirements]
|
||||
const rr = [...(next[idx].rules || [])]
|
||||
rr.push('')
|
||||
next[idx] = { ...next[idx], rules: rr }
|
||||
setRequirements(next)
|
||||
}} className="text-xs text-orange-400 hover:text-orange-300">+ Add rule to this requirement</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" onClick={() => setRequirements([...requirements, { text: '', rules: [] }])} className="border-white/20 text-white hover:bg-white/10">
|
||||
+ Add another requirement
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Analyze all requirements (only those missing rules) and compute overall complexity */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAnalyze}
|
||||
disabled={isAnalyzing || (requirements.every(r => !r.text.trim()))}
|
||||
className="border-white/20 text-white hover:bg-white/10"
|
||||
>
|
||||
{isAnalyzing ? 'Analyzing…' : 'Analyze All with AI'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{analysisError && (
|
||||
<Card className="p-3 bg-red-500/10 border-red-500/30 text-red-300">{analysisError}</Card>
|
||||
)}
|
||||
|
||||
{aiAnalysis && (
|
||||
<div className="space-y-2 p-3 bg-emerald-500/10 border border-emerald-500/30 rounded-lg text-white/90">
|
||||
<div className="font-medium">AI Analysis Complete {(aiAnalysis.confidence_score ? `(${Math.round((aiAnalysis.confidence_score || 0) * 100)}% confidence)` : '')}</div>
|
||||
<div className="text-sm">Suggested Name: <span className="text-white">{aiAnalysis.suggested_name || featureName || 'Custom Feature'}</span></div>
|
||||
<div className="text-sm">Complexity: <span className="capitalize text-white">{aiAnalysis.complexity}</span></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex gap-3 flex-wrap items-center pt-4 border-t border-white/10">
|
||||
{aiAnalysis && (
|
||||
<div className="flex-1 text-white/80 text-sm">
|
||||
Complexity (AI): <span className="capitalize">{aiAnalysis.complexity}</span>
|
||||
</div>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onClose} className="border-white/20 text-white hover:bg-white/10">Cancel</Button>
|
||||
<Button type="submit" disabled={(!featureDescription.trim() && requirements.every(r => !r.text.trim())) || isAnalyzing} className="bg-orange-500 hover:bg-orange-400 text-black">
|
||||
{editingFeature
|
||||
? (aiAnalysis ? 'Update Feature with Tagged Rules' : 'Update Feature')
|
||||
: (aiAnalysis ? 'Add Feature with Tagged Rules' : 'Add Feature')
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AICustomFeatureCreator
|
||||
203
src/components/apis/authApiClients.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import axios from "axios";
|
||||
import { safeLocalStorage } from "@/lib/utils";
|
||||
import { BACKEND_URL } from "@/config/backend";
|
||||
|
||||
let accessToken = safeLocalStorage.getItem('accessToken');
|
||||
let refreshToken = safeLocalStorage.getItem('refreshToken');
|
||||
|
||||
export const setTokens = (newAccessToken: string, newRefreshToken: string) => {
|
||||
accessToken = newAccessToken;
|
||||
refreshToken = newRefreshToken;
|
||||
safeLocalStorage.setItem('accessToken', newAccessToken);
|
||||
safeLocalStorage.setItem('refreshToken', newRefreshToken);
|
||||
};
|
||||
|
||||
export const clearTokens = () => {
|
||||
accessToken = null;
|
||||
refreshToken = null;
|
||||
safeLocalStorage.removeItem('accessToken');
|
||||
safeLocalStorage.removeItem('refreshToken');
|
||||
};
|
||||
|
||||
export const getAccessToken = () => {
|
||||
// Always get the latest token from localStorage
|
||||
const token = safeLocalStorage.getItem('accessToken');
|
||||
console.log('🔐 [getAccessToken] Token check:', {
|
||||
hasToken: !!token,
|
||||
tokenLength: token?.length || 0,
|
||||
tokenStart: token?.substring(0, 20) + '...' || 'No token',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (token) {
|
||||
// Validate token format (basic JWT structure check)
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
console.warn('🔐 [getAccessToken] Invalid token format, clearing...');
|
||||
clearTokens();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update the module variable for consistency
|
||||
accessToken = token;
|
||||
console.log('🔐 [getAccessToken] Valid token found and updated');
|
||||
return token;
|
||||
} else {
|
||||
console.log('🔐 [getAccessToken] No token found in localStorage');
|
||||
accessToken = null; // Ensure module variable is also null
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to clear all authentication data
|
||||
// export const clearAllAuthData = () => {
|
||||
// console.log('🧹 Clearing all authentication data...');
|
||||
// clearTokens();
|
||||
// safeLocalStorage.removeItem('codenuk_user');
|
||||
// console.log('✅ All authentication data cleared');
|
||||
// };
|
||||
|
||||
export const getRefreshToken = () => {
|
||||
// Always get the latest token from localStorage
|
||||
const token = safeLocalStorage.getItem('refreshToken');
|
||||
if (token) {
|
||||
refreshToken = token; // Update the module variable
|
||||
}
|
||||
return refreshToken;
|
||||
};
|
||||
|
||||
// Logout function that calls the backend API
|
||||
export const logout = async () => {
|
||||
try {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (refreshToken) {
|
||||
await authApiClient.post('/api/auth/logout', { refreshToken });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout API call failed:', error);
|
||||
// Continue with logout even if API call fails
|
||||
} finally {
|
||||
// Always clear tokens
|
||||
clearTokens();
|
||||
// Clear any other user data
|
||||
safeLocalStorage.removeItem('codenuk_user');
|
||||
}
|
||||
};
|
||||
|
||||
export const authApiClient = axios.create({
|
||||
baseURL: BACKEND_URL,
|
||||
withCredentials: true,
|
||||
timeout: 10000, // 10 second timeout
|
||||
});
|
||||
|
||||
// Add auth token to requests
|
||||
const addAuthTokenInterceptor = (client: typeof authApiClient) => {
|
||||
client.interceptors.request.use(
|
||||
(config) => {
|
||||
// Always get fresh token from localStorage instead of using module variable
|
||||
const freshToken = getAccessToken();
|
||||
// Attach user_id for backend routing that requires it
|
||||
try {
|
||||
const rawUser = safeLocalStorage.getItem('codenuk_user');
|
||||
if (rawUser) {
|
||||
const parsed = JSON.parse(rawUser);
|
||||
const userId = parsed?.id;
|
||||
if (userId) {
|
||||
config.headers = config.headers || {};
|
||||
// Header preferred by backend
|
||||
(config.headers as any)['x-user-id'] = userId;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
if (freshToken) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers.Authorization = `Bearer ${freshToken}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
};
|
||||
|
||||
// Handle token refresh
|
||||
const addTokenRefreshInterceptor = (client: typeof authApiClient) => {
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
// Surface detailed server error info in the console for debugging
|
||||
try {
|
||||
const status = error?.response?.status
|
||||
const data = error?.response?.data
|
||||
const url = error?.config?.url
|
||||
const method = error?.config?.method
|
||||
const message = error?.message || 'Unknown error'
|
||||
const code = error?.code
|
||||
|
||||
// Check if it's a network connectivity issue
|
||||
if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ETIMEDOUT') {
|
||||
console.error('🛑 Network connectivity issue:', {
|
||||
url: url || 'Unknown URL',
|
||||
method: method || 'Unknown method',
|
||||
code: code,
|
||||
message: message
|
||||
})
|
||||
} else {
|
||||
console.error('🛑 API error:', {
|
||||
url: url || 'Unknown URL',
|
||||
method: method || 'Unknown method',
|
||||
status: status || 'No status',
|
||||
data: data || 'No response data',
|
||||
message: message,
|
||||
errorType: error?.name || 'Unknown error type',
|
||||
code: code
|
||||
})
|
||||
}
|
||||
} catch (debugError) {
|
||||
console.error('🛑 Error logging failed:', debugError)
|
||||
console.error('🛑 Original error:', error)
|
||||
}
|
||||
|
||||
const originalRequest = error.config;
|
||||
const isRefreshEndpoint = originalRequest?.url?.includes('/api/auth/refresh');
|
||||
if (error.response?.status === 401 && !originalRequest._retry && !isRefreshEndpoint) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
// Always get fresh refresh token from localStorage
|
||||
const freshRefreshToken = getRefreshToken();
|
||||
if (freshRefreshToken) {
|
||||
const response = await client.post('/api/auth/refresh', {
|
||||
refreshToken: freshRefreshToken
|
||||
});
|
||||
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = response.data.data.tokens;
|
||||
setTokens(newAccessToken, newRefreshToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
return client(originalRequest);
|
||||
}
|
||||
// No refresh token available
|
||||
clearTokens();
|
||||
safeLocalStorage.removeItem('codenuk_user');
|
||||
// Prevent redirect loops by checking current location
|
||||
if (typeof window !== 'undefined' && !window.location.pathname.includes('/signin')) {
|
||||
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search)
|
||||
window.location.href = `/signin?error=Please sign in to continue.&returnUrl=${returnUrl}`;
|
||||
}
|
||||
return Promise.reject(error);
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh failed:', refreshError);
|
||||
clearTokens();
|
||||
safeLocalStorage.removeItem('codenuk_user');
|
||||
// Prevent redirect loops by checking current location
|
||||
if (typeof window !== 'undefined' && !window.location.pathname.includes('/signin')) {
|
||||
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search)
|
||||
window.location.href = `/signin?error=Session expired. Please sign in again.&returnUrl=${returnUrl}`;
|
||||
}
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
addAuthTokenInterceptor(authApiClient);
|
||||
addTokenRefreshInterceptor(authApiClient);
|
||||
130
src/components/apis/authenticationHandler.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { authApiClient } from "./authApiClients";
|
||||
import { safeLocalStorage } from "@/lib/utils";
|
||||
import { BACKEND_URL } from "@/config/backend";
|
||||
|
||||
interface ApiResponse {
|
||||
data?: {
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiError extends Error {
|
||||
response?: ApiResponse;
|
||||
}
|
||||
|
||||
export const registerUser = async (
|
||||
data: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role: string;
|
||||
}
|
||||
) => {
|
||||
console.log("Registering user with data:", data);
|
||||
try {
|
||||
// Using centralized backend URL
|
||||
const response = await fetch(`${BACKEND_URL}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// Extract error message from server response
|
||||
const errorMessage = responseData?.message || responseData?.error || responseData?.detail || `Registration failed (${response.status})`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
} catch (error: unknown) {
|
||||
console.error("Error registering user:", error);
|
||||
|
||||
// If it's already our enhanced error with proper message, re-throw it
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Create a proper error object for unexpected errors
|
||||
const enhancedError: ApiError = new Error(
|
||||
"Failed to register user. Please try again."
|
||||
);
|
||||
throw enhancedError;
|
||||
}
|
||||
};
|
||||
|
||||
export const loginUser = async (email: string, password: string) => {
|
||||
console.log("Logging in user with email:", email);
|
||||
try {
|
||||
// Using centralized backend URL
|
||||
const response = await fetch(`${BACKEND_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// Extract error message from server response
|
||||
const errorMessage = data?.message || data?.error || data?.detail || `Login failed (${response.status})`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error: unknown) {
|
||||
console.error("Error logging in user:", error);
|
||||
|
||||
// If it's already our enhanced error with proper message, re-throw it
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Create a proper error object for unexpected errors
|
||||
const enhancedError: ApiError = new Error(
|
||||
"Failed to log in. Please check your credentials."
|
||||
);
|
||||
throw enhancedError;
|
||||
}
|
||||
}
|
||||
|
||||
export const logoutUser = async () => {
|
||||
console.log("Logging out user");
|
||||
try {
|
||||
const refreshToken = safeLocalStorage.getItem('refreshToken');
|
||||
if (refreshToken) {
|
||||
// Using centralized backend URL
|
||||
const response = await fetch(`${BACKEND_URL}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ refreshToken })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
}
|
||||
return { success: true, message: "Logged out successfully" };
|
||||
} catch (error: unknown) {
|
||||
console.error("Error logging out user:", error);
|
||||
|
||||
// Create a proper error object
|
||||
const enhancedError: ApiError = new Error(
|
||||
error instanceof Error ? error.message : "Failed to log out. Please try again."
|
||||
);
|
||||
throw enhancedError;
|
||||
}
|
||||
}
|
||||
191
src/components/architecture/architecture-generator.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Code, Database, Server, Shield, Zap, Layers, GitBranch } from "lucide-react"
|
||||
|
||||
export default function ArchitectureGenerator() {
|
||||
const [selectedArchitecture, setSelectedArchitecture] = useState<string>("")
|
||||
const [projectName, setProjectName] = useState("")
|
||||
|
||||
const architecturePatterns = [
|
||||
{
|
||||
id: "monolithic",
|
||||
name: "Monolithic Architecture",
|
||||
description: "Single application with all components tightly coupled",
|
||||
icon: Layers,
|
||||
complexity: "Low",
|
||||
bestFor: "Small to medium projects",
|
||||
pros: ["Simple to develop", "Easy to deploy", "Lower initial cost"],
|
||||
cons: ["Harder to scale", "Technology lock-in", "Difficult to maintain"],
|
||||
},
|
||||
{
|
||||
id: "microservices",
|
||||
name: "Microservices Architecture",
|
||||
description: "Loosely coupled services that can be developed and deployed independently",
|
||||
icon: GitBranch,
|
||||
complexity: "High",
|
||||
bestFor: "Large, complex applications",
|
||||
pros: ["Independent scaling", "Technology diversity", "Easier maintenance"],
|
||||
cons: ["Distributed complexity", "Network overhead", "Data consistency challenges"],
|
||||
},
|
||||
{
|
||||
id: "serverless",
|
||||
name: "Serverless Architecture",
|
||||
description: "Event-driven, auto-scaling functions without server management",
|
||||
icon: Zap,
|
||||
complexity: "Medium",
|
||||
bestFor: "Event-driven applications",
|
||||
pros: ["Auto-scaling", "Pay-per-use", "No server management"],
|
||||
cons: ["Cold start latency", "Vendor lock-in", "Limited execution time"],
|
||||
},
|
||||
{
|
||||
id: "layered",
|
||||
name: "Layered Architecture",
|
||||
description: "Separation of concerns with distinct layers for different responsibilities",
|
||||
icon: Database,
|
||||
complexity: "Medium",
|
||||
bestFor: "Business applications",
|
||||
pros: ["Clear separation", "Easy to test", "Maintainable"],
|
||||
cons: ["Performance overhead", "Tight coupling between layers"],
|
||||
},
|
||||
]
|
||||
|
||||
const generateArchitecture = () => {
|
||||
if (!selectedArchitecture || !projectName) return
|
||||
|
||||
// Here you would typically call an API to generate the architecture
|
||||
console.log(`Generating ${selectedArchitecture} architecture for ${projectName}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-4 mb-12">
|
||||
<h1 className="text-4xl font-bold text-gray-900">Architecture Generator</h1>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Generate optimal architecture patterns for your project based on requirements and scale
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Project Name Input */}
|
||||
<div className="max-w-md mx-auto mb-8">
|
||||
<label htmlFor="projectName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Project Name
|
||||
</label>
|
||||
<Input
|
||||
id="projectName"
|
||||
placeholder="Enter your project name"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Architecture Patterns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||
{architecturePatterns.map((pattern) => {
|
||||
const Icon = pattern.icon
|
||||
return (
|
||||
<Card
|
||||
key={pattern.id}
|
||||
className={`cursor-pointer transition-all duration-300 hover:shadow-lg ${
|
||||
selectedArchitecture === pattern.id
|
||||
? "ring-2 ring-blue-500 bg-blue-50"
|
||||
: "hover:border-blue-300"
|
||||
}`}
|
||||
onClick={() => setSelectedArchitecture(pattern.id)}
|
||||
>
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="mx-auto w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mb-3">
|
||||
<Icon className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">{pattern.name}</CardTitle>
|
||||
<Badge variant="outline" className="w-fit mx-auto">
|
||||
{pattern.complexity} Complexity
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-gray-600 text-center">{pattern.description}</p>
|
||||
<div className="text-center">
|
||||
<p className="text-xs font-medium text-gray-700">Best for:</p>
|
||||
<p className="text-xs text-gray-600">{pattern.bestFor}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<div className="text-center">
|
||||
<Button
|
||||
onClick={generateArchitecture}
|
||||
disabled={!selectedArchitecture || !projectName}
|
||||
className="bg-blue-600 hover:bg-blue-700 px-8 py-3 text-lg"
|
||||
>
|
||||
<Code className="mr-2 h-5 w-5" />
|
||||
Generate Architecture
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Selected Architecture Details */}
|
||||
{selectedArchitecture && (
|
||||
<div className="mt-12 max-w-4xl mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Layers className="mr-2 h-5 w-5" />
|
||||
Architecture Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(() => {
|
||||
const pattern = architecturePatterns.find(p => p.id === selectedArchitecture)
|
||||
if (!pattern) return null
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-semibold text-green-700 mb-3 flex items-center">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
Advantages
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{pattern.pros.map((pro, index) => (
|
||||
<li key={index} className="flex items-center text-sm text-gray-700">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full mr-3"></div>
|
||||
{pro}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-red-700 mb-3 flex items-center">
|
||||
<Server className="mr-2 h-4 w-4" />
|
||||
Considerations
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{pattern.cons.map((con, index) => (
|
||||
<li key={index} className="flex items-center text-sm text-gray-700">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full mr-3"></div>
|
||||
{con}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
src/components/auth/auth-page.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { SignInForm } from "./signin-form"
|
||||
import { SignUpForm } from "./signup-form"
|
||||
|
||||
export function AuthPage() {
|
||||
const [isSignIn, setIsSignIn] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-2 gap-10">
|
||||
{/* Left: Gradient panel with steps */}
|
||||
<div className="hidden lg:block">
|
||||
<div className="relative h-full rounded-3xl overflow-hidden border border-white/10 bg-gradient-to-b from-orange-400 via-orange-500 to-black p-10">
|
||||
{/* subtle grain */}
|
||||
<div className="pointer-events-none absolute inset-0 opacity-20 [mask-image:radial-gradient(ellipse_at_center,black_70%,transparent_100%)]">
|
||||
<div className="w-full h-full bg-[radial-gradient(circle_at_1px_1px,rgba(255,255,255,0.12)_1px,transparent_0)] bg-[length:18px_18px]"></div>
|
||||
</div>
|
||||
|
||||
{/* soft circular accents (minimal) */}
|
||||
<div className="pointer-events-none absolute -top-10 -right-10 w-72 h-72 rounded-full blur-3xl bg-purple-300/40"></div>
|
||||
<div className="pointer-events-none absolute top-1/3 -left-10 w-56 h-56 rounded-full blur-2xl bg-indigo-300/25"></div>
|
||||
<div className="pointer-events-none absolute -bottom-12 left-1/3 w-64 h-64 rounded-full blur-3xl bg-purple-400/20"></div>
|
||||
|
||||
<div className="relative z-10 flex h-full flex-col justify-center">
|
||||
<div className="mb-12">
|
||||
<p className="text-5xl font-bold text-center text-white/80">Codenuk</p>
|
||||
</div>
|
||||
<h2 className="text-3xl text-center font-bold mb-3">Get Started with Us</h2>
|
||||
<p className="text-white/80 text-center mb-10">Complete these easy steps to register your account.</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Step 1 */}
|
||||
<div className={`${!isSignIn ? "bg-white text-black" : "bg-white/10 text-white border border-white/20"} rounded-xl p-4 flex items-center`}>
|
||||
<div className={`${!isSignIn ? "bg-black text-white" : "bg-white/20 text-white"} w-9 h-9 rounded-full flex items-center justify-center mr-4 text-sm font-semibold`}>1</div>
|
||||
<span className="font-medium">Sign up your account</span>
|
||||
</div>
|
||||
{/* Step 2 */}
|
||||
<div className={`${isSignIn ? "bg-white text-black" : "bg-white/10 text-white border border-white/20"} rounded-xl p-4 flex items-center`}>
|
||||
<div className={`${isSignIn ? "bg-black text-white" : "bg-white/20 text-white"} w-9 h-9 rounded-full flex items-center justify-center mr-4 text-sm font-semibold`}>2</div>
|
||||
<span className="font-medium">Set up your workspace</span>
|
||||
</div>
|
||||
{/* Step 3 */}
|
||||
{/* <div className="bg-white/10 text-white border border-white/20 rounded-xl p-4 flex items-center">
|
||||
<div className="w-9 h-9 rounded-full bg-white/20 text-white flex items-center justify-center mr-4 text-sm font-semibold">3</div>
|
||||
<span className="font-medium">Set up your profile</span>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Form area */}
|
||||
<div className="flex items-center">
|
||||
<div className="w-full max-w-md ml-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold text-white mb-1">{isSignIn ? "Sign In Account" : "Sign Up Account"}</h1>
|
||||
<p className="text-white/60">{isSignIn ? "Enter your credentials to access your account." : "Enter your personal data to create your account."}</p>
|
||||
</div>
|
||||
|
||||
{isSignIn ? (
|
||||
<SignInForm onToggleMode={() => setIsSignIn(false)} />
|
||||
) : (
|
||||
<SignUpForm onToggleMode={() => setIsSignIn(true)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
src/components/auth/emailVerification.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import EmailVerification from "@/app/auth/emailVerification";
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return <EmailVerification />;
|
||||
}
|
||||
|
||||
168
src/components/auth/signin-form.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Eye, EyeOff, Loader2, Shield } from "lucide-react"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { loginUser } from "@/components/apis/authenticationHandler"
|
||||
import { setTokens } from "@/components/apis/authApiClients"
|
||||
|
||||
interface SignInFormProps {
|
||||
onToggleMode?: () => void
|
||||
}
|
||||
|
||||
export function SignInForm({ }: SignInFormProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [formData, setFormData] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const { setUserFromApi } = useAuth()
|
||||
|
||||
// Get return URL from query parameters
|
||||
const getReturnUrl = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const returnUrl = urlParams.get('returnUrl')
|
||||
return returnUrl ? decodeURIComponent(returnUrl) : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
|
||||
if (!formData.email || !formData.password) {
|
||||
setError("Please fill in all required fields.")
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await loginUser(formData.email, formData.password)
|
||||
|
||||
if (response && response.data && response.data.tokens && response.data.user) {
|
||||
setTokens(response.data.tokens.accessToken, response.data.tokens.refreshToken)
|
||||
// Set user in context so header updates immediately
|
||||
setUserFromApi(response.data.user)
|
||||
// Persist for refresh
|
||||
localStorage.setItem("codenuk_user", JSON.stringify(response.data.user))
|
||||
|
||||
// Redirect based on return URL or user role
|
||||
try {
|
||||
const returnUrl = getReturnUrl()
|
||||
if (returnUrl && returnUrl !== '/signin' && returnUrl !== '/signup') {
|
||||
router.push(returnUrl)
|
||||
} else if (response.data.user.role === 'admin') {
|
||||
router.push("/admin")
|
||||
} else {
|
||||
router.push("/")
|
||||
}
|
||||
} catch (redirectError) {
|
||||
console.error('Redirect failed:', redirectError)
|
||||
// Fallback redirect
|
||||
const returnUrl = getReturnUrl()
|
||||
if (returnUrl && returnUrl !== '/signin' && returnUrl !== '/signup') {
|
||||
window.location.href = returnUrl
|
||||
} else {
|
||||
window.location.href = response.data.user.role === 'admin' ? '/admin' : '/'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setError("Invalid response from server. Please try again.")
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('Login error:', err)
|
||||
|
||||
// Handle different types of errors
|
||||
const error = err as { response?: { data?: { message?: string; error?: string } }; message?: string };
|
||||
if (error.response?.data?.message) {
|
||||
setError(error.response.data.message)
|
||||
} else if (error.response?.data?.error) {
|
||||
setError(error.response.data.error)
|
||||
} else if (error.message) {
|
||||
setError(error.message)
|
||||
} else {
|
||||
setError("An error occurred during login. Please try again.")
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-white text-sm">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
required
|
||||
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-white text-sm">Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter your password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required
|
||||
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 pr-12"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 text-white/60 hover:text-white hover:bg-white/5 transition-colors"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-300 text-sm text-center bg-red-900/20 p-3 rounded-md border border-red-500/30">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 cursor-pointer bg-gradient-to-r from-orange-500 to-orange-600 text-white hover:from-orange-600 hover:to-orange-700 transition-all duration-200 font-semibold shadow-lg hover:shadow-orange-500/25"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Signing In...
|
||||
</>
|
||||
) : (
|
||||
"Sign In"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
126
src/components/auth/signin-page.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { SignInForm } from "./signin-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CheckCircle, AlertCircle } from "lucide-react"
|
||||
|
||||
export function SignInPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [verificationMessage, setVerificationMessage] = useState<string | null>(null)
|
||||
const [verificationType, setVerificationType] = useState<'success' | 'error'>('success')
|
||||
|
||||
useEffect(() => {
|
||||
// Check for verification messages in URL
|
||||
if (typeof window !== 'undefined') {
|
||||
const verified = searchParams.get('verified')
|
||||
const message = searchParams.get('message')
|
||||
const error = searchParams.get('error')
|
||||
|
||||
if (verified === 'true') {
|
||||
setVerificationMessage('Email verified successfully! You can now sign in to your account.')
|
||||
setVerificationType('success')
|
||||
} else if (message) {
|
||||
setVerificationMessage(decodeURIComponent(message))
|
||||
setVerificationType('success')
|
||||
} else if (error) {
|
||||
setVerificationMessage(decodeURIComponent(error))
|
||||
setVerificationType('error')
|
||||
}
|
||||
|
||||
// Clear the message after 5 seconds
|
||||
if (verified || message || error) {
|
||||
const timer = setTimeout(() => {
|
||||
setVerificationMessage(null)
|
||||
}, 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-2 gap-10">
|
||||
{/* Left: Gradient panel with steps */}
|
||||
<div className="hidden lg:block">
|
||||
<div className="relative h-full rounded-3xl overflow-hidden border border-white/10 bg-gradient-to-b from-orange-400 via-orange-500 to-black p-10">
|
||||
{/* subtle grain */}
|
||||
<div className="pointer-events-none absolute inset-0 opacity-20 [mask-image:radial-gradient(ellipse_at_center,black_70%,transparent_100%)]">
|
||||
<div className="w-full h-full bg-[radial-gradient(circle_at_1px_1px,rgba(255,255,255,0.12)_1px,transparent_0)] bg-[length:18px_18px]"></div>
|
||||
</div>
|
||||
|
||||
{/* soft circular accents */}
|
||||
<div className="pointer-events-none absolute -top-10 -right-10 w-72 h-72 rounded-full blur-3xl bg-purple-300/40"></div>
|
||||
<div className="pointer-events-none absolute top-1/3 -left-10 w-56 h-56 rounded-full blur-2xl bg-indigo-300/25"></div>
|
||||
<div className="pointer-events-none absolute -bottom-12 left-1/3 w-64 h-64 rounded-full blur-3xl bg-purple-400/20"></div>
|
||||
|
||||
<div className="relative z-10 flex h-full flex-col justify-center">
|
||||
<div className="mb-12">
|
||||
<p className="text-5xl font-bold text-center text-white/80">Codenuk</p>
|
||||
</div>
|
||||
<h2 className="text-3xl text-center font-bold mb-3">Welcome Back!</h2>
|
||||
<p className="text-white/80 text-center mb-10">Sign in to access your workspace and continue building.</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Step 1 - Completed */}
|
||||
<div className="bg-white/10 text-white border border-white/20 rounded-xl p-4 flex items-center">
|
||||
<div className="w-9 h-9 rounded-full bg-white/20 text-white flex items-center justify-center mr-4 text-sm font-semibold">1</div>
|
||||
<span className="font-medium">Sign up your account</span>
|
||||
</div>
|
||||
{/* Step 2 - Active */}
|
||||
<div className="bg-white text-black rounded-xl p-4 flex items-center">
|
||||
<div className="w-9 h-9 rounded-full bg-black text-white flex items-center justify-center mr-4 text-sm font-semibold">2</div>
|
||||
<span className="font-medium">Sign in to workspace</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Form area */}
|
||||
<div className="flex items-center">
|
||||
<div className="w-full max-w-md ml-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold text-white mb-1">Sign In Account</h1>
|
||||
<p className="text-white/60">Enter your credentials to access your account.</p>
|
||||
</div>
|
||||
|
||||
{/* Verification Message */}
|
||||
{verificationMessage && (
|
||||
<div className={`mb-6 p-4 rounded-lg border ${
|
||||
verificationType === 'success'
|
||||
? 'bg-green-500/10 border-green-500/30 text-green-300'
|
||||
: 'bg-red-500/10 border-red-500/30 text-red-300'
|
||||
}`}>
|
||||
<div className="flex items-center space-x-2">
|
||||
{verificationType === 'success' ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-sm">{verificationMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SignInForm />
|
||||
|
||||
<div className="text-center pt-4">
|
||||
<div className="text-white/60 text-xs mb-1">Don't have an account?</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => router.push("/signup")}
|
||||
className="text-orange-400 hover:text-orange-300 font-medium transition-colors text-sm p-0 h-auto cursor-pointer"
|
||||
>
|
||||
Create a new account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
276
src/components/auth/signup-form.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
// Removed unused Card components
|
||||
import { Eye, EyeOff, Loader2, User, Mail, Lock, Shield } from "lucide-react"
|
||||
import { registerUser } from "../apis/authenticationHandler"
|
||||
|
||||
interface SignUpFormProps {
|
||||
onSignUpSuccess?: () => void
|
||||
onToggleMode?: () => void
|
||||
}
|
||||
|
||||
export function SignUpForm({ onSignUpSuccess }: SignUpFormProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
role: "user" // default role, adjust as needed
|
||||
})
|
||||
|
||||
// const { signup } = useAuth() // Unused variable
|
||||
const router = useRouter()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
|
||||
// Validation
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError("Passwords don't match")
|
||||
return
|
||||
}
|
||||
if (!formData.username || !formData.first_name || !formData.last_name || !formData.email || !formData.password) {
|
||||
setError("Please fill in all required fields.")
|
||||
return
|
||||
}
|
||||
|
||||
// Password strength validation
|
||||
if (formData.password.length < 8) {
|
||||
setError("Password must be at least 8 characters long")
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await registerUser({
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
role: formData.role
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
// Call success callback if provided
|
||||
if (onSignUpSuccess) {
|
||||
onSignUpSuccess()
|
||||
} else {
|
||||
// Default behavior - redirect to signin with message
|
||||
if (typeof window !== 'undefined') {
|
||||
const message = encodeURIComponent("Account created successfully! Please check your email to verify your account.")
|
||||
// Preserve return URL if it exists
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const returnUrl = urlParams.get('returnUrl')
|
||||
const redirectUrl = returnUrl ? `/signin?message=${message}&returnUrl=${returnUrl}` : `/signin?message=${message}`
|
||||
try {
|
||||
router.push(redirectUrl)
|
||||
} catch (redirectError) {
|
||||
console.error('Signup form redirect failed:', redirectError)
|
||||
// Fallback redirect
|
||||
window.location.href = redirectUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setError(response.message || "Failed to create account. Please try again.")
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('Signup error:', err)
|
||||
|
||||
// The authentication handler now properly extracts error messages from server responses
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError("An error occurred during registration. Please try again.")
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Personal Information Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<User className="h-4 w-4 text-orange-400" />
|
||||
<h3 className="text-base font-semibold text-white">Personal Information</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="first_name" className="text-white/80 text-xs font-medium">First Name</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
type="text"
|
||||
placeholder="John"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
||||
required
|
||||
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="last_name" className="text-white/80 text-xs font-medium">Last Name</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
type="text"
|
||||
placeholder="Doe"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
|
||||
required
|
||||
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="username" className="text-white/80 text-xs font-medium">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="johndoe123"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
required
|
||||
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{/* <div className="space-y-1">
|
||||
<Label htmlFor="role" className="text-white/80 text-xs font-medium">Role</Label>
|
||||
<Input
|
||||
id="role"
|
||||
type="text"
|
||||
placeholder="user"
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||
required
|
||||
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm"
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Information Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Mail className="h-4 w-4 text-orange-400" />
|
||||
<h3 className="text-base font-semibold text-white">Contact Information</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="email" className="text-white/80 text-xs font-medium">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="john.doe@example.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
required
|
||||
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Lock className="h-4 w-4 text-orange-400" />
|
||||
<h3 className="text-base font-semibold text-white">Security</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="password" className="text-white/80 text-xs font-medium">Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required
|
||||
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-2 py-1 text-white/60 hover:text-white hover:bg-white/5 transition-colors"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="confirmPassword" className="text-white/80 text-xs font-medium">Confirm</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Confirm"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
required
|
||||
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-2 py-1 text-white/60 hover:text-white hover:bg-white/5 transition-colors"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-300 text-xs text-center bg-red-900/20 p-3 rounded-md border border-red-500/30">
|
||||
<div className="flex items-center justify-center space-x-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-10 cursor-pointer bg-gradient-to-r from-orange-500 to-orange-600 text-white hover:from-orange-600 hover:to-orange-700 transition-all duration-200 font-semibold text-sm shadow-lg hover:shadow-orange-500/25"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating Account...
|
||||
</>
|
||||
) : (
|
||||
"Create Account"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
170
src/components/auth/signup-page.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { SignUpForm } from "./signup-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CheckCircle, ArrowRight } from "lucide-react"
|
||||
|
||||
export function SignUpPage() {
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleSignUpSuccess = () => {
|
||||
setIsSuccess(true)
|
||||
// Redirect to signin after 3 seconds with proper URL encoding
|
||||
setTimeout(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const message = encodeURIComponent("Please check your email to verify your account")
|
||||
// Preserve return URL if it exists
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const returnUrl = urlParams.get('returnUrl')
|
||||
const redirectUrl = returnUrl ? `/signin?message=${message}&returnUrl=${returnUrl}` : `/signin?message=${message}`
|
||||
try {
|
||||
router.push(redirectUrl)
|
||||
} catch (redirectError) {
|
||||
console.error('Signup redirect failed:', redirectError)
|
||||
// Fallback redirect
|
||||
window.location.href = redirectUrl
|
||||
}
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-2 gap-10">
|
||||
{/* Left: Gradient panel */}
|
||||
<div className="hidden lg:block">
|
||||
<div className="relative h-full rounded-3xl overflow-hidden border border-white/10 bg-gradient-to-b from-orange-400 via-orange-500 to-black p-10">
|
||||
{/* subtle grain */}
|
||||
<div className="pointer-events-none absolute inset-0 opacity-20 [mask-image:radial-gradient(ellipse_at_center,black_70%,transparent_100%)]">
|
||||
<div className="w-full h-full bg-[radial-gradient(circle_at_1px_1px,rgba(255,255,255,0.12)_1px,transparent_0)] bg-[length:18px_18px]"></div>
|
||||
</div>
|
||||
|
||||
{/* soft circular accents */}
|
||||
<div className="pointer-events-none absolute -top-10 -right-10 w-72 h-72 rounded-full blur-3xl bg-purple-300/40"></div>
|
||||
<div className="pointer-events-none absolute top-1/3 -left-10 w-56 h-56 rounded-full blur-2xl bg-indigo-300/25"></div>
|
||||
<div className="pointer-events-none absolute -bottom-12 left-1/3 w-64 h-64 rounded-full blur-3xl bg-purple-400/20"></div>
|
||||
|
||||
<div className="relative z-10 flex h-full flex-col justify-center">
|
||||
<div className="mb-12">
|
||||
<p className="text-5xl font-bold text-center text-white/80">Codenuk</p>
|
||||
</div>
|
||||
<h2 className="text-3xl text-center font-bold mb-3">Account Created!</h2>
|
||||
<p className="text-white/80 text-center mb-10">Your account has been successfully created. Please verify your email to continue.</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Step 1 - Completed */}
|
||||
<div className="bg-white text-black rounded-xl p-4 flex items-center">
|
||||
<div className="w-9 h-9 rounded-full bg-green-500 text-white flex items-center justify-center mr-4 text-sm font-semibold">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="font-medium">Sign up completed</span>
|
||||
</div>
|
||||
{/* Step 2 - Next */}
|
||||
<div className="bg-white/10 text-white border border-white/20 rounded-xl p-4 flex items-center">
|
||||
<div className="w-9 h-9 rounded-full bg-white/20 text-white flex items-center justify-center mr-4 text-sm font-semibold">2</div>
|
||||
<span className="font-medium">Verify email & sign in</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Success message */}
|
||||
<div className="flex items-center">
|
||||
<div className="w-full max-w-md ml-auto text-center">
|
||||
<div className="mb-8">
|
||||
<div className="w-20 h-20 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">Account Created Successfully!</h1>
|
||||
<p className="text-white/60 mb-6">We've sent a verification email to your inbox. Please check your email and click the verification link to activate your account.</p>
|
||||
<div className="bg-orange-500/10 border border-orange-500/30 rounded-lg p-4 mb-6">
|
||||
<p className="text-orange-300 text-sm">
|
||||
<strong>Next step:</strong> Check your email and click the verification link, then sign in to your account.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push("/signin")}
|
||||
className="bg-gradient-to-r from-orange-500 to-orange-600 text-white hover:from-orange-600 hover:to-orange-700"
|
||||
>
|
||||
Go to Sign In
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-2 gap-10">
|
||||
{/* Left: Gradient panel with steps */}
|
||||
<div className="hidden lg:block">
|
||||
<div className="relative h-full rounded-3xl overflow-hidden border border-white/10 bg-gradient-to-b from-orange-400 via-orange-500 to-black p-10">
|
||||
{/* subtle grain */}
|
||||
<div className="pointer-events-none absolute inset-0 opacity-20 [mask-image:radial-gradient(ellipse_at_center,black_70%,transparent_100%)]">
|
||||
<div className="w-full h-full bg-[radial-gradient(circle_at_1px_1px,rgba(255,255,255,0.12)_1px,transparent_0)] bg-[length:18px_18px]"></div>
|
||||
</div>
|
||||
|
||||
{/* soft circular accents */}
|
||||
<div className="pointer-events-none absolute -top-10 -right-10 w-72 h-72 rounded-full blur-3xl bg-purple-300/40"></div>
|
||||
<div className="pointer-events-none absolute top-1/3 -left-10 w-56 h-56 rounded-full blur-2xl bg-indigo-300/25"></div>
|
||||
<div className="pointer-events-none absolute -bottom-12 left-1/3 w-64 h-64 rounded-full blur-3xl bg-purple-400/20"></div>
|
||||
|
||||
<div className="relative z-10 flex h-full flex-col justify-center">
|
||||
<div className="mb-12">
|
||||
<p className="text-5xl font-bold text-center text-white/80">Codenuk</p>
|
||||
</div>
|
||||
<h2 className="text-3xl text-center font-bold mb-3">Get Started with Us</h2>
|
||||
<p className="text-white/80 text-center mb-10">Complete these easy steps to register your account.</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Step 1 - Active */}
|
||||
<div className="bg-white text-black rounded-xl p-4 flex items-center">
|
||||
<div className="w-9 h-9 rounded-full bg-black text-white flex items-center justify-center mr-4 text-sm font-semibold">1</div>
|
||||
<span className="font-medium">Sign up your account</span>
|
||||
</div>
|
||||
{/* Step 2 - Next */}
|
||||
<div className="bg-white/10 text-white border border-white/20 rounded-xl p-4 flex items-center">
|
||||
<div className="w-9 h-9 rounded-full bg-white/20 text-white flex items-center justify-center mr-4 text-sm font-semibold">2</div>
|
||||
<span className="font-medium">Verify email & sign in</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Form area */}
|
||||
<div className="flex items-center">
|
||||
<div className="w-full max-w-md ml-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold text-white mb-1">Sign Up Account</h1>
|
||||
<p className="text-white/60">Enter your personal data to create your account.</p>
|
||||
</div>
|
||||
|
||||
<SignUpForm onSignUpSuccess={handleSignUpSuccess} />
|
||||
|
||||
<div className="text-center pt-4">
|
||||
<div className="text-white/60 text-xs mb-1">Already have an account?</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => router.push("/signin")}
|
||||
className="text-orange-400 hover:text-orange-300 font-medium transition-colors text-sm p-0 h-auto cursor-pointer"
|
||||
>
|
||||
Sign in to your account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
546
src/components/business-context/business-context-generator.tsx
Normal file
@ -0,0 +1,546 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Users, Server, DollarSign, Shield } from "lucide-react"
|
||||
|
||||
interface BusinessContext {
|
||||
userScale: {
|
||||
expectedUsers: string
|
||||
growthRate: string
|
||||
peakTraffic: string
|
||||
globalReach: boolean
|
||||
}
|
||||
technical: {
|
||||
performance: string
|
||||
availability: string
|
||||
security: string[]
|
||||
integrations: string[]
|
||||
}
|
||||
business: {
|
||||
model: string
|
||||
revenue: string
|
||||
budget: string
|
||||
timeline: string
|
||||
}
|
||||
operational: {
|
||||
team: string
|
||||
maintenance: string
|
||||
monitoring: string[]
|
||||
compliance: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export default function BusinessContextGenerator() {
|
||||
const [currentSection, setCurrentSection] = useState(0)
|
||||
const [context, setContext] = useState<BusinessContext>({
|
||||
userScale: {
|
||||
expectedUsers: "",
|
||||
growthRate: "",
|
||||
peakTraffic: "",
|
||||
globalReach: false,
|
||||
},
|
||||
technical: {
|
||||
performance: "",
|
||||
availability: "",
|
||||
security: [],
|
||||
integrations: [],
|
||||
},
|
||||
business: {
|
||||
model: "",
|
||||
revenue: "",
|
||||
budget: "",
|
||||
timeline: "",
|
||||
},
|
||||
operational: {
|
||||
team: "",
|
||||
maintenance: "",
|
||||
monitoring: [],
|
||||
compliance: [],
|
||||
},
|
||||
})
|
||||
|
||||
const sections = [
|
||||
{ title: "User Scale & Growth", icon: Users, color: "bg-blue-500" },
|
||||
{ title: "Technical Requirements", icon: Server, color: "bg-green-500" },
|
||||
{ title: "Business Model", icon: DollarSign, color: "bg-purple-500" },
|
||||
{ title: "Operational Context", icon: Shield, color: "bg-orange-500" },
|
||||
]
|
||||
|
||||
const progress = ((currentSection + 1) / sections.length) * 100
|
||||
|
||||
const updateContext = (section: keyof BusinessContext, field: string, value: any) => {
|
||||
setContext((prev) => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section],
|
||||
[field]: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleArrayValue = (section: keyof BusinessContext, field: string, value: string) => {
|
||||
setContext((prev) => {
|
||||
const currentArray = (prev[section] as any)[field] || []
|
||||
const newArray = currentArray.includes(value)
|
||||
? currentArray.filter((item: string) => item !== value)
|
||||
: [...currentArray, value]
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section],
|
||||
[field]: newArray,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const renderUserScaleSection = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label htmlFor="expectedUsers">Expected Number of Users</Label>
|
||||
<Select
|
||||
value={context.userScale.expectedUsers}
|
||||
onValueChange={(value) => updateContext("userScale", "expectedUsers", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select user range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1-100">1-100 users</SelectItem>
|
||||
<SelectItem value="100-1000">100-1,000 users</SelectItem>
|
||||
<SelectItem value="1000-10000">1,000-10,000 users</SelectItem>
|
||||
<SelectItem value="10000-100000">10,000-100,000 users</SelectItem>
|
||||
<SelectItem value="100000+">100,000+ users</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="growthRate">Expected Growth Rate</Label>
|
||||
<Select
|
||||
value={context.userScale.growthRate}
|
||||
onValueChange={(value) => updateContext("userScale", "growthRate", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select growth rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="slow">Slow (10-25% annually)</SelectItem>
|
||||
<SelectItem value="moderate">Moderate (25-50% annually)</SelectItem>
|
||||
<SelectItem value="fast">Fast (50-100% annually)</SelectItem>
|
||||
<SelectItem value="explosive">Explosive (100%+ annually)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="peakTraffic">Peak Traffic Expectations</Label>
|
||||
<Select
|
||||
value={context.userScale.peakTraffic}
|
||||
onValueChange={(value) => updateContext("userScale", "peakTraffic", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select peak traffic" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2x">2x normal traffic</SelectItem>
|
||||
<SelectItem value="5x">5x normal traffic</SelectItem>
|
||||
<SelectItem value="10x">10x normal traffic</SelectItem>
|
||||
<SelectItem value="50x">50x+ normal traffic</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="globalReach"
|
||||
checked={context.userScale.globalReach}
|
||||
onCheckedChange={(checked) => updateContext("userScale", "globalReach", checked)}
|
||||
/>
|
||||
<Label htmlFor="globalReach">Global user base (multiple regions)</Label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderTechnicalSection = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label>Performance Requirements</Label>
|
||||
<RadioGroup
|
||||
value={context.technical.performance}
|
||||
onValueChange={(value) => updateContext("technical", "performance", value)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="basic" id="perf-basic" />
|
||||
<Label htmlFor="perf-basic">Basic (3-5s load time)</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="good" id="perf-good" />
|
||||
<Label htmlFor="perf-good">Good (1-3s load time)</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="excellent" id="perf-excellent" />
|
||||
<Label htmlFor="perf-excellent">Excellent (<1s load time)</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Availability Requirements</Label>
|
||||
<RadioGroup
|
||||
value={context.technical.availability}
|
||||
onValueChange={(value) => updateContext("technical", "availability", value)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="99" id="avail-99" />
|
||||
<Label htmlFor="avail-99">99% uptime (8.76 hours downtime/year)</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="99.9" id="avail-999" />
|
||||
<Label htmlFor="avail-999">99.9% uptime (8.76 hours downtime/year)</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="99.99" id="avail-9999" />
|
||||
<Label htmlFor="avail-9999">99.99% uptime (52.56 minutes downtime/year)</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Security Requirements</Label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
{[
|
||||
"Authentication",
|
||||
"Authorization",
|
||||
"Data Encryption",
|
||||
"GDPR Compliance",
|
||||
"SOC2 Compliance",
|
||||
"PCI Compliance",
|
||||
].map((security) => (
|
||||
<div key={security} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={security}
|
||||
checked={context.technical.security.includes(security)}
|
||||
onCheckedChange={() => toggleArrayValue("technical", "security", security)}
|
||||
/>
|
||||
<Label htmlFor={security}>{security}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Required Integrations</Label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
{[
|
||||
"Payment Processing",
|
||||
"Email Service",
|
||||
"SMS Service",
|
||||
"Analytics",
|
||||
"CRM",
|
||||
"Social Media",
|
||||
"File Storage",
|
||||
"CDN",
|
||||
].map((integration) => (
|
||||
<div key={integration} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={integration}
|
||||
checked={context.technical.integrations.includes(integration)}
|
||||
onCheckedChange={() => toggleArrayValue("technical", "integrations", integration)}
|
||||
/>
|
||||
<Label htmlFor={integration}>{integration}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderBusinessSection = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label>Business Model</Label>
|
||||
<RadioGroup value={context.business.model} onValueChange={(value) => updateContext("business", "model", value)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="saas" id="model-saas" />
|
||||
<Label htmlFor="model-saas">SaaS (Subscription)</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="ecommerce" id="model-ecommerce" />
|
||||
<Label htmlFor="model-ecommerce">E-commerce</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="marketplace" id="model-marketplace" />
|
||||
<Label htmlFor="model-marketplace">Marketplace</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="freemium" id="model-freemium" />
|
||||
<Label htmlFor="model-freemium">Freemium</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="enterprise" id="model-enterprise" />
|
||||
<Label htmlFor="model-enterprise">Enterprise B2B</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="revenue">Expected Revenue (Year 1)</Label>
|
||||
<Select value={context.business.revenue} onValueChange={(value) => updateContext("business", "revenue", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select revenue range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0-10k">$0 - $10K</SelectItem>
|
||||
<SelectItem value="10k-100k">$10K - $100K</SelectItem>
|
||||
<SelectItem value="100k-1m">$100K - $1M</SelectItem>
|
||||
<SelectItem value="1m+">$1M+</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="budget">Development Budget</Label>
|
||||
<Select value={context.business.budget} onValueChange={(value) => updateContext("business", "budget", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select budget range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0-5k">$0 - $5K</SelectItem>
|
||||
<SelectItem value="5k-25k">$5K - $25K</SelectItem>
|
||||
<SelectItem value="25k-100k">$25K - $100K</SelectItem>
|
||||
<SelectItem value="100k+">$100K+</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="timeline">Launch Timeline</Label>
|
||||
<Select
|
||||
value={context.business.timeline}
|
||||
onValueChange={(value) => updateContext("business", "timeline", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select timeline" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1-3months">1-3 months</SelectItem>
|
||||
<SelectItem value="3-6months">3-6 months</SelectItem>
|
||||
<SelectItem value="6-12months">6-12 months</SelectItem>
|
||||
<SelectItem value="12months+">12+ months</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderOperationalSection = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label>Team Size</Label>
|
||||
<RadioGroup
|
||||
value={context.operational.team}
|
||||
onValueChange={(value) => updateContext("operational", "team", value)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="solo" id="team-solo" />
|
||||
<Label htmlFor="team-solo">Solo developer</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="small" id="team-small" />
|
||||
<Label htmlFor="team-small">Small team (2-5 people)</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="medium" id="team-medium" />
|
||||
<Label htmlFor="team-medium">Medium team (5-15 people)</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="large" id="team-large" />
|
||||
<Label htmlFor="team-large">Large team (15+ people)</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Maintenance Approach</Label>
|
||||
<RadioGroup
|
||||
value={context.operational.maintenance}
|
||||
onValueChange={(value) => updateContext("operational", "maintenance", value)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="minimal" id="maint-minimal" />
|
||||
<Label htmlFor="maint-minimal">Minimal maintenance</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="regular" id="maint-regular" />
|
||||
<Label htmlFor="maint-regular">Regular updates</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="continuous" id="maint-continuous" />
|
||||
<Label htmlFor="maint-continuous">Continuous deployment</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Monitoring Requirements</Label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
{[
|
||||
"Error Tracking",
|
||||
"Performance Monitoring",
|
||||
"User Analytics",
|
||||
"Security Monitoring",
|
||||
"Uptime Monitoring",
|
||||
"Log Management",
|
||||
].map((monitoring) => (
|
||||
<div key={monitoring} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={monitoring}
|
||||
checked={context.operational.monitoring.includes(monitoring)}
|
||||
onCheckedChange={() => toggleArrayValue("operational", "monitoring", monitoring)}
|
||||
/>
|
||||
<Label htmlFor={monitoring}>{monitoring}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Compliance Requirements</Label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
{["GDPR", "CCPA", "HIPAA", "SOX", "PCI DSS", "ISO 27001"].map((compliance) => (
|
||||
<div key={compliance} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={compliance}
|
||||
checked={context.operational.compliance.includes(compliance)}
|
||||
onCheckedChange={() => toggleArrayValue("operational", "compliance", compliance)}
|
||||
/>
|
||||
<Label htmlFor={compliance}>{compliance}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderSection = () => {
|
||||
switch (currentSection) {
|
||||
case 0:
|
||||
return renderUserScaleSection()
|
||||
case 1:
|
||||
return renderTechnicalSection()
|
||||
case 2:
|
||||
return renderBusinessSection()
|
||||
case 3:
|
||||
return renderOperationalSection()
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Business Context Generator</h1>
|
||||
<p className="text-gray-600">
|
||||
Help us understand your business requirements to generate the perfect architecture
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-gray-700">Progress</span>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{currentSection + 1} of {sections.length}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 mb-8">
|
||||
{sections.map((section, index) => {
|
||||
const Icon = section.icon
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
className={`cursor-pointer transition-all ${
|
||||
index === currentSection
|
||||
? "ring-2 ring-blue-500 shadow-lg"
|
||||
: index < currentSection
|
||||
? "bg-green-50 border-green-200"
|
||||
: "hover:shadow-md"
|
||||
}`}
|
||||
onClick={() => setCurrentSection(index)}
|
||||
>
|
||||
<CardContent className="p-4 text-center">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-full ${section.color} flex items-center justify-center mx-auto mb-2`}
|
||||
>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-sm">{section.title}</h3>
|
||||
{index < currentSection && (
|
||||
<Badge variant="secondary" className="mt-2">
|
||||
Completed
|
||||
</Badge>
|
||||
)}
|
||||
{index === currentSection && <Badge className="mt-2">Current</Badge>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{React.createElement(sections[currentSection].icon, { className: "w-5 h-5" })}
|
||||
{sections[currentSection].title}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{currentSection === 0 && "Define your expected user base and growth patterns"}
|
||||
{currentSection === 1 && "Specify technical performance and security requirements"}
|
||||
{currentSection === 2 && "Outline your business model and financial expectations"}
|
||||
{currentSection === 3 && "Configure operational and compliance requirements"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{renderSection()}</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentSection(Math.max(0, currentSection - 1))}
|
||||
disabled={currentSection === 0}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (currentSection < sections.length - 1) {
|
||||
setCurrentSection(currentSection + 1)
|
||||
} else {
|
||||
// Generate architecture
|
||||
console.log("Business Context:", context)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{currentSection === sections.length - 1 ? "Generate Architecture" : "Next"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
332
src/components/business-context/typeform-survey.tsx
Normal file
@ -0,0 +1,332 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react'
|
||||
|
||||
interface Question {
|
||||
id: number
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
interface TypeformSurveyProps {
|
||||
questions: string[]
|
||||
onComplete: (answers: Question[]) => void
|
||||
onProgress?: (answers: Question[]) => void
|
||||
onBack: () => void
|
||||
projectName?: string
|
||||
}
|
||||
|
||||
export default function TypeformSurvey({
|
||||
questions,
|
||||
onComplete,
|
||||
onProgress,
|
||||
onBack,
|
||||
projectName = 'your project'
|
||||
}: TypeformSurveyProps) {
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
|
||||
const [answers, setAnswers] = useState<Record<number, string>>({})
|
||||
const [isTransitioning, setIsTransitioning] = useState(false)
|
||||
|
||||
const totalQuestions = questions.length
|
||||
const progress = ((currentQuestionIndex + 1) / totalQuestions) * 100
|
||||
const answeredCount = Object.values(answers).filter(answer => answer.trim()).length
|
||||
|
||||
// Initialize answers
|
||||
useEffect(() => {
|
||||
const initialAnswers: Record<number, string> = {}
|
||||
questions.forEach((_, index) => {
|
||||
initialAnswers[index] = ''
|
||||
})
|
||||
setAnswers(initialAnswers)
|
||||
}, [questions])
|
||||
|
||||
const handleAnswerChange = (value: string) => {
|
||||
const newAnswers = {
|
||||
...answers,
|
||||
[currentQuestionIndex]: value
|
||||
}
|
||||
setAnswers(newAnswers)
|
||||
|
||||
// Call onProgress callback if provided
|
||||
if (onProgress) {
|
||||
const questionAnswers: Question[] = questions.map((question, index) => ({
|
||||
id: index,
|
||||
question,
|
||||
answer: newAnswers[index] || ''
|
||||
}))
|
||||
onProgress(questionAnswers)
|
||||
}
|
||||
}
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
if (currentQuestionIndex < totalQuestions - 1) {
|
||||
setIsTransitioning(true)
|
||||
setTimeout(() => {
|
||||
setCurrentQuestionIndex(prev => prev + 1)
|
||||
setIsTransitioning(false)
|
||||
}, 150)
|
||||
} else {
|
||||
// Submit all answers
|
||||
const questionAnswers: Question[] = questions.map((question, index) => ({
|
||||
id: index,
|
||||
question,
|
||||
answer: answers[index] || ''
|
||||
}))
|
||||
onComplete(questionAnswers)
|
||||
}
|
||||
}, [currentQuestionIndex, totalQuestions, answers, questions, onComplete])
|
||||
|
||||
const goToPrevious = useCallback(() => {
|
||||
if (currentQuestionIndex > 0) {
|
||||
setIsTransitioning(true)
|
||||
setTimeout(() => {
|
||||
setCurrentQuestionIndex(prev => prev - 1)
|
||||
setIsTransitioning(false)
|
||||
}, 150)
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
}, [currentQuestionIndex, onBack])
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
goToNext()
|
||||
}
|
||||
}
|
||||
|
||||
const currentAnswer = answers[currentQuestionIndex] || ''
|
||||
const canProceed = currentAnswer.trim().length > 0
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white relative overflow-hidden">
|
||||
{/* Progress Bar */}
|
||||
<div className="bg-black/80 backdrop-blur-sm border-b border-white/10">
|
||||
<div className="max-w-4xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-sm text-white/60">
|
||||
Question {currentQuestionIndex + 1} of {totalQuestions}
|
||||
</div>
|
||||
<div className="text-sm text-white/60">
|
||||
{Math.round(progress)}% complete
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-white/10 rounded-full h-2">
|
||||
<motion.div
|
||||
className="bg-orange-500 h-2 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="pt-4 pb-16 px-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentQuestionIndex}
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
className="flex flex-col pt-8"
|
||||
>
|
||||
{/* Question */}
|
||||
<div className="text-center mb-6">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.4 }}
|
||||
className="text-xl font-semibold leading-tight mb-4"
|
||||
>
|
||||
{questions[currentQuestionIndex]}
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.4 }}
|
||||
className="text-base text-white/60"
|
||||
>
|
||||
Help us understand your {projectName} better
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Answer Input */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.4 }}
|
||||
className="w-full mx-auto mb-6"
|
||||
>
|
||||
<textarea
|
||||
value={currentAnswer}
|
||||
onChange={(e) => handleAnswerChange(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder="Type your answer here..."
|
||||
className="w-full bg-white/5 border border-white/20 rounded-lg p-3 text-white placeholder:text-white/40 focus:outline-none focus:ring-1 focus:ring-orange-500 focus:border-orange-500 resize-none h-24 text-base leading-relaxed"
|
||||
autoFocus
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Navigation */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.4 }}
|
||||
className="flex items-center justify-between mt-6 max-w-6xl mx-auto w-full"
|
||||
>
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-lg transition-all duration-200 group"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
||||
<span>{currentQuestionIndex === 0 ? 'Back to Features' : 'Previous'}</span>
|
||||
</button>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
onClick={goToNext}
|
||||
disabled={!canProceed || isTransitioning}
|
||||
className={`flex items-center gap-2 px-6 py-2 rounded-lg transition-all duration-200 group ${
|
||||
canProceed
|
||||
? 'bg-orange-500 hover:bg-orange-600 text-white'
|
||||
: 'bg-white/10 text-white/40 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<span>{currentQuestionIndex === totalQuestions - 1 ? 'Submit' : 'Next'}</span>
|
||||
{currentQuestionIndex === totalQuestions - 1 ? (
|
||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5, duration: 0.4 }}
|
||||
className="text-center mt-4"
|
||||
>
|
||||
<div className="text-sm text-white/40">
|
||||
{answeredCount} of {totalQuestions} questions answered
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background Elements */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-orange-500/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-orange-500/3 rounded-full blur-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Summary Component for final review
|
||||
interface SummaryProps {
|
||||
questions: Question[]
|
||||
onBack: () => void
|
||||
onSubmit: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function SurveySummary({ questions, onBack, onSubmit, loading = false }: SummaryProps) {
|
||||
const answeredQuestions = questions.filter(q => q.answer.trim())
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
{/* Header */}
|
||||
<div className="border-b border-white/10 bg-black/80 backdrop-blur-sm">
|
||||
<div className="max-w-4xl mx-auto px-6 py-4">
|
||||
<h1 className="text-xl font-semibold text-center">Review Your Answers</h1>
|
||||
<p className="text-center text-white/60 mt-1 text-sm">
|
||||
Please review your responses before submitting
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Content */}
|
||||
<div className="max-w-4xl mx-auto px-6 py-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
{answeredQuestions.map((question, index) => (
|
||||
<motion.div
|
||||
key={question.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="bg-white/5 border border-white/10 rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-orange-500/20 text-orange-300 rounded-full w-6 h-6 flex items-center justify-center text-xs font-semibold flex-shrink-0 mt-0.5">
|
||||
{question.id + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-white mb-2">
|
||||
{question.question}
|
||||
</h3>
|
||||
<p className="text-white/80 leading-relaxed text-sm">
|
||||
{question.answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="flex items-center justify-between mt-6"
|
||||
>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span>Edit Answers</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={loading}
|
||||
className={`flex items-center gap-2 px-6 py-2 rounded-lg transition-all duration-200 group ${
|
||||
loading
|
||||
? 'bg-orange-500/50 text-white cursor-not-allowed'
|
||||
: 'bg-orange-500 hover:bg-orange-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Generating Recommendations...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Submit & Generate Recommendations</span>
|
||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
src/components/canvas.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { useDroppable } from "@dnd-kit/core"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useEditorStore } from "@/lib/store"
|
||||
import { ComponentRenderer } from "@/components/component-renderer"
|
||||
import { MousePointer } from "lucide-react"
|
||||
|
||||
export function Canvas() {
|
||||
const { components, selectComponent, moveComponent } = useEditorStore()
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [dragState, setDragState] = useState<{
|
||||
id: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
} | null>(null)
|
||||
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: "canvas",
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Canvas Header */}
|
||||
<div className="p-4 border-b border-border bg-card">
|
||||
<h2 className="text-lg font-semibold text-card-foreground">Canvas</h2>
|
||||
<p className="text-sm text-muted-foreground">Drag components here to build your interface</p>
|
||||
</div>
|
||||
|
||||
{/* Canvas Area */}
|
||||
<div
|
||||
ref={(node) => {
|
||||
setNodeRef(node)
|
||||
containerRef.current = node
|
||||
}}
|
||||
className={`flex-1 relative canvas-grid overflow-auto ${isOver ? "drop-zone-active" : ""}`}
|
||||
onClick={() => selectComponent(null)}
|
||||
onMouseMove={(e) => {
|
||||
if (!dragState || !containerRef.current) return
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left - dragState.offsetX + containerRef.current.scrollLeft
|
||||
const y = e.clientY - rect.top - dragState.offsetY + containerRef.current.scrollTop
|
||||
moveComponent(dragState.id, { x: Math.max(0, x), y: Math.max(0, y) })
|
||||
}}
|
||||
onMouseUp={() => setDragState(null)}
|
||||
onMouseLeave={() => setDragState(null)}
|
||||
>
|
||||
{components.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-muted flex items-center justify-center">
|
||||
<MousePointer className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">Start Building</h3>
|
||||
<p className="text-muted-foreground max-w-sm">
|
||||
Drag components from the sidebar to start building your interface
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
components.map((component) => (
|
||||
<ComponentRenderer
|
||||
key={component.id}
|
||||
component={component}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
selectComponent(component)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
const target = e.currentTarget as HTMLDivElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
const offsetX = e.clientX - rect.left
|
||||
const offsetY = e.clientY - rect.top
|
||||
setDragState({ id: component.id, offsetX, offsetY })
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
132
src/components/component-palette.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
"use client"
|
||||
|
||||
import { useDraggable } from "@dnd-kit/core"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Table,
|
||||
Calendar,
|
||||
Circle,
|
||||
Square,
|
||||
Type,
|
||||
MousePointer,
|
||||
CreditCard,
|
||||
ChevronDown,
|
||||
Tags as Tabs,
|
||||
BarChart3,
|
||||
ToggleLeft,
|
||||
User,
|
||||
Medal as Modal,
|
||||
Search,
|
||||
} from "lucide-react"
|
||||
|
||||
const COMPONENT_TYPES = [
|
||||
{ id: "data-table", name: "Data Table", icon: Table, category: "Data" },
|
||||
{ id: "contextmenu", name: "Context Menu", icon: MousePointer, category: "Overlay" },
|
||||
{ id: "menubar", name: "Menubar", icon: Tabs, category: "Layout" },
|
||||
{ id: "carousel", name: "Carousel", icon: BarChart3, category: "Display" },
|
||||
{ id: "drawer", name: "Drawer", icon: Modal, category: "Overlay" },
|
||||
{ id: "table", name: "Table", icon: Table, category: "Data" },
|
||||
{ id: "datepicker", name: "Date Picker", icon: Calendar, category: "Input" },
|
||||
{ id: "radiogroup", name: "Radio Group", icon: Circle, category: "Input" },
|
||||
{ id: "checkbox", name: "Checkbox", icon: Square, category: "Input" },
|
||||
{ id: "input", name: "Input", icon: Type, category: "Input" },
|
||||
{ id: "textarea", name: "Textarea", icon: Type, category: "Input" },
|
||||
{ id: "button", name: "Button", icon: MousePointer, category: "Action" },
|
||||
{ id: "card", name: "Card", icon: CreditCard, category: "Layout" },
|
||||
{ id: "select", name: "Select", icon: ChevronDown, category: "Input" },
|
||||
{ id: "tabs", name: "Tabs", icon: Tabs, category: "Layout" },
|
||||
{ id: "progress", name: "Progress", icon: BarChart3, category: "Display" },
|
||||
{ id: "switch", name: "Switch", icon: ToggleLeft, category: "Input" },
|
||||
{ id: "avatar", name: "Avatar", icon: User, category: "Display" },
|
||||
{ id: "dialog", name: "Dialog", icon: Modal, category: "Overlay" },
|
||||
]
|
||||
|
||||
function DraggableComponent({ component }: { component: (typeof COMPONENT_TYPES)[0] }) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: component.id,
|
||||
})
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`p-3 bg-card border border-border rounded-md cursor-grab hover:bg-accent/10 transition-colors ${
|
||||
isDragging ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<component.icon className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-card-foreground">{component.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComponentPalette() {
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("All")
|
||||
|
||||
const categories = ["All", ...Array.from(new Set(COMPONENT_TYPES.map((c) => c.category)))]
|
||||
|
||||
const filteredComponents = COMPONENT_TYPES.filter((component) => {
|
||||
const matchesSearch = component.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesCategory = selectedCategory === "All" || component.category === selectedCategory
|
||||
return matchesSearch && matchesCategory
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-sidebar-border">
|
||||
<h2 className="text-lg font-semibold text-sidebar-foreground mb-3">Components</h2>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search components..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 bg-input border-border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-2 py-1 text-xs rounded-md transition-colors ${
|
||||
selectedCategory === category
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "bg-sidebar hover:bg-sidebar-accent/20 text-sidebar-foreground"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Component List */}
|
||||
<ScrollArea className="flex-1 component-panel-scroll">
|
||||
<div className="p-4 space-y-2">
|
||||
{filteredComponents.map((component) => (
|
||||
<DraggableComponent key={component.id} component={component} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
795
src/components/component-renderer.tsx
Normal file
@ -0,0 +1,795 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { type ComponentInstance, useEditorStore } from "@/lib/store"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
} from "@/components/ui/menubar"
|
||||
import {
|
||||
Carousel as UiCarousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from "@/components/ui/carousel"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Plus, Minus, User } from "lucide-react"
|
||||
import { WireframeRenderer } from "./wireframe-renderer"
|
||||
|
||||
interface ComponentRendererProps {
|
||||
component: ComponentInstance
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
onMouseDown?: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
export function ComponentRenderer({ component, onClick, onMouseDown }: ComponentRendererProps) {
|
||||
const { selectedComponent, updateComponent } = useEditorStore()
|
||||
const isSelected = selectedComponent?.id === component.id
|
||||
|
||||
const [checkboxState, setCheckboxState] = useState(component.props.checked || false)
|
||||
const [switchState, setSwitchState] = useState(component.props.checked || false)
|
||||
const [radioValue, setRadioValue] = useState(component.props.value || "")
|
||||
const [progressValue, setProgressValue] = useState(component.props.value || 50)
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
|
||||
component.props.selectedDate ? new Date(component.props.selectedDate) : undefined,
|
||||
)
|
||||
const [tableData, setTableData] = useState(
|
||||
component.props.rows || [
|
||||
["John", "john@example.com"],
|
||||
["Jane", "jane@example.com"],
|
||||
["kenil", "kenil@example.com"],
|
||||
["kavya", "kavya@example.com"],
|
||||
["raj", "raj@example.com"]
|
||||
],
|
||||
)
|
||||
const [editingCell, setEditingCell] = useState<{ row: number; col: number } | null>(null)
|
||||
const [editValue, setEditValue] = useState("")
|
||||
const [cardTitle, setCardTitle] = useState(component.props.title || "Card Title")
|
||||
const [cardDescription, setCardDescription] = useState(component.props.description || "Card description")
|
||||
const [cardContent, setCardContent] = useState(component.props.content || "Card content goes here")
|
||||
const [editingCardField, setEditingCardField] = useState<string | null>(null)
|
||||
const [dataTableSortAsc, setDataTableSortAsc] = useState(true)
|
||||
const [dataTablePage, setDataTablePage] = useState(1)
|
||||
const [dataTableSelected, setDataTableSelected] = useState<number[]>([])
|
||||
const [tableSortAsc, setTableSortAsc] = useState(true)
|
||||
const [tablePage, setTablePage] = useState(1)
|
||||
const [tableSelected, setTableSelected] = useState<number[]>([])
|
||||
|
||||
const renderComponent = () => {
|
||||
switch (component.type) {
|
||||
case "button":
|
||||
return (
|
||||
<Button
|
||||
variant={component.props.variant || "default"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.alert(`${component.props.children || "Button"} was clicked`)
|
||||
}}
|
||||
>
|
||||
{component.props.children || "Button"}
|
||||
</Button>
|
||||
)
|
||||
|
||||
case "input":
|
||||
return <Input placeholder={component.props.placeholder || "Enter text..."} className="w-full max-w-sm" />
|
||||
|
||||
case "textarea":
|
||||
return <Textarea placeholder={component.props.placeholder || "Enter text..."} className="w-full max-w-sm" />
|
||||
|
||||
case "card":
|
||||
return (
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{editingCardField === "title" ? (
|
||||
<Input
|
||||
value={cardTitle}
|
||||
onChange={(e) => setCardTitle(e.target.value)}
|
||||
onBlur={() => {
|
||||
setEditingCardField(null)
|
||||
updateComponent(component.id, { props: { title: cardTitle } })
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setEditingCardField(null)
|
||||
updateComponent(component.id, { props: { title: cardTitle } })
|
||||
}
|
||||
}}
|
||||
className="text-lg font-semibold"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingCardField("title")
|
||||
}}
|
||||
className="cursor-text hover:bg-muted/50 px-1 rounded"
|
||||
>
|
||||
{cardTitle}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{editingCardField === "description" ? (
|
||||
<Input
|
||||
value={cardDescription}
|
||||
onChange={(e) => setCardDescription(e.target.value)}
|
||||
onBlur={() => {
|
||||
setEditingCardField(null)
|
||||
updateComponent(component.id, { props: { description: cardDescription } })
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setEditingCardField(null)
|
||||
updateComponent(component.id, { props: { description: cardDescription } })
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingCardField("description")
|
||||
}}
|
||||
className="cursor-text hover:bg-muted/50 px-1 rounded"
|
||||
>
|
||||
{cardDescription}
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{editingCardField === "content" ? (
|
||||
<Textarea
|
||||
value={cardContent}
|
||||
onChange={(e) => setCardContent(e.target.value)}
|
||||
onBlur={() => {
|
||||
setEditingCardField(null)
|
||||
updateComponent(component.id, { props: { content: cardContent } })
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingCardField("content")
|
||||
}}
|
||||
className="cursor-text hover:bg-muted/50 px-1 rounded"
|
||||
>
|
||||
{cardContent}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
case "checkbox":
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={component.id}
|
||||
checked={checkboxState}
|
||||
onCheckedChange={(checked) => {
|
||||
setCheckboxState(!!checked)
|
||||
updateComponent(component.id, { props: { checked: !!checked } })
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={component.id}>{component.props.label || "Checkbox"}</Label>
|
||||
</div>
|
||||
)
|
||||
|
||||
case "switch":
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id={component.id}
|
||||
checked={switchState}
|
||||
onCheckedChange={(checked) => {
|
||||
setSwitchState(checked)
|
||||
updateComponent(component.id, { props: { checked } })
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={component.id}>{component.props.label || "Switch"}</Label>
|
||||
</div>
|
||||
)
|
||||
|
||||
case "select":
|
||||
return (
|
||||
<Select>
|
||||
<SelectTrigger className="w-full max-w-sm">
|
||||
<SelectValue placeholder={component.props.placeholder || "Select option..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(component.props.options || ["Option 1", "Option 2"]).map((option: string, index: number) => (
|
||||
<SelectItem key={index} value={option.toLowerCase()}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
|
||||
case "radiogroup":
|
||||
return (
|
||||
<RadioGroup
|
||||
value={radioValue}
|
||||
onValueChange={(value) => {
|
||||
setRadioValue(value)
|
||||
updateComponent(component.id, { props: { value } })
|
||||
}}
|
||||
>
|
||||
{(component.props.options || ["Option 1", "Option 2"]).map((option: string, index: number) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={option} id={`${component.id}-${index}`} />
|
||||
<Label htmlFor={`${component.id}-${index}`}>{option}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)
|
||||
|
||||
case "progress":
|
||||
return (
|
||||
<div className="w-full max-w-sm space-y-2">
|
||||
<Progress value={progressValue} className="w-full" />
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const newValue = Math.max(0, progressValue - 10)
|
||||
setProgressValue(newValue)
|
||||
updateComponent(component.id, { props: { value: newValue } })
|
||||
}}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">{progressValue}%</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const newValue = Math.min(100, progressValue + 10)
|
||||
setProgressValue(newValue)
|
||||
updateComponent(component.id, { props: { value: newValue } })
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case "avatar":
|
||||
return (
|
||||
<Avatar>
|
||||
{component.props.src && <AvatarImage src={component.props.src || "/placeholder.svg"} alt="Avatar" />}
|
||||
<AvatarFallback>
|
||||
{component.props.fallback ? component.props.fallback : <User className="h-4 w-4" />}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
|
||||
case "table": {
|
||||
const headers: string[] = component.props.headers || ["Name", "Email"]
|
||||
const pageSize: number = component.props.pageSize || 5
|
||||
const sortIndex: number = component.props.sortIndex ?? 0
|
||||
const sorted = [...tableData].sort((a, b) => {
|
||||
const va = a[sortIndex]?.toString().toLowerCase() ?? ""
|
||||
const vb = b[sortIndex]?.toString().toLowerCase() ?? ""
|
||||
const cmp = va.localeCompare(vb)
|
||||
return tableSortAsc ? cmp : -cmp
|
||||
})
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize))
|
||||
const page = Math.min(tablePage, totalPages)
|
||||
const start = (page - 1) * pageSize
|
||||
const pageRows = sorted.slice(start, start + pageSize)
|
||||
|
||||
const toggleRow = (idx: number) => {
|
||||
setTableSelected((sel) => (sel.includes(idx) ? sel.filter((i) => i !== idx) : [...sel, idx]))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">Sel</TableHead>
|
||||
{headers.map((header: string, index: number) => (
|
||||
<TableHead key={index}>
|
||||
<button
|
||||
className="underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (sortIndex === index) setTableSortAsc(!tableSortAsc)
|
||||
updateComponent(component.id, { props: { sortIndex: index } })
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</button>
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="w-[50px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageRows.map((row: string[], rowIndex: number) => (
|
||||
<TableRow key={start + rowIndex} className={tableSelected.includes(start + rowIndex) ? "bg-accent/20" : ""}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
id={`${component.id}-t-sel-${start + rowIndex}`}
|
||||
checked={tableSelected.includes(start + rowIndex)}
|
||||
onCheckedChange={() => toggleRow(start + rowIndex)}
|
||||
/>
|
||||
</TableCell>
|
||||
{row.map((cell: string, cellIndex: number) => (
|
||||
<TableCell key={cellIndex}>
|
||||
{editingCell?.row === start + rowIndex && editingCell?.col === cellIndex ? (
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
const newData = [...sorted]
|
||||
newData[start + rowIndex][cellIndex] = editValue
|
||||
// map back to original order not required for display; persist merged
|
||||
const merged = [...tableData]
|
||||
merged[start + rowIndex] = newData[start + rowIndex]
|
||||
setTableData(merged)
|
||||
updateComponent(component.id, { props: { rows: merged } })
|
||||
setEditingCell(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const newData = [...sorted]
|
||||
newData[start + rowIndex][cellIndex] = editValue
|
||||
const merged = [...tableData]
|
||||
merged[start + rowIndex] = newData[start + rowIndex]
|
||||
setTableData(merged)
|
||||
updateComponent(component.id, { props: { rows: merged } })
|
||||
setEditingCell(null)
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
className="h-8"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingCell({ row: start + rowIndex, col: cellIndex })
|
||||
setEditValue(cell)
|
||||
}}
|
||||
className="cursor-text hover:bg-muted/50 px-1 rounded block min-h-[20px]"
|
||||
>
|
||||
{cell}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const merged = tableData.filter((_: string[], index: number) => index !== start + rowIndex)
|
||||
setTableData(merged)
|
||||
updateComponent(component.id, { props: { rows: merged } })
|
||||
}}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setTablePage(Math.max(1, page - 1))
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">Page {page} / {totalPages}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setTablePage(Math.min(totalPages, page + 1))
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const newRow = Array(tableData[0]?.length || headers.length || 2).fill("New")
|
||||
const newData = [...tableData, newRow]
|
||||
setTableData(newData)
|
||||
updateComponent(component.id, { props: { rows: newData } })
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add Row
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case "data-table": {
|
||||
const headers: string[] = component.props.headers || ["Name", "Email"]
|
||||
const pageSize: number = component.props.pageSize || 5
|
||||
const sortIndex: number = component.props.sortIndex ?? 0
|
||||
const sorted = [...tableData].sort((a, b) => {
|
||||
const va = a[sortIndex]?.toString().toLowerCase() ?? ""
|
||||
const vb = b[sortIndex]?.toString().toLowerCase() ?? ""
|
||||
const cmp = va.localeCompare(vb)
|
||||
return dataTableSortAsc ? cmp : -cmp
|
||||
})
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize))
|
||||
const page = Math.min(dataTablePage, totalPages)
|
||||
const start = (page - 1) * pageSize
|
||||
const pageRows = sorted.slice(start, start + pageSize)
|
||||
|
||||
const toggleRow = (idx: number) => {
|
||||
setDataTableSelected((sel) =>
|
||||
sel.includes(idx) ? sel.filter((i) => i !== idx) : [...sel, idx],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">Sel</TableHead>
|
||||
{headers.map((header: string, index: number) => (
|
||||
<TableHead key={index}>
|
||||
<button
|
||||
className="underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (sortIndex === index) setDataTableSortAsc(!dataTableSortAsc)
|
||||
updateComponent(component.id, { props: { sortIndex: index } })
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</button>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageRows.map((row: string[], rowIndex: number) => (
|
||||
<TableRow key={start + rowIndex} className={dataTableSelected.includes(start + rowIndex) ? "bg-accent/20" : ""}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
id={`${component.id}-sel-${start + rowIndex}`}
|
||||
checked={dataTableSelected.includes(start + rowIndex)}
|
||||
onCheckedChange={() => toggleRow(start + rowIndex)}
|
||||
/>
|
||||
</TableCell>
|
||||
{row.map((cell: string, cellIndex: number) => (
|
||||
<TableCell key={cellIndex}>
|
||||
{editingCell?.row === start + rowIndex && editingCell?.col === cellIndex ? (
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
const merged = [...tableData]
|
||||
merged[start + rowIndex][cellIndex] = editValue
|
||||
setTableData(merged)
|
||||
updateComponent(component.id, { props: { rows: merged } })
|
||||
setEditingCell(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const merged = [...tableData]
|
||||
merged[start + rowIndex][cellIndex] = editValue
|
||||
setTableData(merged)
|
||||
updateComponent(component.id, { props: { rows: merged } })
|
||||
setEditingCell(null)
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
className="h-8"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingCell({ row: start + rowIndex, col: cellIndex })
|
||||
setEditValue(cell)
|
||||
}}
|
||||
className="cursor-text hover:bg-muted/50 px-1 rounded block min-h-[20px]"
|
||||
>
|
||||
{cell}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const newRow = Array(headers.length || 2).fill("New")
|
||||
const newData = [...tableData, newRow]
|
||||
setTableData(newData)
|
||||
updateComponent(component.id, { props: { rows: newData } })
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add Row
|
||||
</Button>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDataTablePage(Math.max(1, page - 1))
|
||||
}}
|
||||
>
|
||||
Prev
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Page {page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDataTablePage(Math.min(totalPages, page + 1))
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case "contextmenu":
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="w-[220px] h-[120px] bg-muted/30 flex items-center justify-center rounded">
|
||||
Right-click me
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-64">
|
||||
<ContextMenuLabel>Quick Actions</ContextMenuLabel>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>Copy</ContextMenuItem>
|
||||
<ContextMenuItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>Paste</ContextMenuItem>
|
||||
<ContextMenuItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>Delete</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
|
||||
case "menubar":
|
||||
return (
|
||||
<Menubar>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>File</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>New</MenubarItem>
|
||||
<MenubarItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>Open</MenubarItem>
|
||||
<MenubarItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>Save</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>Edit</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>Cut</MenubarItem>
|
||||
<MenubarItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>Copy</MenubarItem>
|
||||
<MenubarItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>Paste</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
)
|
||||
|
||||
case "carousel":
|
||||
return (
|
||||
<div className="w-[260px]">
|
||||
<UiCarousel className="relative">
|
||||
<CarouselContent>
|
||||
{(["One", "Two", "Three"] as string[]).map((label, idx) => (
|
||||
<CarouselItem key={idx} className="p-4">
|
||||
<Card className="h-[120px] flex items-center justify-center">
|
||||
<CardContent className="p-0">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</UiCarousel>
|
||||
</div>
|
||||
)
|
||||
|
||||
case "drawer":
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="outline">Open Drawer</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{component.props.title || "Drawer Title"}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
{component.props.description || "This is a drawer. You can close it below."}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="p-4">
|
||||
<DrawerClose asChild>
|
||||
<Button variant="secondary">Close</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
case "tabs":
|
||||
return (
|
||||
<Tabs defaultValue="tab-0" className="w-full max-w-sm">
|
||||
<TabsList>
|
||||
{(component.props.tabs || [{ label: "Tab 1" }, { label: "Tab 2" }]).map((tab: any, index: number) => (
|
||||
<TabsTrigger key={index} value={`tab-${index}`}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{(component.props.tabs || [{ content: "Content 1" }, { content: "Content 2" }]).map(
|
||||
(tab: any, index: number) => (
|
||||
<TabsContent key={index} value={`tab-${index}`}>
|
||||
{tab.content || `Content ${index + 1}`}
|
||||
</TabsContent>
|
||||
),
|
||||
)}
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
case "datepicker":
|
||||
return (
|
||||
<div
|
||||
className="w-[360px]"
|
||||
onMouseDown={(e) => e.stopPropagation()} // prevent dragging out of the component
|
||||
onClick={(e) => e.stopPropagation()} // prevent clicking out of the component
|
||||
>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={(date: Date | undefined) => {
|
||||
setSelectedDate(date)
|
||||
updateComponent(component.id, {
|
||||
props: {
|
||||
...component.props,
|
||||
selectedDate: date?.toISOString(),
|
||||
},
|
||||
})
|
||||
}}
|
||||
className="rounded-md border w-[360px] p-4 [--cell-size:2.4rem]"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case "dialog":
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Open Dialog</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{component.props.title || "Dialog Title"}</DialogTitle>
|
||||
<DialogDescription>{component.props.description || "Dialog description"}</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
// Wireframe components
|
||||
case "wireframe-svg":
|
||||
case "wireframe-rect":
|
||||
case "wireframe-circle":
|
||||
case "wireframe-text":
|
||||
case "wireframe-line":
|
||||
case "wireframe-path":
|
||||
case "wireframe-group":
|
||||
return (
|
||||
<WireframeRenderer
|
||||
component={component}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
/>
|
||||
)
|
||||
|
||||
default:
|
||||
return <div className="p-4 border border-dashed border-border rounded">Unknown Component</div>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute cursor-pointer transition-all ${
|
||||
isSelected ? "ring-2 ring-accent ring-offset-2" : "hover:ring-1 hover:ring-border"
|
||||
}`}
|
||||
style={{
|
||||
left: component.position.x,
|
||||
top: component.position.y,
|
||||
}}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
{renderComponent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
210
src/components/custom-template-form.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { DatabaseTemplate } from "@/lib/template-service"
|
||||
import { Plus, X, Save } from "lucide-react"
|
||||
|
||||
interface CustomTemplateFormProps {
|
||||
onSubmit: (templateData: Partial<DatabaseTemplate>) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function CustomTemplateForm({ onSubmit, onCancel }: CustomTemplateFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
type: "",
|
||||
title: "",
|
||||
description: "",
|
||||
category: "",
|
||||
icon: "",
|
||||
gradient: "",
|
||||
border: "",
|
||||
text: "",
|
||||
subtext: ""
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const categories = [
|
||||
"Food Delivery",
|
||||
"E-commerce",
|
||||
"SaaS Platform",
|
||||
"Mobile App",
|
||||
"Dashboard",
|
||||
"CRM System",
|
||||
"Learning Platform",
|
||||
"Healthcare",
|
||||
"Real Estate",
|
||||
"Travel",
|
||||
"Entertainment",
|
||||
"Finance",
|
||||
"Social Media",
|
||||
"Marketplace",
|
||||
"Other"
|
||||
]
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await onSubmit(formData)
|
||||
} catch (error) {
|
||||
console.error('Error creating template:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
Create Custom Template
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Template Type *</label>
|
||||
<Input
|
||||
placeholder="e.g., multi_restaurant_food_delivery"
|
||||
value={formData.type}
|
||||
onChange={(e) => handleInputChange('type', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-white/60">Unique identifier for the template</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Title *</label>
|
||||
<Input
|
||||
placeholder="e.g., Multi-Restaurant Food Delivery App"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleInputChange('title', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Description *</label>
|
||||
<Textarea
|
||||
placeholder="Describe your template and its key features..."
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40 min-h-[100px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Category *</label>
|
||||
<Select value={formData.category} onValueChange={(value) => handleInputChange('category', value)}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 border-white/10">
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category} className="text-white">
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Icon (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., restaurant, shopping-cart, users"
|
||||
value={formData.icon}
|
||||
onChange={(e) => handleInputChange('icon', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Gradient (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., from-orange-400 to-red-500"
|
||||
value={formData.gradient}
|
||||
onChange={(e) => handleInputChange('gradient', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Border (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., border-orange-500"
|
||||
value={formData.border}
|
||||
onChange={(e) => handleInputChange('border', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Text Color (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., text-orange-500"
|
||||
value={formData.text}
|
||||
onChange={(e) => handleInputChange('text', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Subtext (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., Perfect for food delivery startups"
|
||||
value={formData.subtext}
|
||||
onChange={(e) => handleInputChange('subtext', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="border-white/20 text-white hover:bg-white/10 cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-orange-500 hover:bg-orange-400 text-black font-semibold cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{loading ? "Creating..." : "Create Template"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
60
src/components/delete-confirmation-dialog.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { AlertTriangle, Trash2, X } from "lucide-react"
|
||||
|
||||
interface DeleteConfirmationDialogProps {
|
||||
templateTitle: string
|
||||
onConfirm: () => Promise<void>
|
||||
onCancel: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function DeleteConfirmationDialog({
|
||||
templateTitle,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
loading = false
|
||||
}: DeleteConfirmationDialogProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<Card className="bg-white/5 border-white/10 max-w-md w-full mx-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<AlertTriangle className="mr-2 h-5 w-5 text-red-400" />
|
||||
Delete Template
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-white/80">
|
||||
Are you sure you want to delete the template <strong className="text-white">"{templateTitle}"</strong>?
|
||||
</p>
|
||||
<p className="text-white/60 text-sm">
|
||||
This action cannot be undone. The template will be permanently removed from the database.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="border-white/20 text-white hover:bg-white/10"
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
className="bg-red-500 hover:bg-red-400 text-white"
|
||||
disabled={loading}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{loading ? "Deleting..." : "Delete Template"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
219
src/components/diff-viewer/DiffControls.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
// components/diff-viewer/DiffControls.tsx
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Monitor,
|
||||
Code,
|
||||
Settings,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Type,
|
||||
Eye,
|
||||
EyeOff
|
||||
} from 'lucide-react';
|
||||
import { DiffStatistics, DiffPreferences } from './DiffViewerContext';
|
||||
|
||||
interface DiffControlsProps {
|
||||
currentView: 'side-by-side' | 'unified';
|
||||
onViewChange: (view: 'side-by-side' | 'unified') => void;
|
||||
statistics: DiffStatistics | null;
|
||||
preferences: DiffPreferences;
|
||||
onPreferencesChange: (preferences: DiffPreferences) => void;
|
||||
}
|
||||
|
||||
const DiffControls: React.FC<DiffControlsProps> = ({
|
||||
currentView,
|
||||
onViewChange,
|
||||
statistics,
|
||||
preferences,
|
||||
onPreferencesChange
|
||||
}) => {
|
||||
const handlePreferenceChange = (key: keyof DiffPreferences, value: any) => {
|
||||
onPreferencesChange({
|
||||
...preferences,
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const handleFontSizeChange = (value: number[]) => {
|
||||
handlePreferenceChange('fontSize', value[0]);
|
||||
};
|
||||
|
||||
const handleThemeChange = (theme: DiffPreferences['theme']) => {
|
||||
handlePreferenceChange('theme', theme);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* View selector */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">View Mode</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={currentView === 'side-by-side' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onViewChange('side-by-side')}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Monitor className="h-4 w-4" />
|
||||
<span>Side-by-Side</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentView === 'unified' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onViewChange('unified')}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
<span>Unified</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
{statistics && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Statistics</Label>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Files:</span>
|
||||
<Badge variant="outline">{statistics.total_files}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Additions:</span>
|
||||
<Badge variant="default" className="bg-green-100 text-green-800">
|
||||
+{statistics.total_additions}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Deletions:</span>
|
||||
<Badge variant="destructive">
|
||||
-{statistics.total_deletions}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Size:</span>
|
||||
<Badge variant="outline">
|
||||
{(statistics.total_size_bytes / 1024).toFixed(1)} KB
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display preferences */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Display Options</Label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="line-numbers" className="text-sm">
|
||||
Show Line Numbers
|
||||
</Label>
|
||||
<Switch
|
||||
id="line-numbers"
|
||||
checked={preferences.showLineNumbers}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePreferenceChange('showLineNumbers', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="whitespace" className="text-sm">
|
||||
Show Whitespace
|
||||
</Label>
|
||||
<Switch
|
||||
id="whitespace"
|
||||
checked={preferences.showWhitespace}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePreferenceChange('showWhitespace', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="wrap-lines" className="text-sm">
|
||||
Wrap Lines
|
||||
</Label>
|
||||
<Switch
|
||||
id="wrap-lines"
|
||||
checked={preferences.wrapLines}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePreferenceChange('wrapLines', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font size */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">
|
||||
Font Size: {preferences.fontSize}px
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ZoomOut className="h-4 w-4 text-muted-foreground" />
|
||||
<Slider
|
||||
value={[preferences.fontSize]}
|
||||
onValueChange={handleFontSizeChange}
|
||||
min={10}
|
||||
max={24}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<ZoomIn className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font family */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Font Family</Label>
|
||||
<select
|
||||
value={preferences.fontFamily}
|
||||
onChange={(e) => handlePreferenceChange('fontFamily', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-input rounded-md bg-background text-sm"
|
||||
>
|
||||
<option value="monospace">Monospace</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Consolas">Consolas</option>
|
||||
<option value="Fira Code">Fira Code</option>
|
||||
<option value="JetBrains Mono">JetBrains Mono</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Theme selector */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Theme</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={preferences.theme === 'light' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleThemeChange('light')}
|
||||
>
|
||||
Light
|
||||
</Button>
|
||||
<Button
|
||||
variant={preferences.theme === 'dark' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleThemeChange('dark')}
|
||||
>
|
||||
Dark
|
||||
</Button>
|
||||
<Button
|
||||
variant={preferences.theme === 'high-contrast' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleThemeChange('high-contrast')}
|
||||
>
|
||||
High Contrast
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffControls;
|
||||
186
src/components/diff-viewer/DiffStats.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
// components/diff-viewer/DiffStats.tsx
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Minus,
|
||||
GitCommit,
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
TrendingDown
|
||||
} from 'lucide-react';
|
||||
import { DiffStatistics } from './DiffViewerContext';
|
||||
|
||||
interface DiffStatsProps {
|
||||
statistics: DiffStatistics;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DiffStats: React.FC<DiffStatsProps> = ({ statistics, className = '' }) => {
|
||||
const totalChanges = statistics.total_additions + statistics.total_deletions;
|
||||
const additionPercentage = totalChanges > 0 ? (statistics.total_additions / totalChanges) * 100 : 0;
|
||||
const deletionPercentage = totalChanges > 0 ? (statistics.total_deletions / totalChanges) * 100 : 0;
|
||||
|
||||
const getChangeTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'modified':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'deleted':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'renamed':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center space-x-2 text-lg">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
<span>Diff Statistics</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Overview stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 mb-1">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-2xl font-bold">{statistics.total_files}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Files Changed</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 mb-1">
|
||||
<Plus className="h-4 w-4 text-green-600" />
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
+{statistics.total_additions}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Additions</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 mb-1">
|
||||
<Minus className="h-4 w-4 text-red-600" />
|
||||
<span className="text-2xl font-bold text-red-600">
|
||||
-{statistics.total_deletions}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Deletions</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 mb-1">
|
||||
<GitCommit className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-2xl font-bold">
|
||||
{totalChanges}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Total Changes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change distribution */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Change Distribution</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Additions</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress value={additionPercentage} className="w-20" />
|
||||
<span className="text-sm text-muted-foreground w-12 text-right">
|
||||
{additionPercentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Deletions</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress value={deletionPercentage} className="w-20" />
|
||||
<span className="text-sm text-muted-foreground w-12 text-right">
|
||||
{deletionPercentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File types breakdown */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">File Types</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(statistics.files_by_type).map(([type, count]) => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant="outline"
|
||||
className={`${getChangeTypeColor(type)} border`}
|
||||
>
|
||||
{type}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size information */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Size Information</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total Size:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{(statistics.total_size_bytes / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Avg per File:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{statistics.total_files > 0
|
||||
? (statistics.total_size_bytes / statistics.total_files / 1024).toFixed(1)
|
||||
: 0} KB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Net change indicator */}
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
{statistics.total_additions > statistics.total_deletions ? (
|
||||
<>
|
||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm text-green-600 font-medium">
|
||||
Net Addition: +{statistics.total_additions - statistics.total_deletions} lines
|
||||
</span>
|
||||
</>
|
||||
) : statistics.total_deletions > statistics.total_additions ? (
|
||||
<>
|
||||
<TrendingDown className="h-4 w-4 text-red-600" />
|
||||
<span className="text-sm text-red-600 font-medium">
|
||||
Net Deletion: -{statistics.total_deletions - statistics.total_additions} lines
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground font-medium">
|
||||
Balanced: {statistics.total_additions} additions, {statistics.total_deletions} deletions
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffStats;
|
||||
249
src/components/diff-viewer/DiffViewer.tsx
Normal file
@ -0,0 +1,249 @@
|
||||
// components/diff-viewer/DiffViewer.tsx
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Monitor,
|
||||
Code,
|
||||
GitCommit,
|
||||
FileText,
|
||||
Settings,
|
||||
Search,
|
||||
Filter,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
|
||||
import SideBySideView from './SideBySideView';
|
||||
import UnifiedView from './UnifiedView';
|
||||
import ThemeSelector from './ThemeSelector';
|
||||
import { DiffViewerProvider, useDiffViewer } from './DiffViewerContext';
|
||||
|
||||
interface DiffViewerProps {
|
||||
repositoryId: string;
|
||||
commitId?: string;
|
||||
initialView?: 'side-by-side' | 'unified';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DiffViewer: React.FC<DiffViewerProps> = ({
|
||||
repositoryId,
|
||||
commitId,
|
||||
initialView = 'side-by-side',
|
||||
className = ''
|
||||
}) => {
|
||||
const [currentView, setCurrentView] = useState(initialView);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
state,
|
||||
loadCommitDiffs,
|
||||
loadRepositoryCommits,
|
||||
setTheme,
|
||||
setPreferences
|
||||
} = useDiffViewer();
|
||||
|
||||
const { commit, files, statistics, preferences } = state;
|
||||
const theme = preferences.theme;
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (commitId) {
|
||||
await loadCommitDiffs(commitId);
|
||||
} else {
|
||||
await loadRepositoryCommits(repositoryId);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load diff data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [repositoryId, commitId, loadCommitDiffs, loadRepositoryCommits]);
|
||||
|
||||
const handleViewChange = useCallback((newView: string) => {
|
||||
const view = newView as 'side-by-side' | 'unified';
|
||||
setCurrentView(view);
|
||||
setPreferences({ ...preferences, defaultView: view });
|
||||
}, [setPreferences, preferences]);
|
||||
|
||||
const handleFileSelect = useCallback((filePath: string) => {
|
||||
setSelectedFile(filePath);
|
||||
}, []);
|
||||
|
||||
const renderView = () => {
|
||||
if (!files || files.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No diff data available</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedFileData = selectedFile
|
||||
? files.find((f: any) => f.file_path === selectedFile) || null
|
||||
: files[0] || null;
|
||||
|
||||
switch (currentView) {
|
||||
case 'side-by-side':
|
||||
return (
|
||||
<SideBySideView
|
||||
files={files}
|
||||
selectedFile={selectedFileData}
|
||||
onFileSelect={handleFileSelect}
|
||||
theme={theme}
|
||||
preferences={preferences}
|
||||
/>
|
||||
);
|
||||
case 'unified':
|
||||
return (
|
||||
<UnifiedView
|
||||
files={files}
|
||||
selectedFile={selectedFileData}
|
||||
onFileSelect={handleFileSelect}
|
||||
theme={theme}
|
||||
preferences={preferences}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p>Loading diff data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="font-medium">Failed to load diff data</p>
|
||||
<p className="text-sm mt-2">{error}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`diff-viewer ${className}`}>
|
||||
{/* Header with commit info and controls */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<GitCommit className="h-5 w-5" />
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{commit?.message || 'Diff Viewer'}
|
||||
</CardTitle>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<Badge variant="outline">
|
||||
{commit?.author_name}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{commit?.committed_at ? new Date(commit.committed_at).toLocaleString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* File type badges */}
|
||||
{statistics && (
|
||||
<div className="flex items-center space-x-2">
|
||||
{statistics.files_by_type.added > 0 && (
|
||||
<Badge variant="default" className="bg-green-100 text-green-800">
|
||||
+{statistics.files_by_type.added} added
|
||||
</Badge>
|
||||
)}
|
||||
{statistics.files_by_type.modified > 0 && (
|
||||
<Badge variant="secondary">
|
||||
{statistics.files_by_type.modified} modified
|
||||
</Badge>
|
||||
)}
|
||||
{statistics.files_by_type.deleted > 0 && (
|
||||
<Badge variant="destructive">
|
||||
-{statistics.files_by_type.deleted} deleted
|
||||
</Badge>
|
||||
)}
|
||||
{statistics.files_by_type.renamed > 0 && (
|
||||
<Badge variant="outline">
|
||||
{statistics.files_by_type.renamed} renamed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
|
||||
{/* Main diff content */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Tabs value={currentView} onValueChange={handleViewChange}>
|
||||
<div className="border-b">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="side-by-side" className="flex items-center space-x-2">
|
||||
<Monitor className="h-4 w-4" />
|
||||
<span>Side-by-Side</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="unified" className="flex items-center space-x-2">
|
||||
<Code className="h-4 w-4" />
|
||||
<span>Unified</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="h-[600px] overflow-hidden border rounded-md">
|
||||
{renderView()}
|
||||
</div>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Wrapper component with context provider
|
||||
const DiffViewerWithProvider: React.FC<DiffViewerProps> = (props) => {
|
||||
return (
|
||||
<DiffViewerProvider>
|
||||
<DiffViewer {...props} />
|
||||
</DiffViewerProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffViewerWithProvider;
|
||||
256
src/components/diff-viewer/DiffViewerContext.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
// components/diff-viewer/DiffViewerContext.tsx
|
||||
import React, { createContext, useContext, useReducer, useCallback } from 'react';
|
||||
|
||||
// Types
|
||||
export interface DiffFile {
|
||||
file_change_id: string;
|
||||
file_path: string;
|
||||
change_type: 'added' | 'modified' | 'deleted' | 'renamed';
|
||||
diff_content_id?: string;
|
||||
diff_header?: string;
|
||||
diff_size_bytes?: number;
|
||||
storage_type?: string;
|
||||
external_storage_path?: string;
|
||||
processing_status?: string;
|
||||
diff_content?: string;
|
||||
}
|
||||
|
||||
export interface Commit {
|
||||
id: string;
|
||||
commit_sha: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
message: string;
|
||||
url: string;
|
||||
committed_at: string;
|
||||
repository_name: string;
|
||||
owner_name: string;
|
||||
}
|
||||
|
||||
export interface DiffStatistics {
|
||||
total_files: number;
|
||||
total_additions: number;
|
||||
total_deletions: number;
|
||||
total_size_bytes: number;
|
||||
files_by_type: {
|
||||
added: number;
|
||||
modified: number;
|
||||
deleted: number;
|
||||
renamed: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DiffPreferences {
|
||||
defaultView: 'side-by-side' | 'unified';
|
||||
showLineNumbers: boolean;
|
||||
showWhitespace: boolean;
|
||||
wrapLines: boolean;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
theme: 'light' | 'dark' | 'high-contrast' | 'custom';
|
||||
customTheme?: {
|
||||
background: string;
|
||||
text: string;
|
||||
added: string;
|
||||
removed: string;
|
||||
unchanged: string;
|
||||
border: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DiffViewerState {
|
||||
commit: Commit | null;
|
||||
files: DiffFile[];
|
||||
statistics: DiffStatistics | null;
|
||||
preferences: DiffPreferences;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Actions
|
||||
type DiffViewerAction =
|
||||
| { type: 'SET_LOADING'; payload: boolean }
|
||||
| { type: 'SET_ERROR'; payload: string | null }
|
||||
| { type: 'SET_COMMIT'; payload: Commit | null }
|
||||
| { type: 'SET_FILES'; payload: DiffFile[] }
|
||||
| { type: 'SET_STATISTICS'; payload: DiffStatistics | null }
|
||||
| { type: 'SET_PREFERENCES'; payload: DiffPreferences }
|
||||
| { type: 'SET_THEME'; payload: DiffPreferences['theme'] }
|
||||
| { type: 'SET_CUSTOM_THEME'; payload: DiffPreferences['customTheme'] };
|
||||
|
||||
// Initial state
|
||||
const initialState: DiffViewerState = {
|
||||
commit: null,
|
||||
files: [],
|
||||
statistics: null,
|
||||
preferences: {
|
||||
defaultView: 'side-by-side',
|
||||
showLineNumbers: true,
|
||||
showWhitespace: false,
|
||||
wrapLines: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
theme: 'light'
|
||||
},
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
// Reducer
|
||||
function diffViewerReducer(state: DiffViewerState, action: DiffViewerAction): DiffViewerState {
|
||||
switch (action.type) {
|
||||
case 'SET_LOADING':
|
||||
return { ...state, isLoading: action.payload };
|
||||
case 'SET_ERROR':
|
||||
return { ...state, error: action.payload, isLoading: false };
|
||||
case 'SET_COMMIT':
|
||||
return { ...state, commit: action.payload };
|
||||
case 'SET_FILES':
|
||||
return { ...state, files: action.payload };
|
||||
case 'SET_STATISTICS':
|
||||
return { ...state, statistics: action.payload };
|
||||
case 'SET_PREFERENCES':
|
||||
return { ...state, preferences: action.payload };
|
||||
case 'SET_THEME':
|
||||
return {
|
||||
...state,
|
||||
preferences: { ...state.preferences, theme: action.payload }
|
||||
};
|
||||
case 'SET_CUSTOM_THEME':
|
||||
return {
|
||||
...state,
|
||||
preferences: { ...state.preferences, customTheme: action.payload }
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// Context
|
||||
const DiffViewerContext = createContext<{
|
||||
state: DiffViewerState;
|
||||
dispatch: React.Dispatch<DiffViewerAction>;
|
||||
loadCommitDiffs: (commitId: string) => Promise<void>;
|
||||
loadRepositoryCommits: (repositoryId: string) => Promise<void>;
|
||||
setTheme: (theme: DiffPreferences['theme']) => void;
|
||||
setCustomTheme: (customTheme: DiffPreferences['customTheme']) => void;
|
||||
setPreferences: (preferences: DiffPreferences) => void;
|
||||
} | null>(null);
|
||||
|
||||
// Provider component
|
||||
export const DiffViewerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(diffViewerReducer, initialState);
|
||||
|
||||
// API functions
|
||||
const loadCommitDiffs = useCallback(async (commitId: string) => {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
dispatch({ type: 'SET_ERROR', payload: null });
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/diffs/commits/${commitId}/diffs`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to load commit diffs');
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_COMMIT', payload: data.data.commit });
|
||||
dispatch({ type: 'SET_FILES', payload: data.data.files });
|
||||
dispatch({ type: 'SET_STATISTICS', payload: data.data.statistics });
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: 'SET_ERROR',
|
||||
payload: error instanceof Error ? error.message : 'Failed to load commit diffs'
|
||||
});
|
||||
} finally {
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRepositoryCommits = useCallback(async (repositoryId: string) => {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
dispatch({ type: 'SET_ERROR', payload: null });
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/diffs/repositories/${repositoryId}/commits`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to load repository commits');
|
||||
}
|
||||
|
||||
// Load the first commit's diffs by default
|
||||
if (data.data.commits.length > 0) {
|
||||
await loadCommitDiffs(data.data.commits[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: 'SET_ERROR',
|
||||
payload: error instanceof Error ? error.message : 'Failed to load repository commits'
|
||||
});
|
||||
} finally {
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
}
|
||||
}, [loadCommitDiffs]);
|
||||
|
||||
const setTheme = useCallback((theme: DiffPreferences['theme']) => {
|
||||
dispatch({ type: 'SET_THEME', payload: theme });
|
||||
}, []);
|
||||
|
||||
const setCustomTheme = useCallback((customTheme: DiffPreferences['customTheme']) => {
|
||||
dispatch({ type: 'SET_CUSTOM_THEME', payload: customTheme });
|
||||
}, []);
|
||||
|
||||
const setPreferences = useCallback((preferences: DiffPreferences) => {
|
||||
dispatch({ type: 'SET_PREFERENCES', payload: preferences });
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
state,
|
||||
dispatch,
|
||||
loadCommitDiffs,
|
||||
loadRepositoryCommits,
|
||||
setTheme,
|
||||
setCustomTheme,
|
||||
setPreferences
|
||||
};
|
||||
|
||||
return (
|
||||
<DiffViewerContext.Provider value={value}>
|
||||
{children}
|
||||
</DiffViewerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to use the context
|
||||
export const useDiffViewer = () => {
|
||||
const context = useContext(DiffViewerContext);
|
||||
if (!context) {
|
||||
throw new Error('useDiffViewer must be used within a DiffViewerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Destructured state and actions for convenience
|
||||
export const useDiffViewerState = () => {
|
||||
const { state } = useDiffViewer();
|
||||
return state;
|
||||
};
|
||||
|
||||
export const useDiffViewerActions = () => {
|
||||
const {
|
||||
loadCommitDiffs,
|
||||
loadRepositoryCommits,
|
||||
setTheme,
|
||||
setCustomTheme,
|
||||
setPreferences
|
||||
} = useDiffViewer();
|
||||
|
||||
return {
|
||||
loadCommitDiffs,
|
||||
loadRepositoryCommits,
|
||||
setTheme,
|
||||
setCustomTheme,
|
||||
setPreferences
|
||||
};
|
||||
};
|
||||
368
src/components/diff-viewer/SideBySideView.tsx
Normal file
@ -0,0 +1,368 @@
|
||||
// components/diff-viewer/SideBySideView.tsx
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Minus,
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
import { DiffFile, DiffPreferences } from './DiffViewerContext';
|
||||
|
||||
interface SideBySideViewProps {
|
||||
files: DiffFile[];
|
||||
selectedFile: DiffFile | null;
|
||||
onFileSelect: (filePath: string) => void;
|
||||
theme: string;
|
||||
preferences: DiffPreferences;
|
||||
}
|
||||
|
||||
interface DiffLine {
|
||||
type: 'added' | 'removed' | 'unchanged' | 'context';
|
||||
content: string;
|
||||
oldLineNumber?: number;
|
||||
newLineNumber?: number;
|
||||
}
|
||||
|
||||
const SideBySideView: React.FC<SideBySideViewProps> = ({
|
||||
files,
|
||||
selectedFile,
|
||||
onFileSelect,
|
||||
theme,
|
||||
preferences
|
||||
}) => {
|
||||
const [expandedHunks, setExpandedHunks] = useState<Set<string>>(new Set());
|
||||
|
||||
// Parse diff content into structured format
|
||||
const parseDiffContent = (diffContent: string): DiffLine[] => {
|
||||
if (!diffContent) return [];
|
||||
|
||||
const lines = diffContent.split('\n');
|
||||
const diffLines: DiffLine[] = [];
|
||||
let oldLineNumber = 0;
|
||||
let newLineNumber = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('@@')) {
|
||||
// Parse hunk header
|
||||
const match = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/);
|
||||
if (match) {
|
||||
oldLineNumber = parseInt(match[1]) - 1;
|
||||
newLineNumber = parseInt(match[3]) - 1;
|
||||
}
|
||||
diffLines.push({ type: 'context', content: line });
|
||||
} else if (line.startsWith('+')) {
|
||||
newLineNumber++;
|
||||
diffLines.push({
|
||||
type: 'added',
|
||||
content: line.substring(1),
|
||||
oldLineNumber: undefined,
|
||||
newLineNumber
|
||||
});
|
||||
} else if (line.startsWith('-')) {
|
||||
oldLineNumber++;
|
||||
diffLines.push({
|
||||
type: 'removed',
|
||||
content: line.substring(1),
|
||||
oldLineNumber,
|
||||
newLineNumber: undefined
|
||||
});
|
||||
} else {
|
||||
oldLineNumber++;
|
||||
newLineNumber++;
|
||||
diffLines.push({
|
||||
type: 'unchanged',
|
||||
content: line.substring(1),
|
||||
oldLineNumber,
|
||||
newLineNumber
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return diffLines;
|
||||
};
|
||||
|
||||
// Group diff lines into hunks
|
||||
const groupIntoHunks = (diffLines: DiffLine[]) => {
|
||||
const hunks: { header: string; lines: DiffLine[] }[] = [];
|
||||
let currentHunk: { header: string; lines: DiffLine[] } | null = null;
|
||||
|
||||
for (const line of diffLines) {
|
||||
if (line.type === 'context' && line.content.startsWith('@@')) {
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
currentHunk = { header: line.content, lines: [] };
|
||||
} else if (currentHunk) {
|
||||
currentHunk.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
|
||||
return hunks;
|
||||
};
|
||||
|
||||
const diffLines = useMemo(() => {
|
||||
if (!selectedFile?.diff_content) return [];
|
||||
return parseDiffContent(selectedFile.diff_content);
|
||||
}, [selectedFile]);
|
||||
|
||||
const hunks = useMemo(() => {
|
||||
return groupIntoHunks(diffLines);
|
||||
}, [diffLines]);
|
||||
|
||||
const toggleHunk = (hunkIndex: number) => {
|
||||
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
||||
setExpandedHunks(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(hunkId)) {
|
||||
newSet.delete(hunkId);
|
||||
} else {
|
||||
newSet.add(hunkId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const downloadDiff = () => {
|
||||
if (!selectedFile?.diff_content) return;
|
||||
|
||||
const blob = new Blob([selectedFile.diff_content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${selectedFile.file_path}.diff`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const getLineClass = (type: DiffLine['type']) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return 'bg-green-50 dark:bg-green-900/20 border-l-4 border-green-500';
|
||||
case 'removed':
|
||||
return 'bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500';
|
||||
case 'unchanged':
|
||||
return 'bg-gray-50 dark:bg-gray-800/50';
|
||||
case 'context':
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 font-mono text-sm';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getLineIcon = (type: DiffLine['type']) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return <Plus className="h-3 w-3 text-green-600" />;
|
||||
case 'removed':
|
||||
return <Minus className="h-3 w-3 text-red-600" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* File tabs */}
|
||||
<div className="border-b">
|
||||
<Tabs value={selectedFile?.file_path || ''} onValueChange={onFileSelect}>
|
||||
<TabsList className="w-full justify-start">
|
||||
{files.map((file) => (
|
||||
<TabsTrigger
|
||||
key={file.file_path}
|
||||
value={file.file_path}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="truncate max-w-32">{file.file_path.split('/').pop()}</span>
|
||||
<Badge
|
||||
variant={
|
||||
file.change_type === 'added' ? 'default' :
|
||||
file.change_type === 'modified' ? 'secondary' :
|
||||
file.change_type === 'deleted' ? 'destructive' : 'outline'
|
||||
}
|
||||
className="ml-1"
|
||||
>
|
||||
{file.change_type}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* File info and controls */}
|
||||
{selectedFile && (
|
||||
<div className="p-4 border-b bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div>
|
||||
<h3 className="font-medium">{selectedFile.file_path}</h3>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<span>{selectedFile.change_type}</span>
|
||||
{selectedFile.diff_size_bytes && (
|
||||
<span>• {(selectedFile.diff_size_bytes / 1024).toFixed(1)} KB</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(selectedFile.diff_content || '')}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={downloadDiff}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diff content */}
|
||||
<div className="flex-1 overflow-hidden h-[500px]">
|
||||
<div className="grid grid-cols-2 h-full">
|
||||
{/* Old version */}
|
||||
<div className="border-r">
|
||||
<div className="bg-muted/50 px-4 py-2 border-b">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Minus className="h-4 w-4 text-red-600" />
|
||||
<span className="font-medium">Old Version</span>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="font-mono text-sm">
|
||||
{hunks.map((hunk, hunkIndex) => {
|
||||
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
||||
const isExpanded = expandedHunks.has(hunkId);
|
||||
|
||||
return (
|
||||
<div key={hunkIndex}>
|
||||
<div
|
||||
className="bg-blue-100 dark:bg-blue-900/30 px-4 py-2 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50"
|
||||
onClick={() => toggleHunk(hunkIndex)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm">{hunk.header}</span>
|
||||
<Button variant="ghost" size="sm">
|
||||
{isExpanded ? 'Collapse' : 'Expand'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIndex) => (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className={`px-4 py-1 flex items-center space-x-2 ${getLineClass(line.type)}`}
|
||||
>
|
||||
<div className="w-8 text-right text-xs text-muted-foreground">
|
||||
{line.oldLineNumber || ''}
|
||||
</div>
|
||||
<div className="w-4 flex justify-center">
|
||||
{getLineIcon(line.type)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<code className="text-sm">{line.content}</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* New version */}
|
||||
<div>
|
||||
<div className="bg-muted/50 px-4 py-2 border-b">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Plus className="h-4 w-4 text-green-600" />
|
||||
<span className="font-medium">New Version</span>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="font-mono text-sm">
|
||||
{hunks.map((hunk, hunkIndex) => {
|
||||
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
||||
const isExpanded = expandedHunks.has(hunkId);
|
||||
|
||||
return (
|
||||
<div key={hunkIndex}>
|
||||
<div
|
||||
className="bg-blue-100 dark:bg-blue-900/30 px-4 py-2 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50"
|
||||
onClick={() => toggleHunk(hunkIndex)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm">{hunk.header}</span>
|
||||
<Button variant="ghost" size="sm">
|
||||
{isExpanded ? 'Collapse' : 'Expand'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIndex) => (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className={`px-4 py-1 flex items-center space-x-2 ${getLineClass(line.type)}`}
|
||||
>
|
||||
<div className="w-8 text-right text-xs text-muted-foreground">
|
||||
{line.newLineNumber || ''}
|
||||
</div>
|
||||
<div className="w-4 flex justify-center">
|
||||
{getLineIcon(line.type)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<code className="text-sm">{line.content}</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SideBySideView;
|
||||
134
src/components/diff-viewer/ThemeSelector.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
// components/diff-viewer/ThemeSelector.tsx
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Palette,
|
||||
Sun,
|
||||
Moon,
|
||||
Contrast,
|
||||
Settings,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import { useDiffViewer } from './DiffViewerContext';
|
||||
|
||||
const ThemeSelector: React.FC = () => {
|
||||
const { state, setTheme, setCustomTheme } = useDiffViewer();
|
||||
const [showCustomTheme, setShowCustomTheme] = useState(false);
|
||||
|
||||
const themes = [
|
||||
{
|
||||
id: 'light',
|
||||
name: 'Light',
|
||||
icon: Sun,
|
||||
description: 'Clean and bright theme',
|
||||
colors: {
|
||||
background: '#ffffff',
|
||||
text: '#333333',
|
||||
added: '#d4edda',
|
||||
removed: '#f8d7da',
|
||||
unchanged: '#f8f9fa',
|
||||
border: '#dee2e6'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'dark',
|
||||
name: 'Dark',
|
||||
icon: Moon,
|
||||
description: 'Dark theme for low light',
|
||||
colors: {
|
||||
background: '#1e1e1e',
|
||||
text: '#ffffff',
|
||||
added: '#0d5016',
|
||||
removed: '#721c24',
|
||||
unchanged: '#2d2d2d',
|
||||
border: '#404040'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'high-contrast',
|
||||
name: 'High Contrast',
|
||||
icon: Contrast,
|
||||
description: 'High contrast for accessibility',
|
||||
colors: {
|
||||
background: '#000000',
|
||||
text: '#ffffff',
|
||||
added: '#00ff00',
|
||||
removed: '#ff0000',
|
||||
unchanged: '#333333',
|
||||
border: '#666666'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const handleThemeChange = (themeId: string) => {
|
||||
if (themeId === 'custom') {
|
||||
setShowCustomTheme(true);
|
||||
} else {
|
||||
setTheme(themeId as any);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomTheme = (colors: any) => {
|
||||
setCustomTheme(colors);
|
||||
setTheme('custom');
|
||||
setShowCustomTheme(false);
|
||||
};
|
||||
|
||||
const currentTheme = themes.find(t => t.id === state.preferences.theme) || themes[0];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="flex items-center space-x-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
<span>{currentTheme.name}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64">
|
||||
<DropdownMenuLabel>Choose Theme</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{themes.map((theme) => (
|
||||
<DropdownMenuItem
|
||||
key={theme.id}
|
||||
onClick={() => handleThemeChange(theme.id)}
|
||||
className="flex items-center space-x-3 p-3"
|
||||
>
|
||||
<theme.icon className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{theme.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{theme.description}</div>
|
||||
</div>
|
||||
{state.preferences.theme === theme.id && (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleThemeChange('custom')}
|
||||
className="flex items-center space-x-3 p-3"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Custom Theme</div>
|
||||
<div className="text-xs text-muted-foreground">Create your own theme</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSelector;
|
||||
323
src/components/diff-viewer/UnifiedView.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
// components/diff-viewer/UnifiedView.tsx
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Minus,
|
||||
Copy,
|
||||
Download,
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { DiffFile, DiffPreferences } from './DiffViewerContext';
|
||||
|
||||
interface UnifiedViewProps {
|
||||
files: DiffFile[];
|
||||
selectedFile: DiffFile | null;
|
||||
onFileSelect: (filePath: string) => void;
|
||||
theme: string;
|
||||
preferences: DiffPreferences;
|
||||
}
|
||||
|
||||
interface DiffLine {
|
||||
type: 'added' | 'removed' | 'unchanged' | 'context';
|
||||
content: string;
|
||||
lineNumber?: number;
|
||||
}
|
||||
|
||||
const UnifiedView: React.FC<UnifiedViewProps> = ({
|
||||
files,
|
||||
selectedFile,
|
||||
onFileSelect,
|
||||
theme,
|
||||
preferences
|
||||
}) => {
|
||||
const [expandedHunks, setExpandedHunks] = useState<Set<string>>(new Set());
|
||||
|
||||
// Parse diff content into structured format
|
||||
const parseDiffContent = (diffContent: string): DiffLine[] => {
|
||||
if (!diffContent) return [];
|
||||
|
||||
const lines = diffContent.split('\n');
|
||||
const diffLines: DiffLine[] = [];
|
||||
let lineNumber = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('@@')) {
|
||||
// Parse hunk header
|
||||
const match = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/);
|
||||
if (match) {
|
||||
lineNumber = parseInt(match[3]) - 1;
|
||||
}
|
||||
diffLines.push({ type: 'context', content: line });
|
||||
} else if (line.startsWith('+')) {
|
||||
lineNumber++;
|
||||
diffLines.push({
|
||||
type: 'added',
|
||||
content: line.substring(1),
|
||||
lineNumber
|
||||
});
|
||||
} else if (line.startsWith('-')) {
|
||||
diffLines.push({
|
||||
type: 'removed',
|
||||
content: line.substring(1),
|
||||
lineNumber: undefined
|
||||
});
|
||||
} else {
|
||||
lineNumber++;
|
||||
diffLines.push({
|
||||
type: 'unchanged',
|
||||
content: line.substring(1),
|
||||
lineNumber
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return diffLines;
|
||||
};
|
||||
|
||||
// Group diff lines into hunks
|
||||
const groupIntoHunks = (diffLines: DiffLine[]) => {
|
||||
const hunks: { header: string; lines: DiffLine[] }[] = [];
|
||||
let currentHunk: { header: string; lines: DiffLine[] } | null = null;
|
||||
|
||||
for (const line of diffLines) {
|
||||
if (line.type === 'context' && line.content.startsWith('@@')) {
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
currentHunk = { header: line.content, lines: [] };
|
||||
} else if (currentHunk) {
|
||||
currentHunk.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
|
||||
return hunks;
|
||||
};
|
||||
|
||||
const diffLines = useMemo(() => {
|
||||
if (!selectedFile?.diff_content) return [];
|
||||
return parseDiffContent(selectedFile.diff_content);
|
||||
}, [selectedFile]);
|
||||
|
||||
const hunks = useMemo(() => {
|
||||
return groupIntoHunks(diffLines);
|
||||
}, [diffLines]);
|
||||
|
||||
const toggleHunk = (hunkIndex: number) => {
|
||||
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
||||
setExpandedHunks(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(hunkId)) {
|
||||
newSet.delete(hunkId);
|
||||
} else {
|
||||
newSet.add(hunkId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const downloadDiff = () => {
|
||||
if (!selectedFile?.diff_content) return;
|
||||
|
||||
const blob = new Blob([selectedFile.diff_content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${selectedFile.file_path}.diff`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const getLineClass = (type: DiffLine['type']) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return 'bg-green-50 dark:bg-green-900/20 border-l-4 border-green-500';
|
||||
case 'removed':
|
||||
return 'bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500';
|
||||
case 'unchanged':
|
||||
return 'bg-gray-50 dark:bg-gray-800/50';
|
||||
case 'context':
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 font-mono text-sm';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getLineIcon = (type: DiffLine['type']) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return <Plus className="h-3 w-3 text-green-600" />;
|
||||
case 'removed':
|
||||
return <Minus className="h-3 w-3 text-red-600" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getLinePrefix = (type: DiffLine['type']) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return '+';
|
||||
case 'removed':
|
||||
return '-';
|
||||
case 'unchanged':
|
||||
return ' ';
|
||||
case 'context':
|
||||
return '@';
|
||||
default:
|
||||
return ' ';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* File tabs */}
|
||||
<div className="border-b">
|
||||
<Tabs value={selectedFile?.file_path || ''} onValueChange={onFileSelect}>
|
||||
<TabsList className="w-full justify-start">
|
||||
{files.map((file) => (
|
||||
<TabsTrigger
|
||||
key={file.file_path}
|
||||
value={file.file_path}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="truncate max-w-32">{file.file_path.split('/').pop()}</span>
|
||||
<Badge
|
||||
variant={
|
||||
file.change_type === 'added' ? 'default' :
|
||||
file.change_type === 'modified' ? 'secondary' :
|
||||
file.change_type === 'deleted' ? 'destructive' : 'outline'
|
||||
}
|
||||
className="ml-1"
|
||||
>
|
||||
{file.change_type}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* File info and controls */}
|
||||
{selectedFile && (
|
||||
<div className="p-4 border-b bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div>
|
||||
<h3 className="font-medium">{selectedFile.file_path}</h3>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<span>{selectedFile.change_type}</span>
|
||||
{selectedFile.diff_size_bytes && (
|
||||
<span>• {(selectedFile.diff_size_bytes / 1024).toFixed(1)} KB</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(selectedFile.diff_content || '')}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={downloadDiff}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diff content */}
|
||||
<div className="flex-1 overflow-hidden h-[500px]">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="font-mono text-sm">
|
||||
{hunks.map((hunk, hunkIndex) => {
|
||||
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
||||
const isExpanded = expandedHunks.has(hunkId);
|
||||
|
||||
return (
|
||||
<div key={hunkIndex} className="border-b last:border-b-0">
|
||||
<div
|
||||
className="bg-blue-100 dark:bg-blue-900/30 px-4 py-2 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50"
|
||||
onClick={() => toggleHunk(hunkIndex)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm">{hunk.header}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="ghost" size="sm">
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 mr-1" />
|
||||
Collapse
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="h-4 w-4 mr-1" />
|
||||
Expand
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIndex) => (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className={`px-4 py-1 flex items-center space-x-2 ${getLineClass(line.type)}`}
|
||||
>
|
||||
<div className="w-8 text-right text-xs text-muted-foreground">
|
||||
{line.lineNumber || ''}
|
||||
</div>
|
||||
<div className="w-4 flex justify-center">
|
||||
{getLineIcon(line.type)}
|
||||
</div>
|
||||
<div className="w-4 text-center font-mono text-xs">
|
||||
{getLinePrefix(line.type)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<code className="text-sm">{line.content}</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedView;
|
||||
267
src/components/dual-canvas-editor.tsx
Normal file
@ -0,0 +1,267 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useState, useEffect } from "react"
|
||||
import { DndContext, type DragEndEvent, DragOverlay, type DragStartEvent } from "@dnd-kit/core"
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Trash2, Palette, PenTool, Eye, EyeOff, SquarePen } from "lucide-react"
|
||||
|
||||
import WireframeCanvas from "@/components/wireframe-canvas"
|
||||
import PromptSidePanel from "@/components/prompt-side-panel"
|
||||
import { Canvas } from "@/components/canvas"
|
||||
import { ComponentPalette } from "@/components/component-palette"
|
||||
import { PropertiesPanel } from "@/components/properties-panel"
|
||||
import { useEditorStore } from "@/lib/store"
|
||||
import { useWireframeIntegration } from "@/lib/wireframe-integration"
|
||||
|
||||
interface DualCanvasEditorProps {
|
||||
className?: string
|
||||
onWireframeGenerated?: (data: any) => void
|
||||
onGenerationStart?: () => void
|
||||
selectedDevice?: 'desktop' | 'tablet' | 'mobile'
|
||||
onDeviceChange?: (device: 'desktop' | 'tablet' | 'mobile') => void
|
||||
initialPrompt?: string
|
||||
}
|
||||
|
||||
export function DualCanvasEditor({
|
||||
className,
|
||||
onWireframeGenerated,
|
||||
onGenerationStart,
|
||||
selectedDevice = 'desktop',
|
||||
onDeviceChange,
|
||||
initialPrompt
|
||||
}: DualCanvasEditorProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const [canvasMode, setCanvasMode] = useState<'wireframe' | 'components'>('wireframe')
|
||||
const {
|
||||
selectedComponent,
|
||||
addComponent,
|
||||
clearAll,
|
||||
components,
|
||||
showWireframes,
|
||||
setShowWireframes,
|
||||
wireframeRenderMode,
|
||||
setWireframeRenderMode
|
||||
} = useEditorStore()
|
||||
|
||||
// Initialize wireframe integration
|
||||
useWireframeIntegration()
|
||||
|
||||
// Auto-show wireframes when components are added
|
||||
useEffect(() => {
|
||||
if (components.length > 0 && components.some(comp => comp.type.startsWith('wireframe-'))) {
|
||||
setShowWireframes(true)
|
||||
}
|
||||
}, [components, setShowWireframes])
|
||||
|
||||
const handleDeviceChange = (device: 'desktop' | 'tablet' | 'mobile') => {
|
||||
if (onDeviceChange) {
|
||||
onDeviceChange(device)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string)
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setActiveId(null)
|
||||
|
||||
if (event.over?.id === "canvas" && event.active) {
|
||||
const componentType = event.active.id as string
|
||||
|
||||
// Get the drop position relative to the canvas
|
||||
const canvasRect = event.over.rect
|
||||
const dragRect = event.active.rect.current.translated
|
||||
|
||||
if (dragRect && canvasRect) {
|
||||
const newComponent = {
|
||||
id: `${componentType}-${Date.now()}`,
|
||||
type: componentType,
|
||||
props: getDefaultProps(componentType),
|
||||
position: {
|
||||
x: Math.max(0, dragRect.left - canvasRect.left),
|
||||
y: Math.max(0, dragRect.top - canvasRect.top),
|
||||
},
|
||||
}
|
||||
|
||||
addComponent(newComponent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getDefaultProps = (componentType: string): Record<string, any> => {
|
||||
const defaults: Record<string, any> = {
|
||||
button: { children: "Button", variant: "default" },
|
||||
input: { placeholder: "Enter text..." },
|
||||
textarea: { placeholder: "Enter text..." },
|
||||
card: { title: "Card Title", description: "Card description" },
|
||||
checkbox: { label: "Checkbox", checked: false },
|
||||
switch: { label: "Switch", checked: false },
|
||||
select: { placeholder: "Select option...", options: ["Option 1", "Option 2"] },
|
||||
radiogroup: { options: ["Option 1", "Option 2"], value: "Option 1" },
|
||||
progress: { value: 50, max: 100 },
|
||||
avatar: { fallback: "AB" },
|
||||
table: {
|
||||
headers: ["Name", "Email", "Role"],
|
||||
rows: [
|
||||
["John Doe", "john@example.com", "Admin"],
|
||||
["Jane Smith", "jane@example.com", "User"],
|
||||
["kenil", "kenil@example.com", "User"],
|
||||
["kavya", "kavya@example.com", "User"],
|
||||
["raj", "raj@example.com", "User"]
|
||||
],
|
||||
},
|
||||
tabs: {
|
||||
tabs: [
|
||||
{ label: "Tab 1", content: "Content 1" },
|
||||
{ label: "Tab 2", content: "Content 2" },
|
||||
],
|
||||
},
|
||||
datepicker: { placeholder: "Pick a date", selectedDate: null },
|
||||
dialog: { title: "Dialog Title", description: "Dialog description" },
|
||||
}
|
||||
|
||||
return defaults[componentType] || {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-full w-full bg-background text-foreground ${className || ''}`}>
|
||||
<header className="border-b border-border">
|
||||
<div className="mx-auto max-w-6xl px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-pretty text-lg font-semibold tracking-tight">Dual Canvas Editor</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Switch between AI wireframe generation and drag-and-drop component editor.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tabs value={canvasMode} onValueChange={(value) => setCanvasMode(value as 'wireframe' | 'components')}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="wireframe" className="flex items-center gap-2">
|
||||
<PenTool className="h-4 w-4" />
|
||||
AI Wireframe
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="components" className="flex items-center gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
Components
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{canvasMode === 'components' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Switch to wireframe mode to generate wireframe
|
||||
setCanvasMode('wireframe')
|
||||
// Trigger wireframe generation after a short delay
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent("tldraw:generate", {
|
||||
detail: {
|
||||
prompt: "Dashboard with header, left sidebar, 3 stats cards, a line chart and a data table, plus footer.",
|
||||
device: selectedDevice
|
||||
}
|
||||
}))
|
||||
// Switch back to components mode after generation
|
||||
setTimeout(() => setCanvasMode('components'), 2000)
|
||||
}, 100)
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<PenTool className="h-4 w-4" />
|
||||
Generate Wireframe
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowWireframes(!showWireframes)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{showWireframes ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
{showWireframes ? 'Hide Wireframes' : 'Show Wireframes'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearAll}
|
||||
disabled={components.length === 0}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Clear All
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="h-[calc(100%-80px)] w-full">
|
||||
{canvasMode === 'wireframe' ? (
|
||||
<div className="h-full w-full flex">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Suspense fallback={<div className="p-4">Loading canvas…</div>}>
|
||||
<WireframeCanvas
|
||||
className="h-full w-full"
|
||||
selectedDevice={selectedDevice}
|
||||
onWireframeGenerated={onWireframeGenerated}
|
||||
onGenerationStart={onGenerationStart}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
<PromptSidePanel
|
||||
className="shrink-0"
|
||||
selectedDevice={selectedDevice}
|
||||
onDeviceChange={handleDeviceChange}
|
||||
initialPrompt={initialPrompt}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full">
|
||||
{/* Component Palette Sidebar */}
|
||||
<ResizablePanel defaultSize={20} minSize={15} maxSize={30}>
|
||||
<div className="h-full bg-sidebar border-r border-sidebar-border">
|
||||
<ComponentPalette />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle className="w-1 bg-border hover:bg-accent transition-colors" />
|
||||
|
||||
{/* Canvas Area */}
|
||||
<ResizablePanel defaultSize={selectedComponent ? 60 : 80} minSize={40}>
|
||||
<div className="h-full bg-background">
|
||||
<Canvas />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
{/* Properties Panel - Only show when component is selected */}
|
||||
{selectedComponent && (
|
||||
<>
|
||||
<ResizableHandle className="w-1 bg-border hover:bg-accent transition-colors" />
|
||||
<ResizablePanel defaultSize={20} minSize={15} maxSize={35}>
|
||||
<div className="h-full bg-sidebar border-l border-sidebar-border">
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
|
||||
{/* Drag Overlay */}
|
||||
<DragOverlay>
|
||||
{activeId ? (
|
||||
<div className="drag-overlay p-2 bg-card border border-border rounded-md shadow-lg">{activeId}</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
src/components/edit-feature-form.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { TemplateFeature } from "@/lib/template-service"
|
||||
|
||||
interface EditFeatureFormProps {
|
||||
feature: TemplateFeature
|
||||
onSubmit: (feature: Partial<TemplateFeature>) => Promise<void>
|
||||
onCancel: () => void
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
export function EditFeatureForm({ feature, onSubmit, onCancel, isOpen }: EditFeatureFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: feature.name || "",
|
||||
description: feature.description || "",
|
||||
complexity: feature.complexity || "medium",
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await onSubmit(formData)
|
||||
onCancel() // Close the dialog on success
|
||||
} catch (error) {
|
||||
console.error('Error updating feature:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onCancel}>
|
||||
<DialogContent className="sm:max-w-[425px] bg-gray-900 text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Feature</DialogTitle>
|
||||
<DialogDescription>
|
||||
Make changes to your feature here. Click save when you're done.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Feature Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Enter feature name"
|
||||
required
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Enter description"
|
||||
rows={3}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Complexity</Label>
|
||||
<Select
|
||||
value={formData.complexity}
|
||||
onValueChange={(value: "low" | "medium" | "high") =>
|
||||
setFormData({ ...formData, complexity: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select complexity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 border-white/10">
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="border-white/20 text-white hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="bg-orange-500 hover:bg-orange-400 text-black"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
225
src/components/edit-template-form.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { DatabaseTemplate } from "@/lib/template-service"
|
||||
import { Edit, X, Save } from "lucide-react"
|
||||
|
||||
interface EditTemplateFormProps {
|
||||
template: DatabaseTemplate
|
||||
onSubmit: (id: string, templateData: Partial<DatabaseTemplate>) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function EditTemplateForm({ template, onSubmit, onCancel }: EditTemplateFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
type: "",
|
||||
title: "",
|
||||
description: "",
|
||||
category: "",
|
||||
icon: "",
|
||||
gradient: "",
|
||||
border: "",
|
||||
text: "",
|
||||
subtext: ""
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const categories = [
|
||||
"Food Delivery",
|
||||
"E-commerce",
|
||||
"SaaS Platform",
|
||||
"Mobile App",
|
||||
"Dashboard",
|
||||
"CRM System",
|
||||
"Learning Platform",
|
||||
"Healthcare",
|
||||
"Real Estate",
|
||||
"Travel",
|
||||
"Entertainment",
|
||||
"Finance",
|
||||
"Social Media",
|
||||
"Marketplace",
|
||||
"Other"
|
||||
]
|
||||
|
||||
// Initialize form data with template values
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
type: template.type || "",
|
||||
title: template.title || "",
|
||||
description: template.description || "",
|
||||
category: template.category || "",
|
||||
icon: template.icon || "",
|
||||
gradient: template.gradient || "",
|
||||
border: template.border || "",
|
||||
text: template.text || "",
|
||||
subtext: template.subtext || ""
|
||||
})
|
||||
}, [template])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await onSubmit(template.id, formData)
|
||||
} catch (error) {
|
||||
console.error('Error updating template:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<Edit className="mr-2 h-5 w-5" />
|
||||
Edit Template: {template.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Template Type *</label>
|
||||
<Input
|
||||
placeholder="e.g., multi_restaurant_food_delivery"
|
||||
value={formData.type}
|
||||
onChange={(e) => handleInputChange('type', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-white/60">Unique identifier for the template</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Title *</label>
|
||||
<Input
|
||||
placeholder="e.g., Multi-Restaurant Food Delivery App"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleInputChange('title', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Description *</label>
|
||||
<Textarea
|
||||
placeholder="Describe your template and its key features..."
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40 min-h-[100px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Category *</label>
|
||||
<Select value={formData.category} onValueChange={(value) => handleInputChange('category', value)}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 border-white/10">
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category} className="text-white">
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Icon (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., restaurant, shopping-cart, users"
|
||||
value={formData.icon}
|
||||
onChange={(e) => handleInputChange('icon', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Gradient (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., from-orange-400 to-red-500"
|
||||
value={formData.gradient}
|
||||
onChange={(e) => handleInputChange('gradient', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Border (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., border-orange-500"
|
||||
value={formData.border}
|
||||
onChange={(e) => handleInputChange('border', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Text Color (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., text-orange-500"
|
||||
value={formData.text}
|
||||
onChange={(e) => handleInputChange('text', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Subtext (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., Perfect for food delivery startups"
|
||||
value={formData.subtext}
|
||||
onChange={(e) => handleInputChange('subtext', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="border-white/20 text-white hover:bg-white/10 cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-orange-500 hover:bg-orange-400 text-black font-semibold cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{loading ? "Updating..." : "Update Template"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
339
src/components/features/feature-submission-form.tsx
Normal file
@ -0,0 +1,339 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
Search,
|
||||
Info
|
||||
} from 'lucide-react'
|
||||
import { featureApi } from '@/lib/api/admin'
|
||||
import { FeatureSimilarity, DuplicateCheckResult } from '@/types/admin.types'
|
||||
|
||||
interface FeatureSubmissionFormProps {
|
||||
templateId: string
|
||||
templateName?: string
|
||||
onSuccess?: (feature: any) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function FeatureSubmissionForm({
|
||||
templateId,
|
||||
templateName,
|
||||
onSuccess,
|
||||
onCancel
|
||||
}: FeatureSubmissionFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
complexity: 'medium' as 'low' | 'medium' | 'high',
|
||||
business_rules: '',
|
||||
technical_requirements: ''
|
||||
})
|
||||
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [similarFeatures, setSimilarFeatures] = useState<FeatureSimilarity[]>([])
|
||||
const [duplicateInfo, setDuplicateInfo] = useState<DuplicateCheckResult | null>(null)
|
||||
const [searchingSimilar, setSearchingSimilar] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
// Clear previous results when name changes
|
||||
if (field === 'name') {
|
||||
setSimilarFeatures([])
|
||||
setDuplicateInfo(null)
|
||||
}
|
||||
}
|
||||
|
||||
const searchSimilarFeatures = async () => {
|
||||
if (!formData.name.trim()) return
|
||||
|
||||
try {
|
||||
setSearchingSimilar(true)
|
||||
const features = await featureApi.findSimilarFeatures(formData.name, 0.7, 5)
|
||||
setSimilarFeatures(features)
|
||||
|
||||
// Check if any are potential duplicates
|
||||
const potentialDuplicate = features.find(f => f.score >= 0.8)
|
||||
if (potentialDuplicate) {
|
||||
setDuplicateInfo({
|
||||
isDuplicate: true,
|
||||
canonicalFeature: potentialDuplicate,
|
||||
similarityScore: potentialDuplicate.score,
|
||||
matchType: potentialDuplicate.match_type
|
||||
})
|
||||
} else {
|
||||
setDuplicateInfo({ isDuplicate: false })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error searching similar features:', error)
|
||||
} finally {
|
||||
setSearchingSimilar(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
setError('Feature name is required')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
const response = await featureApi.submitCustomFeature({
|
||||
template_id: templateId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
complexity: formData.complexity,
|
||||
business_rules: formData.business_rules.trim() ? JSON.parse(formData.business_rules) : undefined,
|
||||
technical_requirements: formData.technical_requirements.trim() ? JSON.parse(formData.technical_requirements) : undefined,
|
||||
created_by_user_session: 'user-session' // TODO: Get from auth context
|
||||
})
|
||||
|
||||
setSuccess(true)
|
||||
if (onSuccess) {
|
||||
onSuccess(response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to submit feature')
|
||||
console.error('Error submitting feature:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getComplexityColor = (complexity: string) => {
|
||||
switch (complexity) {
|
||||
case 'low':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'medium':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
case 'high':
|
||||
return 'bg-red-100 text-red-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<CheckCircle className="h-12 w-12 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Feature Submitted Successfully!</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Your feature "{formData.name}" has been submitted and is pending admin review.
|
||||
</p>
|
||||
{duplicateInfo?.isDuplicate && (
|
||||
<Alert className="mb-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Similar features were found. An admin will review this submission to determine if it's a duplicate.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-x-2">
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
Submit Another Feature
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Submit Custom Feature</CardTitle>
|
||||
{templateName && (
|
||||
<p className="text-sm text-gray-600">
|
||||
Adding feature to template: <strong>{templateName}</strong>
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Feature Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Feature Name *</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
placeholder="Enter feature name..."
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={searchSimilarFeatures}
|
||||
disabled={!formData.name.trim() || searchingSimilar}
|
||||
>
|
||||
{searchingSimilar ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Similar Features Alert */}
|
||||
{similarFeatures.length > 0 && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-2">
|
||||
<p>Similar features found:</p>
|
||||
<div className="space-y-1">
|
||||
{similarFeatures.map((feature) => (
|
||||
<div key={feature.id} className="flex items-center justify-between text-sm">
|
||||
<span>{feature.name}</span>
|
||||
<Badge variant="outline">
|
||||
{(feature.score * 100).toFixed(1)}% match
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{duplicateInfo?.isDuplicate && (
|
||||
<p className="text-amber-600 font-medium mt-2">
|
||||
⚠️ This may be a duplicate. Please review before submitting.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
placeholder="Describe what this feature does..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Complexity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="complexity">Complexity</Label>
|
||||
<Select value={formData.complexity} onValueChange={(value) => handleInputChange('complexity', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className="bg-green-100 text-green-800">Low</Badge>
|
||||
<span>Simple implementation</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="medium">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className="bg-yellow-100 text-yellow-800">Medium</Badge>
|
||||
<span>Moderate complexity</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="high">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className="bg-red-100 text-red-800">High</Badge>
|
||||
<span>Complex implementation</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Business Rules */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="business_rules">Business Rules (JSON)</Label>
|
||||
<Textarea
|
||||
id="business_rules"
|
||||
value={formData.business_rules}
|
||||
onChange={(e) => handleInputChange('business_rules', e.target.value)}
|
||||
placeholder='{"rule1": "description", "rule2": "description"}'
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Optional: Define business rules for this feature in JSON format
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Technical Requirements */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="technical_requirements">Technical Requirements (JSON)</Label>
|
||||
<Textarea
|
||||
id="technical_requirements"
|
||||
value={formData.technical_requirements}
|
||||
onChange={(e) => handleInputChange('technical_requirements', e.target.value)}
|
||||
placeholder='{"framework": "React", "database": "PostgreSQL"}'
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Optional: Define technical requirements in JSON format
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting || !formData.name.trim()}
|
||||
className="flex-1"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
'Submit Feature'
|
||||
)}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
684
src/components/features/features-page.tsx
Normal file
@ -0,0 +1,684 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
Database,
|
||||
Shield,
|
||||
CreditCard,
|
||||
BarChart3,
|
||||
Bell,
|
||||
Mail,
|
||||
Globe,
|
||||
Smartphone,
|
||||
Zap,
|
||||
Settings,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
Video,
|
||||
MessageSquare,
|
||||
Star,
|
||||
Code,
|
||||
Palette,
|
||||
Layers,
|
||||
Save,
|
||||
Download,
|
||||
} from "lucide-react"
|
||||
import { ArrowRight } from "lucide-react" // Added import for ArrowRight
|
||||
|
||||
interface Feature {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
icon: any
|
||||
complexity: number
|
||||
timeImpact: string
|
||||
dependencies: string[]
|
||||
conflicts: string[]
|
||||
techStack: string[]
|
||||
businessQuestions: string[]
|
||||
isCore?: boolean
|
||||
isPopular?: boolean
|
||||
}
|
||||
|
||||
interface SelectedFeature extends Feature {
|
||||
order: number
|
||||
customConfig?: any
|
||||
}
|
||||
|
||||
export function FeaturesPage() {
|
||||
const [activeTab, setActiveTab] = useState("browse")
|
||||
const [selectedCategory, setSelectedCategory] = useState("all")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedFeatures, setSelectedFeatures] = useState<SelectedFeature[]>([])
|
||||
const [draggedFeature, setDraggedFeature] = useState<Feature | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const features: Feature[] = [
|
||||
// Core Features
|
||||
{
|
||||
id: "user-auth",
|
||||
name: "User Authentication",
|
||||
description: "Secure user registration, login, and session management",
|
||||
category: "core",
|
||||
icon: Shield,
|
||||
complexity: 3,
|
||||
timeImpact: "2-3 days",
|
||||
dependencies: [],
|
||||
conflicts: [],
|
||||
techStack: ["NextAuth.js", "JWT", "OAuth"],
|
||||
businessQuestions: [
|
||||
"How many users do you expect in the first year?",
|
||||
"Do you need social login (Google, Facebook)?",
|
||||
"Will you have different user roles?",
|
||||
],
|
||||
isCore: true,
|
||||
isPopular: true,
|
||||
},
|
||||
{
|
||||
id: "database",
|
||||
name: "Database Integration",
|
||||
description: "Data storage and management system",
|
||||
category: "core",
|
||||
icon: Database,
|
||||
complexity: 4,
|
||||
timeImpact: "3-5 days",
|
||||
dependencies: [],
|
||||
conflicts: [],
|
||||
techStack: ["PostgreSQL", "Prisma", "Redis"],
|
||||
businessQuestions: [
|
||||
"What's your expected data volume?",
|
||||
"Do you need real-time data updates?",
|
||||
"What's your data backup strategy?",
|
||||
],
|
||||
isCore: true,
|
||||
isPopular: true,
|
||||
},
|
||||
{
|
||||
id: "api-management",
|
||||
name: "API Management",
|
||||
description: "RESTful API with rate limiting and documentation",
|
||||
category: "core",
|
||||
icon: Code,
|
||||
complexity: 3,
|
||||
timeImpact: "2-4 days",
|
||||
dependencies: ["database"],
|
||||
conflicts: [],
|
||||
techStack: ["Next.js API Routes", "Swagger", "Rate Limiting"],
|
||||
businessQuestions: [
|
||||
"Will you have external API integrations?",
|
||||
"Do you need API versioning?",
|
||||
"What's your expected API call volume?",
|
||||
],
|
||||
isCore: true,
|
||||
},
|
||||
|
||||
// Business Features
|
||||
{
|
||||
id: "payment-processing",
|
||||
name: "Payment Processing",
|
||||
description: "Secure payment handling with multiple providers",
|
||||
category: "business",
|
||||
icon: CreditCard,
|
||||
complexity: 5,
|
||||
timeImpact: "1-2 weeks",
|
||||
dependencies: ["user-auth", "database"],
|
||||
conflicts: [],
|
||||
techStack: ["Stripe", "PayPal", "Webhook Handling"],
|
||||
businessQuestions: [
|
||||
"What payment methods do you need?",
|
||||
"What's your expected transaction volume?",
|
||||
"Do you need subscription billing?",
|
||||
],
|
||||
isPopular: true,
|
||||
},
|
||||
{
|
||||
id: "analytics",
|
||||
name: "Analytics & Tracking",
|
||||
description: "User behavior tracking and business metrics",
|
||||
category: "business",
|
||||
icon: BarChart3,
|
||||
complexity: 3,
|
||||
timeImpact: "2-3 days",
|
||||
dependencies: [],
|
||||
conflicts: [],
|
||||
techStack: ["Google Analytics", "Mixpanel", "Custom Events"],
|
||||
businessQuestions: [
|
||||
"What metrics are most important to track?",
|
||||
"Do you need real-time analytics?",
|
||||
"What's your privacy policy regarding data?",
|
||||
],
|
||||
isPopular: true,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
name: "Notification System",
|
||||
description: "Email, SMS, and push notifications",
|
||||
category: "business",
|
||||
icon: Bell,
|
||||
complexity: 4,
|
||||
timeImpact: "3-5 days",
|
||||
dependencies: ["user-auth"],
|
||||
conflicts: [],
|
||||
techStack: ["SendGrid", "Twilio", "Push API"],
|
||||
businessQuestions: [
|
||||
"What types of notifications do you need?",
|
||||
"How frequently will you send notifications?",
|
||||
"Do you need notification preferences?",
|
||||
],
|
||||
},
|
||||
|
||||
// UI/UX Features
|
||||
{
|
||||
id: "responsive-design",
|
||||
name: "Responsive Design",
|
||||
description: "Mobile-first responsive layout",
|
||||
category: "ui",
|
||||
icon: Smartphone,
|
||||
complexity: 2,
|
||||
timeImpact: "1-2 days",
|
||||
dependencies: [],
|
||||
conflicts: [],
|
||||
techStack: ["Tailwind CSS", "CSS Grid", "Flexbox"],
|
||||
businessQuestions: ["What devices will your users primarily use?", "Do you need a mobile app later?"],
|
||||
isCore: true,
|
||||
},
|
||||
{
|
||||
id: "dark-mode",
|
||||
name: "Dark Mode",
|
||||
description: "Toggle between light and dark themes",
|
||||
category: "ui",
|
||||
icon: Palette,
|
||||
complexity: 2,
|
||||
timeImpact: "1 day",
|
||||
dependencies: ["responsive-design"],
|
||||
conflicts: [],
|
||||
techStack: ["CSS Variables", "Theme Provider"],
|
||||
businessQuestions: ["Is this important for your user base?"],
|
||||
},
|
||||
{
|
||||
id: "animations",
|
||||
name: "Animations & Transitions",
|
||||
description: "Smooth animations and micro-interactions",
|
||||
category: "ui",
|
||||
icon: Zap,
|
||||
complexity: 3,
|
||||
timeImpact: "2-3 days",
|
||||
dependencies: ["responsive-design"],
|
||||
conflicts: [],
|
||||
techStack: ["Framer Motion", "CSS Animations"],
|
||||
businessQuestions: ["What's your performance priority?"],
|
||||
},
|
||||
|
||||
// Content Features
|
||||
{
|
||||
id: "content-management",
|
||||
name: "Content Management",
|
||||
description: "CMS for managing dynamic content",
|
||||
category: "content",
|
||||
icon: FileText,
|
||||
complexity: 4,
|
||||
timeImpact: "1 week",
|
||||
dependencies: ["database"],
|
||||
conflicts: [],
|
||||
techStack: ["Sanity", "Strapi", "MDX"],
|
||||
businessQuestions: ["Who will manage content?", "How often will content change?"],
|
||||
},
|
||||
{
|
||||
id: "media-upload",
|
||||
name: "Media Upload",
|
||||
description: "File and image upload with optimization",
|
||||
category: "content",
|
||||
icon: ImageIcon, // Updated from Image to ImageIcon
|
||||
complexity: 3,
|
||||
timeImpact: "2-3 days",
|
||||
dependencies: ["database"],
|
||||
conflicts: [],
|
||||
techStack: ["Cloudinary", "AWS S3", "Image Optimization"],
|
||||
businessQuestions: ["What file types do you need?", "What's your storage budget?"],
|
||||
},
|
||||
{
|
||||
id: "video-streaming",
|
||||
name: "Video Streaming",
|
||||
description: "Video upload and streaming capabilities",
|
||||
category: "content",
|
||||
icon: Video,
|
||||
complexity: 5,
|
||||
timeImpact: "1-2 weeks",
|
||||
dependencies: ["media-upload"],
|
||||
conflicts: [],
|
||||
techStack: ["Vimeo API", "YouTube API", "Video.js"],
|
||||
businessQuestions: ["What video quality do you need?", "Expected video volume?"],
|
||||
},
|
||||
|
||||
// Communication Features
|
||||
{
|
||||
id: "chat-system",
|
||||
name: "Real-time Chat",
|
||||
description: "Live messaging and communication",
|
||||
category: "communication",
|
||||
icon: MessageSquare,
|
||||
complexity: 4,
|
||||
timeImpact: "1 week",
|
||||
dependencies: ["user-auth", "database"],
|
||||
conflicts: [],
|
||||
techStack: ["WebSockets", "Socket.io", "Real-time DB"],
|
||||
businessQuestions: ["How many concurrent users?", "Do you need group chats?"],
|
||||
},
|
||||
{
|
||||
id: "email-integration",
|
||||
name: "Email Integration",
|
||||
description: "Email sending and template management",
|
||||
category: "communication",
|
||||
icon: Mail,
|
||||
complexity: 3,
|
||||
timeImpact: "2-3 days",
|
||||
dependencies: [],
|
||||
conflicts: [],
|
||||
techStack: ["SendGrid", "Mailgun", "Email Templates"],
|
||||
businessQuestions: ["What types of emails will you send?", "Expected email volume?"],
|
||||
},
|
||||
|
||||
// Advanced Features
|
||||
{
|
||||
id: "ai-integration",
|
||||
name: "AI Integration",
|
||||
description: "Machine learning and AI-powered features",
|
||||
category: "advanced",
|
||||
icon: Layers,
|
||||
complexity: 5,
|
||||
timeImpact: "1-3 weeks",
|
||||
dependencies: ["api-management"],
|
||||
conflicts: [],
|
||||
techStack: ["OpenAI API", "TensorFlow", "Custom Models"],
|
||||
businessQuestions: ["What AI capabilities do you need?", "What's your AI budget?", "Do you have training data?"],
|
||||
},
|
||||
{
|
||||
id: "real-time",
|
||||
name: "Real-time Features",
|
||||
description: "Live updates and collaboration",
|
||||
category: "advanced",
|
||||
icon: Zap,
|
||||
complexity: 4,
|
||||
timeImpact: "1 week",
|
||||
dependencies: ["database"],
|
||||
conflicts: [],
|
||||
techStack: ["WebSockets", "Socket.io", "Redis Pub/Sub"],
|
||||
businessQuestions: ["How many concurrent users?", "What needs to be real-time?"],
|
||||
},
|
||||
]
|
||||
|
||||
const categories = [
|
||||
{ id: "all", name: "All Features", icon: Globe, count: features.length },
|
||||
{ id: "core", name: "Core", icon: Settings, count: features.filter((f) => f.category === "core").length },
|
||||
{
|
||||
id: "business",
|
||||
name: "Business",
|
||||
icon: BarChart3,
|
||||
count: features.filter((f) => f.category === "business").length,
|
||||
},
|
||||
{ id: "ui", name: "UI/UX", icon: Palette, count: features.filter((f) => f.category === "ui").length },
|
||||
{ id: "content", name: "Content", icon: FileText, count: features.filter((f) => f.category === "content").length },
|
||||
{
|
||||
id: "communication",
|
||||
name: "Communication",
|
||||
icon: MessageSquare,
|
||||
count: features.filter((f) => f.category === "communication").length,
|
||||
},
|
||||
{
|
||||
id: "advanced",
|
||||
name: "Advanced",
|
||||
icon: Layers,
|
||||
count: features.filter((f) => f.category === "advanced").length,
|
||||
},
|
||||
]
|
||||
|
||||
const filteredFeatures = features.filter((feature) => {
|
||||
const matchesCategory = selectedCategory === "all" || feature.category === selectedCategory
|
||||
const matchesSearch =
|
||||
feature.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
feature.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const notAlreadySelected = !selectedFeatures.some((sf) => sf.id === feature.id)
|
||||
return matchesCategory && matchesSearch && notAlreadySelected
|
||||
})
|
||||
|
||||
const getComplexityColor = (complexity: number) => {
|
||||
if (complexity <= 2) return "bg-green-100 text-green-800"
|
||||
if (complexity <= 3) return "bg-yellow-100 text-yellow-800"
|
||||
return "bg-red-100 text-red-800"
|
||||
}
|
||||
|
||||
const getComplexityLabel = (complexity: number) => {
|
||||
if (complexity <= 2) return "Simple"
|
||||
if (complexity <= 3) return "Moderate"
|
||||
return "Complex"
|
||||
}
|
||||
|
||||
const handleDragStart = (feature: Feature) => {
|
||||
setDraggedFeature(feature)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index?: number) => {
|
||||
e.preventDefault()
|
||||
if (typeof index === "number") {
|
||||
setDragOverIndex(index)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent, index?: number) => {
|
||||
e.preventDefault()
|
||||
if (!draggedFeature) return
|
||||
|
||||
const newFeature: SelectedFeature = {
|
||||
...draggedFeature,
|
||||
order: typeof index === "number" ? index : selectedFeatures.length,
|
||||
}
|
||||
|
||||
if (typeof index === "number") {
|
||||
const newFeatures = [...selectedFeatures]
|
||||
newFeatures.splice(index, 0, newFeature)
|
||||
// Update order for all features
|
||||
newFeatures.forEach((f, i) => (f.order = i))
|
||||
setSelectedFeatures(newFeatures)
|
||||
} else {
|
||||
setSelectedFeatures([...selectedFeatures, newFeature])
|
||||
}
|
||||
|
||||
setDraggedFeature(null)
|
||||
setDragOverIndex(null)
|
||||
}
|
||||
|
||||
const removeFeature = (featureId: string) => {
|
||||
setSelectedFeatures(selectedFeatures.filter((f) => f.id !== featureId))
|
||||
}
|
||||
|
||||
const reorderFeatures = (fromIndex: number, toIndex: number) => {
|
||||
const newFeatures = [...selectedFeatures]
|
||||
const [movedFeature] = newFeatures.splice(fromIndex, 1)
|
||||
newFeatures.splice(toIndex, 0, movedFeature)
|
||||
// Update order for all features
|
||||
newFeatures.forEach((f, i) => (f.order = i))
|
||||
setSelectedFeatures(newFeatures)
|
||||
}
|
||||
|
||||
const calculateTotalComplexity = () => {
|
||||
return selectedFeatures.reduce((total, feature) => total + feature.complexity, 0)
|
||||
}
|
||||
|
||||
const calculateEstimatedTime = () => {
|
||||
const totalDays = selectedFeatures.reduce((total, feature) => {
|
||||
const days = feature.timeImpact.match(/(\d+)/g)?.map(Number) || [1]
|
||||
return total + Math.max(...days)
|
||||
}, 0)
|
||||
|
||||
if (totalDays < 7) return `${totalDays} days`
|
||||
if (totalDays < 30) return `${Math.ceil(totalDays / 7)} weeks`
|
||||
return `${Math.ceil(totalDays / 30)} months`
|
||||
}
|
||||
|
||||
const FeatureCard = ({ feature, isDraggable = true }: { feature: Feature; isDraggable?: boolean }) => {
|
||||
const Icon = feature.icon
|
||||
return (
|
||||
<Card
|
||||
className={`group hover:shadow-lg transition-all duration-300 border-2 hover:border-blue-200 ${
|
||||
isDraggable ? "cursor-grab active:cursor-grabbing" : ""
|
||||
}`}
|
||||
draggable={isDraggable}
|
||||
onDragStart={() => isDraggable && handleDragStart(feature)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Icon className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg group-hover:text-blue-600 transition-colors">{feature.name}</CardTitle>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className={getComplexityColor(feature.complexity)}>
|
||||
{getComplexityLabel(feature.complexity)}
|
||||
</Badge>
|
||||
{feature.isCore && <Badge variant="secondary">Core</Badge>}
|
||||
{feature.isPopular && (
|
||||
<Badge variant="outline" className="text-yellow-600 border-yellow-300">
|
||||
<Star className="h-3 w-3 mr-1 fill-current" />
|
||||
Popular
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isDraggable && <GripVertical className="h-5 w-5 text-gray-400 opacity-0 group-hover:opacity-100" />}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mt-2">{feature.description}</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>Time Impact: {feature.timeImpact}</span>
|
||||
<span>Complexity: {feature.complexity}/5</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-sm text-gray-700 mb-2">Tech Stack:</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{feature.techStack.slice(0, 3).map((tech, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{tech}
|
||||
</Badge>
|
||||
))}
|
||||
{feature.techStack.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{feature.techStack.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feature.dependencies.length > 0 && (
|
||||
<div className="p-2 bg-yellow-50 border border-yellow-200 rounded text-sm">
|
||||
<span className="font-medium text-yellow-800">Dependencies:</span>
|
||||
<span className="text-yellow-700 ml-1">
|
||||
{feature.dependencies
|
||||
.map((depId) => {
|
||||
const dep = features.find((f) => f.id === depId)
|
||||
return dep?.name
|
||||
})
|
||||
.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectedFeatureCard = ({ feature, index }: { feature: SelectedFeature; index: number }) => {
|
||||
const Icon = feature.icon
|
||||
return (
|
||||
<Card
|
||||
className="group border-2 border-blue-200 bg-blue-50 hover:shadow-lg transition-all duration-300"
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(feature)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<GripVertical className="h-4 w-4 text-gray-400 cursor-grab" />
|
||||
<div className="p-2 bg-blue-200 rounded-lg">
|
||||
<Icon className="h-4 w-4 text-blue-700" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900">{feature.name}</h4>
|
||||
<p className="text-sm text-blue-700">{feature.timeImpact}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className={getComplexityColor(feature.complexity)}>{feature.complexity}</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeFeature(feature.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Features Library */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Features Library</h2>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Search features..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 w-80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{categories.map((category) => {
|
||||
const Icon = category.icon
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => setSelectedCategory(category.id)}
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-lg border transition-all ${
|
||||
selectedCategory === category.id
|
||||
? "bg-blue-600 text-white border-blue-600"
|
||||
: "bg-white text-gray-700 border-gray-200 hover:border-blue-300"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="font-medium">{category.name}</span>
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{category.count}
|
||||
</Badge>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredFeatures.map((feature) => (
|
||||
<FeatureCard key={feature.id} feature={feature} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Features Panel */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="sticky top-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Selected Features</span>
|
||||
<Badge variant="secondary">{selectedFeatures.length}</Badge>
|
||||
</CardTitle>
|
||||
<p className="text-sm text-gray-600">Drag features here to build your project</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Project Summary */}
|
||||
<div className="p-4 bg-gray-50 rounded-lg space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Total Complexity:</span>
|
||||
<Badge
|
||||
variant={
|
||||
calculateTotalComplexity() > 20
|
||||
? "destructive"
|
||||
: calculateTotalComplexity() > 15
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{calculateTotalComplexity()}/50
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Estimated Time:</span>
|
||||
<span className="font-medium">{calculateEstimatedTime()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
className={`min-h-[200px] border-2 border-dashed rounded-lg p-4 transition-all ${
|
||||
draggedFeature
|
||||
? "border-blue-400 bg-blue-50"
|
||||
: selectedFeatures.length === 0
|
||||
? "border-gray-300"
|
||||
: "border-transparent"
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{selectedFeatures.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Plus className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500 text-sm">
|
||||
Drag features from the library to start building your project
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{selectedFeatures
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((feature, index) => (
|
||||
<SelectedFeatureCard key={feature.id} feature={feature} index={index} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{selectedFeatures.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Button className="w-full bg-blue-600 hover:bg-blue-700">
|
||||
Continue to Business Context
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full bg-transparent" onClick={() => setSelectedFeatures([])}>
|
||||
Clear All Features
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
src/components/github/ViewUserReposButton.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { FolderGit2 } from "lucide-react"
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
size?: "default" | "sm" | "lg" | "icon"
|
||||
label?: string
|
||||
}
|
||||
|
||||
export default function ViewUserReposButton({ className, size = "default", label = "View My Repos" }: Props) {
|
||||
return (
|
||||
<Link href="/github/repos" prefetch>
|
||||
<Button className={className} size={size} variant="default">
|
||||
<FolderGit2 className="mr-2 h-5 w-5" />
|
||||
{label}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
156
src/components/layout/admin-layout.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Bell, Settings, LogOut, User, Shield, ArrowLeft } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { AdminNotificationsPanel } from "@/components/admin/admin-notifications-panel"
|
||||
import { useAdminNotifications } from "@/contexts/AdminNotificationContext"
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AdminLayout({ children }: AdminLayoutProps) {
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false)
|
||||
const [showNotifications, setShowNotifications] = useState(false)
|
||||
// const pathname = usePathname()
|
||||
const { user, logout, isAdmin } = useAuth()
|
||||
const { unreadCount } = useAdminNotifications()
|
||||
|
||||
// Handle logout with loading state
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
setIsLoggingOut(true)
|
||||
await logout()
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error)
|
||||
setIsLoggingOut(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect non-admin users
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-red-600 mb-4">Access Denied</h1>
|
||||
<p className="text-gray-600 mb-4">You don't have permission to access the admin panel.</p>
|
||||
<Link href="/">
|
||||
<Button>Go to Home</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
{/* Admin Header */}
|
||||
<header className="bg-black/90 text-white border-b border-white/10 backdrop-blur">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 justify-between items-center">
|
||||
{/* Logo and Admin Title */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<ArrowLeft className="h-5 w-5 text-white/70 hover:text-white" />
|
||||
</Link>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-orange-500 rounded-lg flex items-center justify-center">
|
||||
<span className="text-black font-bold text-sm">C</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold">Codenuk</span>
|
||||
<Badge className="bg-orange-500 text-black">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Admin
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Notifications */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative text-white/80 hover:text-white hover:bg-white/5"
|
||||
onClick={() => setShowNotifications(true)}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 flex items-center justify-center text-xs bg-orange-500 text-black">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* User Menu */}
|
||||
{user && user.email && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src="/avatars/01.png" alt={user.username || user.email || "User"} />
|
||||
<AvatarFallback>
|
||||
{(user.username && user.username.charAt(0)) || (user.email && user.email.charAt(0)) || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 bg-black text-white border-white/10" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user.username || user.email || "User"}</p>
|
||||
<p className="text-xs leading-none text-white/60">{user.email || "No email"}</p>
|
||||
<Badge className="w-fit bg-orange-500 text-black text-xs">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Admin
|
||||
</Badge>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="hover:bg-white/5 cursor-pointer">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="hover:bg-white/5 cursor-pointer">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="hover:bg-white/5 cursor-pointer" disabled={isLoggingOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>{isLoggingOut ? "Logging out..." : "Log out"}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Admin Content */}
|
||||
<main className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Notifications Panel */}
|
||||
<AdminNotificationsPanel
|
||||
open={showNotifications}
|
||||
onOpenChange={setShowNotifications}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
348
src/components/layout/admin-sidebar-layout.tsx
Normal file
@ -0,0 +1,348 @@
|
||||
"use client"
|
||||
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Bell,
|
||||
Settings,
|
||||
LogOut,
|
||||
User,
|
||||
Shield,
|
||||
ArrowLeft,
|
||||
LayoutDashboard,
|
||||
Files,
|
||||
Zap,
|
||||
Users,
|
||||
BarChart3,
|
||||
FileText,
|
||||
Cog,
|
||||
HelpCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { AdminNotificationsPanel } from "@/components/admin/admin-notifications-panel"
|
||||
import { useAdminNotifications } from "@/contexts/AdminNotificationContext"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface AdminSidebarLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
interface SidebarItem {
|
||||
id: string
|
||||
label: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
href?: string
|
||||
badge?: number
|
||||
subItems?: SidebarItem[]
|
||||
}
|
||||
|
||||
export function AdminSidebarLayout({ children }: AdminSidebarLayoutProps) {
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false)
|
||||
const [showNotifications, setShowNotifications] = useState(false)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const { user, logout, isAdmin } = useAuth()
|
||||
const { unreadCount } = useAdminNotifications()
|
||||
|
||||
// Handle logout with loading state
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
setIsLoggingOut(true)
|
||||
await logout()
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error)
|
||||
setIsLoggingOut(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar navigation items
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
{
|
||||
id: "dashboard",
|
||||
label: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
href: "/admin"
|
||||
},
|
||||
{
|
||||
id: "features",
|
||||
label: "Custom Features",
|
||||
icon: Zap,
|
||||
href: "/admin?tab=features",
|
||||
badge: 0 // This will be updated dynamically
|
||||
},
|
||||
{
|
||||
id: "templates",
|
||||
label: "Templates",
|
||||
icon: Files,
|
||||
href: "/admin/templates"
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
label: "User Management",
|
||||
icon: Users,
|
||||
href: "/admin/users"
|
||||
},
|
||||
{
|
||||
id: "analytics",
|
||||
label: "Analytics",
|
||||
icon: BarChart3,
|
||||
href: "/admin/analytics"
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Settings",
|
||||
icon: Cog,
|
||||
href: "/admin/settings"
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
label: "Help & Support",
|
||||
icon: HelpCircle,
|
||||
href: "/admin/help"
|
||||
}
|
||||
]
|
||||
|
||||
// Redirect non-admin users
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-red-600 mb-4">Access Denied</h1>
|
||||
<p className="text-gray-600 mb-4">You don't have permission to access the admin panel.</p>
|
||||
<Link href="/">
|
||||
<Button>Go to Home</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarItem = ({ item, level = 0 }: { item: SidebarItem; level?: number }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const hasSubItems = item.subItems && item.subItems.length > 0
|
||||
const isActive = pathname === item.href || (item.href && pathname.startsWith(item.href))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
href={item.href || "#"}
|
||||
onClick={hasSubItems ? (e) => {
|
||||
e.preventDefault()
|
||||
setIsExpanded(!isExpanded)
|
||||
} : undefined}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-3 py-2 text-sm rounded-lg transition-colors",
|
||||
level > 0 && "ml-4 pl-6",
|
||||
isActive
|
||||
? "bg-orange-500 text-black font-medium"
|
||||
: "text-white/80 hover:bg-white/10 hover:text-white",
|
||||
sidebarCollapsed && level === 0 && "justify-center px-2"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<item.icon className={cn("h-5 w-5", sidebarCollapsed && level === 0 && "h-6 w-6")} />
|
||||
{(!sidebarCollapsed || level > 0) && (
|
||||
<span className="truncate">{item.label}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center space-x-2">
|
||||
{item.badge !== undefined && item.badge > 0 && (
|
||||
<Badge className="bg-red-500 text-white text-xs h-5 w-5 rounded-full p-0 flex items-center justify-center">
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
{hasSubItems && (
|
||||
<ChevronRight className={cn("h-4 w-4 transition-transform", isExpanded && "rotate-90")} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{hasSubItems && isExpanded && !sidebarCollapsed && (
|
||||
<div className="mt-1 space-y-1">
|
||||
{item.subItems!.map((subItem) => (
|
||||
<SidebarItem key={subItem.id} item={subItem} level={level + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex">
|
||||
{/* Sidebar */}
|
||||
<div className={cn(
|
||||
"bg-gray-900 border-r border-white/10 transition-all duration-300 flex flex-col",
|
||||
sidebarCollapsed ? "w-16" : "w-64"
|
||||
)}>
|
||||
{/* Sidebar Header */}
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-orange-500 rounded-lg flex items-center justify-center">
|
||||
<span className="text-black font-bold text-sm">C</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold">Admin</span>
|
||||
<Badge className="bg-orange-500 text-black text-xs">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Panel
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="text-white/70 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
{sidebarCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
{sidebarItems.map((item) => (
|
||||
<SidebarItem key={item.id} item={item} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Sidebar Footer */}
|
||||
<div className="p-4 border-t border-white/10">
|
||||
{!sidebarCollapsed ? (
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src="/avatars/01.png" alt={user?.username || user?.email || "User"} />
|
||||
<AvatarFallback className="bg-orange-500 text-black">
|
||||
{(user?.username && user.username.charAt(0)) || (user?.email && user.email.charAt(0)) || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{user?.username || user?.email || "User"}
|
||||
</p>
|
||||
<p className="text-xs text-white/60 truncate">Administrator</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src="/avatars/01.png" alt={user?.username || user?.email || "User"} />
|
||||
<AvatarFallback className="bg-orange-500 text-black">
|
||||
{(user?.username && user.username.charAt(0)) || (user?.email && user.email.charAt(0)) || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Top Header */}
|
||||
<header className="bg-black/90 text-white border-b border-white/10 backdrop-blur">
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex h-8 justify-between items-center">
|
||||
{/* Back to Main Site */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/" className="flex items-center space-x-2 text-white/70 hover:text-white">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="text-sm">Back to Site</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Notifications */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative text-white/80 hover:text-white hover:bg-white/5"
|
||||
onClick={() => setShowNotifications(true)}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 flex items-center justify-center text-xs bg-orange-500 text-black">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* User Menu */}
|
||||
{user && user.email && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src="/avatars/01.png" alt={user.username || user.email || "User"} />
|
||||
<AvatarFallback>
|
||||
{(user.username && user.username.charAt(0)) || (user.email && user.email.charAt(0)) || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 bg-black text-white border-white/10" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user.username || user.email || "User"}</p>
|
||||
<p className="text-xs leading-none text-white/60">{user.email || "No email"}</p>
|
||||
<Badge className="w-fit bg-orange-500 text-black text-xs">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Admin
|
||||
</Badge>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="hover:bg-white/5 cursor-pointer">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="hover:bg-white/5 cursor-pointer">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="hover:bg-white/5 cursor-pointer" disabled={isLoggingOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>{isLoggingOut ? "Logging out..." : "Log out"}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 px-6 py-8 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Notifications Panel */}
|
||||
<AdminNotificationsPanel
|
||||
open={showNotifications}
|
||||
onOpenChange={setShowNotifications}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
src/components/layout/app-layout.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import { Header } from "@/components/navigation/header"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
// const { user } = useAuth()
|
||||
const pathname = usePathname()
|
||||
|
||||
// Don't show header on auth pages or admin pages
|
||||
const isAuthPage = pathname === "/signin" || pathname === "/signup"
|
||||
const isAdminPage = pathname?.startsWith("/admin")
|
||||
|
||||
// For auth pages and admin pages, don't show header
|
||||
if (isAuthPage || isAdminPage) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// For all other pages, show header
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
2644
src/components/main-dashboard.tsx
Normal file
203
src/components/navigation/header.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Bell, Settings, LogOut, User, Menu, X, Shield } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
|
||||
export function Header() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
|
||||
// Handle logout with loading state
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
setIsLoggingOut(true);
|
||||
await logout();
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Define routes where the header should not be shown
|
||||
const noHeaderRoutes = ["/signin", "/signup"];
|
||||
if (noHeaderRoutes.includes(pathname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="bg-black/80 text-white border-b border-white/10 backdrop-blur">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 justify-between items-center">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-orange-500 rounded-lg flex items-center justify-center">
|
||||
<span className="text-black font-bold text-sm">C</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold">Codenuk</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex space-x-2">
|
||||
{/* Hide Project Builder and other user nav when admin is logged in */}
|
||||
{!isAdmin && (
|
||||
<Link
|
||||
key="project-builder"
|
||||
href="/project-builder"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
pathname === "/project-builder"
|
||||
? "bg-orange-500 text-black"
|
||||
: "text-white/70 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
Project Builder
|
||||
</Link>
|
||||
)}
|
||||
{/* Admin Navigation */}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors flex items-center space-x-1 ${
|
||||
pathname === "/admin"
|
||||
? "bg-orange-500 text-black"
|
||||
: "text-white/70 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center space-x-4 cursor-pointer">
|
||||
{/* While loading, don't show sign-in or user menu to avoid flicker */}
|
||||
{!user ? (
|
||||
<Link href="/signin">
|
||||
<Button size="sm" className="cursor-pointer">Sign In</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
{/* Notifications */}
|
||||
<Button variant="ghost" size="sm" className="relative text-white/80 hover:text-white hover:bg-white/5">
|
||||
<Bell className="h-5 w-5" />
|
||||
<Badge className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 flex items-center justify-center text-xs bg-orange-500 text-black">
|
||||
3
|
||||
</Badge>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* User Menu - Only show when user is authenticated */}
|
||||
{user && user.email && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src="/avatars/01.png" alt={user.username || user.email || "User"} />
|
||||
<AvatarFallback>
|
||||
{(user.username && user.username.charAt(0)) || (user.email && user.email.charAt(0)) || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 bg-black text-white border-white/10" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user.username || user.email || "User"}</p>
|
||||
<p className="text-xs leading-none text-white/60">{user.email || "No email"}</p>
|
||||
{isAdmin && (
|
||||
<Badge className="w-fit bg-orange-500 text-black text-xs">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="hover:bg-white/5 cursor-pointer">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="hover:bg-white/5 cursor-pointer">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="hover:bg-white/5 cursor-pointer" disabled={isLoggingOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>{isLoggingOut ? "Logging out..." : "Log out"}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="md:hidden"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden py-4 border-t border-white/10">
|
||||
<nav className="flex flex-col space-y-2">
|
||||
{!isAdmin && (
|
||||
<Link
|
||||
key="project-builder-mobile"
|
||||
href="/project-builder"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
pathname === "/project-builder"
|
||||
? "bg-orange-500 text-black"
|
||||
: "text-white/70 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Project Builder
|
||||
</Link>
|
||||
)}
|
||||
{/* Admin Navigation for mobile */}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors flex items-center space-x-1 ${
|
||||
pathname === "/admin"
|
||||
? "bg-orange-500 text-black"
|
||||
: "text-white/70 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
272
src/components/prompt-side-panel.tsx
Normal file
@ -0,0 +1,272 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState, useEffect, useRef } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { SelectDevice } from "@/components/ui/select-device"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getAIMockupHealthUrl, AI_MOCKUP_CONFIG } from "@/lib/api-config"
|
||||
|
||||
export function PromptSidePanel({
|
||||
className,
|
||||
selectedDevice = 'desktop',
|
||||
onDeviceChange,
|
||||
initialPrompt
|
||||
}: {
|
||||
className?: string
|
||||
selectedDevice?: 'desktop' | 'tablet' | 'mobile'
|
||||
onDeviceChange?: (device: 'desktop' | 'tablet' | 'mobile') => void
|
||||
initialPrompt?: string
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [prompt, setPrompt] = useState(
|
||||
"Dashboard with header, left sidebar, 3 stats cards, a line chart and a data table, plus footer.",
|
||||
)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [backendStatus, setBackendStatus] = useState<'connected' | 'disconnected' | 'checking'>('checking')
|
||||
const autoTriggeredRef = useRef(false)
|
||||
|
||||
const examples = useMemo(
|
||||
() => [
|
||||
"Landing page with header, hero, 3x2 feature grid, and footer.",
|
||||
"Settings screen: header, list of toggles, and save button.",
|
||||
"E‑commerce product page: header, 2-column gallery/details, reviews, sticky add-to-cart.",
|
||||
"Dashboard: header, left sidebar, 3 stats cards, line chart, data table, footer.",
|
||||
"Signup page: header, 2-column form, callout, submit button.",
|
||||
"Admin panel: header, left navigation, main content area with data tables, and footer.",
|
||||
"Product catalog: header, search bar, filter sidebar, 4x3 product grid, pagination.",
|
||||
"Blog layout: header, featured post hero, 2-column article list, sidebar with categories.",
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
// Check backend connection status
|
||||
useEffect(() => {
|
||||
const checkBackendStatus = async () => {
|
||||
try {
|
||||
setBackendStatus('checking')
|
||||
const response = await fetch(getAIMockupHealthUrl(), {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setBackendStatus('connected')
|
||||
} else {
|
||||
setBackendStatus('disconnected')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Backend health check failed:', error)
|
||||
setBackendStatus('disconnected')
|
||||
}
|
||||
}
|
||||
|
||||
checkBackendStatus()
|
||||
|
||||
// Check status every 10 seconds
|
||||
const interval = setInterval(checkBackendStatus, AI_MOCKUP_CONFIG.ui.statusCheckInterval)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Update prompt from parent when provided
|
||||
useEffect(() => {
|
||||
if (typeof initialPrompt === 'string' && initialPrompt.trim().length > 0) {
|
||||
setPrompt(initialPrompt)
|
||||
}
|
||||
}, [initialPrompt])
|
||||
|
||||
// Auto-generate once when an initial prompt arrives
|
||||
useEffect(() => {
|
||||
if (!autoTriggeredRef.current && typeof initialPrompt === 'string' && initialPrompt.trim().length > 0) {
|
||||
autoTriggeredRef.current = true
|
||||
// Slight delay to ensure canvas listeners are mounted
|
||||
const id = setTimeout(() => {
|
||||
dispatchGenerate(initialPrompt)
|
||||
}, 300)
|
||||
return () => clearTimeout(id)
|
||||
}
|
||||
}, [initialPrompt])
|
||||
|
||||
const dispatchGenerate = async (text: string) => {
|
||||
setIsGenerating(true)
|
||||
|
||||
// Dispatch the event for the canvas to handle with device information
|
||||
window.dispatchEvent(new CustomEvent("tldraw:generate", {
|
||||
detail: {
|
||||
prompt: text,
|
||||
device: selectedDevice
|
||||
}
|
||||
}))
|
||||
|
||||
// Wait a bit to show the loading state
|
||||
setTimeout(() => setIsGenerating(false), 1000)
|
||||
}
|
||||
|
||||
const dispatchClear = () => {
|
||||
window.dispatchEvent(new Event("tldraw:clear"))
|
||||
}
|
||||
|
||||
const handleDeviceChange = (device: 'desktop' | 'tablet' | 'mobile') => {
|
||||
console.log('DEBUG: PromptSidePanel handleDeviceChange called with:', device)
|
||||
console.log('DEBUG: Current selectedDevice prop:', selectedDevice)
|
||||
console.log('DEBUG: onDeviceChange function exists:', !!onDeviceChange)
|
||||
|
||||
if (onDeviceChange) {
|
||||
console.log('DEBUG: Calling onDeviceChange with:', device)
|
||||
onDeviceChange(device)
|
||||
} else {
|
||||
console.warn('DEBUG: onDeviceChange function not provided')
|
||||
}
|
||||
}
|
||||
|
||||
const getBackendStatusIcon = () => {
|
||||
switch (backendStatus) {
|
||||
case 'connected':
|
||||
return '🟢'
|
||||
case 'disconnected':
|
||||
return '🔴'
|
||||
case 'checking':
|
||||
return '🟡'
|
||||
default:
|
||||
return '⚪'
|
||||
}
|
||||
}
|
||||
|
||||
const getBackendStatusText = () => {
|
||||
switch (backendStatus) {
|
||||
case 'connected':
|
||||
return 'AI Backend Connected'
|
||||
case 'disconnected':
|
||||
return 'AI Backend Disconnected'
|
||||
case 'checking':
|
||||
return 'Checking Backend...'
|
||||
default:
|
||||
return 'Unknown Status'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"h-full border-l bg-white dark:bg-neutral-900 flex flex-col",
|
||||
collapsed ? "w-15" : "w-96",
|
||||
className,
|
||||
)}
|
||||
aria-label="AI prompt side panel"
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<h2 className={cn("text-sm font-medium text-balance", collapsed && "sr-only")}>AI Wireframe</h2>
|
||||
<Button variant="ghost" size="icon" onClick={() => setCollapsed((c) => !c)} aria-label="Toggle panel">
|
||||
{collapsed ? <span aria-hidden>›</span> : <span aria-hidden>‹</span>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{/* Backend Status */}
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg text-xs",
|
||||
backendStatus === 'connected' ? 'bg-green-50 text-green-700 border border-green-200' :
|
||||
backendStatus === 'disconnected' ? 'bg-red-50 text-red-700 border border-red-200' :
|
||||
'bg-yellow-50 text-yellow-700 border border-yellow-200'
|
||||
)}>
|
||||
<span>{getBackendStatusIcon()}</span>
|
||||
<span className="font-medium">{getBackendStatusText()}</span>
|
||||
</div>
|
||||
|
||||
<label className="text-xs font-medium">Prompt</label>
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Describe your screen: Landing with header, hero, 3x2 features, footer"
|
||||
className="min-h-28"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">Device Type</label>
|
||||
<SelectDevice
|
||||
value={selectedDevice}
|
||||
onValueChange={handleDeviceChange}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-medium">Current:</span>
|
||||
<span className={cn(
|
||||
"px-2 py-1 rounded-full text-xs font-medium",
|
||||
selectedDevice === "desktop" && "bg-blue-100 text-blue-700",
|
||||
selectedDevice === "tablet" && "bg-green-100 text-green-700",
|
||||
selectedDevice === "mobile" && "bg-purple-100 text-purple-700"
|
||||
)}>
|
||||
{selectedDevice.charAt(0).toUpperCase() + selectedDevice.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{selectedDevice === "desktop" && "Desktop layout with full navigation and sidebar"}
|
||||
{selectedDevice === "tablet" && "Tablet layout with responsive navigation"}
|
||||
{selectedDevice === "mobile" && "Mobile-first layout with stacked elements"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => dispatchGenerate(prompt)}
|
||||
className="flex-1"
|
||||
disabled={isGenerating || backendStatus !== 'connected'}
|
||||
>
|
||||
{isGenerating ? 'Generating...' : `Generate for ${selectedDevice}`}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={dispatchClear} disabled={isGenerating}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{backendStatus === 'disconnected' && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-xs text-amber-700">
|
||||
<strong>Backend not connected.</strong> Make sure your backend is running on port 8000.
|
||||
The system will use fallback generation instead.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* <div className="pt-1">
|
||||
<p className="text-xs font-medium mb-2">Examples</p>
|
||||
<ScrollArea className="h-40 border rounded">
|
||||
<ul className="p-2 space-y-2">
|
||||
{examples.map((ex) => (
|
||||
<li key={ex}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPrompt(ex)}
|
||||
className="text-left text-xs w-full hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded px-2 py-1"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{ex}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</div> */}
|
||||
|
||||
{/* AI Features Info */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
<strong>AI-Powered Generation:</strong> Claude AI analyzes your prompts and creates professional wireframe layouts with proper spacing, proportions, and UX best practices.
|
||||
</p>
|
||||
<div className="mt-2 pt-2 border-t border-blue-200">
|
||||
<p className="text-xs text-blue-600">
|
||||
<strong>Device-Specific Generation:</strong><br/>
|
||||
• <strong>Desktop:</strong> Uses single-device API for faster generation<br/>
|
||||
• <strong>Tablet/Mobile:</strong> Uses multi-device API for responsive layouts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default PromptSidePanel
|
||||
72
src/components/prompt-toolbar.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
export default function PromptToolbar({
|
||||
busy,
|
||||
onGenerate,
|
||||
onClear,
|
||||
onExample,
|
||||
}: {
|
||||
busy?: boolean
|
||||
onGenerate: (prompt: string) => void
|
||||
onClear: () => void
|
||||
onExample: (text: string) => void
|
||||
}) {
|
||||
const [prompt, setPrompt] = useState("Dashboard with header, sidebar, 3x2 cards grid, and footer")
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!prompt.trim() || busy) return
|
||||
onGenerate(prompt.trim())
|
||||
}
|
||||
|
||||
const examples = [
|
||||
"Marketing landing page with hero, 3x2 features grid, signup form, and footer",
|
||||
"Simple login screen with header and centered form",
|
||||
"Ecommerce product grid 4x2 with header, sidebar filters, and footer",
|
||||
"Admin dashboard with header, sidebar, 2x2 cards and a form",
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<form onSubmit={submit} className="flex items-center gap-2">
|
||||
<input
|
||||
aria-label="Prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="e.g., Dashboard with header, sidebar, 3x2 cards grid, and footer"
|
||||
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
|
||||
>
|
||||
{busy ? "Generating…" : "Generate"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-800 hover:bg-gray-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</form>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-gray-600">Try:</span>
|
||||
{examples.map((ex) => (
|
||||
<button
|
||||
key={ex}
|
||||
onClick={() => onExample(ex)}
|
||||
className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs text-gray-800 hover:bg-gray-50"
|
||||
>
|
||||
{ex}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||