Initial commit for frontend

This commit is contained in:
Kenil 2025-10-10 09:02:08 +05:30
commit 84a25bf091
165 changed files with 41453 additions and 0 deletions

41
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

65
package.json Normal file
View 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
View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

1
public/file.svg Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

1
public/next.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/seo-blog-preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 KiB

1
public/vercel.svg Normal file
View 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
View 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

View 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
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 }
)
}
}

View 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',
},
});
}

View 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 }
);
}
}

View 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 }
);
}
}

View File

@ -0,0 +1,5 @@
import ArchitectureGenerator from "@/components/architecture/architecture-generator"
export default function ArchitecturePage() {
return <ArchitectureGenerator />
}

View 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
View 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>
)
}

View File

@ -0,0 +1,5 @@
import BusinessContextGenerator from "@/components/business-context/business-context-generator"
export default function BusinessContextPage() {
return <BusinessContextGenerator />
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,5 @@
import { FeaturesPage } from "@/components/features/features-page"
export default function FeaturesPageRoute() {
return <FeaturesPage />
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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
View File

@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function HomePage() {
redirect("/project-builder")
}

View 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
View 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
View File

@ -0,0 +1,5 @@
import { SignUpPage } from "@/components/auth/signup-page"
export default function SignUpPageRoute() {
return <SignUpPage />
}

View File

@ -0,0 +1,5 @@
import { TemplatesPage } from "@/components/templates/template-page"
export default function TemplatesPageRoute() {
return <TemplatesPage />
}

View 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>
);
}

View 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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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&apos;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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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&apos;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 &apos;pending&apos; 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>
)
}

View 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>
)
}

View 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.",
"Ecommerce 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>
)
}

View 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

View 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);

View 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;
}
}

View 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>
)
}

View 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>
)
}

View File

@ -0,0 +1,6 @@
import EmailVerification from "@/app/auth/emailVerification";
export default function VerifyEmailPage() {
return <EmailVerification />;
}

View 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>
)
}

View 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&apos;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>
)
}

View 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>
)
}

View 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&apos;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>
)
}

View 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 (&lt;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>
)
}

View 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
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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;

View 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;

View 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;

View 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
};
};

View 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;

View 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;

View 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;

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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&apos;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>
)
}

View 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&apos;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>
)
}

View 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}
</>
)
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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.",
"Ecommerce 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

View 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>
)
}

Some files were not shown because too many files have changed in this diff Show More