Compare commits

...

10 Commits

Author SHA1 Message Date
Kenil
117f22e67c Initial commit for frontend 2025-10-10 08:40:18 +05:30
99107c06c5 frontend changes 2025-10-06 15:06:29 +05:30
68bec83ba4 frontend changes 2025-10-02 12:06:25 +05:30
0831dd5658 frontend changes 2025-09-30 15:47:58 +05:30
b6d91b9a08 frontend changes 2025-09-29 15:41:28 +05:30
2176100b0e frontend changes 2025-09-29 14:34:15 +05:30
69def8560b frontend changes 2025-09-26 17:05:02 +05:30
797f2e8657 frontend changes before tech stack 2025-09-23 12:52:39 +05:30
7c711bc2f8 frontend changes 2025-09-22 10:34:11 +05:30
c5c5cd743f frontend changes 2025-09-22 10:25:53 +05:30
38 changed files with 7753 additions and 1095 deletions

View File

@ -1,231 +0,0 @@
# Authentication Implementation
## Overview
This document describes the authentication system implementation with proper error handling, separate signup/signin routes, and email verification flow.
## Architecture
### Backend (User Auth Service)
- **Port**: 8011
- **Base URL**: `http://localhost:8011`
- **Database**: PostgreSQL
- **Features**: JWT authentication, email verification, session management
### Frontend (Next.js App)
- **Port**: 3001
- **Base URL**: `http://localhost:3001`
- **Framework**: Next.js 15.4.6 with TypeScript
- **UI**: Tailwind CSS + shadcn/ui components
## Routes Structure
### Frontend Routes
```
/signup - User registration page
/signin - User login page
/auth - Redirects to /signin (legacy support)
/verify-email - Email verification page (handled by backend redirect)
```
### Backend API Endpoints
```
POST /api/auth/register - User registration
POST /api/auth/login - User login
GET /api/auth/verify-email - Email verification (redirects to frontend)
POST /api/auth/logout - User logout
POST /api/auth/refresh - Token refresh
GET /api/auth/me - Get user profile
```
## User Flow
### 1. Registration Flow
1. User visits `/signup`
2. Fills out registration form
3. Submits form → `POST /api/auth/register`
4. Backend creates user account and sends verification email
5. Frontend shows success message and redirects to `/signin` after 3 seconds
6. User receives email with verification link
### 2. Email Verification Flow
1. User clicks verification link in email
2. Link points to: `http://localhost:8011/api/auth/verify-email?token=<token>`
3. Backend verifies token and redirects to: `http://localhost:3001/signin?verified=true`
4. Frontend displays success message: "Email verified successfully! You can now sign in to your account."
### 3. Login Flow
1. User visits `/signin`
2. Fills out login form
3. Submits form → `POST /api/auth/login`
4. Backend validates credentials and returns JWT tokens
5. Frontend stores tokens and redirects to dashboard (`/`)
## Error Handling
### Backend Error Responses
All API endpoints return consistent error format:
```json
{
"success": false,
"error": "Error type",
"message": "Detailed error message"
}
```
### Frontend Error Handling
- **Network Errors**: Display user-friendly messages
- **API Errors**: Show specific error messages from backend
- **Validation Errors**: Client-side validation with immediate feedback
- **Authentication Errors**: Clear messaging for login/registration issues
### Common Error Scenarios
#### Registration Errors
- **Email already exists**: "An account with this email already exists"
- **Username taken**: "Username is already taken"
- **Invalid email format**: "Please enter a valid email address"
- **Weak password**: "Password must be at least 8 characters long"
- **Missing fields**: "Please fill in all required fields"
#### Login Errors
- **Invalid credentials**: "Invalid email or password"
- **Email not verified**: "Please verify your email before signing in"
- **Account locked**: "Account is temporarily locked due to multiple failed attempts"
#### Email Verification Errors
- **Invalid token**: "Verification link is invalid or has expired"
- **Already verified**: "Email is already verified"
- **Token expired**: "Verification link has expired. Please request a new one"
## Components Structure
### Signup Flow
```
/signup
├── SignUpPage (main container)
├── SignUpForm (form component)
└── Success State (after registration)
```
### Signin Flow
```
/signin
├── SignInPage (main container)
├── SignInForm (form component)
└── Verification Messages (from URL params)
```
## API Integration
### Authentication Handler (`authenticationHandler.tsx`)
- Handles API calls to backend
- Proper error propagation
- TypeScript interfaces for type safety
### Key Functions
```typescript
registerUser(data: RegisterData): Promise<ApiResponse>
loginUser(email: string, password: string): Promise<ApiResponse>
```
## Security Features
### Backend Security
- JWT token authentication
- Password hashing (bcrypt)
- Rate limiting on auth endpoints
- CORS configuration
- Helmet security headers
- Session management
### Frontend Security
- Token storage in localStorage
- Automatic token refresh
- Secure API communication
- Input validation and sanitization
## Environment Configuration
### Backend (.env)
```env
PORT=8011
FRONTEND_URL=http://localhost:3001
POSTGRES_HOST=localhost
POSTGRES_DB=user_auth
JWT_SECRET=your-secret-key
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
```
### Frontend (.env.local)
```env
NEXT_PUBLIC_API_URL=http://localhost:8011
NEXT_PUBLIC_FRONTEND_URL=http://localhost:3001
```
## Testing the Implementation
### 1. Start Services
```bash
# Backend
cd automated-dev-pipeline
docker-compose up user-auth
# Frontend
cd codenuk-frontend-dark-theme
npm run dev
```
### 2. Test Registration
1. Visit `http://localhost:3001/signup`
2. Fill out registration form
3. Submit and check for success message
4. Check email for verification link
### 3. Test Email Verification
1. Click verification link in email
2. Should redirect to `http://localhost:3001/signin?verified=true`
3. Verify success message appears
### 4. Test Login
1. Visit `http://localhost:3001/signin`
2. Enter credentials
3. Should redirect to dashboard on success
## Troubleshooting
### Common Issues
1. **CORS Errors**
- Check backend CORS configuration
- Verify frontend URL in allowed origins
2. **Email Not Sending**
- Check SMTP configuration in backend
- Verify email credentials
3. **Verification Link Not Working**
- Check frontend URL in backend configuration
- Verify token expiration settings
4. **Login Fails After Verification**
- Check if user is properly verified in database
- Verify JWT token generation
### Debug Steps
1. Check browser network tab for API calls
2. Check backend logs for errors
3. Verify database connections
4. Test API endpoints directly with Postman/curl
## Future Enhancements
1. **Password Reset Flow**
2. **Two-Factor Authentication**
3. **Social Login Integration**
4. **Account Lockout Protection**
5. **Session Management Dashboard**
6. **Audit Logging**

View File

@ -1,118 +0,0 @@
# Dynamic Templates Implementation
## Overview
The frontend now fetches templates dynamically from the database instead of using static templates. This allows for real-time template management and custom template creation.
## Changes Made
### 1. Template Service (`src/lib/template-service.ts`)
- Created service to communicate with the template-manager API
- Handles fetching templates by category, individual templates, and creating new templates
- Includes TypeScript interfaces for type safety
### 2. Custom Hook (`src/hooks/useTemplates.ts`)
- Manages template data fetching and state
- Converts database templates to UI format
- Handles loading states and error handling
- Provides template feature fetching
### 3. Custom Template Form (`src/components/custom-template-form.tsx`)
- Form component for creating new templates
- Includes all required fields: type, title, description, category
- Optional fields: icon, gradient, border, text, subtext
- Validates required fields before submission
### 4. Updated Main Dashboard (`src/components/main-dashboard.tsx`)
- Replaced static templates with dynamic database templates
- Added loading and error states
- Dynamic category generation based on available templates
- Custom template creation functionality
- Fallback to static templates if database is unavailable
### 5. UI Components
- Added `textarea.tsx` component for form inputs
- Enhanced existing components with proper styling
## API Integration
### Template Manager Service
- **Base URL**: `http://localhost:8009`
- **Endpoints**:
- `GET /api/templates` - Get all templates grouped by category
- `GET /api/templates/:id` - Get specific template with features
- `GET /api/templates/type/:type` - Get template by type
- `POST /api/templates` - Create new template
### Database Schema
Templates are stored in PostgreSQL with the following structure:
```sql
CREATE TABLE templates (
id UUID PRIMARY KEY,
type VARCHAR(100) UNIQUE,
title VARCHAR(200),
description TEXT,
category VARCHAR(100),
icon VARCHAR(50),
gradient VARCHAR(100),
border VARCHAR(100),
text VARCHAR(100),
subtext VARCHAR(100),
is_active BOOLEAN,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
```
## Usage
### Viewing Templates
1. Templates are automatically loaded from the database on page load
2. If database is unavailable, fallback static templates are shown
3. Templates are grouped by category dynamically
### Creating Custom Templates
1. Click "Create Custom Template" button
2. Fill in required fields (type, title, description, category)
3. Optionally add styling fields (icon, gradient, border, text, subtext)
4. Submit to create the template in the database
5. Template will appear in the list after creation
### Template Features
- Each template can have associated features stored in `template_features` table
- Features are fetched when a template is selected
- Features include complexity, type, and usage statistics
## Error Handling
- Network errors show retry button
- Loading states with spinner
- Graceful fallback to static templates
- Form validation for required fields
## ✅ Implemented Features
### Template Management
- ✅ **Dynamic Template Display** - Templates fetched from database
- ✅ **Custom Template Creation** - Create new templates via form
- ✅ **Template Editing** - Edit existing templates
- ✅ **Template Deletion** - Delete templates with confirmation
- ✅ **Real-time Updates** - Changes reflect immediately in UI
### API Endpoints
- ✅ `GET /api/templates` - Get all templates grouped by category
- ✅ `GET /api/templates/:id` - Get specific template with features
- ✅ `POST /api/templates` - Create new template
- ✅ `PUT /api/templates/:id` - Update existing template
- ✅ `DELETE /api/templates/:id` - Delete template (soft delete)
### Database Operations
- ✅ **Soft Delete** - Templates are marked as inactive rather than physically deleted
- ✅ **Data Integrity** - All operations maintain referential integrity
- ✅ **Error Handling** - Comprehensive error handling for all operations
## Future Enhancements
- Feature management for templates
- Bulk template operations
- Template versioning
- Template sharing between users
- Template import/export functionality
- Template analytics and usage tracking

View File

@ -2,7 +2,6 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ['@tldraw/tldraw'],
output: 'export',
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
webpack: (config, { isServer }) => {

138
package-lock.json generated
View File

@ -16,7 +16,7 @@
"@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.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",
@ -34,8 +34,9 @@
"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.4.6",
"next": "^15.5.4",
"react": "19.1.0",
"react-day-picker": "^9.9.0",
"react-dom": "19.1.0",
@ -932,9 +933,9 @@
}
},
"node_modules/@next/env": {
"version": "15.4.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.6.tgz",
"integrity": "sha512-yHDKVTcHrZy/8TWhj0B23ylKv5ypocuCwey9ZqPyv4rPdUdRzpGCkSi03t04KBPyU96kxVtUqx6O3nE1kpxASQ==",
"version": "15.5.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.4.tgz",
"integrity": "sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@ -948,9 +949,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.4.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.6.tgz",
"integrity": "sha512-667R0RTP4DwxzmrqTs4Lr5dcEda9OxuZsVFsjVtxVMVhzSpo6nLclXejJVfQo2/g7/Z9qF3ETDmN3h65mTjpTQ==",
"version": "15.5.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.4.tgz",
"integrity": "sha512-nopqz+Ov6uvorej8ndRX6HlxCYWCO3AHLfKK2TYvxoSB2scETOcfm/HSS3piPqc3A+MUgyHoqE6je4wnkjfrOA==",
"cpu": [
"arm64"
],
@ -964,9 +965,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.4.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.6.tgz",
"integrity": "sha512-KMSFoistFkaiQYVQQnaU9MPWtp/3m0kn2Xed1Ces5ll+ag1+rlac20sxG+MqhH2qYWX1O2GFOATQXEyxKiIscg==",
"version": "15.5.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.4.tgz",
"integrity": "sha512-QOTCFq8b09ghfjRJKfb68kU9k2K+2wsC4A67psOiMn849K9ZXgCSRQr0oVHfmKnoqCbEmQWG1f2h1T2vtJJ9mA==",
"cpu": [
"x64"
],
@ -980,9 +981,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.4.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.6.tgz",
"integrity": "sha512-PnOx1YdO0W7m/HWFeYd2A6JtBO8O8Eb9h6nfJia2Dw1sRHoHpNf6lN1U4GKFRzRDBi9Nq2GrHk9PF3Vmwf7XVw==",
"version": "15.5.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.4.tgz",
"integrity": "sha512-eRD5zkts6jS3VfE/J0Kt1VxdFqTnMc3QgO5lFE5GKN3KDI/uUpSyK3CjQHmfEkYR4wCOl0R0XrsjpxfWEA++XA==",
"cpu": [
"arm64"
],
@ -996,9 +997,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.4.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.6.tgz",
"integrity": "sha512-XBbuQddtY1p5FGPc2naMO0kqs4YYtLYK/8aPausI5lyOjr4J77KTG9mtlU4P3NwkLI1+OjsPzKVvSJdMs3cFaw==",
"version": "15.5.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.4.tgz",
"integrity": "sha512-TOK7iTxmXFc45UrtKqWdZ1shfxuL4tnVAOuuJK4S88rX3oyVV4ZkLjtMT85wQkfBrOOvU55aLty+MV8xmcJR8A==",
"cpu": [
"arm64"
],
@ -1012,9 +1013,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.4.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.6.tgz",
"integrity": "sha512-+WTeK7Qdw82ez3U9JgD+igBAP75gqZ1vbK6R8PlEEuY0OIe5FuYXA4aTjL811kWPf7hNeslD4hHK2WoM9W0IgA==",
"version": "15.5.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.4.tgz",
"integrity": "sha512-7HKolaj+481FSW/5lL0BcTkA4Ueam9SPYWyN/ib/WGAFZf0DGAN8frNpNZYFHtM4ZstrHZS3LY3vrwlIQfsiMA==",
"cpu": [
"x64"
],
@ -1028,9 +1029,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.4.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.6.tgz",
"integrity": "sha512-XP824mCbgQsK20jlXKrUpZoh/iO3vUWhMpxCz8oYeagoiZ4V0TQiKy0ASji1KK6IAe3DYGfj5RfKP6+L2020OQ==",
"version": "15.5.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.4.tgz",
"integrity": "sha512-nlQQ6nfgN0nCO/KuyEUwwOdwQIGjOs4WNMjEUtpIQJPR2NUfmGpW2wkJln1d4nJ7oUzd1g4GivH5GoEPBgfsdw==",
"cpu": [
"x64"
],
@ -1044,9 +1045,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.4.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.6.tgz",
"integrity": "sha512-FxrsenhUz0LbgRkNWx6FRRJIPe/MI1JRA4W4EPd5leXO00AZ6YU8v5vfx4MDXTvN77lM/EqsE3+6d2CIeF5NYg==",
"version": "15.5.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.4.tgz",
"integrity": "sha512-PcR2bN7FlM32XM6eumklmyWLLbu2vs+D7nJX8OAIoWy69Kef8mfiN4e8TUv2KohprwifdpFKPzIP1njuCjD0YA==",
"cpu": [
"arm64"
],
@ -1060,9 +1061,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.4.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.6.tgz",
"integrity": "sha512-T4ufqnZ4u88ZheczkBTtOF+eKaM14V8kbjud/XrAakoM5DKQWjW09vD6B9fsdsWS2T7D5EY31hRHdta7QKWOng==",
"version": "15.5.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.4.tgz",
"integrity": "sha512-1ur2tSHZj8Px/KMAthmuI9FMp/YFusMMGoRNJaRZMOlSkgvLjzosSdQI0cJAKogdHl3qXUQKL9MGaYvKwA7DXg==",
"cpu": [
"x64"
],
@ -3707,7 +3708,7 @@
"version": "19.1.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz",
"integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@ -3717,7 +3718,7 @@
"version": "19.1.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.0.0"
@ -4605,9 +4606,9 @@
}
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@ -4895,7 +4896,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/d3-array": {
@ -6124,6 +6125,33 @@
"integrity": "sha512-0tLU0FOedVY7lrvN4LK0DVj6FTuYM0pWDpN97/8UTZE2lx1+OwX8+2uL7IOWc2PmktYTHQjMT6FvZZ3SGCdZdg==",
"license": "CC0-1.0"
},
"node_modules/framer-motion": {
"version": "12.23.22",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz",
"integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.21",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -7542,6 +7570,21 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/motion-dom": {
"version": "12.23.21",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.21.tgz",
"integrity": "sha512-5xDXx/AbhrfgsQmSE7YESMn4Dpo6x5/DTZ4Iyy4xqDvVHWvFVoV+V2Ri2S/ksx+D40wrZ7gPYiMWshkdoqNgNQ==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -7590,12 +7633,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "15.4.6",
"resolved": "https://registry.npmjs.org/next/-/next-15.4.6.tgz",
"integrity": "sha512-us++E/Q80/8+UekzB3SAGs71AlLDsadpFMXVNM/uQ0BMwsh9m3mr0UNQIfjKed8vpWXsASe+Qifrnu1oLIcKEQ==",
"version": "15.5.4",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.4.tgz",
"integrity": "sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA==",
"license": "MIT",
"dependencies": {
"@next/env": "15.4.6",
"@next/env": "15.5.4",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@ -7608,14 +7651,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.4.6",
"@next/swc-darwin-x64": "15.4.6",
"@next/swc-linux-arm64-gnu": "15.4.6",
"@next/swc-linux-arm64-musl": "15.4.6",
"@next/swc-linux-x64-gnu": "15.4.6",
"@next/swc-linux-x64-musl": "15.4.6",
"@next/swc-win32-arm64-msvc": "15.4.6",
"@next/swc-win32-x64-msvc": "15.4.6",
"@next/swc-darwin-arm64": "15.5.4",
"@next/swc-darwin-x64": "15.5.4",
"@next/swc-linux-arm64-gnu": "15.5.4",
"@next/swc-linux-arm64-musl": "15.5.4",
"@next/swc-linux-x64-gnu": "15.5.4",
"@next/swc-linux-x64-musl": "15.5.4",
"@next/swc-win32-arm64-msvc": "15.5.4",
"@next/swc-win32-x64-msvc": "15.5.4",
"sharp": "^0.34.3"
},
"peerDependencies": {
@ -8350,6 +8393,7 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-redux": {

View File

@ -17,7 +17,7 @@
"@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.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",
@ -35,8 +35,9 @@
"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.4.6",
"next": "^15.5.4",
"react": "19.1.0",
"react-day-picker": "^9.9.0",
"react-dom": "19.1.0",

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

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;

View File

@ -1,12 +1,14 @@
"use client"
import { Suspense, useEffect } from "react"
import { useRouter } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import { MainDashboard } from "@/components/main-dashboard"
import { useAuth } from "@/contexts/auth-context"
export default function ProjectBuilderPage() {
// Component that uses useSearchParams - needs to be wrapped in Suspense
function ProjectBuilderContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { user, isLoading } = useAuth()
useEffect(() => {
@ -17,13 +19,60 @@ export default function ProjectBuilderPage() {
}
}, [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>Loading...</div>}>
<MainDashboard />
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Loading...</div>}>
<ProjectBuilderContent />
</Suspense>
)
}

View File

@ -119,7 +119,7 @@ export function AICustomFeatureCreator({
const hasAnyAnalysis = !!aiAnalysis || requirements.some(r => (r.rules || []).length > 0)
const handleAnalyze = async () => {
if (hasAnyAnalysis) return
// 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)
@ -146,13 +146,17 @@ export function AICustomFeatureCreator({
})
// Generate logic rules per requirement in parallel and attach to each requirement
// 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(
featureName,
featureDescription,
r.text || featureName,
r.text || featureDescription,
r.text ? [r.text] : [],
projectType
)
@ -172,15 +176,14 @@ export function AICustomFeatureCreator({
const handleAnalyzeRequirement = async (idx: number) => {
const req = requirements[idx]
if (hasAnyAnalysis) return
if (!req?.text?.trim()) return
if ((req.rules || []).length > 0) return
setAnalyzingIdx(idx)
setAnalysisError(null)
try {
const res = await analyzeFeatureWithAI(
featureName,
featureDescription,
req.text || featureName,
req.text || featureDescription,
[req.text],
projectType
)
@ -269,10 +272,10 @@ export function AICustomFeatureCreator({
type="button"
variant="outline"
onClick={() => handleAnalyzeRequirement(idx)}
disabled={isAnalyzing || analyzingIdx === idx || !r.text.trim() || hasAnyAnalysis || (r.rules || []).length > 0}
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) || hasAnyAnalysis ? 'Analyzed' : 'Analyze With AI')}
{analyzingIdx === idx ? 'Analyzing…' : ((r.rules || []).length > 0 ? 'Analyzed' : 'Analyze With AI')}
</Button>
<button
type="button"
@ -324,7 +327,18 @@ export function AICustomFeatureCreator({
</Button>
</div>
{/* Removed global Analyze button; use per-requirement Analyze instead */}
{/* 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>

View File

@ -87,6 +87,7 @@ export const logout = async () => {
export const authApiClient = axios.create({
baseURL: BACKEND_URL,
withCredentials: true,
timeout: 10000, // 10 second timeout
});
// Add auth token to requests
@ -95,6 +96,19 @@ const addAuthTokenInterceptor = (client: typeof authApiClient) => {
(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}`;
@ -110,6 +124,39 @@ 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) {

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -121,7 +121,7 @@ function DialogDescription({
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
<div
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}

View File

@ -1,257 +1,201 @@
// components/ui/dropdown-menu.tsx
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
const DropdownMenu = DropdownMenuPrimitive.Root
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
const DropdownMenuGroup = DropdownMenuPrimitive.Group
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
const DropdownMenuSub = DropdownMenuPrimitive.Sub
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
)
}
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
function DropdownMenuSeparator({
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,42 @@
"use client"
import { AlertCircle, RefreshCw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
interface ErrorBannerProps {
title?: string
message?: string
onRetry?: () => void
showRetry?: boolean
}
export function ErrorBanner({
title = "Connection Issue",
message = "Unable to connect to the server. Please check your internet connection and try again.",
onRetry,
showRetry = true
}: ErrorBannerProps) {
return (
<Card className="border-red-200 bg-red-50">
<CardContent className="flex items-center gap-3 p-4">
<AlertCircle className="h-5 w-5 text-red-600" />
<div className="flex-1">
<h3 className="font-medium text-red-800">{title}</h3>
<p className="text-sm text-red-700">{message}</p>
</div>
{showRetry && onRetry && (
<Button
variant="outline"
size="sm"
onClick={onRetry}
className="border-red-300 text-red-700 hover:bg-red-100"
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
)}
</CardContent>
</Card>
)
}

View File

@ -1,3 +1,4 @@
// components/ui/progress.tsx
"use client"
import * as React from "react"
@ -5,27 +6,24 @@ import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
export { Progress }

View File

@ -1,3 +1,4 @@
// components/ui/slider.tsx
"use client"
import * as React from "react"
@ -25,4 +26,4 @@ const Slider = React.forwardRef<
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
export { Slider }

View File

@ -1,31 +1,30 @@
// components/ui/switch.tsx
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
export { Switch }

View File

@ -1,8 +1,6 @@
//
export const BACKEND_URL = 'https://backend.codenuk.com';
// export const BACKEND_URL = 'http://192.168.1.16:8000';
export const BACKEND_URL = 'http://localhost:8000';
export const SOCKET_URL = BACKEND_URL;

491
src/lib/api/github.ts Normal file
View File

@ -0,0 +1,491 @@
import { authApiClient } from '@/components/apis/authApiClients'
export interface AttachRepositoryPayload {
repository_url: string
branch_name?: string
user_id?: string
}
// -------- Repository contents --------
export interface RepoStructureEntry {
name: string
type: 'file' | 'directory'
size?: number
path: string
}
export interface RepoStructureResponse {
repository_id: string
directory_path: string
structure: RepoStructureEntry[]
}
export async function getRepositoryStructure(repositoryId: string, path: string = ''): Promise<RepoStructureResponse> {
const url = `/api/github/repository/${encodeURIComponent(repositoryId)}/structure${path ? `?path=${encodeURIComponent(path)}` : ''}`
const res = await authApiClient.get(url)
return res.data?.data as RepoStructureResponse
}
export interface FileContentResponse {
file_info: {
id: string
filename: string
file_extension?: string
relative_path: string
file_size_bytes?: number
mime_type?: string
is_binary?: boolean
language_detected?: string
line_count?: number
char_count?: number
}
content: string | null
preview?: string | null
}
export async function getRepositoryFileContent(repositoryId: string, filePath: string): Promise<FileContentResponse> {
const url = `/api/github/repository/${encodeURIComponent(repositoryId)}/file-content?file_path=${encodeURIComponent(filePath)}`
const res = await authApiClient.get(url)
return res.data?.data as FileContentResponse
}
export interface CommitSummaryResponse {
last_commit: {
hash: string
short_hash: string
author_name: string
author_email: string
committed_at: string
message: string
} | null
total_commits: number
}
export async function getRepositoryCommitSummary(repositoryId: string): Promise<CommitSummaryResponse> {
const url = `/api/github/repository/${encodeURIComponent(repositoryId)}/commit-summary`
const res = await authApiClient.get(url)
return res.data?.data as CommitSummaryResponse
}
export interface PathCommitResponse {
hash: string
short_hash: string
author_name: string
author_email: string
committed_at: string
message: string
path: string
}
export async function getPathCommit(repositoryId: string, relPath: string): Promise<PathCommitResponse | null> {
const url = `/api/github/repository/${encodeURIComponent(repositoryId)}/path-commit?path=${encodeURIComponent(relPath)}`
const res = await authApiClient.get(url)
return (res.data?.data as PathCommitResponse) || null
}
export interface CommitsListResponse {
items: Array<{
hash: string
short_hash: string
author_name: string
author_email: string
committed_at: string
message: string
}>
page: number
limit: number
total: number
has_next: boolean
}
export async function getRepositoryCommits(repositoryId: string, opts?: { page?: number; limit?: number; path?: string }): Promise<CommitsListResponse> {
const page = opts?.page ?? 1
const limit = opts?.limit ?? 20
const path = opts?.path ? `&path=${encodeURIComponent(opts.path)}` : ''
const url = `/api/github/repository/${encodeURIComponent(repositoryId)}/commits?page=${page}&limit=${limit}${path}`
const res = await authApiClient.get(url)
return res.data?.data as CommitsListResponse
}
export interface ResolvePathInfo {
repository_id: string
local_path: string
requested_file_path: string
resolved_absolute_path: string | null
exists: boolean
is_directory: boolean
}
export async function resolveRepositoryPath(repositoryId: string, filePath: string): Promise<ResolvePathInfo> {
const url = `/api/github/repository/${encodeURIComponent(repositoryId)}/resolve-path?file_path=${encodeURIComponent(filePath)}`
const res = await authApiClient.get(url)
return res.data?.data as ResolvePathInfo
}
export interface AttachRepositoryResponse<T = unknown> {
success: boolean
message?: string
data?: T & {
is_public?: boolean
requires_auth?: boolean
repository_name?: string
owner_name?: string
}
requires_auth?: boolean
auth_url?: string
auth_error?: boolean
}
export async function attachRepository(payload: AttachRepositoryPayload, retries = 3): Promise<AttachRepositoryResponse> {
// Add user_id as query fallback besides header for gateway caching/proxies
const rawUser = (typeof window !== 'undefined') ? localStorage.getItem('codenuk_user') : null
const userId = rawUser ? (JSON.parse(rawUser)?.id || null) : null
const url = userId ? `/api/github/attach-repository?user_id=${encodeURIComponent(userId)}` : '/api/github/attach-repository'
// Retry logic for connection issues
for (let i = 0; i < retries; i++) {
try {
// Use authApiClient but with extended timeout for repository operations
const response = await authApiClient.post(url, { ...payload, user_id: userId || payload.user_id }, {
headers: {
'Content-Type': 'application/json',
},
timeout: 60000, // 60 seconds for repository operations
})
console.log('📡 [attachRepository] Raw axios response:', response)
console.log('📡 [attachRepository] response.data:', response.data)
console.log('📡 [attachRepository] response.data type:', typeof response.data)
// Normalize response: API gateway may stringify JSON bodies or booleans
let parsed: any = response.data
if (typeof parsed === 'string') {
try {
parsed = JSON.parse(parsed)
console.log('📡 [attachRepository] Parsed string response to JSON')
} catch (e) {
console.warn('📡 [attachRepository] Failed to parse string response, returning as-is')
}
}
// Coerce success to a real boolean if it comes back as a string
const normalized: AttachRepositoryResponse = {
...(parsed || {}),
success: (parsed?.success === true || parsed?.success === 'true')
} as AttachRepositoryResponse
console.log('📡 [attachRepository] Returning normalized result:', normalized)
console.log('📡 [attachRepository] normalized.success:', normalized?.success)
return normalized
} catch (error: any) {
// If it's the last retry or not a connection error, throw immediately
if (i === retries - 1 || (error.code !== 'ECONNREFUSED' && error.code !== 'ECONNRESET')) {
throw error
}
// Wait before retrying (exponential backoff)
const waitTime = Math.min(1000 * Math.pow(2, i), 5000)
console.log(`⚠️ Connection failed, retrying in ${waitTime}ms... (attempt ${i + 1}/${retries})`)
await new Promise(resolve => setTimeout(resolve, waitTime))
}
}
// This should never be reached, but TypeScript needs it
throw new Error('Failed to attach repository after retries')
}
export interface GitHubAuthStatusData {
connected: boolean
github_username?: string
github_user_id?: string
connected_at?: string
scopes?: string[]
requires_auth?: boolean
auth_url?: string
}
export async function getGitHubAuthStatus(): Promise<AttachRepositoryResponse<GitHubAuthStatusData>> {
try {
const rawUser = (typeof window !== 'undefined') ? localStorage.getItem('codenuk_user') : null
const userId = rawUser ? (JSON.parse(rawUser)?.id || null) : null
const url = userId ? `/api/github/auth/github/status?user_id=${encodeURIComponent(userId)}` : '/api/github/auth/github/status'
const response = await authApiClient.get(url)
return response.data as AttachRepositoryResponse<GitHubAuthStatusData>
} catch (error: any) {
console.error('GitHub auth status check failed:', error)
// Return a default response indicating no connection
return {
success: false,
message: 'Failed to check GitHub auth status',
data: {
connected: false,
requires_auth: true
}
}
}
}
// -------- User repositories (UI-only helper) --------
export interface GitHubRepoSummary {
id?: string | number
full_name: string
name: string
owner?: { login?: string } | null
description?: string | null
visibility?: 'public' | 'private'
stargazers_count?: number
forks_count?: number
language?: string | null
updated_at?: string
html_url?: string
}
// Tries backend gateway route first. If backend does not yet provide it, returns an empty list gracefully.
export async function getUserRepositories(clearCache = false): Promise<GitHubRepoSummary[]> {
try {
const rawUser = (typeof window !== 'undefined') ? localStorage.getItem('codenuk_user') : null
const userId = rawUser ? (JSON.parse(rawUser)?.id || null) : null
// Clear cache if requested
if (clearCache && typeof window !== 'undefined') {
try {
const cacheKey = `user_repos_cache_${userId || 'anon'}`
sessionStorage.removeItem(cacheKey)
console.log('🧹 Cleared GitHub repository cache')
} catch (e) {
console.warn('Failed to clear cache:', e)
}
}
const buildUrl = (base: string) => {
const ts = Date.now()
const sep = base.includes('?') ? '&' : '?'
return `${base}${sep}nc=${ts}`
}
const primaryBase = userId ? `/api/github/user/${encodeURIComponent(userId)}/repositories` : '/api/github/user/repositories'
let res: any = await authApiClient.get(buildUrl(primaryBase), {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Pragma': 'no-cache', 'Accept': 'application/json' },
validateStatus: () => true,
})
// On 304 or empty body, retry once with a different cache-buster and legacy fallback
if (res?.status === 304 || res?.data == null || res?.data === '') {
try {
const fallbackBase = userId ? `/api/github/user/repos?user_id=${encodeURIComponent(userId)}` : '/api/github/user/repos'
res = await authApiClient.get(buildUrl(fallbackBase), {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Pragma': 'no-cache', 'Accept': 'application/json' },
validateStatus: () => true,
})
} catch { /* ignore and handle below */ }
}
// Parse response body if it is a JSON string (gateway may return text)
let body: any = res?.data
if (typeof body === 'string') {
try {
body = JSON.parse(body)
console.log('📡 [getUserRepositories] Parsed string response to JSON')
} catch (e) {
console.warn('📡 [getUserRepositories] Failed to parse string response, returning as-is')
}
}
let data = body?.data || body
// Session cache fallback if still empty
if ((!Array.isArray(data) || data.length === 0) && typeof window !== 'undefined') {
try {
const cacheKey = `user_repos_cache_${userId || 'anon'}`
const cached = sessionStorage.getItem(cacheKey)
if (cached) {
const parsed = JSON.parse(cached)
if (Array.isArray(parsed)) return parsed as GitHubRepoSummary[]
}
} catch {}
}
if (Array.isArray(data)) {
const normalized = data.map((r: any) => {
if (r && (r.full_name || (r.name && r.owner))) return r
const md = r?.metadata || {}
const owner = r?.owner_name || md?.owner?.login || (typeof md?.full_name === 'string' ? md.full_name.split('/')[0] : undefined)
const name = r?.repository_name || md?.name || (typeof md?.full_name === 'string' ? md.full_name.split('/')[1] : undefined) || r?.repo
const full = md?.full_name || (owner && name ? `${owner}/${name}` : r?.repository_url)
return {
id: r?.id,
full_name: full,
name: name,
owner: owner ? { login: owner } : undefined,
description: md?.description || null,
visibility: md?.visibility || (r?.is_public ? 'public' : 'private'),
stargazers_count: md?.stargazers_count || 0,
forks_count: md?.forks_count || 0,
language: md?.language || null,
updated_at: md?.updated_at || r?.updated_at,
html_url: md?.html_url || (full ? `https://github.com/${full}` : undefined),
} as GitHubRepoSummary
})
try { if (typeof window !== 'undefined') sessionStorage.setItem(`user_repos_cache_${userId || 'anon'}`, JSON.stringify(normalized)) } catch {}
return normalized
}
return []
} catch (e: any) {
return []
}
}
/**
* Clears all GitHub-related cache from session and local storage
*/
export function clearGitHubCache(): void {
if (typeof window === 'undefined') return
try {
// Clear session storage cache
const sessionKeys = Object.keys(sessionStorage)
const githubSessionKeys = sessionKeys.filter(key => key.startsWith('user_repos_cache_'))
githubSessionKeys.forEach(key => sessionStorage.removeItem(key))
// Clear localStorage GitHub-related data
const localKeys = Object.keys(localStorage)
const githubLocalKeys = localKeys.filter(key =>
key.includes('github') || key.includes('repo') || key.includes('codenuk_user')
)
githubLocalKeys.forEach(key => localStorage.removeItem(key))
console.log('🧹 Cleared all GitHub cache')
} catch (e) {
console.warn('Failed to clear GitHub cache:', e)
}
}
/**
* Initiates GitHub OAuth flow
* This will redirect the user to GitHub for authentication
* After successful authentication, GitHub will redirect back to /project-builder
*/
export async function initiateGitHubOAuth(): Promise<void> {
try {
// Get user_id from localStorage
const rawUser = (typeof window !== 'undefined') ? localStorage.getItem('codenuk_user') : null
const userId = rawUser ? (JSON.parse(rawUser)?.id || null) : null
if (!userId) {
console.error('Cannot initiate GitHub OAuth: user_id not found')
alert('Please sign in first to connect your GitHub account')
return
}
// Generate state for OAuth security
const state = Math.random().toString(36).substring(7)
console.log('Initiating GitHub OAuth for user:', userId)
// Get the OAuth URL from the backend (without redirect)
// Then manually redirect the browser to avoid API gateway interference
try {
const response = await authApiClient.get(`/api/github/auth/github?user_id=${encodeURIComponent(userId)}&state=${state}`)
// Handle both normal JSON and double-encoded JSON responses
let responseData = response.data
if (typeof responseData === 'string') {
try {
responseData = JSON.parse(responseData)
} catch (e) {
console.error('Failed to parse response data:', e)
}
}
const authUrl = responseData?.data?.auth_url
if (authUrl) {
console.log('Redirecting to GitHub OAuth:', authUrl)
window.location.href = authUrl
} else {
console.error('No auth URL in response:', responseData)
throw new Error('No auth URL received from backend')
}
} catch (error) {
console.error('Failed to get OAuth URL:', error)
throw error
}
} catch (error) {
console.error('Error initiating GitHub OAuth:', error)
alert('Failed to initiate GitHub authentication. Please try again.')
}
}
/**
* Connects GitHub account with optional repository context
* If repository details are provided, the repository will be auto-attached after OAuth
*/
export async function connectGitHubWithRepo(repositoryUrl?: string, branchName?: string): Promise<void> {
try {
const rawUser = (typeof window !== 'undefined') ? localStorage.getItem('codenuk_user') : null
const userId = rawUser ? (JSON.parse(rawUser)?.id || null) : null
if (!userId) {
console.error('Cannot initiate GitHub OAuth: user_id not found')
alert('Please sign in first to connect your GitHub account')
return
}
// Build state with repository context if provided
const stateBase = Math.random().toString(36).substring(7)
let state = stateBase
if (repositoryUrl) {
const encodedRepoUrl = encodeURIComponent(repositoryUrl)
const encodedBranch = encodeURIComponent(branchName || 'main')
state = `${stateBase}|uid=${userId}|repo=${encodedRepoUrl}|branch=${encodedBranch}`
// Store in sessionStorage for recovery
try {
sessionStorage.setItem('pending_git_attach', JSON.stringify({
repository_url: repositoryUrl,
branch_name: branchName || 'main'
}))
} catch (e) {
console.warn('Failed to store pending attach in sessionStorage:', e)
}
}
console.log('Connecting GitHub account for user:', userId, repositoryUrl ? `with repo: ${repositoryUrl}` : '')
// Get the OAuth URL from the backend (without redirect parameter)
// Then manually redirect the browser to avoid API gateway interference
try {
const response = await authApiClient.get(`/api/github/auth/github?user_id=${encodeURIComponent(userId)}&state=${encodeURIComponent(state)}`)
// Handle both normal JSON and double-encoded JSON responses
let responseData = response.data
if (typeof responseData === 'string') {
try {
responseData = JSON.parse(responseData)
} catch (e) {
console.error('Failed to parse response data:', e)
}
}
const authUrl = responseData?.data?.auth_url
if (authUrl) {
console.log('Redirecting to GitHub OAuth with repository context:', authUrl)
window.location.href = authUrl
} else {
console.error('No auth URL in response:', responseData)
throw new Error('No auth URL received from backend')
}
} catch (error) {
console.error('Failed to get OAuth URL:', error)
throw error
}
} catch (error) {
console.error('Error connecting GitHub:', error)
alert('Failed to connect GitHub account. Please try again.')
}
}

View File

@ -16,13 +16,24 @@ export async function analyzeFeatureWithAI(
): Promise<AIAnalysisResponse> {
try {
// Prefer dedicated analysis endpoint if available in requirements service
const primaryUrl = `${BACKEND_URL}/api/requirements/analyze-feature`
const primaryUrl = `${BACKEND_URL}/api/ai/analyze-feature`
const accessToken = (typeof window !== 'undefined') ? localStorage.getItem('accessToken') : null
const authHeaders = accessToken ? { Authorization: `Bearer ${accessToken}` } : {}
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
let res = await fetch(primaryUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders },
body: JSON.stringify({ featureName, description, requirements, projectType, complexity: userSelectedComplexity }),
headers,
body: JSON.stringify({
featureName,
feature_name: featureName, // Backend expects this field name
description,
requirements,
projectType,
project_type: projectType, // Backend expects this field name
complexity: userSelectedComplexity
}),
})
// If primary endpoint not found, fall back to mockup service to at least return deterministic hints
@ -30,9 +41,9 @@ export async function analyzeFeatureWithAI(
const fallbackUrl = `${BACKEND_URL}/api/mockup/generate-wireframe`
res = await fetch(fallbackUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders },
headers,
body: JSON.stringify({
prompt: `${featureName}: ${description}. Requirements: ${requirements.join('; ')}`,
prompt: `${featureName}: ${description}. Requirements: ${Array.isArray(requirements) ? requirements.join('; ') : 'No requirements provided'}`,
device: 'desktop',
}),
})
@ -47,6 +58,9 @@ export async function analyzeFeatureWithAI(
}
// Map various backend shapes to AIAnalysisResponse
if (json?.success && json?.analysis?.complexity && json?.analysis?.logicRules) {
return { complexity: json.analysis.complexity as Complexity, logicRules: json.analysis.logicRules as string[] }
}
if (json?.success && json?.data?.complexity && json?.data?.logicRules) {
return { complexity: json.data.complexity as Complexity, logicRules: json.data.logicRules as string[] }
}

View File

@ -0,0 +1,430 @@
import Anthropic from '@anthropic-ai/sdk';
// Initialize Claude API client
const anthropic = new Anthropic({
apiKey: process.env.NEXT_PUBLIC_CLAUDE_API_KEY || '',
});
export interface TemplateData {
id: string;
title: string;
description: string;
category: string;
type?: string;
}
export interface FeatureData {
id: string;
name: string;
description: string;
feature_type: 'essential' | 'suggested' | 'custom';
complexity: 'low' | 'medium' | 'high';
business_rules?: any;
technical_requirements?: any;
}
export interface BusinessContextData {
questions: Array<{
question: string;
answer: string;
}>;
}
export interface TechRecommendationRequest {
template: TemplateData;
features: FeatureData[];
businessContext: BusinessContextData;
projectName?: string;
projectType?: string;
}
export interface TechnologyRecommendations {
frontend: {
framework: string;
libraries: string[];
reasoning: string;
};
backend: {
language: string;
framework: string;
libraries: string[];
reasoning: string;
};
database: {
primary: string;
secondary: string[];
reasoning: string;
};
mobile: {
framework: string;
libraries: string[];
reasoning: string;
};
devops: {
tools: string[];
platforms: string[];
reasoning: string;
};
tools: {
development: string[];
monitoring: string[];
reasoning: string;
};
ai_ml: {
frameworks: string[];
libraries: string[];
reasoning: string;
};
}
export interface ClaudeRecommendations {
technology_recommendations: TechnologyRecommendations;
implementation_strategy: {
architecture_pattern: string;
deployment_strategy: string;
scalability_approach: string;
};
business_alignment: {
scalability: string;
maintainability: string;
cost_effectiveness: string;
time_to_market: string;
};
risk_assessment: {
technical_risks: string[];
mitigation_strategies: string[];
};
}
export interface TechRecommendationResponse {
success: boolean;
data: {
claude_recommendations: ClaudeRecommendations;
functional_requirements: {
feature_name: string;
description: string;
complexity_level: string;
technical_requirements: string[];
business_logic_rules: string[];
all_features: string[];
};
};
error?: string;
}
/**
* Generate technology recommendations using Claude AI
*/
export async function generateTechRecommendations(
request: TechRecommendationRequest
): Promise<TechRecommendationResponse> {
try {
console.log('🤖 Generating tech recommendations with Claude...');
console.log('📊 Request data:', {
template: request.template.title,
featuresCount: request.features.length,
businessQuestionsCount: request.businessContext.questions.length,
});
// Build comprehensive prompt for Claude
const prompt = buildTechRecommendationPrompt(request);
// Call Claude API
const response = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 4000,
temperature: 0.7,
messages: [
{
role: 'user',
content: prompt,
},
],
});
// Parse Claude's response
const claudeResponse = response.content[0];
if (claudeResponse.type !== 'text') {
throw new Error('Unexpected response type from Claude');
}
const recommendations = parseClaudeResponse(claudeResponse.text, request);
console.log('✅ Tech recommendations generated successfully');
return {
success: true,
data: recommendations,
};
} catch (error) {
console.error('❌ Error generating tech recommendations:', error);
return {
success: false,
data: {
claude_recommendations: getFallbackRecommendations(),
functional_requirements: getFallbackFunctionalRequirements(request),
},
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Build comprehensive prompt for Claude AI
*/
function buildTechRecommendationPrompt(request: TechRecommendationRequest): string {
const { template, features, businessContext, projectName, projectType } = request;
// Extract feature information
const featureNames = features.map(f => f.name).join(', ');
const featureDescriptions = features.map(f => `- ${f.name}: ${f.description}`).join('\n');
const complexityLevels = features.map(f => f.complexity);
const hasHighComplexity = complexityLevels.includes('high');
const hasMediumComplexity = complexityLevels.includes('medium');
// Extract business context
const businessAnswers = businessContext.questions
.map(qa => `Q: ${qa.question}\nA: ${qa.answer}`)
.join('\n\n');
return `You are an expert technology architect and consultant. Analyze the following project requirements and provide comprehensive technology recommendations.
PROJECT OVERVIEW:
- Project Name: ${projectName || template.title}
- Project Type: ${projectType || template.category}
- Template: ${template.title} - ${template.description}
SELECTED FEATURES (${features.length} total):
${featureDescriptions}
COMPLEXITY ANALYSIS:
- High complexity features: ${hasHighComplexity ? 'Yes' : 'No'}
- Medium complexity features: ${hasMediumComplexity ? 'Yes' : 'No'}
- Overall complexity: ${hasHighComplexity ? 'High' : hasMediumComplexity ? 'Medium' : 'Low'}
BUSINESS CONTEXT:
${businessAnswers}
Please provide a comprehensive technology recommendation in the following JSON format:
{
"technology_recommendations": {
"frontend": {
"framework": "Recommended frontend framework",
"libraries": ["library1", "library2"],
"reasoning": "Detailed explanation for frontend choice"
},
"backend": {
"language": "Recommended backend language",
"framework": "Recommended backend framework",
"libraries": ["library1", "library2"],
"reasoning": "Detailed explanation for backend choice"
},
"database": {
"primary": "Primary database recommendation",
"secondary": ["secondary1", "secondary2"],
"reasoning": "Detailed explanation for database choice"
},
"mobile": {
"framework": "Recommended mobile framework (if applicable)",
"libraries": ["library1", "library2"],
"reasoning": "Detailed explanation for mobile choice"
},
"devops": {
"tools": ["tool1", "tool2"],
"platforms": ["platform1", "platform2"],
"reasoning": "Detailed explanation for DevOps choices"
},
"tools": {
"development": ["dev_tool1", "dev_tool2"],
"monitoring": ["monitoring_tool1", "monitoring_tool2"],
"reasoning": "Detailed explanation for tool choices"
},
"ai_ml": {
"frameworks": ["framework1", "framework2"],
"libraries": ["library1", "library2"],
"reasoning": "Detailed explanation for AI/ML choices"
}
},
"implementation_strategy": {
"architecture_pattern": "Recommended architecture pattern (e.g., MVC, Microservices, etc.)",
"deployment_strategy": "Recommended deployment approach",
"scalability_approach": "How to handle scaling"
},
"business_alignment": {
"scalability": "How the tech stack supports scalability",
"maintainability": "How the tech stack supports maintainability",
"cost_effectiveness": "Cost considerations and optimization",
"time_to_market": "How the tech stack affects development speed"
},
"risk_assessment": {
"technical_risks": ["risk1", "risk2"],
"mitigation_strategies": ["strategy1", "strategy2"]
}
}
CONSIDERATIONS:
1. Choose technologies that work well together
2. Consider the complexity level of features
3. Factor in business requirements from the context
4. Prioritize scalability and maintainability
5. Consider developer experience and community support
6. Balance performance with development speed
7. Include modern, actively maintained technologies
Provide only the JSON response, no additional text.`;
}
/**
* Parse Claude's response and structure it properly
*/
function parseClaudeResponse(responseText: string, request: TechRecommendationRequest): any {
try {
// Extract JSON from response (handle cases where Claude adds extra text)
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No JSON found in Claude response');
}
const parsed = JSON.parse(jsonMatch[0]);
// Validate required fields
if (!parsed.technology_recommendations) {
throw new Error('Missing technology_recommendations in response');
}
// Build functional requirements from the request
const functionalRequirements = {
feature_name: `${request.template.title} - Integrated System`,
description: `Complete ${request.template.category} system with ${request.features.length} integrated features`,
complexity_level: request.features.some(f => f.complexity === 'high') ? 'high' :
request.features.some(f => f.complexity === 'medium') ? 'medium' : 'low',
technical_requirements: request.features.flatMap(f => f.technical_requirements || []),
business_logic_rules: request.features.flatMap(f => f.business_rules || []),
all_features: request.features.map(f => f.name),
};
return {
claude_recommendations: parsed,
functional_requirements: functionalRequirements,
};
} catch (error) {
console.error('Error parsing Claude response:', error);
throw new Error('Failed to parse Claude response');
}
}
/**
* Fallback recommendations when Claude API fails
*/
function getFallbackRecommendations(): ClaudeRecommendations {
return {
technology_recommendations: {
frontend: {
framework: 'React',
libraries: ['TypeScript', 'Tailwind CSS', 'React Router'],
reasoning: 'React provides excellent component reusability and ecosystem support for modern web applications.',
},
backend: {
language: 'Node.js',
framework: 'Express.js',
libraries: ['TypeScript', 'Prisma', 'JWT'],
reasoning: 'Node.js offers great performance and JavaScript ecosystem consistency between frontend and backend.',
},
database: {
primary: 'PostgreSQL',
secondary: ['Redis'],
reasoning: 'PostgreSQL provides robust ACID compliance and excellent performance for complex applications.',
},
mobile: {
framework: 'React Native',
libraries: ['Expo', 'React Navigation', 'AsyncStorage'],
reasoning: 'React Native enables cross-platform mobile development with shared codebase and native performance.',
},
devops: {
tools: ['Docker', 'GitHub Actions', 'Kubernetes'],
platforms: ['AWS', 'Vercel'],
reasoning: 'Modern DevOps stack for containerization, CI/CD, and cloud deployment with excellent scalability.',
},
tools: {
development: ['VS Code', 'Git', 'ESLint', 'Prettier'],
monitoring: ['Sentry', 'LogRocket', 'New Relic'],
reasoning: 'Essential development tools for code quality and comprehensive monitoring for production applications.',
},
ai_ml: {
frameworks: ['TensorFlow.js', 'OpenAI API'],
libraries: ['NumPy', 'Pandas', 'Scikit-learn'],
reasoning: 'AI/ML capabilities for data analysis, machine learning, and integration with modern AI services.',
},
},
implementation_strategy: {
architecture_pattern: 'MVC (Model-View-Controller)',
deployment_strategy: 'Containerized deployment with Docker',
scalability_approach: 'Horizontal scaling with load balancing',
},
business_alignment: {
scalability: 'Designed for horizontal scaling with microservices architecture',
maintainability: 'Modular architecture with clear separation of concerns',
cost_effectiveness: 'Open-source technologies reduce licensing costs',
time_to_market: 'Rapid development with modern frameworks and tools',
},
risk_assessment: {
technical_risks: ['Learning curve for new technologies', 'Integration complexity'],
mitigation_strategies: ['Comprehensive documentation', 'Phased implementation approach'],
},
};
}
/**
* Fallback functional requirements when Claude API fails
*/
function getFallbackFunctionalRequirements(request: TechRecommendationRequest): any {
return {
feature_name: `${request.template.title} - Integrated System`,
description: `Complete ${request.template.category} system with ${request.features.length} integrated features`,
complexity_level: request.features.some(f => f.complexity === 'high') ? 'high' :
request.features.some(f => f.complexity === 'medium') ? 'medium' : 'low',
technical_requirements: request.features.flatMap(f => f.technical_requirements || []),
business_logic_rules: request.features.flatMap(f => f.business_rules || []),
all_features: request.features.map(f => f.name),
};
}
/**
* Test function to validate Claude API integration
*/
export async function testClaudeIntegration(): Promise<boolean> {
try {
const testRequest: TechRecommendationRequest = {
template: {
id: 'test',
title: 'Test Project',
description: 'A test project for validation',
category: 'Web Application',
type: 'web-app',
},
features: [
{
id: 'test-feature',
name: 'User Authentication',
description: 'Basic user login and registration',
feature_type: 'essential',
complexity: 'medium',
},
],
businessContext: {
questions: [
{
question: 'What is your target audience?',
answer: 'Small to medium businesses',
},
],
},
};
const result = await generateTechRecommendations(testRequest);
return result.success;
} catch (error) {
console.error('Claude integration test failed:', error);
return false;
}
}