Compare commits
10 Commits
f87b6a864f
...
117f22e67c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
117f22e67c | ||
| 99107c06c5 | |||
| 68bec83ba4 | |||
| 0831dd5658 | |||
| b6d91b9a08 | |||
| 2176100b0e | |||
| 69def8560b | |||
| 797f2e8657 | |||
| 7c711bc2f8 | |||
| c5c5cd743f |
@ -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**
|
||||
@ -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
|
||||
@ -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
138
package-lock.json
generated
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
116
src/app/api/ai/tech-recommendations/route.ts
Normal file
116
src/app/api/ai/tech-recommendations/route.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
console.log('🚀 Tech recommendations API called - redirecting to unified service');
|
||||
|
||||
// Parse request body
|
||||
const body = await request.json();
|
||||
console.log('📊 Request body:', {
|
||||
template: body.template?.title,
|
||||
featuresCount: body.features?.length,
|
||||
businessQuestionsCount: body.businessContext?.questions?.length,
|
||||
});
|
||||
|
||||
// Validate required fields
|
||||
if (!body.template || !body.features || !body.businessContext) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Missing required fields: template, features, or businessContext',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate template structure
|
||||
if (!body.template.title || !body.template.category) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Template must have title and category',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate features array
|
||||
if (!Array.isArray(body.features) || body.features.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Features must be a non-empty array',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate business context
|
||||
if (!body.businessContext.questions || !Array.isArray(body.businessContext.questions)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Business context must have questions array',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to unified service through API Gateway
|
||||
const apiGatewayUrl = process.env.BACKEND_URL || 'http://localhost:8000';
|
||||
|
||||
const response = await fetch(`${apiGatewayUrl}/api/unified/comprehensive-recommendations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
// Add optional parameters for template-based and domain-based recommendations
|
||||
templateId: body.template.id,
|
||||
budget: 15000, // Default budget - could be made configurable
|
||||
domain: body.template.category?.toLowerCase() || 'general',
|
||||
includeClaude: true,
|
||||
includeTemplateBased: true,
|
||||
includeDomainBased: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unified service error: ${response.status}`);
|
||||
}
|
||||
|
||||
const recommendations = await response.json();
|
||||
console.log('✅ Comprehensive recommendations received from unified service:', {
|
||||
success: recommendations.success,
|
||||
hasClaudeRecommendations: !!recommendations.data?.claude?.success,
|
||||
hasTemplateRecommendations: !!recommendations.data?.templateBased?.success,
|
||||
hasDomainRecommendations: !!recommendations.data?.domainBased?.success,
|
||||
});
|
||||
|
||||
// Return the recommendations
|
||||
return NextResponse.json(recommendations);
|
||||
} catch (error) {
|
||||
console.error('❌ Error in tech recommendations API:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle OPTIONS request for CORS
|
||||
export async function OPTIONS(request: NextRequest) {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
});
|
||||
}
|
||||
78
src/app/api/diffs/[...path]/route.ts
Normal file
78
src/app/api/diffs/[...path]/route.ts
Normal file
@ -0,0 +1,78 @@
|
||||
// app/api/diffs/[...path]/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const GIT_INTEGRATION_URL = process.env.GIT_INTEGRATION_URL || 'http://localhost:8012';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
try {
|
||||
const path = params.path.join('/');
|
||||
const url = new URL(request.url);
|
||||
const searchParams = url.searchParams.toString();
|
||||
const fullUrl = `${GIT_INTEGRATION_URL}/api/diffs/${path}${searchParams ? `?${searchParams}` : ''}`;
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Git integration service responded with status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Error proxying diff request:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Failed to fetch diff data',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
try {
|
||||
const path = params.path.join('/');
|
||||
const body = await request.json();
|
||||
const url = new URL(request.url);
|
||||
const searchParams = url.searchParams.toString();
|
||||
const fullUrl = `${GIT_INTEGRATION_URL}/api/diffs/${path}${searchParams ? `?${searchParams}` : ''}`;
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Git integration service responded with status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Error proxying diff request:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Failed to process diff request',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
32
src/app/api/diffs/repositories/route.ts
Normal file
32
src/app/api/diffs/repositories/route.ts
Normal file
@ -0,0 +1,32 @@
|
||||
// app/api/diffs/repositories/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const GIT_INTEGRATION_URL = process.env.GIT_INTEGRATION_URL || 'http://localhost:8012';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const response = await fetch(`${GIT_INTEGRATION_URL}/api/diffs/repositories`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Git integration service responded with status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching repositories:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Failed to fetch repositories',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
324
src/app/diff-viewer/page.tsx
Normal file
324
src/app/diff-viewer/page.tsx
Normal file
@ -0,0 +1,324 @@
|
||||
// app/diff-viewer/page.tsx
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
GitCommit,
|
||||
FolderOpen,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ExternalLink
|
||||
} from 'lucide-react';
|
||||
import DiffViewer from '@/components/diff-viewer/DiffViewer';
|
||||
|
||||
interface Repository {
|
||||
id: string;
|
||||
repository_name: string;
|
||||
owner_name: string;
|
||||
sync_status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Commit {
|
||||
id: string;
|
||||
commit_sha: string;
|
||||
author_name: string;
|
||||
message: string;
|
||||
committed_at: string;
|
||||
files_changed: number;
|
||||
diffs_processed: number;
|
||||
total_diff_size: number;
|
||||
}
|
||||
|
||||
const DiffViewerPage: React.FC = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||
const [commits, setCommits] = useState<Commit[]>([]);
|
||||
const [selectedRepository, setSelectedRepository] = useState<string>('');
|
||||
const [selectedCommit, setSelectedCommit] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Handle URL parameters
|
||||
useEffect(() => {
|
||||
const repoId = searchParams.get('repo');
|
||||
if (repoId) {
|
||||
setSelectedRepository(repoId);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Load repositories
|
||||
useEffect(() => {
|
||||
const loadRepositories = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch('/api/diffs/repositories');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setRepositories(data.data.repositories);
|
||||
} else {
|
||||
setError(data.message || 'Failed to load repositories');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load repositories');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRepositories();
|
||||
}, []);
|
||||
|
||||
// Load commits when repository is selected
|
||||
useEffect(() => {
|
||||
if (selectedRepository) {
|
||||
const loadCommits = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(`/api/diffs/repositories/${selectedRepository}/commits`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setCommits(data.data.commits);
|
||||
// Auto-select first commit
|
||||
if (data.data.commits.length > 0) {
|
||||
setSelectedCommit(data.data.commits[0].id);
|
||||
}
|
||||
} else {
|
||||
setError(data.message || 'Failed to load commits');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load commits');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCommits();
|
||||
}
|
||||
}, [selectedRepository]);
|
||||
|
||||
const handleRepositoryChange = (repositoryId: string) => {
|
||||
setSelectedRepository(repositoryId);
|
||||
setSelectedCommit('');
|
||||
};
|
||||
|
||||
const handleCommitChange = (commitId: string) => {
|
||||
setSelectedCommit(commitId);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (selectedRepository) {
|
||||
const loadCommits = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(`/api/diffs/repositories/${selectedRepository}/commits`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setCommits(data.data.commits);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to refresh commits');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCommits();
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCommits = commits.filter(commit =>
|
||||
commit.message.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
commit.author_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
commit.commit_sha.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Git Diff Viewer</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
View and analyze git diffs from your repositories
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading || !selectedRepository}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Repository and Commit Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<FolderOpen className="h-5 w-5" />
|
||||
<span>Select Repository & Commit</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Repository Selection */}
|
||||
<div>
|
||||
<Label htmlFor="repository">Repository</Label>
|
||||
<select
|
||||
id="repository"
|
||||
value={selectedRepository}
|
||||
onChange={(e) => handleRepositoryChange(e.target.value)}
|
||||
className="w-full mt-1 px-3 py-2 border border-input rounded-md bg-background"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">Select a repository...</option>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.owner_name}/{repo.repository_name} ({repo.sync_status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Commit Selection */}
|
||||
{selectedRepository && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label htmlFor="commit">Commit</Label>
|
||||
<Badge variant="outline">
|
||||
{commits.length} commits
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search commits..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
id="commit"
|
||||
value={selectedCommit}
|
||||
onChange={(e) => handleCommitChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-input rounded-md bg-background"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">Select a commit...</option>
|
||||
{filteredCommits.map((commit) => (
|
||||
<option key={commit.id} value={commit.id}>
|
||||
{commit.commit_sha.substring(0, 8)} - {commit.message.substring(0, 50)}
|
||||
{commit.message.length > 50 ? '...' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commit Info */}
|
||||
{selectedCommit && (
|
||||
<div className="bg-muted/50 p-4 rounded-lg">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<GitCommit className="h-4 w-4" />
|
||||
<span className="font-medium">Selected Commit</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const commit = commits.find(c => c.id === selectedCommit);
|
||||
return commit ? (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-mono text-xs bg-muted px-2 py-1 rounded">
|
||||
{commit.commit_sha.substring(0, 8)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">by {commit.author_name}</span>
|
||||
</div>
|
||||
<p className="font-medium">{commit.message}</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-muted-foreground">
|
||||
<span>{commit.files_changed} files changed</span>
|
||||
<span>{commit.diffs_processed} diffs processed</span>
|
||||
<span>{(commit.total_diff_size / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="font-medium">Error</p>
|
||||
<p className="text-sm mt-2">{error}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Diff Viewer */}
|
||||
{selectedRepository && selectedCommit && (
|
||||
<DiffViewer
|
||||
repositoryId={selectedRepository}
|
||||
commitId={selectedCommit}
|
||||
initialView="side-by-side"
|
||||
className="min-h-[600px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No Selection State */}
|
||||
{!selectedRepository && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<FolderOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="font-medium">No Repository Selected</p>
|
||||
<p className="text-sm mt-2">
|
||||
Please select a repository to view its diffs
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{selectedRepository && !selectedCommit && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<GitCommit className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="font-medium">No Commit Selected</p>
|
||||
<p className="text-sm mt-2">
|
||||
Please select a commit to view its diffs
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffViewerPage;
|
||||
329
src/app/github/analyze/page.tsx
Normal file
329
src/app/github/analyze/page.tsx
Normal file
@ -0,0 +1,329 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, Suspense } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Brain,
|
||||
Code,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Star,
|
||||
Shield,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Zap,
|
||||
Target,
|
||||
BarChart3,
|
||||
Layers,
|
||||
Cpu,
|
||||
Search,
|
||||
File,
|
||||
Folder,
|
||||
Eye,
|
||||
AlertCircle
|
||||
} from "lucide-react"
|
||||
|
||||
interface FileAnalysis {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'folder'
|
||||
status: 'scanning' | 'analyzing' | 'completed' | 'pending'
|
||||
size?: string
|
||||
language?: string
|
||||
issues?: number
|
||||
score?: number
|
||||
details?: string
|
||||
}
|
||||
|
||||
// Component that uses useSearchParams - needs to be wrapped in Suspense
|
||||
function AIAnalysisContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const repoId = searchParams.get('repoId')
|
||||
const repoName = searchParams.get('repoName') || 'Repository'
|
||||
|
||||
const [analysisProgress, setAnalysisProgress] = useState(0)
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(true)
|
||||
const [currentFile, setCurrentFile] = useState('Initializing analysis...')
|
||||
|
||||
const [files, setFiles] = useState<FileAnalysis[]>([
|
||||
{ id: '1', name: 'package.json', path: '/package.json', type: 'file', status: 'pending', language: 'JSON', size: '2.1 KB' },
|
||||
{ id: '2', name: 'src', path: '/src', type: 'folder', status: 'pending' },
|
||||
{ id: '3', name: 'App.js', path: '/src/App.js', type: 'file', status: 'pending', language: 'JavaScript', size: '5.2 KB' },
|
||||
{ id: '4', name: 'components', path: '/src/components', type: 'folder', status: 'pending' },
|
||||
{ id: '5', name: 'Header.jsx', path: '/src/components/Header.jsx', type: 'file', status: 'pending', language: 'JavaScript', size: '3.8 KB' },
|
||||
{ id: '6', name: 'Footer.jsx', path: '/src/components/Footer.jsx', type: 'file', status: 'pending', language: 'JavaScript', size: '2.5 KB' },
|
||||
{ id: '7', name: 'utils', path: '/src/utils', type: 'folder', status: 'pending' },
|
||||
{ id: '8', name: 'helpers.js', path: '/src/utils/helpers.js', type: 'file', status: 'pending', language: 'JavaScript', size: '4.1 KB' },
|
||||
{ id: '9', name: 'README.md', path: '/README.md', type: 'file', status: 'pending', language: 'Markdown', size: '1.8 KB' },
|
||||
{ id: '10', name: 'styles', path: '/styles', type: 'folder', status: 'pending' },
|
||||
{ id: '11', name: 'main.css', path: '/styles/main.css', type: 'file', status: 'pending', language: 'CSS', size: '6.3 KB' },
|
||||
{ id: '12', name: 'tests', path: '/tests', type: 'folder', status: 'pending' },
|
||||
{ id: '13', name: 'App.test.js', path: '/tests/App.test.js', type: 'file', status: 'pending', language: 'JavaScript', size: '2.9 KB' }
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!repoId) {
|
||||
router.push('/github/repos')
|
||||
return
|
||||
}
|
||||
|
||||
let fileIndex = 0
|
||||
let progress = 0
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (fileIndex < files.length) {
|
||||
const currentFileData = files[fileIndex]
|
||||
|
||||
// Update current file being analyzed
|
||||
setCurrentFile(`Analyzing ${currentFileData.name}...`)
|
||||
|
||||
// Update file status to scanning
|
||||
setFiles(prev => prev.map((file, index) =>
|
||||
index === fileIndex ? { ...file, status: 'scanning' as const } : file
|
||||
))
|
||||
|
||||
// After a short delay, mark as analyzing
|
||||
setTimeout(() => {
|
||||
setFiles(prev => prev.map((file, index) =>
|
||||
index === fileIndex ? { ...file, status: 'analyzing' as const } : file
|
||||
))
|
||||
}, 500)
|
||||
|
||||
// After another delay, mark as completed with mock data
|
||||
setTimeout(() => {
|
||||
setFiles(prev => prev.map((file, index) =>
|
||||
index === fileIndex ? {
|
||||
...file,
|
||||
status: 'completed' as const,
|
||||
score: Math.floor(Math.random() * 30) + 70, // 70-100
|
||||
issues: Math.floor(Math.random() * 5),
|
||||
details: file.type === 'file' ? 'Analysis completed successfully' : 'Directory scanned'
|
||||
} : file
|
||||
))
|
||||
}, 1000)
|
||||
|
||||
progress = Math.min(100, ((fileIndex + 1) / files.length) * 100)
|
||||
setAnalysisProgress(progress)
|
||||
fileIndex++
|
||||
} else {
|
||||
// Complete analysis
|
||||
setIsAnalyzing(false)
|
||||
setCurrentFile('Analysis completed!')
|
||||
setAnalysisProgress(100)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 1500)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [repoId, router, files.length])
|
||||
|
||||
const getStatusIcon = (status: FileAnalysis['status'], type: FileAnalysis['type']) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-4 w-4 text-green-400" />
|
||||
case 'analyzing':
|
||||
return <div className="h-4 w-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
|
||||
case 'scanning':
|
||||
return <Search className="h-4 w-4 text-yellow-400 animate-pulse" />
|
||||
case 'pending':
|
||||
return <Clock className="h-4 w-4 text-white/40" />
|
||||
}
|
||||
}
|
||||
|
||||
const getFileIcon = (type: FileAnalysis['type'], language?: string) => {
|
||||
if (type === 'folder') {
|
||||
return <Folder className="h-4 w-4 text-blue-400" />
|
||||
}
|
||||
|
||||
switch (language) {
|
||||
case 'JavaScript':
|
||||
return <Code className="h-4 w-4 text-yellow-400" />
|
||||
case 'CSS':
|
||||
return <FileText className="h-4 w-4 text-blue-400" />
|
||||
case 'Markdown':
|
||||
return <FileText className="h-4 w-4 text-gray-400" />
|
||||
case 'JSON':
|
||||
return <FileText className="h-4 w-4 text-orange-400" />
|
||||
default:
|
||||
return <File className="h-4 w-4 text-white/60" />
|
||||
}
|
||||
}
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 90) return 'text-green-400'
|
||||
if (score >= 80) return 'text-yellow-400'
|
||||
if (score >= 70) return 'text-orange-400'
|
||||
return 'text-red-400'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-8 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/github/repos">
|
||||
<Button variant="ghost" className="text-white/80 hover:text-white">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Repositories
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white flex items-center gap-3">
|
||||
<Brain className="h-8 w-8 text-orange-400" />
|
||||
AI Code Analysis
|
||||
</h1>
|
||||
<p className="text-white/60 mt-1">Analyzing: <span className="text-orange-400 font-medium">{repoName}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analysis Progress */}
|
||||
{isAnalyzing && (
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white">Analysis Progress</h3>
|
||||
<span className="text-sm text-white/60">{Math.round(analysisProgress)}%</span>
|
||||
</div>
|
||||
<Progress value={analysisProgress} className="h-2" />
|
||||
<p className="text-white/80 text-sm flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-blue-400" />
|
||||
{currentFile}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* File Analysis List */}
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<FileText className="h-6 w-6 text-orange-400" />
|
||||
File Analysis Results
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-white/10">
|
||||
{files.map((file) => (
|
||||
<div key={file.id} className="p-4 hover:bg-white/5 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{getFileIcon(file.type, file.language)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white truncate">{file.name}</span>
|
||||
{file.language && (
|
||||
<Badge variant="outline" className="text-xs border-white/20 text-white/60">
|
||||
{file.language}
|
||||
</Badge>
|
||||
)}
|
||||
{file.size && (
|
||||
<span className="text-xs text-white/40">{file.size}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-white/60 truncate">{file.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{file.status === 'completed' && file.score && (
|
||||
<div className="flex items-center gap-2">
|
||||
{file.issues && file.issues > 0 && (
|
||||
<div className="flex items-center gap-1 text-red-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{file.issues}</span>
|
||||
</div>
|
||||
)}
|
||||
<Badge className={`${getScoreColor(file.score)} bg-transparent border`}>
|
||||
{file.score}/100
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{getStatusIcon(file.status, file.type)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{file.status === 'completed' && file.details && (
|
||||
<div className="mt-2 text-sm text-white/70">
|
||||
{file.details}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
{!isAnalyzing && (
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<BarChart3 className="h-6 w-6 text-orange-400" />
|
||||
Analysis Summary
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-400">
|
||||
{files.filter(f => f.status === 'completed').length}
|
||||
</div>
|
||||
<div className="text-white/60 text-sm">Files Analyzed</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-yellow-400">
|
||||
{files.filter(f => f.language).length}
|
||||
</div>
|
||||
<div className="text-white/60 text-sm">Languages Found</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-400">
|
||||
{files.reduce((sum, f) => sum + (f.issues || 0), 0)}
|
||||
</div>
|
||||
<div className="text-white/60 text-sm">Issues Found</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-400">
|
||||
{Math.round(files.filter(f => f.score).reduce((sum, f) => sum + (f.score || 0), 0) / files.filter(f => f.score).length) || 0}
|
||||
</div>
|
||||
<div className="text-white/60 text-sm">Avg Score</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{!isAnalyzing && (
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button className="bg-orange-600 hover:bg-orange-700 text-white">
|
||||
<Layers className="mr-2 h-4 w-4" />
|
||||
Generate Detailed Report
|
||||
</Button>
|
||||
<Button variant="outline" className="border-white/20 text-white hover:bg-white/10">
|
||||
<Cpu className="mr-2 h-4 w-4" />
|
||||
Export Analysis
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AIAnalysisPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="mx-auto max-w-7xl px-4 py-8 flex items-center justify-center min-h-screen"><div className="text-white">Loading analysis...</div></div>}>
|
||||
<AIAnalysisContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
76
src/app/github/repo/RepoTree.tsx
Normal file
76
src/app/github/repo/RepoTree.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { getRepositoryStructure, type RepoStructureEntry } from "@/lib/api/github"
|
||||
import { ChevronDown, ChevronRight, FileText, Folder } from "lucide-react"
|
||||
|
||||
type Node = {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'directory'
|
||||
}
|
||||
|
||||
export default function RepoTree({ repositoryId, rootPath = "", onSelectFile, onSelectDirectory }: { repositoryId: string, rootPath?: string, onSelectFile: (path: string) => void, onSelectDirectory?: (path: string) => void }) {
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
const [loading, setLoading] = useState<Record<string, boolean>>({})
|
||||
const [children, setChildren] = useState<Record<string, Node[]>>({})
|
||||
|
||||
const toggle = async (nodePath: string) => {
|
||||
const next = !expanded[nodePath]
|
||||
setExpanded(prev => ({ ...prev, [nodePath]: next }))
|
||||
if (next && !children[nodePath]) {
|
||||
setLoading(prev => ({ ...prev, [nodePath]: true }))
|
||||
try {
|
||||
const res = await getRepositoryStructure(repositoryId, nodePath)
|
||||
const list = (res?.structure || []) as RepoStructureEntry[]
|
||||
const mapped: Node[] = list.map(e => ({
|
||||
name: (e as any).name || (e as any).filename || (((e as any).path || (e as any).relative_path || '').split('/').slice(-1)[0]) || 'unknown',
|
||||
path: (e as any).path || (e as any).relative_path || '',
|
||||
type: (e as any).type === 'directory' || (e as any).type === 'dir' ? 'directory' : 'file'
|
||||
}))
|
||||
setChildren(prev => ({ ...prev, [nodePath]: mapped }))
|
||||
onSelectDirectory && onSelectDirectory(nodePath)
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, [nodePath]: false }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initial load for root
|
||||
useEffect(() => {
|
||||
toggle(rootPath)
|
||||
onSelectDirectory && onSelectDirectory(rootPath)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [repositoryId, rootPath])
|
||||
|
||||
const renderNode = (node: Node) => {
|
||||
const isDir = node.type === 'directory'
|
||||
const isOpen = !!expanded[node.path]
|
||||
return (
|
||||
<div key={node.path} className="select-none">
|
||||
<div className="flex items-center gap-1 py-1 px-1 hover:bg-white/5 rounded cursor-pointer"
|
||||
onClick={() => isDir ? toggle(node.path) : onSelectFile(node.path)}>
|
||||
{isDir ? (
|
||||
isOpen ? <ChevronDown className="h-4 w-4 text-white/70"/> : <ChevronRight className="h-4 w-4 text-white/70"/>
|
||||
) : (
|
||||
<span className="w-4"/>
|
||||
)}
|
||||
{isDir ? <Folder className="h-4 w-4 mr-1"/> : <FileText className="h-4 w-4 mr-1"/>}
|
||||
<span className="truncate text-sm text-white">{node.name}</span>
|
||||
</div>
|
||||
{isDir && isOpen && (
|
||||
<div className="pl-5 border-l border-white/10 ml-2">
|
||||
{loading[node.path] && <div className="text-xs text-white/60 py-1">Loading…</div>}
|
||||
{(children[node.path] || []).map(ch => renderNode(ch))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-white/90">
|
||||
{(children[rootPath] || []).map(n => renderNode(n))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
src/app/github/repo/page.tsx
Normal file
48
src/app/github/repo/page.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, Suspense } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import RepoByIdClient from "./repo-client"
|
||||
|
||||
// Component that uses useSearchParams - needs to be wrapped in Suspense
|
||||
function RepoPageContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [repositoryId, setRepositoryId] = useState<string>("")
|
||||
const [initialPath, setInitialPath] = useState<string>("")
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const id = searchParams.get('id')
|
||||
const path = searchParams.get('path') || ""
|
||||
|
||||
if (id) {
|
||||
setRepositoryId(id)
|
||||
setInitialPath(path)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}, [searchParams])
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="mx-auto max-w-3xl px-4 py-10 text-white/80">Loading...</div>
|
||||
}
|
||||
|
||||
if (!repositoryId) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-10 text-white/80">
|
||||
<h1 className="text-2xl font-semibold">Repository</h1>
|
||||
<p className="mt-2">Missing repository id. Go back to <a href="/github/repos" className="text-orange-400 underline">My GitHub Repositories</a>.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <RepoByIdClient repositoryId={repositoryId} initialPath={initialPath} />
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={<div className="mx-auto max-w-3xl px-4 py-10 text-white/80">Loading...</div>}>
|
||||
<RepoPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
200
src/app/github/repo/repo-client.tsx
Normal file
200
src/app/github/repo/repo-client.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { getRepositoryStructure, getRepositoryFileContent, type RepoStructureEntry } from "@/lib/api/github"
|
||||
import { ArrowLeft, BookText, Clock, Code, FileText, Folder, GitBranch, Search } from "lucide-react"
|
||||
|
||||
export default function RepoByIdClient({ repositoryId, initialPath = "" }: { repositoryId: string, initialPath?: string }) {
|
||||
const [path, setPath] = useState(initialPath)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [entries, setEntries] = useState<RepoStructureEntry[]>([])
|
||||
const [fileQuery, setFileQuery] = useState("")
|
||||
const [readme, setReadme] = useState<string | null>(null)
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = useState<string | null>(null)
|
||||
const [fileLoading, setFileLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
;(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const struct = await getRepositoryStructure(repositoryId, path)
|
||||
if (!mounted) return
|
||||
setEntries(struct?.structure || [])
|
||||
// try README at this path
|
||||
const candidates = ["README.md", "readme.md", "README.MD"]
|
||||
for (const name of candidates) {
|
||||
try {
|
||||
const fp = path ? `${path}/${name}` : name
|
||||
const content = await getRepositoryFileContent(repositoryId, fp)
|
||||
if (content?.content) { setReadme(content.content); break }
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { mounted = false }
|
||||
}, [repositoryId, path])
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = fileQuery.toLowerCase()
|
||||
if (!q) return entries
|
||||
return entries.filter(e => e.name.toLowerCase().includes(q))
|
||||
}, [entries, fileQuery])
|
||||
|
||||
const navigateFolder = (name: string) => {
|
||||
const next = path ? `${path}/${name}` : name
|
||||
setPath(next)
|
||||
setSelectedFile(null)
|
||||
setFileContent(null)
|
||||
}
|
||||
|
||||
const goUp = () => {
|
||||
if (!path) return
|
||||
const parts = path.split("/")
|
||||
parts.pop()
|
||||
setPath(parts.join("/"))
|
||||
setSelectedFile(null)
|
||||
setFileContent(null)
|
||||
}
|
||||
|
||||
const handleFileClick = async (fileName: string) => {
|
||||
const filePath = path ? `${path}/${fileName}` : fileName
|
||||
setSelectedFile(filePath)
|
||||
setFileLoading(true)
|
||||
|
||||
try {
|
||||
const content = await getRepositoryFileContent(repositoryId, filePath)
|
||||
setFileContent(content?.content || null)
|
||||
} catch (error) {
|
||||
console.error('Failed to load file content:', error)
|
||||
setFileContent(null)
|
||||
} finally {
|
||||
setFileLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link href="/github/repos">
|
||||
<Button variant="ghost" className="text-white/80 hover:text-white">
|
||||
<ArrowLeft className="h-4 w-4 mr-2"/> Back to Repos
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-semibold">Repository #{repositoryId}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-green-500/20 text-green-400 border border-green-500/30">
|
||||
Attached
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button variant="outline" className="border-white/20 text-white hover:bg-white/10" onClick={goUp}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2"/> Up one level
|
||||
</Button>
|
||||
<div className="flex-1 flex justify-center">
|
||||
<div className="relative w-full max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40"/>
|
||||
<Input
|
||||
placeholder="Q Go to file"
|
||||
value={fileQuery}
|
||||
onChange={(e) => setFileQuery(e.target.value)}
|
||||
className="pl-10 bg-white/5 border-white/10 text-white placeholder-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="border-white/20 text-white hover:bg-white/10">
|
||||
<Code className="h-4 w-4 mr-2"/> Code
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* File Tree - Left Side */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="bg-white/5 border-white/10 overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-b border-white/10 px-4 py-3 text-sm font-semibold">
|
||||
Files {path && `- ${path}`}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="px-4 py-6 text-white/60">Loading...</div>
|
||||
)}
|
||||
{!loading && visible.length === 0 && (
|
||||
<div className="px-4 py-6 text-white/60">No entries found.</div>
|
||||
)}
|
||||
{visible.map((e, i) => (
|
||||
<div key={i} className={`flex items-center px-4 py-3 border-b border-white/10 hover:bg-white/5 cursor-pointer ${
|
||||
selectedFile === (path ? `${path}/${e.name}` : e.name) ? 'bg-white/10' : ''
|
||||
}`}
|
||||
onClick={() => e.type === 'directory' ? navigateFolder(e.name) : handleFileClick(e.name)}>
|
||||
<div className="w-7 flex justify-center">
|
||||
{e.type === 'directory' ? <Folder className="h-4 w-4"/> : <FileText className="h-4 w-4"/>}
|
||||
</div>
|
||||
<div className="flex-1 font-medium truncate">{e.name}</div>
|
||||
<div className="w-20 text-right text-sm text-white/60">
|
||||
{e.size && `${Math.round(Number(e.size) / 1024)}KB`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* File Content - Right Side */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="bg-white/5 border-white/10 h-[70vh]">
|
||||
<CardContent className="p-0 h-full flex flex-col">
|
||||
<div className="border-b border-white/10 px-4 py-3 text-sm font-semibold flex-shrink-0">
|
||||
{selectedFile ? `File: ${selectedFile}` : 'README'}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selectedFile ? (
|
||||
<div className="h-full p-4">
|
||||
{fileLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
<span className="ml-2 text-white/60">Loading file content...</span>
|
||||
</div>
|
||||
) : fileContent ? (
|
||||
<pre className="whitespace-pre-wrap text-sm text-white/90 bg-black/20 p-4 rounded overflow-auto h-full">{fileContent}</pre>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<FileText className="h-10 w-10 mx-auto text-white/60"/>
|
||||
<h3 className="mt-3 text-xl font-semibold">File content not available</h3>
|
||||
<p className="mt-1 text-white/60 text-sm">This file could not be loaded or is binary.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : !readme ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<BookText className="h-10 w-10 mx-auto text-white/60"/>
|
||||
<h3 className="mt-3 text-xl font-semibold">No README found</h3>
|
||||
<p className="mt-1 text-white/60 text-sm">Add a README.md to the repository to show it here.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full p-4">
|
||||
<pre className="whitespace-pre-wrap text-sm text-white/90 bg-black/20 p-4 rounded overflow-auto h-full">{readme}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
275
src/app/github/repos/page.tsx
Normal file
275
src/app/github/repos/page.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Github,
|
||||
FolderOpen,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Star,
|
||||
Eye,
|
||||
Code,
|
||||
Calendar,
|
||||
GitCompare
|
||||
} from 'lucide-react';
|
||||
import { getUserRepositories, type GitHubRepoSummary } from '@/lib/api/github';
|
||||
import Link from 'next/link';
|
||||
|
||||
const GitHubReposPage: React.FC = () => {
|
||||
const [repositories, setRepositories] = useState<GitHubRepoSummary[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filter, setFilter] = useState<'all' | 'public' | 'private'>('all');
|
||||
|
||||
// Load repositories
|
||||
useEffect(() => {
|
||||
const loadRepositories = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const repos = await getUserRepositories();
|
||||
setRepositories(repos);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load repositories');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRepositories();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const repos = await getUserRepositories(true); // Clear cache
|
||||
setRepositories(repos);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to refresh repositories');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredRepositories = repositories.filter(repo => {
|
||||
const matchesSearch = repo.full_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
repo.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
repo.language?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesFilter = filter === 'all' ||
|
||||
(filter === 'public' && repo.visibility === 'public') ||
|
||||
(filter === 'private' && repo.visibility === 'private');
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
|
||||
const formatDate = (dateString: string | undefined) => {
|
||||
if (!dateString) return 'Unknown';
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold flex items-center space-x-2">
|
||||
<Github className="h-8 w-8" />
|
||||
<span>My GitHub Repositories</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Browse and analyze your GitHub repositories
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/diff-viewer">
|
||||
<GitCompare className="h-4 w-4 mr-2" />
|
||||
Git Diff
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search repositories..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={filter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'public' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('public')}
|
||||
>
|
||||
Public
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'private' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('private')}
|
||||
>
|
||||
Private
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="font-medium">Error</p>
|
||||
<p className="text-sm mt-2">{error}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p>Loading repositories...</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Repositories Grid */}
|
||||
{!isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredRepositories.map((repo) => (
|
||||
<Card key={repo.id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FolderOpen className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{repo.name || 'Unknown Repository'}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{repo.full_name || 'Unknown Owner'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={repo.visibility === 'public' ? 'default' : 'secondary'}>
|
||||
{repo.visibility || 'unknown'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{repo.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Repository Stats */}
|
||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
{repo.language && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Code className="h-4 w-4" />
|
||||
<span>{repo.language}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-1">
|
||||
<Star className="h-4 w-4" />
|
||||
<span>{repo.stargazers_count || 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
<span>{repo.forks_count || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Updated Date */}
|
||||
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>Updated {formatDate(repo.updated_at)}</span>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button asChild size="sm" className="flex-1">
|
||||
<Link href={`/github/repo?id=${repo.id}`}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && filteredRepositories.length === 0 && !error && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Github className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="font-medium">
|
||||
{searchQuery || filter !== 'all' ? 'No repositories found' : 'No repositories available'}
|
||||
</p>
|
||||
<p className="text-sm mt-2">
|
||||
{searchQuery || filter !== 'all'
|
||||
? 'Try adjusting your search or filter criteria'
|
||||
: 'Make sure you have connected your GitHub account and have repositories'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitHubReposPage;
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
332
src/components/business-context/typeform-survey.tsx
Normal file
332
src/components/business-context/typeform-survey.tsx
Normal file
@ -0,0 +1,332 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react'
|
||||
|
||||
interface Question {
|
||||
id: number
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
interface TypeformSurveyProps {
|
||||
questions: string[]
|
||||
onComplete: (answers: Question[]) => void
|
||||
onProgress?: (answers: Question[]) => void
|
||||
onBack: () => void
|
||||
projectName?: string
|
||||
}
|
||||
|
||||
export default function TypeformSurvey({
|
||||
questions,
|
||||
onComplete,
|
||||
onProgress,
|
||||
onBack,
|
||||
projectName = 'your project'
|
||||
}: TypeformSurveyProps) {
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
|
||||
const [answers, setAnswers] = useState<Record<number, string>>({})
|
||||
const [isTransitioning, setIsTransitioning] = useState(false)
|
||||
|
||||
const totalQuestions = questions.length
|
||||
const progress = ((currentQuestionIndex + 1) / totalQuestions) * 100
|
||||
const answeredCount = Object.values(answers).filter(answer => answer.trim()).length
|
||||
|
||||
// Initialize answers
|
||||
useEffect(() => {
|
||||
const initialAnswers: Record<number, string> = {}
|
||||
questions.forEach((_, index) => {
|
||||
initialAnswers[index] = ''
|
||||
})
|
||||
setAnswers(initialAnswers)
|
||||
}, [questions])
|
||||
|
||||
const handleAnswerChange = (value: string) => {
|
||||
const newAnswers = {
|
||||
...answers,
|
||||
[currentQuestionIndex]: value
|
||||
}
|
||||
setAnswers(newAnswers)
|
||||
|
||||
// Call onProgress callback if provided
|
||||
if (onProgress) {
|
||||
const questionAnswers: Question[] = questions.map((question, index) => ({
|
||||
id: index,
|
||||
question,
|
||||
answer: newAnswers[index] || ''
|
||||
}))
|
||||
onProgress(questionAnswers)
|
||||
}
|
||||
}
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
if (currentQuestionIndex < totalQuestions - 1) {
|
||||
setIsTransitioning(true)
|
||||
setTimeout(() => {
|
||||
setCurrentQuestionIndex(prev => prev + 1)
|
||||
setIsTransitioning(false)
|
||||
}, 150)
|
||||
} else {
|
||||
// Submit all answers
|
||||
const questionAnswers: Question[] = questions.map((question, index) => ({
|
||||
id: index,
|
||||
question,
|
||||
answer: answers[index] || ''
|
||||
}))
|
||||
onComplete(questionAnswers)
|
||||
}
|
||||
}, [currentQuestionIndex, totalQuestions, answers, questions, onComplete])
|
||||
|
||||
const goToPrevious = useCallback(() => {
|
||||
if (currentQuestionIndex > 0) {
|
||||
setIsTransitioning(true)
|
||||
setTimeout(() => {
|
||||
setCurrentQuestionIndex(prev => prev - 1)
|
||||
setIsTransitioning(false)
|
||||
}, 150)
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
}, [currentQuestionIndex, onBack])
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
goToNext()
|
||||
}
|
||||
}
|
||||
|
||||
const currentAnswer = answers[currentQuestionIndex] || ''
|
||||
const canProceed = currentAnswer.trim().length > 0
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white relative overflow-hidden">
|
||||
{/* Progress Bar */}
|
||||
<div className="bg-black/80 backdrop-blur-sm border-b border-white/10">
|
||||
<div className="max-w-4xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-sm text-white/60">
|
||||
Question {currentQuestionIndex + 1} of {totalQuestions}
|
||||
</div>
|
||||
<div className="text-sm text-white/60">
|
||||
{Math.round(progress)}% complete
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-white/10 rounded-full h-2">
|
||||
<motion.div
|
||||
className="bg-orange-500 h-2 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="pt-4 pb-16 px-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentQuestionIndex}
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
className="flex flex-col pt-8"
|
||||
>
|
||||
{/* Question */}
|
||||
<div className="text-center mb-6">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.4 }}
|
||||
className="text-xl font-semibold leading-tight mb-4"
|
||||
>
|
||||
{questions[currentQuestionIndex]}
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.4 }}
|
||||
className="text-base text-white/60"
|
||||
>
|
||||
Help us understand your {projectName} better
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Answer Input */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.4 }}
|
||||
className="w-full mx-auto mb-6"
|
||||
>
|
||||
<textarea
|
||||
value={currentAnswer}
|
||||
onChange={(e) => handleAnswerChange(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder="Type your answer here..."
|
||||
className="w-full bg-white/5 border border-white/20 rounded-lg p-3 text-white placeholder:text-white/40 focus:outline-none focus:ring-1 focus:ring-orange-500 focus:border-orange-500 resize-none h-24 text-base leading-relaxed"
|
||||
autoFocus
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Navigation */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.4 }}
|
||||
className="flex items-center justify-between mt-6 max-w-6xl mx-auto w-full"
|
||||
>
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-lg transition-all duration-200 group"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
||||
<span>{currentQuestionIndex === 0 ? 'Back to Features' : 'Previous'}</span>
|
||||
</button>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
onClick={goToNext}
|
||||
disabled={!canProceed || isTransitioning}
|
||||
className={`flex items-center gap-2 px-6 py-2 rounded-lg transition-all duration-200 group ${
|
||||
canProceed
|
||||
? 'bg-orange-500 hover:bg-orange-600 text-white'
|
||||
: 'bg-white/10 text-white/40 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<span>{currentQuestionIndex === totalQuestions - 1 ? 'Submit' : 'Next'}</span>
|
||||
{currentQuestionIndex === totalQuestions - 1 ? (
|
||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5, duration: 0.4 }}
|
||||
className="text-center mt-4"
|
||||
>
|
||||
<div className="text-sm text-white/40">
|
||||
{answeredCount} of {totalQuestions} questions answered
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background Elements */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-orange-500/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-orange-500/3 rounded-full blur-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Summary Component for final review
|
||||
interface SummaryProps {
|
||||
questions: Question[]
|
||||
onBack: () => void
|
||||
onSubmit: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function SurveySummary({ questions, onBack, onSubmit, loading = false }: SummaryProps) {
|
||||
const answeredQuestions = questions.filter(q => q.answer.trim())
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
{/* Header */}
|
||||
<div className="border-b border-white/10 bg-black/80 backdrop-blur-sm">
|
||||
<div className="max-w-4xl mx-auto px-6 py-4">
|
||||
<h1 className="text-xl font-semibold text-center">Review Your Answers</h1>
|
||||
<p className="text-center text-white/60 mt-1 text-sm">
|
||||
Please review your responses before submitting
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Content */}
|
||||
<div className="max-w-4xl mx-auto px-6 py-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
{answeredQuestions.map((question, index) => (
|
||||
<motion.div
|
||||
key={question.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="bg-white/5 border border-white/10 rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-orange-500/20 text-orange-300 rounded-full w-6 h-6 flex items-center justify-center text-xs font-semibold flex-shrink-0 mt-0.5">
|
||||
{question.id + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-white mb-2">
|
||||
{question.question}
|
||||
</h3>
|
||||
<p className="text-white/80 leading-relaxed text-sm">
|
||||
{question.answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="flex items-center justify-between mt-6"
|
||||
>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span>Edit Answers</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={loading}
|
||||
className={`flex items-center gap-2 px-6 py-2 rounded-lg transition-all duration-200 group ${
|
||||
loading
|
||||
? 'bg-orange-500/50 text-white cursor-not-allowed'
|
||||
: 'bg-orange-500 hover:bg-orange-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Generating Recommendations...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Submit & Generate Recommendations</span>
|
||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
219
src/components/diff-viewer/DiffControls.tsx
Normal file
219
src/components/diff-viewer/DiffControls.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
// components/diff-viewer/DiffControls.tsx
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Monitor,
|
||||
Code,
|
||||
Settings,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Type,
|
||||
Eye,
|
||||
EyeOff
|
||||
} from 'lucide-react';
|
||||
import { DiffStatistics, DiffPreferences } from './DiffViewerContext';
|
||||
|
||||
interface DiffControlsProps {
|
||||
currentView: 'side-by-side' | 'unified';
|
||||
onViewChange: (view: 'side-by-side' | 'unified') => void;
|
||||
statistics: DiffStatistics | null;
|
||||
preferences: DiffPreferences;
|
||||
onPreferencesChange: (preferences: DiffPreferences) => void;
|
||||
}
|
||||
|
||||
const DiffControls: React.FC<DiffControlsProps> = ({
|
||||
currentView,
|
||||
onViewChange,
|
||||
statistics,
|
||||
preferences,
|
||||
onPreferencesChange
|
||||
}) => {
|
||||
const handlePreferenceChange = (key: keyof DiffPreferences, value: any) => {
|
||||
onPreferencesChange({
|
||||
...preferences,
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const handleFontSizeChange = (value: number[]) => {
|
||||
handlePreferenceChange('fontSize', value[0]);
|
||||
};
|
||||
|
||||
const handleThemeChange = (theme: DiffPreferences['theme']) => {
|
||||
handlePreferenceChange('theme', theme);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* View selector */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">View Mode</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={currentView === 'side-by-side' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onViewChange('side-by-side')}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Monitor className="h-4 w-4" />
|
||||
<span>Side-by-Side</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentView === 'unified' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onViewChange('unified')}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
<span>Unified</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
{statistics && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Statistics</Label>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Files:</span>
|
||||
<Badge variant="outline">{statistics.total_files}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Additions:</span>
|
||||
<Badge variant="default" className="bg-green-100 text-green-800">
|
||||
+{statistics.total_additions}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Deletions:</span>
|
||||
<Badge variant="destructive">
|
||||
-{statistics.total_deletions}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Size:</span>
|
||||
<Badge variant="outline">
|
||||
{(statistics.total_size_bytes / 1024).toFixed(1)} KB
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display preferences */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Display Options</Label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="line-numbers" className="text-sm">
|
||||
Show Line Numbers
|
||||
</Label>
|
||||
<Switch
|
||||
id="line-numbers"
|
||||
checked={preferences.showLineNumbers}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePreferenceChange('showLineNumbers', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="whitespace" className="text-sm">
|
||||
Show Whitespace
|
||||
</Label>
|
||||
<Switch
|
||||
id="whitespace"
|
||||
checked={preferences.showWhitespace}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePreferenceChange('showWhitespace', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="wrap-lines" className="text-sm">
|
||||
Wrap Lines
|
||||
</Label>
|
||||
<Switch
|
||||
id="wrap-lines"
|
||||
checked={preferences.wrapLines}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePreferenceChange('wrapLines', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font size */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">
|
||||
Font Size: {preferences.fontSize}px
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ZoomOut className="h-4 w-4 text-muted-foreground" />
|
||||
<Slider
|
||||
value={[preferences.fontSize]}
|
||||
onValueChange={handleFontSizeChange}
|
||||
min={10}
|
||||
max={24}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<ZoomIn className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font family */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Font Family</Label>
|
||||
<select
|
||||
value={preferences.fontFamily}
|
||||
onChange={(e) => handlePreferenceChange('fontFamily', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-input rounded-md bg-background text-sm"
|
||||
>
|
||||
<option value="monospace">Monospace</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Consolas">Consolas</option>
|
||||
<option value="Fira Code">Fira Code</option>
|
||||
<option value="JetBrains Mono">JetBrains Mono</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Theme selector */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Theme</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={preferences.theme === 'light' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleThemeChange('light')}
|
||||
>
|
||||
Light
|
||||
</Button>
|
||||
<Button
|
||||
variant={preferences.theme === 'dark' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleThemeChange('dark')}
|
||||
>
|
||||
Dark
|
||||
</Button>
|
||||
<Button
|
||||
variant={preferences.theme === 'high-contrast' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleThemeChange('high-contrast')}
|
||||
>
|
||||
High Contrast
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffControls;
|
||||
186
src/components/diff-viewer/DiffStats.tsx
Normal file
186
src/components/diff-viewer/DiffStats.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
// components/diff-viewer/DiffStats.tsx
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Minus,
|
||||
GitCommit,
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
TrendingDown
|
||||
} from 'lucide-react';
|
||||
import { DiffStatistics } from './DiffViewerContext';
|
||||
|
||||
interface DiffStatsProps {
|
||||
statistics: DiffStatistics;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DiffStats: React.FC<DiffStatsProps> = ({ statistics, className = '' }) => {
|
||||
const totalChanges = statistics.total_additions + statistics.total_deletions;
|
||||
const additionPercentage = totalChanges > 0 ? (statistics.total_additions / totalChanges) * 100 : 0;
|
||||
const deletionPercentage = totalChanges > 0 ? (statistics.total_deletions / totalChanges) * 100 : 0;
|
||||
|
||||
const getChangeTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'modified':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'deleted':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'renamed':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center space-x-2 text-lg">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
<span>Diff Statistics</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Overview stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 mb-1">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-2xl font-bold">{statistics.total_files}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Files Changed</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 mb-1">
|
||||
<Plus className="h-4 w-4 text-green-600" />
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
+{statistics.total_additions}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Additions</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 mb-1">
|
||||
<Minus className="h-4 w-4 text-red-600" />
|
||||
<span className="text-2xl font-bold text-red-600">
|
||||
-{statistics.total_deletions}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Deletions</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 mb-1">
|
||||
<GitCommit className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-2xl font-bold">
|
||||
{totalChanges}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Total Changes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change distribution */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Change Distribution</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Additions</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress value={additionPercentage} className="w-20" />
|
||||
<span className="text-sm text-muted-foreground w-12 text-right">
|
||||
{additionPercentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Deletions</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress value={deletionPercentage} className="w-20" />
|
||||
<span className="text-sm text-muted-foreground w-12 text-right">
|
||||
{deletionPercentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File types breakdown */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">File Types</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(statistics.files_by_type).map(([type, count]) => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant="outline"
|
||||
className={`${getChangeTypeColor(type)} border`}
|
||||
>
|
||||
{type}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size information */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Size Information</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total Size:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{(statistics.total_size_bytes / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Avg per File:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{statistics.total_files > 0
|
||||
? (statistics.total_size_bytes / statistics.total_files / 1024).toFixed(1)
|
||||
: 0} KB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Net change indicator */}
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
{statistics.total_additions > statistics.total_deletions ? (
|
||||
<>
|
||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm text-green-600 font-medium">
|
||||
Net Addition: +{statistics.total_additions - statistics.total_deletions} lines
|
||||
</span>
|
||||
</>
|
||||
) : statistics.total_deletions > statistics.total_additions ? (
|
||||
<>
|
||||
<TrendingDown className="h-4 w-4 text-red-600" />
|
||||
<span className="text-sm text-red-600 font-medium">
|
||||
Net Deletion: -{statistics.total_deletions - statistics.total_additions} lines
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground font-medium">
|
||||
Balanced: {statistics.total_additions} additions, {statistics.total_deletions} deletions
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffStats;
|
||||
249
src/components/diff-viewer/DiffViewer.tsx
Normal file
249
src/components/diff-viewer/DiffViewer.tsx
Normal file
@ -0,0 +1,249 @@
|
||||
// components/diff-viewer/DiffViewer.tsx
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Monitor,
|
||||
Code,
|
||||
GitCommit,
|
||||
FileText,
|
||||
Settings,
|
||||
Search,
|
||||
Filter,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
|
||||
import SideBySideView from './SideBySideView';
|
||||
import UnifiedView from './UnifiedView';
|
||||
import ThemeSelector from './ThemeSelector';
|
||||
import { DiffViewerProvider, useDiffViewer } from './DiffViewerContext';
|
||||
|
||||
interface DiffViewerProps {
|
||||
repositoryId: string;
|
||||
commitId?: string;
|
||||
initialView?: 'side-by-side' | 'unified';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DiffViewer: React.FC<DiffViewerProps> = ({
|
||||
repositoryId,
|
||||
commitId,
|
||||
initialView = 'side-by-side',
|
||||
className = ''
|
||||
}) => {
|
||||
const [currentView, setCurrentView] = useState(initialView);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
state,
|
||||
loadCommitDiffs,
|
||||
loadRepositoryCommits,
|
||||
setTheme,
|
||||
setPreferences
|
||||
} = useDiffViewer();
|
||||
|
||||
const { commit, files, statistics, preferences } = state;
|
||||
const theme = preferences.theme;
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (commitId) {
|
||||
await loadCommitDiffs(commitId);
|
||||
} else {
|
||||
await loadRepositoryCommits(repositoryId);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load diff data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [repositoryId, commitId, loadCommitDiffs, loadRepositoryCommits]);
|
||||
|
||||
const handleViewChange = useCallback((newView: string) => {
|
||||
const view = newView as 'side-by-side' | 'unified';
|
||||
setCurrentView(view);
|
||||
setPreferences({ ...preferences, defaultView: view });
|
||||
}, [setPreferences, preferences]);
|
||||
|
||||
const handleFileSelect = useCallback((filePath: string) => {
|
||||
setSelectedFile(filePath);
|
||||
}, []);
|
||||
|
||||
const renderView = () => {
|
||||
if (!files || files.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No diff data available</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedFileData = selectedFile
|
||||
? files.find((f: any) => f.file_path === selectedFile) || null
|
||||
: files[0] || null;
|
||||
|
||||
switch (currentView) {
|
||||
case 'side-by-side':
|
||||
return (
|
||||
<SideBySideView
|
||||
files={files}
|
||||
selectedFile={selectedFileData}
|
||||
onFileSelect={handleFileSelect}
|
||||
theme={theme}
|
||||
preferences={preferences}
|
||||
/>
|
||||
);
|
||||
case 'unified':
|
||||
return (
|
||||
<UnifiedView
|
||||
files={files}
|
||||
selectedFile={selectedFileData}
|
||||
onFileSelect={handleFileSelect}
|
||||
theme={theme}
|
||||
preferences={preferences}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p>Loading diff data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="font-medium">Failed to load diff data</p>
|
||||
<p className="text-sm mt-2">{error}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`diff-viewer ${className}`}>
|
||||
{/* Header with commit info and controls */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<GitCommit className="h-5 w-5" />
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{commit?.message || 'Diff Viewer'}
|
||||
</CardTitle>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<Badge variant="outline">
|
||||
{commit?.author_name}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{commit?.committed_at ? new Date(commit.committed_at).toLocaleString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* File type badges */}
|
||||
{statistics && (
|
||||
<div className="flex items-center space-x-2">
|
||||
{statistics.files_by_type.added > 0 && (
|
||||
<Badge variant="default" className="bg-green-100 text-green-800">
|
||||
+{statistics.files_by_type.added} added
|
||||
</Badge>
|
||||
)}
|
||||
{statistics.files_by_type.modified > 0 && (
|
||||
<Badge variant="secondary">
|
||||
{statistics.files_by_type.modified} modified
|
||||
</Badge>
|
||||
)}
|
||||
{statistics.files_by_type.deleted > 0 && (
|
||||
<Badge variant="destructive">
|
||||
-{statistics.files_by_type.deleted} deleted
|
||||
</Badge>
|
||||
)}
|
||||
{statistics.files_by_type.renamed > 0 && (
|
||||
<Badge variant="outline">
|
||||
{statistics.files_by_type.renamed} renamed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
|
||||
{/* Main diff content */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Tabs value={currentView} onValueChange={handleViewChange}>
|
||||
<div className="border-b">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="side-by-side" className="flex items-center space-x-2">
|
||||
<Monitor className="h-4 w-4" />
|
||||
<span>Side-by-Side</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="unified" className="flex items-center space-x-2">
|
||||
<Code className="h-4 w-4" />
|
||||
<span>Unified</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="h-[600px] overflow-hidden border rounded-md">
|
||||
{renderView()}
|
||||
</div>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Wrapper component with context provider
|
||||
const DiffViewerWithProvider: React.FC<DiffViewerProps> = (props) => {
|
||||
return (
|
||||
<DiffViewerProvider>
|
||||
<DiffViewer {...props} />
|
||||
</DiffViewerProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffViewerWithProvider;
|
||||
256
src/components/diff-viewer/DiffViewerContext.tsx
Normal file
256
src/components/diff-viewer/DiffViewerContext.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
// components/diff-viewer/DiffViewerContext.tsx
|
||||
import React, { createContext, useContext, useReducer, useCallback } from 'react';
|
||||
|
||||
// Types
|
||||
export interface DiffFile {
|
||||
file_change_id: string;
|
||||
file_path: string;
|
||||
change_type: 'added' | 'modified' | 'deleted' | 'renamed';
|
||||
diff_content_id?: string;
|
||||
diff_header?: string;
|
||||
diff_size_bytes?: number;
|
||||
storage_type?: string;
|
||||
external_storage_path?: string;
|
||||
processing_status?: string;
|
||||
diff_content?: string;
|
||||
}
|
||||
|
||||
export interface Commit {
|
||||
id: string;
|
||||
commit_sha: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
message: string;
|
||||
url: string;
|
||||
committed_at: string;
|
||||
repository_name: string;
|
||||
owner_name: string;
|
||||
}
|
||||
|
||||
export interface DiffStatistics {
|
||||
total_files: number;
|
||||
total_additions: number;
|
||||
total_deletions: number;
|
||||
total_size_bytes: number;
|
||||
files_by_type: {
|
||||
added: number;
|
||||
modified: number;
|
||||
deleted: number;
|
||||
renamed: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DiffPreferences {
|
||||
defaultView: 'side-by-side' | 'unified';
|
||||
showLineNumbers: boolean;
|
||||
showWhitespace: boolean;
|
||||
wrapLines: boolean;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
theme: 'light' | 'dark' | 'high-contrast' | 'custom';
|
||||
customTheme?: {
|
||||
background: string;
|
||||
text: string;
|
||||
added: string;
|
||||
removed: string;
|
||||
unchanged: string;
|
||||
border: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DiffViewerState {
|
||||
commit: Commit | null;
|
||||
files: DiffFile[];
|
||||
statistics: DiffStatistics | null;
|
||||
preferences: DiffPreferences;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Actions
|
||||
type DiffViewerAction =
|
||||
| { type: 'SET_LOADING'; payload: boolean }
|
||||
| { type: 'SET_ERROR'; payload: string | null }
|
||||
| { type: 'SET_COMMIT'; payload: Commit | null }
|
||||
| { type: 'SET_FILES'; payload: DiffFile[] }
|
||||
| { type: 'SET_STATISTICS'; payload: DiffStatistics | null }
|
||||
| { type: 'SET_PREFERENCES'; payload: DiffPreferences }
|
||||
| { type: 'SET_THEME'; payload: DiffPreferences['theme'] }
|
||||
| { type: 'SET_CUSTOM_THEME'; payload: DiffPreferences['customTheme'] };
|
||||
|
||||
// Initial state
|
||||
const initialState: DiffViewerState = {
|
||||
commit: null,
|
||||
files: [],
|
||||
statistics: null,
|
||||
preferences: {
|
||||
defaultView: 'side-by-side',
|
||||
showLineNumbers: true,
|
||||
showWhitespace: false,
|
||||
wrapLines: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
theme: 'light'
|
||||
},
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
// Reducer
|
||||
function diffViewerReducer(state: DiffViewerState, action: DiffViewerAction): DiffViewerState {
|
||||
switch (action.type) {
|
||||
case 'SET_LOADING':
|
||||
return { ...state, isLoading: action.payload };
|
||||
case 'SET_ERROR':
|
||||
return { ...state, error: action.payload, isLoading: false };
|
||||
case 'SET_COMMIT':
|
||||
return { ...state, commit: action.payload };
|
||||
case 'SET_FILES':
|
||||
return { ...state, files: action.payload };
|
||||
case 'SET_STATISTICS':
|
||||
return { ...state, statistics: action.payload };
|
||||
case 'SET_PREFERENCES':
|
||||
return { ...state, preferences: action.payload };
|
||||
case 'SET_THEME':
|
||||
return {
|
||||
...state,
|
||||
preferences: { ...state.preferences, theme: action.payload }
|
||||
};
|
||||
case 'SET_CUSTOM_THEME':
|
||||
return {
|
||||
...state,
|
||||
preferences: { ...state.preferences, customTheme: action.payload }
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// Context
|
||||
const DiffViewerContext = createContext<{
|
||||
state: DiffViewerState;
|
||||
dispatch: React.Dispatch<DiffViewerAction>;
|
||||
loadCommitDiffs: (commitId: string) => Promise<void>;
|
||||
loadRepositoryCommits: (repositoryId: string) => Promise<void>;
|
||||
setTheme: (theme: DiffPreferences['theme']) => void;
|
||||
setCustomTheme: (customTheme: DiffPreferences['customTheme']) => void;
|
||||
setPreferences: (preferences: DiffPreferences) => void;
|
||||
} | null>(null);
|
||||
|
||||
// Provider component
|
||||
export const DiffViewerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(diffViewerReducer, initialState);
|
||||
|
||||
// API functions
|
||||
const loadCommitDiffs = useCallback(async (commitId: string) => {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
dispatch({ type: 'SET_ERROR', payload: null });
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/diffs/commits/${commitId}/diffs`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to load commit diffs');
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_COMMIT', payload: data.data.commit });
|
||||
dispatch({ type: 'SET_FILES', payload: data.data.files });
|
||||
dispatch({ type: 'SET_STATISTICS', payload: data.data.statistics });
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: 'SET_ERROR',
|
||||
payload: error instanceof Error ? error.message : 'Failed to load commit diffs'
|
||||
});
|
||||
} finally {
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRepositoryCommits = useCallback(async (repositoryId: string) => {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
dispatch({ type: 'SET_ERROR', payload: null });
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/diffs/repositories/${repositoryId}/commits`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to load repository commits');
|
||||
}
|
||||
|
||||
// Load the first commit's diffs by default
|
||||
if (data.data.commits.length > 0) {
|
||||
await loadCommitDiffs(data.data.commits[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: 'SET_ERROR',
|
||||
payload: error instanceof Error ? error.message : 'Failed to load repository commits'
|
||||
});
|
||||
} finally {
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
}
|
||||
}, [loadCommitDiffs]);
|
||||
|
||||
const setTheme = useCallback((theme: DiffPreferences['theme']) => {
|
||||
dispatch({ type: 'SET_THEME', payload: theme });
|
||||
}, []);
|
||||
|
||||
const setCustomTheme = useCallback((customTheme: DiffPreferences['customTheme']) => {
|
||||
dispatch({ type: 'SET_CUSTOM_THEME', payload: customTheme });
|
||||
}, []);
|
||||
|
||||
const setPreferences = useCallback((preferences: DiffPreferences) => {
|
||||
dispatch({ type: 'SET_PREFERENCES', payload: preferences });
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
state,
|
||||
dispatch,
|
||||
loadCommitDiffs,
|
||||
loadRepositoryCommits,
|
||||
setTheme,
|
||||
setCustomTheme,
|
||||
setPreferences
|
||||
};
|
||||
|
||||
return (
|
||||
<DiffViewerContext.Provider value={value}>
|
||||
{children}
|
||||
</DiffViewerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to use the context
|
||||
export const useDiffViewer = () => {
|
||||
const context = useContext(DiffViewerContext);
|
||||
if (!context) {
|
||||
throw new Error('useDiffViewer must be used within a DiffViewerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Destructured state and actions for convenience
|
||||
export const useDiffViewerState = () => {
|
||||
const { state } = useDiffViewer();
|
||||
return state;
|
||||
};
|
||||
|
||||
export const useDiffViewerActions = () => {
|
||||
const {
|
||||
loadCommitDiffs,
|
||||
loadRepositoryCommits,
|
||||
setTheme,
|
||||
setCustomTheme,
|
||||
setPreferences
|
||||
} = useDiffViewer();
|
||||
|
||||
return {
|
||||
loadCommitDiffs,
|
||||
loadRepositoryCommits,
|
||||
setTheme,
|
||||
setCustomTheme,
|
||||
setPreferences
|
||||
};
|
||||
};
|
||||
368
src/components/diff-viewer/SideBySideView.tsx
Normal file
368
src/components/diff-viewer/SideBySideView.tsx
Normal file
@ -0,0 +1,368 @@
|
||||
// components/diff-viewer/SideBySideView.tsx
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Minus,
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
import { DiffFile, DiffPreferences } from './DiffViewerContext';
|
||||
|
||||
interface SideBySideViewProps {
|
||||
files: DiffFile[];
|
||||
selectedFile: DiffFile | null;
|
||||
onFileSelect: (filePath: string) => void;
|
||||
theme: string;
|
||||
preferences: DiffPreferences;
|
||||
}
|
||||
|
||||
interface DiffLine {
|
||||
type: 'added' | 'removed' | 'unchanged' | 'context';
|
||||
content: string;
|
||||
oldLineNumber?: number;
|
||||
newLineNumber?: number;
|
||||
}
|
||||
|
||||
const SideBySideView: React.FC<SideBySideViewProps> = ({
|
||||
files,
|
||||
selectedFile,
|
||||
onFileSelect,
|
||||
theme,
|
||||
preferences
|
||||
}) => {
|
||||
const [expandedHunks, setExpandedHunks] = useState<Set<string>>(new Set());
|
||||
|
||||
// Parse diff content into structured format
|
||||
const parseDiffContent = (diffContent: string): DiffLine[] => {
|
||||
if (!diffContent) return [];
|
||||
|
||||
const lines = diffContent.split('\n');
|
||||
const diffLines: DiffLine[] = [];
|
||||
let oldLineNumber = 0;
|
||||
let newLineNumber = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('@@')) {
|
||||
// Parse hunk header
|
||||
const match = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/);
|
||||
if (match) {
|
||||
oldLineNumber = parseInt(match[1]) - 1;
|
||||
newLineNumber = parseInt(match[3]) - 1;
|
||||
}
|
||||
diffLines.push({ type: 'context', content: line });
|
||||
} else if (line.startsWith('+')) {
|
||||
newLineNumber++;
|
||||
diffLines.push({
|
||||
type: 'added',
|
||||
content: line.substring(1),
|
||||
oldLineNumber: undefined,
|
||||
newLineNumber
|
||||
});
|
||||
} else if (line.startsWith('-')) {
|
||||
oldLineNumber++;
|
||||
diffLines.push({
|
||||
type: 'removed',
|
||||
content: line.substring(1),
|
||||
oldLineNumber,
|
||||
newLineNumber: undefined
|
||||
});
|
||||
} else {
|
||||
oldLineNumber++;
|
||||
newLineNumber++;
|
||||
diffLines.push({
|
||||
type: 'unchanged',
|
||||
content: line.substring(1),
|
||||
oldLineNumber,
|
||||
newLineNumber
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return diffLines;
|
||||
};
|
||||
|
||||
// Group diff lines into hunks
|
||||
const groupIntoHunks = (diffLines: DiffLine[]) => {
|
||||
const hunks: { header: string; lines: DiffLine[] }[] = [];
|
||||
let currentHunk: { header: string; lines: DiffLine[] } | null = null;
|
||||
|
||||
for (const line of diffLines) {
|
||||
if (line.type === 'context' && line.content.startsWith('@@')) {
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
currentHunk = { header: line.content, lines: [] };
|
||||
} else if (currentHunk) {
|
||||
currentHunk.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
|
||||
return hunks;
|
||||
};
|
||||
|
||||
const diffLines = useMemo(() => {
|
||||
if (!selectedFile?.diff_content) return [];
|
||||
return parseDiffContent(selectedFile.diff_content);
|
||||
}, [selectedFile]);
|
||||
|
||||
const hunks = useMemo(() => {
|
||||
return groupIntoHunks(diffLines);
|
||||
}, [diffLines]);
|
||||
|
||||
const toggleHunk = (hunkIndex: number) => {
|
||||
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
||||
setExpandedHunks(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(hunkId)) {
|
||||
newSet.delete(hunkId);
|
||||
} else {
|
||||
newSet.add(hunkId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const downloadDiff = () => {
|
||||
if (!selectedFile?.diff_content) return;
|
||||
|
||||
const blob = new Blob([selectedFile.diff_content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${selectedFile.file_path}.diff`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const getLineClass = (type: DiffLine['type']) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return 'bg-green-50 dark:bg-green-900/20 border-l-4 border-green-500';
|
||||
case 'removed':
|
||||
return 'bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500';
|
||||
case 'unchanged':
|
||||
return 'bg-gray-50 dark:bg-gray-800/50';
|
||||
case 'context':
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 font-mono text-sm';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getLineIcon = (type: DiffLine['type']) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return <Plus className="h-3 w-3 text-green-600" />;
|
||||
case 'removed':
|
||||
return <Minus className="h-3 w-3 text-red-600" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* File tabs */}
|
||||
<div className="border-b">
|
||||
<Tabs value={selectedFile?.file_path || ''} onValueChange={onFileSelect}>
|
||||
<TabsList className="w-full justify-start">
|
||||
{files.map((file) => (
|
||||
<TabsTrigger
|
||||
key={file.file_path}
|
||||
value={file.file_path}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="truncate max-w-32">{file.file_path.split('/').pop()}</span>
|
||||
<Badge
|
||||
variant={
|
||||
file.change_type === 'added' ? 'default' :
|
||||
file.change_type === 'modified' ? 'secondary' :
|
||||
file.change_type === 'deleted' ? 'destructive' : 'outline'
|
||||
}
|
||||
className="ml-1"
|
||||
>
|
||||
{file.change_type}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* File info and controls */}
|
||||
{selectedFile && (
|
||||
<div className="p-4 border-b bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div>
|
||||
<h3 className="font-medium">{selectedFile.file_path}</h3>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<span>{selectedFile.change_type}</span>
|
||||
{selectedFile.diff_size_bytes && (
|
||||
<span>• {(selectedFile.diff_size_bytes / 1024).toFixed(1)} KB</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(selectedFile.diff_content || '')}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={downloadDiff}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diff content */}
|
||||
<div className="flex-1 overflow-hidden h-[500px]">
|
||||
<div className="grid grid-cols-2 h-full">
|
||||
{/* Old version */}
|
||||
<div className="border-r">
|
||||
<div className="bg-muted/50 px-4 py-2 border-b">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Minus className="h-4 w-4 text-red-600" />
|
||||
<span className="font-medium">Old Version</span>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="font-mono text-sm">
|
||||
{hunks.map((hunk, hunkIndex) => {
|
||||
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
||||
const isExpanded = expandedHunks.has(hunkId);
|
||||
|
||||
return (
|
||||
<div key={hunkIndex}>
|
||||
<div
|
||||
className="bg-blue-100 dark:bg-blue-900/30 px-4 py-2 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50"
|
||||
onClick={() => toggleHunk(hunkIndex)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm">{hunk.header}</span>
|
||||
<Button variant="ghost" size="sm">
|
||||
{isExpanded ? 'Collapse' : 'Expand'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIndex) => (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className={`px-4 py-1 flex items-center space-x-2 ${getLineClass(line.type)}`}
|
||||
>
|
||||
<div className="w-8 text-right text-xs text-muted-foreground">
|
||||
{line.oldLineNumber || ''}
|
||||
</div>
|
||||
<div className="w-4 flex justify-center">
|
||||
{getLineIcon(line.type)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<code className="text-sm">{line.content}</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* New version */}
|
||||
<div>
|
||||
<div className="bg-muted/50 px-4 py-2 border-b">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Plus className="h-4 w-4 text-green-600" />
|
||||
<span className="font-medium">New Version</span>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="font-mono text-sm">
|
||||
{hunks.map((hunk, hunkIndex) => {
|
||||
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
||||
const isExpanded = expandedHunks.has(hunkId);
|
||||
|
||||
return (
|
||||
<div key={hunkIndex}>
|
||||
<div
|
||||
className="bg-blue-100 dark:bg-blue-900/30 px-4 py-2 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50"
|
||||
onClick={() => toggleHunk(hunkIndex)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm">{hunk.header}</span>
|
||||
<Button variant="ghost" size="sm">
|
||||
{isExpanded ? 'Collapse' : 'Expand'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIndex) => (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className={`px-4 py-1 flex items-center space-x-2 ${getLineClass(line.type)}`}
|
||||
>
|
||||
<div className="w-8 text-right text-xs text-muted-foreground">
|
||||
{line.newLineNumber || ''}
|
||||
</div>
|
||||
<div className="w-4 flex justify-center">
|
||||
{getLineIcon(line.type)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<code className="text-sm">{line.content}</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SideBySideView;
|
||||
134
src/components/diff-viewer/ThemeSelector.tsx
Normal file
134
src/components/diff-viewer/ThemeSelector.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
// components/diff-viewer/ThemeSelector.tsx
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Palette,
|
||||
Sun,
|
||||
Moon,
|
||||
Contrast,
|
||||
Settings,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import { useDiffViewer } from './DiffViewerContext';
|
||||
|
||||
const ThemeSelector: React.FC = () => {
|
||||
const { state, setTheme, setCustomTheme } = useDiffViewer();
|
||||
const [showCustomTheme, setShowCustomTheme] = useState(false);
|
||||
|
||||
const themes = [
|
||||
{
|
||||
id: 'light',
|
||||
name: 'Light',
|
||||
icon: Sun,
|
||||
description: 'Clean and bright theme',
|
||||
colors: {
|
||||
background: '#ffffff',
|
||||
text: '#333333',
|
||||
added: '#d4edda',
|
||||
removed: '#f8d7da',
|
||||
unchanged: '#f8f9fa',
|
||||
border: '#dee2e6'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'dark',
|
||||
name: 'Dark',
|
||||
icon: Moon,
|
||||
description: 'Dark theme for low light',
|
||||
colors: {
|
||||
background: '#1e1e1e',
|
||||
text: '#ffffff',
|
||||
added: '#0d5016',
|
||||
removed: '#721c24',
|
||||
unchanged: '#2d2d2d',
|
||||
border: '#404040'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'high-contrast',
|
||||
name: 'High Contrast',
|
||||
icon: Contrast,
|
||||
description: 'High contrast for accessibility',
|
||||
colors: {
|
||||
background: '#000000',
|
||||
text: '#ffffff',
|
||||
added: '#00ff00',
|
||||
removed: '#ff0000',
|
||||
unchanged: '#333333',
|
||||
border: '#666666'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const handleThemeChange = (themeId: string) => {
|
||||
if (themeId === 'custom') {
|
||||
setShowCustomTheme(true);
|
||||
} else {
|
||||
setTheme(themeId as any);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomTheme = (colors: any) => {
|
||||
setCustomTheme(colors);
|
||||
setTheme('custom');
|
||||
setShowCustomTheme(false);
|
||||
};
|
||||
|
||||
const currentTheme = themes.find(t => t.id === state.preferences.theme) || themes[0];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="flex items-center space-x-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
<span>{currentTheme.name}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64">
|
||||
<DropdownMenuLabel>Choose Theme</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{themes.map((theme) => (
|
||||
<DropdownMenuItem
|
||||
key={theme.id}
|
||||
onClick={() => handleThemeChange(theme.id)}
|
||||
className="flex items-center space-x-3 p-3"
|
||||
>
|
||||
<theme.icon className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{theme.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{theme.description}</div>
|
||||
</div>
|
||||
{state.preferences.theme === theme.id && (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleThemeChange('custom')}
|
||||
className="flex items-center space-x-3 p-3"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Custom Theme</div>
|
||||
<div className="text-xs text-muted-foreground">Create your own theme</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSelector;
|
||||
323
src/components/diff-viewer/UnifiedView.tsx
Normal file
323
src/components/diff-viewer/UnifiedView.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
// components/diff-viewer/UnifiedView.tsx
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Minus,
|
||||
Copy,
|
||||
Download,
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { DiffFile, DiffPreferences } from './DiffViewerContext';
|
||||
|
||||
interface UnifiedViewProps {
|
||||
files: DiffFile[];
|
||||
selectedFile: DiffFile | null;
|
||||
onFileSelect: (filePath: string) => void;
|
||||
theme: string;
|
||||
preferences: DiffPreferences;
|
||||
}
|
||||
|
||||
interface DiffLine {
|
||||
type: 'added' | 'removed' | 'unchanged' | 'context';
|
||||
content: string;
|
||||
lineNumber?: number;
|
||||
}
|
||||
|
||||
const UnifiedView: React.FC<UnifiedViewProps> = ({
|
||||
files,
|
||||
selectedFile,
|
||||
onFileSelect,
|
||||
theme,
|
||||
preferences
|
||||
}) => {
|
||||
const [expandedHunks, setExpandedHunks] = useState<Set<string>>(new Set());
|
||||
|
||||
// Parse diff content into structured format
|
||||
const parseDiffContent = (diffContent: string): DiffLine[] => {
|
||||
if (!diffContent) return [];
|
||||
|
||||
const lines = diffContent.split('\n');
|
||||
const diffLines: DiffLine[] = [];
|
||||
let lineNumber = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('@@')) {
|
||||
// Parse hunk header
|
||||
const match = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/);
|
||||
if (match) {
|
||||
lineNumber = parseInt(match[3]) - 1;
|
||||
}
|
||||
diffLines.push({ type: 'context', content: line });
|
||||
} else if (line.startsWith('+')) {
|
||||
lineNumber++;
|
||||
diffLines.push({
|
||||
type: 'added',
|
||||
content: line.substring(1),
|
||||
lineNumber
|
||||
});
|
||||
} else if (line.startsWith('-')) {
|
||||
diffLines.push({
|
||||
type: 'removed',
|
||||
content: line.substring(1),
|
||||
lineNumber: undefined
|
||||
});
|
||||
} else {
|
||||
lineNumber++;
|
||||
diffLines.push({
|
||||
type: 'unchanged',
|
||||
content: line.substring(1),
|
||||
lineNumber
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return diffLines;
|
||||
};
|
||||
|
||||
// Group diff lines into hunks
|
||||
const groupIntoHunks = (diffLines: DiffLine[]) => {
|
||||
const hunks: { header: string; lines: DiffLine[] }[] = [];
|
||||
let currentHunk: { header: string; lines: DiffLine[] } | null = null;
|
||||
|
||||
for (const line of diffLines) {
|
||||
if (line.type === 'context' && line.content.startsWith('@@')) {
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
currentHunk = { header: line.content, lines: [] };
|
||||
} else if (currentHunk) {
|
||||
currentHunk.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
|
||||
return hunks;
|
||||
};
|
||||
|
||||
const diffLines = useMemo(() => {
|
||||
if (!selectedFile?.diff_content) return [];
|
||||
return parseDiffContent(selectedFile.diff_content);
|
||||
}, [selectedFile]);
|
||||
|
||||
const hunks = useMemo(() => {
|
||||
return groupIntoHunks(diffLines);
|
||||
}, [diffLines]);
|
||||
|
||||
const toggleHunk = (hunkIndex: number) => {
|
||||
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
||||
setExpandedHunks(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(hunkId)) {
|
||||
newSet.delete(hunkId);
|
||||
} else {
|
||||
newSet.add(hunkId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const downloadDiff = () => {
|
||||
if (!selectedFile?.diff_content) return;
|
||||
|
||||
const blob = new Blob([selectedFile.diff_content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${selectedFile.file_path}.diff`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const getLineClass = (type: DiffLine['type']) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return 'bg-green-50 dark:bg-green-900/20 border-l-4 border-green-500';
|
||||
case 'removed':
|
||||
return 'bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500';
|
||||
case 'unchanged':
|
||||
return 'bg-gray-50 dark:bg-gray-800/50';
|
||||
case 'context':
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 font-mono text-sm';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getLineIcon = (type: DiffLine['type']) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return <Plus className="h-3 w-3 text-green-600" />;
|
||||
case 'removed':
|
||||
return <Minus className="h-3 w-3 text-red-600" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getLinePrefix = (type: DiffLine['type']) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return '+';
|
||||
case 'removed':
|
||||
return '-';
|
||||
case 'unchanged':
|
||||
return ' ';
|
||||
case 'context':
|
||||
return '@';
|
||||
default:
|
||||
return ' ';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* File tabs */}
|
||||
<div className="border-b">
|
||||
<Tabs value={selectedFile?.file_path || ''} onValueChange={onFileSelect}>
|
||||
<TabsList className="w-full justify-start">
|
||||
{files.map((file) => (
|
||||
<TabsTrigger
|
||||
key={file.file_path}
|
||||
value={file.file_path}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="truncate max-w-32">{file.file_path.split('/').pop()}</span>
|
||||
<Badge
|
||||
variant={
|
||||
file.change_type === 'added' ? 'default' :
|
||||
file.change_type === 'modified' ? 'secondary' :
|
||||
file.change_type === 'deleted' ? 'destructive' : 'outline'
|
||||
}
|
||||
className="ml-1"
|
||||
>
|
||||
{file.change_type}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* File info and controls */}
|
||||
{selectedFile && (
|
||||
<div className="p-4 border-b bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div>
|
||||
<h3 className="font-medium">{selectedFile.file_path}</h3>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<span>{selectedFile.change_type}</span>
|
||||
{selectedFile.diff_size_bytes && (
|
||||
<span>• {(selectedFile.diff_size_bytes / 1024).toFixed(1)} KB</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(selectedFile.diff_content || '')}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={downloadDiff}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diff content */}
|
||||
<div className="flex-1 overflow-hidden h-[500px]">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="font-mono text-sm">
|
||||
{hunks.map((hunk, hunkIndex) => {
|
||||
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
||||
const isExpanded = expandedHunks.has(hunkId);
|
||||
|
||||
return (
|
||||
<div key={hunkIndex} className="border-b last:border-b-0">
|
||||
<div
|
||||
className="bg-blue-100 dark:bg-blue-900/30 px-4 py-2 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50"
|
||||
onClick={() => toggleHunk(hunkIndex)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm">{hunk.header}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="ghost" size="sm">
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 mr-1" />
|
||||
Collapse
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="h-4 w-4 mr-1" />
|
||||
Expand
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIndex) => (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className={`px-4 py-1 flex items-center space-x-2 ${getLineClass(line.type)}`}
|
||||
>
|
||||
<div className="w-8 text-right text-xs text-muted-foreground">
|
||||
{line.lineNumber || ''}
|
||||
</div>
|
||||
<div className="w-4 flex justify-center">
|
||||
{getLineIcon(line.type)}
|
||||
</div>
|
||||
<div className="w-4 text-center font-mono text-xs">
|
||||
{getLinePrefix(line.type)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<code className="text-sm">{line.content}</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedView;
|
||||
22
src/components/github/ViewUserReposButton.tsx
Normal file
22
src/components/github/ViewUserReposButton.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { FolderGit2 } from "lucide-react"
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
size?: "default" | "sm" | "lg" | "icon"
|
||||
label?: string
|
||||
}
|
||||
|
||||
export default function ViewUserReposButton({ className, size = "default", label = "View My Repos" }: Props) {
|
||||
return (
|
||||
<Link href="/github/repos" prefetch>
|
||||
<Button className={className} size={size} variant="default">
|
||||
<FolderGit2 className="mr-2 h-5 w-5" />
|
||||
{label}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
1867
src/components/tech-stack-summary.tsx
Normal file
1867
src/components/tech-stack-summary.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
42
src/components/ui/error-banner.tsx
Normal file
42
src/components/ui/error-banner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 }
|
||||
@ -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 }
|
||||
@ -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 }
|
||||
@ -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
491
src/lib/api/github.ts
Normal 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.')
|
||||
}
|
||||
}
|
||||
@ -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[] }
|
||||
}
|
||||
|
||||
430
src/services/claudeTechRecommendations.ts
Normal file
430
src/services/claudeTechRecommendations.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user