Merge pull request 'feature/dark-theme-v2' (#4) from feature/dark-theme-v2 into main
Reviewed-on: https://git.tech4biz.wiki/Tech4Biz-Services/CodeNuk-FrontEnd/pulls/4
This commit is contained in:
commit
9d95f25f16
231
AUTH_IMPLEMENTATION.md
Normal file
231
AUTH_IMPLEMENTATION.md
Normal file
@ -0,0 +1,231 @@
|
||||
# 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**
|
||||
118
DYNAMIC_TEMPLATES.md
Normal file
118
DYNAMIC_TEMPLATES.md
Normal file
@ -0,0 +1,118 @@
|
||||
# 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
|
||||
2073
package-lock.json
generated
2073
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,9 +9,11 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.57.0",
|
||||
"@next/font": "^14.2.15",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
@ -19,13 +21,17 @@
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@tldraw/tldraw": "^3.15.4",
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next": "15.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"svg-path-parser": "^1.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@ -33,6 +39,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/svg-path-parser": "^1.1.6",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.4.6",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
59
src/app/admin/page.tsx
Normal file
59
src/app/admin/page.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { AdminDashboard } from '@/components/admin/admin-dashboard'
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user, isAdmin } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect non-admin users to home page
|
||||
if (user && !isAdmin) {
|
||||
router.push('/')
|
||||
}
|
||||
// Redirect unauthenticated users to signin
|
||||
if (!user) {
|
||||
router.push('/signin')
|
||||
}
|
||||
}, [user, isAdmin, router])
|
||||
|
||||
// Show loading while checking auth
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500 mx-auto mb-4"></div>
|
||||
<p className="text-white/60">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show access denied for non-admin users
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 text-6xl mb-4">🚫</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Access Denied</h1>
|
||||
<p className="text-white/60 mb-4">You don't have permission to access this page.</p>
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="px-4 py-2 bg-orange-500 text-black rounded-md hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Go to Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<AdminDashboard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
src/app/api/ai/analyze/route.ts
Normal file
91
src/app/api/ai/analyze/route.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const featureName: string = body.featureName || 'Custom Feature'
|
||||
const description: string = body.description || ''
|
||||
const requirements: string[] = Array.isArray(body.requirements) ? body.requirements : []
|
||||
const projectType: string | undefined = body.projectType
|
||||
|
||||
const apiKey =
|
||||
process.env.ANTHROPIC_API_KEY ||
|
||||
process.env.REACT_APP_ANTHROPIC_API_KEY ||
|
||||
process.env.NEXT_PUBLIC_ANTHROPIC_API_KEY ||
|
||||
''
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Missing Anthropic API key in env' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const anthropic = new Anthropic({ apiKey })
|
||||
|
||||
const requirementsText = (requirements || [])
|
||||
.filter((r) => r && r.trim())
|
||||
.map((r) => `- ${r}`)
|
||||
.join('\n')
|
||||
|
||||
const prompt = `
|
||||
Analyze this feature and provide complexity assessment and business logic rules:
|
||||
|
||||
Project Type: ${projectType || 'Generic'}
|
||||
Feature Name: ${featureName}
|
||||
Description: ${description}
|
||||
|
||||
Requirements:
|
||||
${requirementsText}
|
||||
|
||||
Based on these requirements, provide a JSON response with:
|
||||
1. Complexity level (low/medium/high)
|
||||
2. Business logic rules that should be implemented
|
||||
|
||||
Complexity Guidelines:
|
||||
- LOW: Simple CRUD operations, basic display features
|
||||
- MEDIUM: Moderate business logic, some validations, basic integrations
|
||||
- HIGH: Complex business rules, security requirements, external integrations, compliance needs
|
||||
|
||||
Return ONLY a JSON object in this exact format:
|
||||
{
|
||||
"complexity": "low|medium|high",
|
||||
"logicRules": [
|
||||
"Business rule 1 based on requirements",
|
||||
"Business rule 2 based on requirements",
|
||||
"Business rule 3 based on requirements"
|
||||
]
|
||||
}`
|
||||
|
||||
const message = await anthropic.messages.create({
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
max_tokens: 1000,
|
||||
temperature: 0.2,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
})
|
||||
|
||||
const responseText = (message as any).content?.[0]?.text?.trim?.() || ''
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/)
|
||||
if (!jsonMatch) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Invalid AI response format' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0])
|
||||
const complexity = (parsed.complexity as 'low' | 'medium' | 'high') || 'medium'
|
||||
const logicRules = Array.isArray(parsed.logicRules) ? parsed.logicRules : []
|
||||
|
||||
return NextResponse.json({ success: true, data: { complexity, logicRules } })
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: error?.message || 'AI analysis failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
170
src/app/auth/emailVerification.tsx
Normal file
170
src/app/auth/emailVerification.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
|
||||
type VerifyState = "idle" | "loading" | "success" | "error";
|
||||
|
||||
interface VerificationResponse {
|
||||
success: boolean;
|
||||
data: { message: string; user: { email: string; username: string } };
|
||||
message: string;
|
||||
}
|
||||
interface ErrorResponse {
|
||||
success: false;
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const API_BASE_URL = "http://localhost:8011";
|
||||
|
||||
const EmailVerification: React.FC = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const [status, setStatus] = useState<VerifyState>("idle");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const token = searchParams.get("token");
|
||||
const didRun = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus("error");
|
||||
setError("No verification token found.");
|
||||
return;
|
||||
}
|
||||
if (didRun.current) return;
|
||||
didRun.current = true;
|
||||
|
||||
void verifyEmail(token);
|
||||
}, [token]);
|
||||
|
||||
const verifyEmail = async (verificationToken: string) => {
|
||||
setStatus("loading");
|
||||
setMessage("Verifying your email...");
|
||||
setError("");
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const timeout = setTimeout(() => ctrl.abort(), 10000);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/auth/verify-email?token=${verificationToken}`,
|
||||
{ method: "GET", headers: { "Content-Type": "application/json" }, signal: ctrl.signal }
|
||||
);
|
||||
|
||||
const txt = await res.text();
|
||||
let data: any = {};
|
||||
try { data = JSON.parse(txt || "{}"); } catch { /* ignore non-JSON */ }
|
||||
|
||||
if (res.ok && (data as VerificationResponse)?.success) {
|
||||
router.replace("/auth?verified=1");
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = String(data?.message || "").toLowerCase();
|
||||
if (msg.includes("already verified")) {
|
||||
router.replace("/auth?verified=1");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("error");
|
||||
setError(data?.message || `Verification failed (HTTP ${res.status}).`);
|
||||
} catch (e: any) {
|
||||
setStatus("error");
|
||||
setError(e?.name === "AbortError" ? "Request timed out. Please try again." : "Network error. Please try again.");
|
||||
console.error("Email verification error:", e);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
setStatus("loading");
|
||||
setMessage("Sending verification email...");
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const email = prompt("Enter your email to resend the verification link:");
|
||||
if (!email) {
|
||||
setStatus("error");
|
||||
setError("Email is required to resend verification.");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/resend-verification`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data?.success) {
|
||||
setStatus("success");
|
||||
setMessage("Verification email sent! Please check your inbox.");
|
||||
} else {
|
||||
setStatus("error");
|
||||
setError(data?.message || "Failed to resend verification email.");
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
setError("Network error. Please try again.");
|
||||
console.error("Resend verification error:", e);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-1">Verifying Email</h2>
|
||||
<p className="text-gray-600">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
|
||||
<div className="max-w-md w-full space-y-6">
|
||||
{status === "success" && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-md p-4 text-center">
|
||||
<p className="text-green-800 font-medium">{message || "Email verified. Redirecting..."}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<p className="text-red-800 font-medium text-center">{error}</p>
|
||||
<button
|
||||
onClick={handleResendVerification}
|
||||
className="mt-4 w-full py-2 rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Resend Verification Email
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => (window.location.href = "/auth")}
|
||||
className="w-1/2 py-2 rounded-md border bg-white text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Go to Login
|
||||
</button>
|
||||
<button
|
||||
onClick={() => (window.location.href = "/")}
|
||||
className="w-1/2 py-2 rounded-md border bg-white text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Go to Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailVerification;
|
||||
@ -1,5 +1,33 @@
|
||||
import { AuthPage } from "@/components/auth/auth-page"
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
|
||||
export default function AuthPageRoute() {
|
||||
return <AuthPage />
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user wants to sign up or sign in
|
||||
const mode = searchParams.get('mode')
|
||||
|
||||
if (mode === 'signup') {
|
||||
router.replace('/signup')
|
||||
} else if (mode === 'signin') {
|
||||
router.replace('/signin')
|
||||
} else {
|
||||
// Default to signin page
|
||||
router.replace('/signin')
|
||||
}
|
||||
}, [router, searchParams])
|
||||
|
||||
// Show loading while redirecting
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500 mx-auto mb-4"></div>
|
||||
<p className="text-white/60">Redirecting...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
5
src/app/signin/page.tsx
Normal file
5
src/app/signin/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { SignInPage } from "@/components/auth/signin-page"
|
||||
|
||||
export default function SignInPageRoute() {
|
||||
return <SignInPage />
|
||||
}
|
||||
5
src/app/signup/page.tsx
Normal file
5
src/app/signup/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { SignUpPage } from "@/components/auth/signup-page"
|
||||
|
||||
export default function SignUpPageRoute() {
|
||||
return <SignUpPage />
|
||||
}
|
||||
5
src/app/verify-email/page.tsx
Normal file
5
src/app/verify-email/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import EmailVerification from "@/app/auth/emailVerification";
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return <EmailVerification />;
|
||||
}
|
||||
360
src/components/admin/admin-dashboard.tsx
Normal file
360
src/components/admin/admin-dashboard.tsx
Normal file
@ -0,0 +1,360 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
Copy,
|
||||
Bell,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Filter
|
||||
} from 'lucide-react'
|
||||
import { adminApi, formatDate, getStatusColor, getComplexityColor } from '@/lib/api/admin'
|
||||
import { AdminFeature, AdminNotification, AdminStats } from '@/types/admin.types'
|
||||
import { FeatureReviewDialog } from './feature-review-dialog'
|
||||
import { AdminNotificationsPanel } from './admin-notifications-panel'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
|
||||
export function AdminDashboard() {
|
||||
const [pendingFeatures, setPendingFeatures] = useState<AdminFeature[]>([])
|
||||
const [notifications, setNotifications] = useState<AdminNotification[]>([])
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedFeature, setSelectedFeature] = useState<AdminFeature | null>(null)
|
||||
const [showReviewDialog, setShowReviewDialog] = useState(false)
|
||||
const [showNotifications, setShowNotifications] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
|
||||
// Load dashboard data
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [pendingData, notificationsData, statsData] = await Promise.all([
|
||||
adminApi.getPendingFeatures(),
|
||||
adminApi.getNotifications(true, 10), // Get 10 unread notifications
|
||||
adminApi.getFeatureStats()
|
||||
])
|
||||
|
||||
setPendingFeatures(pendingData)
|
||||
setNotifications(notificationsData)
|
||||
setStats(statsData)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load dashboard data')
|
||||
console.error('Error loading dashboard data:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData()
|
||||
}, [])
|
||||
|
||||
// Handle feature review
|
||||
const handleFeatureReview = async (featureId: string, reviewData: any) => {
|
||||
try {
|
||||
await adminApi.reviewFeature(featureId, reviewData)
|
||||
|
||||
// Remove the reviewed feature from pending list
|
||||
setPendingFeatures(prev => prev.filter(f => f.id !== featureId))
|
||||
|
||||
// Reload stats
|
||||
const newStats = await adminApi.getFeatureStats()
|
||||
setStats(newStats)
|
||||
|
||||
setShowReviewDialog(false)
|
||||
setSelectedFeature(null)
|
||||
} catch (err) {
|
||||
console.error('Error reviewing feature:', err)
|
||||
// Handle error (show toast notification, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter features based on search and status
|
||||
const filteredFeatures = pendingFeatures.filter(feature => {
|
||||
const matchesSearch = feature.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
feature.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
feature.template_title?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || feature.status === statusFilter
|
||||
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
// Get status counts
|
||||
const getStatusCount = (status: string) => {
|
||||
return stats?.features.find(s => s.status === status)?.count || 0
|
||||
}
|
||||
|
||||
const getUnreadNotificationCount = () => {
|
||||
return stats?.notifications.unread || 0
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
<span>Loading admin dashboard...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2 text-red-600">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
<span>Error loading dashboard</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-600">{error}</p>
|
||||
<Button onClick={loadDashboardData} className="mt-4">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
|
||||
<p className="text-gray-600">Manage feature approvals and system notifications</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowNotifications(true)}
|
||||
className="relative"
|
||||
>
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
Notifications
|
||||
{getUnreadNotificationCount() > 0 && (
|
||||
<Badge className="ml-2 bg-red-500 text-white">
|
||||
{getUnreadNotificationCount()}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={loadDashboardData}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-5 w-5 text-yellow-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Pending</p>
|
||||
<p className="text-2xl font-bold">{getStatusCount('pending')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Approved</p>
|
||||
<p className="text-2xl font-bold">{getStatusCount('approved')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="h-5 w-5 text-red-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Rejected</p>
|
||||
<p className="text-2xl font-bold">{getStatusCount('rejected')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Copy className="h-5 w-5 text-orange-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Duplicates</p>
|
||||
<p className="text-2xl font-bold">{getStatusCount('duplicate')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<Tabs defaultValue="pending" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="pending" className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Pending Review ({pendingFeatures.length})</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="all" className="flex items-center space-x-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>All Features</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pending" className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search features..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
<SelectItem value="duplicate">Duplicate</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Features List */}
|
||||
<div className="space-y-4">
|
||||
{filteredFeatures.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-gray-500">
|
||||
<Clock className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>No features found</p>
|
||||
<p className="text-sm">All features have been reviewed or no features match your filters.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredFeatures.map((feature) => (
|
||||
<Card key={feature.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h3 className="text-lg font-semibold">{feature.name}</h3>
|
||||
<Badge className={getStatusColor(feature.status)}>
|
||||
{feature.status}
|
||||
</Badge>
|
||||
<Badge className={getComplexityColor(feature.complexity)}>
|
||||
{feature.complexity}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{feature.description && (
|
||||
<p className="text-gray-600 mb-2">{feature.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>Template: {feature.template_title || 'Unknown'}</span>
|
||||
<span>Submitted: {formatDate(feature.created_at)}</span>
|
||||
{feature.similarity_score && (
|
||||
<span>Similarity: {(feature.similarity_score * 100).toFixed(1)}%</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{feature.admin_notes && (
|
||||
<div className="mt-2 p-2 bg-gray-50 rounded">
|
||||
<p className="text-sm text-gray-600">
|
||||
<strong>Admin Notes:</strong> {feature.admin_notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedFeature(feature)
|
||||
setShowReviewDialog(true)
|
||||
}}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="all" className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
View and manage all features across different statuses. Use the filters above to narrow down results.
|
||||
</p>
|
||||
{/* TODO: Implement all features view with pagination */}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Review Dialog */}
|
||||
{selectedFeature && (
|
||||
<FeatureReviewDialog
|
||||
feature={selectedFeature}
|
||||
open={showReviewDialog}
|
||||
onOpenChange={setShowReviewDialog}
|
||||
onReview={handleFeatureReview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notifications Panel */}
|
||||
<AdminNotificationsPanel
|
||||
open={showNotifications}
|
||||
onOpenChange={setShowNotifications}
|
||||
notifications={notifications}
|
||||
onNotificationRead={async (id) => {
|
||||
try {
|
||||
await adminApi.markNotificationAsRead(id)
|
||||
setNotifications(prev => prev.filter(n => n.id !== id))
|
||||
} catch (err) {
|
||||
console.error('Error marking notification as read:', err)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
220
src/components/admin/admin-notifications-panel.tsx
Normal file
220
src/components/admin/admin-notifications-panel.tsx
Normal file
@ -0,0 +1,220 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Bell,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Copy,
|
||||
Clock,
|
||||
Check,
|
||||
Trash2
|
||||
} from 'lucide-react'
|
||||
import { AdminNotification } from '@/types/admin.types'
|
||||
import { formatDate } from '@/lib/api/admin'
|
||||
|
||||
interface AdminNotificationsPanelProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
notifications: AdminNotification[]
|
||||
onNotificationRead: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function AdminNotificationsPanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
notifications,
|
||||
onNotificationRead
|
||||
}: AdminNotificationsPanelProps) {
|
||||
const [markingAsRead, setMarkingAsRead] = useState<string | null>(null)
|
||||
|
||||
const handleMarkAsRead = async (id: string) => {
|
||||
try {
|
||||
setMarkingAsRead(id)
|
||||
await onNotificationRead(id)
|
||||
} catch (error) {
|
||||
console.error('Error marking notification as read:', error)
|
||||
} finally {
|
||||
setMarkingAsRead(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'new_feature':
|
||||
return <Clock className="h-5 w-5 text-blue-600" />
|
||||
case 'feature_reviewed':
|
||||
return <CheckCircle className="h-5 w-5 text-green-600" />
|
||||
default:
|
||||
return <Bell className="h-5 w-5 text-gray-600" />
|
||||
}
|
||||
}
|
||||
|
||||
const getNotificationColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'new_feature':
|
||||
return 'border-l-blue-500'
|
||||
case 'feature_reviewed':
|
||||
return 'border-l-green-500'
|
||||
default:
|
||||
return 'border-l-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
const unreadNotifications = notifications.filter(n => !n.is_read)
|
||||
const readNotifications = notifications.filter(n => n.is_read)
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-96 sm:w-[540px] overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center space-x-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
<span>Admin Notifications</span>
|
||||
{unreadNotifications.length > 0 && (
|
||||
<Badge className="bg-red-500 text-white">
|
||||
{unreadNotifications.length}
|
||||
</Badge>
|
||||
)}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
System notifications and feature review updates
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-4 mt-6">
|
||||
{/* Unread Notifications */}
|
||||
{unreadNotifications.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-medium text-sm text-gray-600 mb-2">
|
||||
Unread ({unreadNotifications.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{unreadNotifications.map((notification) => (
|
||||
<Card
|
||||
key={notification.id}
|
||||
className={`border-l-4 ${getNotificationColor(notification.type)}`}
|
||||
>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
{getNotificationIcon(notification.type)}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{notification.message}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{formatDate(notification.created_at)}
|
||||
</p>
|
||||
{notification.reference_type && (
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
{notification.reference_type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleMarkAsRead(notification.id)}
|
||||
disabled={markingAsRead === notification.id}
|
||||
className="ml-2"
|
||||
>
|
||||
{markingAsRead === notification.id ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900"></div>
|
||||
) : (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Read Notifications */}
|
||||
{readNotifications.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-medium text-sm text-gray-600 mb-2">
|
||||
Read ({readNotifications.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{readNotifications.map((notification) => (
|
||||
<Card
|
||||
key={notification.id}
|
||||
className={`border-l-4 ${getNotificationColor(notification.type)} opacity-75`}
|
||||
>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
{getNotificationIcon(notification.type)}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm">{notification.message}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{formatDate(notification.created_at)}
|
||||
{notification.read_at && (
|
||||
<span className="ml-2">
|
||||
• Read {formatDate(notification.read_at)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{notification.reference_type && (
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
{notification.reference_type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{notifications.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<Bell className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500">No notifications</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
You're all caught up! New notifications will appear here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
{unreadNotifications.length} unread, {readNotifications.length} read
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// TODO: Implement mark all as read
|
||||
console.log('Mark all as read')
|
||||
}}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Mark All Read
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
311
src/components/admin/feature-review-dialog.tsx
Normal file
311
src/components/admin/feature-review-dialog.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Copy,
|
||||
AlertTriangle,
|
||||
Search,
|
||||
ExternalLink
|
||||
} from 'lucide-react'
|
||||
import { AdminFeature, FeatureSimilarity, FeatureReviewData } from '@/types/admin.types'
|
||||
import { adminApi, formatDate, getStatusColor, getComplexityColor } from '@/lib/api/admin'
|
||||
|
||||
interface FeatureReviewDialogProps {
|
||||
feature: AdminFeature
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onReview: (featureId: string, reviewData: FeatureReviewData) => Promise<void>
|
||||
}
|
||||
|
||||
export function FeatureReviewDialog({
|
||||
feature,
|
||||
open,
|
||||
onOpenChange,
|
||||
onReview
|
||||
}: FeatureReviewDialogProps) {
|
||||
const [status, setStatus] = useState<'approved' | 'rejected' | 'duplicate'>('approved')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [canonicalFeatureId, setCanonicalFeatureId] = useState('')
|
||||
const [similarFeatures, setSimilarFeatures] = useState<FeatureSimilarity[]>([])
|
||||
const [loadingSimilar, setLoadingSimilar] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Load similar features when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && feature.name) {
|
||||
loadSimilarFeatures(feature.name)
|
||||
}
|
||||
}, [open, feature.name])
|
||||
|
||||
const loadSimilarFeatures = async (query: string) => {
|
||||
try {
|
||||
setLoadingSimilar(true)
|
||||
const features = await adminApi.findSimilarFeatures(query, 0.7, 5)
|
||||
setSimilarFeatures(features)
|
||||
} catch (error) {
|
||||
console.error('Error loading similar features:', error)
|
||||
} finally {
|
||||
setLoadingSimilar(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!status) return
|
||||
|
||||
const reviewData: FeatureReviewData = {
|
||||
status,
|
||||
notes: notes.trim() || undefined,
|
||||
canonical_feature_id: status === 'duplicate' ? canonicalFeatureId : undefined,
|
||||
admin_reviewed_by: 'admin' // TODO: Get from auth context
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
await onReview(feature.id, reviewData)
|
||||
} catch (error) {
|
||||
console.error('Error reviewing feature:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = (newStatus: string) => {
|
||||
setStatus(newStatus as 'approved' | 'rejected' | 'duplicate')
|
||||
if (newStatus !== 'duplicate') {
|
||||
setCanonicalFeatureId('')
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSimilarFeatures = similarFeatures.filter(f =>
|
||||
f.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review Feature: {feature.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review and approve, reject, or mark as duplicate this custom feature submission.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Feature Details */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="text-lg font-semibold">{feature.name}</h3>
|
||||
<Badge className={getStatusColor(feature.status)}>
|
||||
{feature.status}
|
||||
</Badge>
|
||||
<Badge className={getComplexityColor(feature.complexity)}>
|
||||
{feature.complexity}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{feature.description && (
|
||||
<p className="text-gray-600">{feature.description}</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Template:</span> {feature.template_title || 'Unknown'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Submitted:</span> {formatDate(feature.created_at)}
|
||||
</div>
|
||||
{feature.similarity_score && (
|
||||
<div>
|
||||
<span className="font-medium">Similarity Score:</span> {(feature.similarity_score * 100).toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">Usage Count:</span> {feature.usage_count}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feature.business_rules && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Business Rules:</h4>
|
||||
<pre className="text-sm bg-gray-50 p-2 rounded">
|
||||
{JSON.stringify(feature.business_rules, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feature.technical_requirements && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Technical Requirements:</h4>
|
||||
<pre className="text-sm bg-gray-50 p-2 rounded">
|
||||
{JSON.stringify(feature.technical_requirements, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Similar Features */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-medium">Similar Features</h4>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search similar features..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 border rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingSimilar ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900 mx-auto"></div>
|
||||
<p className="text-sm text-gray-500 mt-2">Loading similar features...</p>
|
||||
</div>
|
||||
) : filteredSimilarFeatures.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{filteredSimilarFeatures.map((similar) => (
|
||||
<div
|
||||
key={similar.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium">{similar.name}</span>
|
||||
<Badge className={getComplexityColor(similar.complexity)}>
|
||||
{similar.complexity}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{similar.match_type} ({(similar.score * 100).toFixed(1)}%)
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{similar.feature_type}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCanonicalFeatureId(similar.id)}
|
||||
disabled={status !== 'duplicate'}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
Select as Duplicate
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
<AlertTriangle className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
||||
<p>No similar features found</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Review Form */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="status">Review Decision</Label>
|
||||
<Select value={status} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select review decision" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="approved">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<span>Approve</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="rejected">
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
<span>Reject</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="duplicate">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Copy className="h-4 w-4 text-orange-600" />
|
||||
<span>Mark as Duplicate</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{status === 'duplicate' && (
|
||||
<div>
|
||||
<Label htmlFor="canonical">Canonical Feature ID</Label>
|
||||
<input
|
||||
id="canonical"
|
||||
type="text"
|
||||
value={canonicalFeatureId}
|
||||
onChange={(e) => setCanonicalFeatureId(e.target.value)}
|
||||
placeholder="Enter the ID of the canonical feature"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Select a similar feature above or enter the canonical feature ID manually
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="notes">Admin Notes (Optional)</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add notes about your decision..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || (status === 'duplicate' && !canonicalFeatureId)}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
`Submit ${status.charAt(0).toUpperCase() + status.slice(1)}`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
215
src/components/ai-side-panel.tsx
Normal file
215
src/components/ai-side-panel.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type Block = {
|
||||
id: string
|
||||
label: string
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export type WireframePlan = {
|
||||
version: number
|
||||
blocks: Block[]
|
||||
}
|
||||
|
||||
export function AISidePanel({
|
||||
onGenerate,
|
||||
onClear,
|
||||
className,
|
||||
}: {
|
||||
onGenerate: (plan: WireframePlan) => void
|
||||
onClear: () => void
|
||||
className?: string
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [prompt, setPrompt] = useState(
|
||||
"Dashboard with header, left sidebar, 3 stats cards, a line chart and a data table, plus footer.",
|
||||
)
|
||||
const [version, setVersion] = useState(1)
|
||||
|
||||
const examples = useMemo(
|
||||
() => [
|
||||
"Landing page with header, hero, 3x2 feature grid, and footer.",
|
||||
"Settings screen: header, list of toggles, and save button.",
|
||||
"E‑commerce product page: header, 2-column gallery/details, reviews, sticky add-to-cart.",
|
||||
"Dashboard: header, left sidebar, 3 stats cards, line chart, data table, footer.",
|
||||
"Signup page: header, 2-column form, callout, submit button.",
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
function parsePromptToPlan(input: string): WireframePlan {
|
||||
const W = 1200
|
||||
const H = 800
|
||||
const P = 24
|
||||
const blocks: Block[] = []
|
||||
let y = P
|
||||
let x = P
|
||||
let width = W - 2 * P
|
||||
|
||||
const lower = input.toLowerCase()
|
||||
const addBlock = (label: string, bx: number, by: number, bw: number, bh: number) => {
|
||||
blocks.push({
|
||||
id: `${label}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
label,
|
||||
x: Math.round(bx),
|
||||
y: Math.round(by),
|
||||
w: Math.round(bw),
|
||||
h: Math.round(bh),
|
||||
})
|
||||
}
|
||||
|
||||
if (/\bheader\b/.test(lower) || /\bnavbar\b/.test(lower)) {
|
||||
addBlock("Header", x, y, width, 72)
|
||||
y += 72 + P
|
||||
}
|
||||
|
||||
let hasSidebar = false
|
||||
if (/\bsidebar\b/.test(lower) || /\bleft sidebar\b/.test(lower)) {
|
||||
hasSidebar = true
|
||||
addBlock("Sidebar", x, y, 260, H - y - P)
|
||||
x += 260 + P
|
||||
width = W - x - P
|
||||
}
|
||||
|
||||
if (/\bhero\b/.test(lower)) {
|
||||
addBlock("Hero", x, y, width, 200)
|
||||
y += 200 + P
|
||||
}
|
||||
|
||||
if (/stats?\b/.test(lower) || /\bcards?\b/.test(lower)) {
|
||||
const cols = /4/.test(lower) ? 4 : 3
|
||||
const gap = P
|
||||
const cardW = (width - gap * (cols - 1)) / cols
|
||||
const cardH = 100
|
||||
for (let i = 0; i < cols; i++) {
|
||||
addBlock(`Card ${i + 1}`, x + i * (cardW + gap), y, cardW, cardH)
|
||||
}
|
||||
y += cardH + P
|
||||
}
|
||||
|
||||
const gridMatch = lower.match(/(\d)\s*x\s*(\d)/)
|
||||
if (gridMatch) {
|
||||
const cols = Number.parseInt(gridMatch[1], 10)
|
||||
const rows = Number.parseInt(gridMatch[2], 10)
|
||||
const gap = P
|
||||
const cellW = (width - gap * (cols - 1)) / cols
|
||||
const cellH = 120
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
addBlock(`Feature ${r * cols + c + 1}`, x + c * (cellW + gap), y + r * (cellH + gap), cellW, cellH)
|
||||
}
|
||||
}
|
||||
y += rows * cellH + (rows - 1) * gap + P
|
||||
}
|
||||
|
||||
if (/\b2[-\s]?column\b/.test(lower) || /gallery\/details/.test(lower) || /gallery/.test(lower)) {
|
||||
const gap = P
|
||||
const colW = (width - gap) / 2
|
||||
const colH = 260
|
||||
addBlock("Left Column", x, y, colW, colH)
|
||||
addBlock("Right Column", x + colW + gap, y, colW, colH)
|
||||
y += colH + P
|
||||
}
|
||||
|
||||
if (/\bchart\b/.test(lower) || /\bline chart\b/.test(lower)) {
|
||||
addBlock("Chart", x, y, width, 220)
|
||||
y += 220 + P
|
||||
}
|
||||
if (/\btable\b/.test(lower)) {
|
||||
addBlock("Data Table", x, y, width, 260)
|
||||
y += 260 + P
|
||||
}
|
||||
|
||||
if (/\bform\b/.test(lower) || /\bsignup\b/.test(lower) || /\blogin\b/.test(lower)) {
|
||||
const gap = P
|
||||
const twoCol = /\b2[-\s]?column\b/.test(lower)
|
||||
if (twoCol) {
|
||||
const colW = (width - gap) / 2
|
||||
addBlock("Form Left", x, y, colW, 260)
|
||||
addBlock("Form Right", x + colW + gap, y, colW, 260)
|
||||
y += 260 + P
|
||||
} else {
|
||||
addBlock("Form", x, y, width, 220)
|
||||
y += 220 + P
|
||||
}
|
||||
}
|
||||
|
||||
if (/\bfooter\b/.test(lower)) {
|
||||
addBlock("Footer", P, H - 80 - P, W - 2 * P, 80)
|
||||
}
|
||||
|
||||
return { version: Date.now() + version, blocks }
|
||||
}
|
||||
|
||||
const handleGenerate = () => {
|
||||
const plan = parsePromptToPlan(prompt)
|
||||
setVersion((v) => v + 1)
|
||||
onGenerate(plan)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"h-full border-l bg-white dark:bg-neutral-900 flex flex-col",
|
||||
collapsed ? "w-12" : "w-96",
|
||||
className,
|
||||
)}
|
||||
aria-label="AI prompt side panel"
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<h2 className={cn("text-sm font-medium text-balance", collapsed && "sr-only")}>AI Wireframe</h2>
|
||||
<Button variant="ghost" size="icon" onClick={() => setCollapsed((c) => !c)} aria-label="Toggle panel">
|
||||
{collapsed ? <span aria-hidden>›</span> : <span aria-hidden>‹</span>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<label className="text-xs font-medium">Prompt</label>
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Describe your screen: Landing with header, hero, 3x2 features, footer"
|
||||
className="min-h-28"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleGenerate} className="flex-1">
|
||||
Generate
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onClear}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-1">
|
||||
<p className="text-xs font-medium mb-2">Examples</p>
|
||||
<ScrollArea className="h-40 border rounded">
|
||||
<ul className="p-2 space-y-2">
|
||||
{examples.map((ex) => (
|
||||
<li key={ex}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPrompt(ex)}
|
||||
className="text-left text-xs w-full hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded px-2 py-1"
|
||||
>
|
||||
{ex}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
243
src/components/ai/AICustomFeatureCreator.tsx
Normal file
243
src/components/ai/AICustomFeatureCreator.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { analyzeFeatureWithAI } from '@/services/aiAnalysis'
|
||||
|
||||
type Complexity = 'low' | 'medium' | 'high'
|
||||
|
||||
export interface AIAnalysisResult {
|
||||
suggested_name?: string
|
||||
complexity?: Complexity
|
||||
implementation_details?: string[]
|
||||
technical_requirements?: string[]
|
||||
estimated_effort?: string
|
||||
dependencies?: string[]
|
||||
api_endpoints?: string[]
|
||||
database_tables?: string[]
|
||||
confidence_score?: number
|
||||
}
|
||||
|
||||
export function AICustomFeatureCreator({
|
||||
projectType,
|
||||
onAdd,
|
||||
onClose,
|
||||
}: {
|
||||
projectType?: string
|
||||
onAdd: (feature: { name: string; description: string; complexity: Complexity }) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [featureName, setFeatureName] = useState('')
|
||||
const [featureDescription, setFeatureDescription] = useState('')
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
||||
const [aiAnalysis, setAiAnalysis] = useState<AIAnalysisResult | null>(null)
|
||||
const [analysisError, setAnalysisError] = useState<string | null>(null)
|
||||
const [requirements, setRequirements] = useState<Array<{ text: string; rules: string[] }>>([
|
||||
{ text: '', rules: [] },
|
||||
])
|
||||
const [logicRules, setLogicRules] = useState<string[]>([])
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!featureDescription.trim() && requirements.every(r => !r.text.trim())) return
|
||||
setIsAnalyzing(true)
|
||||
setAnalysisError(null)
|
||||
try {
|
||||
// Aggregate requirements texts for richer context
|
||||
const reqTexts = requirements.map(r => r.text).filter(t => t && t.trim())
|
||||
const overall = await analyzeFeatureWithAI(
|
||||
featureName,
|
||||
featureDescription,
|
||||
reqTexts,
|
||||
projectType
|
||||
)
|
||||
|
||||
setAiAnalysis({
|
||||
suggested_name: featureName,
|
||||
complexity: overall.complexity,
|
||||
implementation_details: [],
|
||||
technical_requirements: [],
|
||||
estimated_effort: 'Medium',
|
||||
dependencies: [],
|
||||
api_endpoints: [],
|
||||
database_tables: [],
|
||||
confidence_score: 0.9,
|
||||
})
|
||||
|
||||
// Capture dynamic logic rules (editable)
|
||||
setLogicRules(Array.isArray(overall.logicRules) ? overall.logicRules : [])
|
||||
} catch (e: any) {
|
||||
setAnalysisError(e?.message || 'AI analysis failed')
|
||||
} finally {
|
||||
setIsAnalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!aiAnalysis) {
|
||||
await handleAnalyze()
|
||||
return
|
||||
}
|
||||
onAdd({
|
||||
name: aiAnalysis.suggested_name || featureName.trim() || 'Custom Feature',
|
||||
description: featureDescription.trim(),
|
||||
complexity: aiAnalysis.complexity || 'medium',
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl max-w-4xl w-full max-h-[90vh] backdrop-blur flex flex-col">
|
||||
<div className="p-6 border-b border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-white text-lg font-semibold">AI-Powered Feature Creator</h3>
|
||||
<button onClick={onClose} className="text-white/60 hover:text-white">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">Feature Name (optional)</label>
|
||||
<Input value={featureName} onChange={(e) => setFeatureName(e.target.value)} placeholder="e.g., Subscriptions" className="bg-white/10 border-white/20 text-white placeholder:text-white/40" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">Describe Your Feature Requirements</label>
|
||||
<textarea value={featureDescription} onChange={(e) => setFeatureDescription(e.target.value)} rows={4} className="w-full bg-white/10 border border-white/20 text-white rounded-md p-3 placeholder:text-white/40" placeholder="Describe what this feature should do..." required />
|
||||
<p className="text-xs text-white/50 mt-1">Be as detailed as possible. The AI will analyze and break down your requirements.</p>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Requirements List */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-white font-medium">Detailed Requirements (Add one by one)</div>
|
||||
{requirements.map((r, idx) => (
|
||||
<div key={idx} className="rounded-lg border border-white/10 bg-white/5 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-white/60 text-sm w-6">{idx + 1}</div>
|
||||
<Input
|
||||
placeholder="Requirement..."
|
||||
value={r.text}
|
||||
onChange={(e) => {
|
||||
const next = [...requirements]
|
||||
next[idx] = { ...r, text: e.target.value }
|
||||
setRequirements(next)
|
||||
}}
|
||||
className="bg-white/10 border-white/20 text-white placeholder:text-white/40"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRequirements(requirements.filter((_, i) => i !== idx))}
|
||||
className="text-white/50 hover:text-red-400"
|
||||
aria-label="Remove requirement"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{r.rules?.length > 0 && (
|
||||
<div className="pl-8 space-y-2">
|
||||
<div className="text-white/70 text-sm">Logic Rules for this requirement:</div>
|
||||
<div className="space-y-1">
|
||||
{r.rules.map((rule, ridx) => (
|
||||
<div key={ridx} className="flex items-center gap-2">
|
||||
<div className="text-white/50 text-xs w-8">R{ridx + 1}</div>
|
||||
<Input value={rule} onChange={(e) => {
|
||||
const next = [...requirements]
|
||||
const rr = [...(next[idx].rules || [])]
|
||||
rr[ridx] = e.target.value
|
||||
next[idx] = { ...next[idx], rules: rr }
|
||||
setRequirements(next)
|
||||
}} className="bg-white/10 border-white/20 text-white placeholder:text-white/40" />
|
||||
<button type="button" onClick={() => {
|
||||
const next = [...requirements]
|
||||
const rr = [...(next[idx].rules || [])]
|
||||
rr.splice(ridx, 1)
|
||||
next[idx] = { ...next[idx], rules: rr }
|
||||
setRequirements(next)
|
||||
}} className="text-white/50 hover:text-red-400">×</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => {
|
||||
const next = [...requirements]
|
||||
const rr = [...(next[idx].rules || [])]
|
||||
rr.push('')
|
||||
next[idx] = { ...next[idx], rules: rr }
|
||||
setRequirements(next)
|
||||
}} className="text-xs text-orange-400 hover:text-orange-300">+ Add rule to this requirement</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" onClick={() => setRequirements([...requirements, { text: '', rules: [] }])} className="border-white/20 text-white hover:bg-white/10">
|
||||
+ Add another requirement
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!aiAnalysis && (featureDescription.trim() || requirements.some(r => r.text.trim())) && (
|
||||
<Button type="button" onClick={handleAnalyze} disabled={isAnalyzing} className="w-full bg-orange-500 hover:bg-orange-400 text-black">
|
||||
{isAnalyzing ? 'Analyzing with AI…' : 'Analyze with AI'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{analysisError && (
|
||||
<Card className="p-3 bg-red-500/10 border-red-500/30 text-red-300">{analysisError}</Card>
|
||||
)}
|
||||
|
||||
{aiAnalysis && (
|
||||
<div className="space-y-2 p-3 bg-emerald-500/10 border border-emerald-500/30 rounded-lg text-white/90">
|
||||
<div className="font-medium">AI Analysis Complete {(aiAnalysis.confidence_score ? `(${Math.round((aiAnalysis.confidence_score || 0) * 100)}% confidence)` : '')}</div>
|
||||
<div className="text-sm">Suggested Name: <span className="text-white">{aiAnalysis.suggested_name || featureName || 'Custom Feature'}</span></div>
|
||||
<div className="text-sm">Complexity: <span className="capitalize text-white">{aiAnalysis.complexity}</span></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aiAnalysis && (
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-2">Logic Rules (AI-generated, editable)</label>
|
||||
<div className="space-y-2">
|
||||
{logicRules.map((rule, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<div className="text-white/60 text-xs w-8">R{idx + 1}</div>
|
||||
<Input
|
||||
value={rule}
|
||||
onChange={(e) => {
|
||||
const next = [...logicRules]
|
||||
next[idx] = e.target.value
|
||||
setLogicRules(next)
|
||||
}}
|
||||
className="bg-white/10 border-white/20 text-white placeholder:text-white/40"
|
||||
/>
|
||||
<button type="button" onClick={() => setLogicRules(logicRules.filter((_, i) => i !== idx))} className="text-white/50 hover:text-red-400">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => setLogicRules([...logicRules, ''])} className="mt-2 border-white/20 text-white hover:bg-white/10">
|
||||
+ Add logic rule
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
<div className="p-6 border-t border-white/10">
|
||||
<div className="flex gap-3 flex-wrap items-center">
|
||||
{aiAnalysis && (
|
||||
<div className="flex-1 text-white/80 text-sm">
|
||||
Overall Complexity: <span className="capitalize">{aiAnalysis.complexity}</span>
|
||||
</div>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onClose} className="border-white/20 text-white hover:bg-white/10">Cancel</Button>
|
||||
<Button type="submit" disabled={(!featureDescription.trim() && requirements.every(r => !r.text.trim())) || isAnalyzing} className="bg-orange-500 hover:bg-orange-400 text-black">
|
||||
{aiAnalysis ? 'Add Feature with Tagged Rules' : 'Analyze & Add Feature'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AICustomFeatureCreator
|
||||
|
||||
|
||||
95
src/components/apis/authApiClients.tsx
Normal file
95
src/components/apis/authApiClients.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import axios from "axios";
|
||||
import { safeLocalStorage, safeRedirect } from "@/lib/utils";
|
||||
|
||||
const API_BASE_URL = "http://localhost:8011";
|
||||
|
||||
let accessToken = safeLocalStorage.getItem('accessToken');
|
||||
let refreshToken = safeLocalStorage.getItem('refreshToken');
|
||||
|
||||
export const setTokens = (newAccessToken: string, newRefreshToken: string) => {
|
||||
accessToken = newAccessToken;
|
||||
refreshToken = newRefreshToken;
|
||||
safeLocalStorage.setItem('accessToken', newAccessToken);
|
||||
safeLocalStorage.setItem('refreshToken', newRefreshToken);
|
||||
};
|
||||
|
||||
export const clearTokens = () => {
|
||||
accessToken = null;
|
||||
refreshToken = null;
|
||||
safeLocalStorage.removeItem('accessToken');
|
||||
safeLocalStorage.removeItem('refreshToken');
|
||||
};
|
||||
|
||||
export const getAccessToken = () => accessToken;
|
||||
export const getRefreshToken = () => refreshToken;
|
||||
|
||||
// Logout function that calls the backend API
|
||||
export const logout = async () => {
|
||||
try {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (refreshToken) {
|
||||
await authApiClient.post('/api/auth/logout', { refreshToken });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout API call failed:', error);
|
||||
// Continue with logout even if API call fails
|
||||
} finally {
|
||||
// Always clear tokens
|
||||
clearTokens();
|
||||
// Clear any other user data
|
||||
safeLocalStorage.removeItem('codenuk_user');
|
||||
}
|
||||
};
|
||||
|
||||
export const authApiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Add auth token to requests
|
||||
const addAuthTokenInterceptor = (client: typeof authApiClient) => {
|
||||
client.interceptors.request.use(
|
||||
(config) => {
|
||||
if (accessToken) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
};
|
||||
|
||||
// Handle token refresh
|
||||
const addTokenRefreshInterceptor = (client: typeof authApiClient) => {
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
if (refreshToken) {
|
||||
const response = await client.post('/api/auth/refresh', {
|
||||
refreshToken: refreshToken
|
||||
});
|
||||
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = response.data.data.tokens;
|
||||
setTokens(newAccessToken, newRefreshToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
return client(originalRequest);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh failed:', refreshError);
|
||||
clearTokens();
|
||||
safeLocalStorage.removeItem('codenuk_user');
|
||||
window.location.href = '/signin';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
addAuthTokenInterceptor(authApiClient);
|
||||
addTokenRefreshInterceptor(authApiClient);
|
||||
75
src/components/apis/authenticationHandler.tsx
Normal file
75
src/components/apis/authenticationHandler.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { authApiClient } from "./authApiClients";
|
||||
import { safeLocalStorage } from "@/lib/utils";
|
||||
|
||||
interface ApiError extends Error {
|
||||
response?: any;
|
||||
}
|
||||
|
||||
export const registerUser = async (
|
||||
data: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role: string;
|
||||
}
|
||||
) => {
|
||||
console.log("Registering user with data:", data);
|
||||
try {
|
||||
const response = await authApiClient.post(
|
||||
"/api/auth/register",
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("Error registering user:", error.response?.data || error.message);
|
||||
|
||||
// Create a proper error object that preserves the original response
|
||||
const enhancedError: ApiError = new Error(
|
||||
error.response?.data?.message || error.response?.data?.error || "Failed to register user. Please try again."
|
||||
);
|
||||
enhancedError.response = error.response;
|
||||
throw enhancedError;
|
||||
}
|
||||
};
|
||||
|
||||
export const loginUser = async (email: string, password: string) => {
|
||||
console.log("Logging in user with email:", email);
|
||||
try {
|
||||
const response = await authApiClient.post(
|
||||
"/api/auth/login",
|
||||
{ email, password }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("Error logging in user:", error.response?.data || error.message);
|
||||
|
||||
// Create a proper error object that preserves the original response
|
||||
const enhancedError: ApiError = new Error(
|
||||
error.response?.data?.message || error.response?.data?.error || "Failed to log in. Please check your credentials."
|
||||
);
|
||||
enhancedError.response = error.response;
|
||||
throw enhancedError;
|
||||
}
|
||||
}
|
||||
|
||||
export const logoutUser = async () => {
|
||||
console.log("Logging out user");
|
||||
try {
|
||||
const refreshToken = safeLocalStorage.getItem('refreshToken');
|
||||
if (refreshToken) {
|
||||
await authApiClient.post("/api/auth/logout", { refreshToken });
|
||||
}
|
||||
return { success: true, message: "Logged out successfully" };
|
||||
} catch (error: any) {
|
||||
console.error("Error logging out user:", error.response?.data || error.message);
|
||||
|
||||
// Create a proper error object that preserves the original response
|
||||
const enhancedError: ApiError = new Error(
|
||||
error.response?.data?.message || error.response?.data?.error || "Failed to log out. Please try again."
|
||||
);
|
||||
enhancedError.response = error.response;
|
||||
throw enhancedError;
|
||||
}
|
||||
}
|
||||
6
src/components/auth/emailVerification.tsx
Normal file
6
src/components/auth/emailVerification.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import EmailVerification from "@/app/auth/emailVerification";
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return <EmailVerification />;
|
||||
}
|
||||
|
||||
@ -7,14 +7,12 @@ import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
||||
import { Eye, EyeOff, Loader2, Shield } from "lucide-react"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { loginUser } from "@/components/apis/authenticationHandler"
|
||||
import { setTokens } from "@/components/apis/authApiClients"
|
||||
|
||||
interface SignInFormProps {
|
||||
onToggleMode: () => void
|
||||
}
|
||||
|
||||
export function SignInForm({ onToggleMode }: SignInFormProps) {
|
||||
export function SignInForm() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
@ -23,23 +21,51 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
|
||||
password: "",
|
||||
})
|
||||
|
||||
const { login } = useAuth()
|
||||
const router = useRouter()
|
||||
const { setUserFromApi } = useAuth()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
setIsLoading(true)
|
||||
|
||||
if (!formData.email || !formData.password) {
|
||||
setError("Please fill in all required fields.")
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const success = await login(formData.email, formData.password)
|
||||
if (success) {
|
||||
router.push("/")
|
||||
const response = await loginUser(formData.email, formData.password)
|
||||
|
||||
if (response && response.data && response.data.tokens && response.data.user) {
|
||||
setTokens(response.data.tokens.accessToken, response.data.tokens.refreshToken)
|
||||
// Set user in context so header updates immediately
|
||||
setUserFromApi(response.data.user)
|
||||
// Persist for refresh
|
||||
localStorage.setItem("codenuk_user", JSON.stringify(response.data.user))
|
||||
|
||||
// Redirect based on user role
|
||||
if (response.data.user.role === 'admin') {
|
||||
router.push("/admin")
|
||||
} else {
|
||||
router.push("/")
|
||||
}
|
||||
} else {
|
||||
setError("Invalid email or password")
|
||||
setError("Invalid response from server. Please try again.")
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Login error:', err)
|
||||
|
||||
// Handle different types of errors
|
||||
if (err.response?.data?.message) {
|
||||
setError(err.response.data.message)
|
||||
} else if (err.response?.data?.error) {
|
||||
setError(err.response.data.error)
|
||||
} else if (err.message) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError("An error occurred during login. Please try again.")
|
||||
}
|
||||
} catch (err) {
|
||||
setError("An error occurred. Please try again.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@ -47,7 +73,7 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-white text-sm">Email</Label>
|
||||
<Input
|
||||
@ -57,7 +83,7 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
required
|
||||
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30"
|
||||
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -70,13 +96,13 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required
|
||||
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30 pr-12"
|
||||
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 pr-12"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 text-white/60 hover:text-white hover:bg-white/5"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 text-white/60 hover:text-white hover:bg-white/5 transition-colors"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
@ -86,11 +112,18 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
|
||||
|
||||
{error && (
|
||||
<div className="text-red-300 text-sm text-center bg-red-900/20 p-3 rounded-md border border-red-500/30">
|
||||
{error}
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full h-11 cursor-pointer bg-white text-black hover:bg-white/90" disabled={isLoading}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 cursor-pointer bg-gradient-to-r from-orange-500 to-orange-600 text-white hover:from-orange-600 hover:to-orange-700 transition-all duration-200 font-semibold shadow-lg hover:shadow-orange-500/25"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@ -100,11 +133,6 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
|
||||
"Sign In"
|
||||
)}
|
||||
</Button>
|
||||
<div className="text-center">
|
||||
<Button type="button" variant="link" onClick={onToggleMode} className="text-sm cursor-pointer text-white/70 hover:text-white">
|
||||
Don't have an account? Sign up
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
||||
124
src/components/auth/signin-page.tsx
Normal file
124
src/components/auth/signin-page.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { SignInForm } from "./signin-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CheckCircle, AlertCircle } from "lucide-react"
|
||||
|
||||
export function SignInPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [verificationMessage, setVerificationMessage] = useState<string | null>(null)
|
||||
const [verificationType, setVerificationType] = useState<'success' | 'error'>('success')
|
||||
|
||||
useEffect(() => {
|
||||
// Check for verification messages in URL
|
||||
const verified = searchParams.get('verified')
|
||||
const message = searchParams.get('message')
|
||||
const error = searchParams.get('error')
|
||||
|
||||
if (verified === 'true') {
|
||||
setVerificationMessage('Email verified successfully! You can now sign in to your account.')
|
||||
setVerificationType('success')
|
||||
} else if (message) {
|
||||
setVerificationMessage(decodeURIComponent(message))
|
||||
setVerificationType('success')
|
||||
} else if (error) {
|
||||
setVerificationMessage(decodeURIComponent(error))
|
||||
setVerificationType('error')
|
||||
}
|
||||
|
||||
// Clear the message after 5 seconds
|
||||
if (verified || message || error) {
|
||||
const timer = setTimeout(() => {
|
||||
setVerificationMessage(null)
|
||||
}, 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-2 gap-10">
|
||||
{/* Left: Gradient panel with steps */}
|
||||
<div className="hidden lg:block">
|
||||
<div className="relative h-full rounded-3xl overflow-hidden border border-white/10 bg-gradient-to-b from-orange-400 via-orange-500 to-black p-10">
|
||||
{/* subtle grain */}
|
||||
<div className="pointer-events-none absolute inset-0 opacity-20 [mask-image:radial-gradient(ellipse_at_center,black_70%,transparent_100%)]">
|
||||
<div className="w-full h-full bg-[radial-gradient(circle_at_1px_1px,rgba(255,255,255,0.12)_1px,transparent_0)] bg-[length:18px_18px]"></div>
|
||||
</div>
|
||||
|
||||
{/* soft circular accents */}
|
||||
<div className="pointer-events-none absolute -top-10 -right-10 w-72 h-72 rounded-full blur-3xl bg-purple-300/40"></div>
|
||||
<div className="pointer-events-none absolute top-1/3 -left-10 w-56 h-56 rounded-full blur-2xl bg-indigo-300/25"></div>
|
||||
<div className="pointer-events-none absolute -bottom-12 left-1/3 w-64 h-64 rounded-full blur-3xl bg-purple-400/20"></div>
|
||||
|
||||
<div className="relative z-10 flex h-full flex-col justify-center">
|
||||
<div className="mb-12">
|
||||
<p className="text-5xl font-bold text-center text-white/80">Codenuk</p>
|
||||
</div>
|
||||
<h2 className="text-3xl text-center font-bold mb-3">Welcome Back!</h2>
|
||||
<p className="text-white/80 text-center mb-10">Sign in to access your workspace and continue building.</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Step 1 - Completed */}
|
||||
<div className="bg-white/10 text-white border border-white/20 rounded-xl p-4 flex items-center">
|
||||
<div className="w-9 h-9 rounded-full bg-white/20 text-white flex items-center justify-center mr-4 text-sm font-semibold">1</div>
|
||||
<span className="font-medium">Sign up your account</span>
|
||||
</div>
|
||||
{/* Step 2 - Active */}
|
||||
<div className="bg-white text-black rounded-xl p-4 flex items-center">
|
||||
<div className="w-9 h-9 rounded-full bg-black text-white flex items-center justify-center mr-4 text-sm font-semibold">2</div>
|
||||
<span className="font-medium">Sign in to workspace</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Form area */}
|
||||
<div className="flex items-center">
|
||||
<div className="w-full max-w-md ml-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold text-white mb-1">Sign In Account</h1>
|
||||
<p className="text-white/60">Enter your credentials to access your account.</p>
|
||||
</div>
|
||||
|
||||
{/* Verification Message */}
|
||||
{verificationMessage && (
|
||||
<div className={`mb-6 p-4 rounded-lg border ${
|
||||
verificationType === 'success'
|
||||
? 'bg-green-500/10 border-green-500/30 text-green-300'
|
||||
: 'bg-red-500/10 border-red-500/30 text-red-300'
|
||||
}`}>
|
||||
<div className="flex items-center space-x-2">
|
||||
{verificationType === 'success' ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-sm">{verificationMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SignInForm />
|
||||
|
||||
<div className="text-center pt-4">
|
||||
<div className="text-white/60 text-xs mb-1">Don't have an account?</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => router.push("/signup")}
|
||||
className="text-orange-400 hover:text-orange-300 font-medium transition-colors text-sm p-0 h-auto cursor-pointer"
|
||||
>
|
||||
Create a new account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -8,165 +8,257 @@ import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
||||
import { Eye, EyeOff, Loader2, User, Mail, Lock, Shield } from "lucide-react"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { registerUser } from "../apis/authenticationHandler"
|
||||
|
||||
interface SignUpFormProps {
|
||||
onToggleMode: () => void
|
||||
onSignUpSuccess?: () => void
|
||||
}
|
||||
|
||||
export function SignUpForm({ onToggleMode }: SignUpFormProps) {
|
||||
export function SignUpForm({ onSignUpSuccess }: SignUpFormProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
username: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
role: "user" // default role, adjust as needed
|
||||
})
|
||||
|
||||
const { signup } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
|
||||
// Validation
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError("Passwords don't match")
|
||||
return
|
||||
}
|
||||
if (!formData.username || !formData.first_name || !formData.last_name || !formData.email || !formData.password) {
|
||||
setError("Please fill in all required fields.")
|
||||
return
|
||||
}
|
||||
|
||||
// Password strength validation
|
||||
if (formData.password.length < 8) {
|
||||
setError("Password must be at least 8 characters long")
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const success = await signup(formData.name, formData.email, formData.password)
|
||||
if (success) {
|
||||
router.push("/")
|
||||
const response = await registerUser({
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
role: formData.role
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
// Call success callback if provided
|
||||
if (onSignUpSuccess) {
|
||||
onSignUpSuccess()
|
||||
} else {
|
||||
// Default behavior - redirect to signin with message
|
||||
router.push("/signin?message=Account created successfully! Please check your email to verify your account.")
|
||||
}
|
||||
} else {
|
||||
setError("Failed to create account. Please try again.")
|
||||
setError(response.message || "Failed to create account. Please try again.")
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Signup error:', err)
|
||||
|
||||
// Handle different types of errors
|
||||
if (err.response?.data?.message) {
|
||||
setError(err.response.data.message)
|
||||
} else if (err.message) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError("An error occurred during registration. Please try again.")
|
||||
}
|
||||
} catch (err) {
|
||||
setError("An error occurred. Please try again.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md bg-transparent border-0 shadow-none">
|
||||
<CardHeader className="space-y-1 p-0 mb-2">
|
||||
<CardTitle className="sr-only">Sign Up</CardTitle>
|
||||
<CardDescription className="sr-only">Create your account to get started</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-white text-sm">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Enter your full name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30"
|
||||
/>
|
||||
<div className="w-full max-w-md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Personal Information Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<User className="h-4 w-4 text-orange-400" />
|
||||
<h3 className="text-base font-semibold text-white">Personal Information</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-white text-sm">Email</Label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="first_name" className="text-white/80 text-xs font-medium">First Name</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
type="text"
|
||||
placeholder="John"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
||||
required
|
||||
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="last_name" className="text-white/80 text-xs font-medium">Last Name</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
type="text"
|
||||
placeholder="Doe"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
|
||||
required
|
||||
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="username" className="text-white/80 text-xs font-medium">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="johndoe123"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
required
|
||||
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="role" className="text-white/80 text-xs font-medium">Role</Label>
|
||||
<Input
|
||||
id="role"
|
||||
type="text"
|
||||
placeholder="user"
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||
required
|
||||
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Information Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Mail className="h-4 w-4 text-orange-400" />
|
||||
<h3 className="text-base font-semibold text-white">Contact Information</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="email" className="text-white/80 text-xs font-medium">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
placeholder="john.doe@example.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
required
|
||||
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30"
|
||||
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-white text-sm">Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter your password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required
|
||||
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30 pr-12"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 text-white/60 hover:text-white hover:bg-white/5"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword" className="text-white text-sm">Confirm Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Confirm your password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
required
|
||||
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30 pr-12"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 text-white/60 hover:text-white hover:bg-white/5"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Lock className="h-4 w-4 text-orange-400" />
|
||||
<h3 className="text-base font-semibold text-white">Security</h3>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-300 text-sm text-center bg-red-900/20 p-3 rounded-md border border-red-500/30">
|
||||
{error}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="password" className="text-white/80 text-xs font-medium">Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required
|
||||
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-2 py-1 text-white/60 hover:text-white hover:bg-white/5 transition-colors"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="confirmPassword" className="text-white/80 text-xs font-medium">Confirm</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Confirm"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
required
|
||||
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-2 py-1 text-white/60 hover:text-white hover:bg-white/5 transition-colors"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-300 text-xs text-center bg-red-900/20 p-3 rounded-md border border-red-500/30">
|
||||
<div className="flex items-center justify-center space-x-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-10 cursor-pointer bg-gradient-to-r from-orange-500 to-orange-600 text-white hover:from-orange-600 hover:to-orange-700 transition-all duration-200 font-semibold text-sm shadow-lg hover:shadow-orange-500/25"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating Account...
|
||||
</>
|
||||
) : (
|
||||
"Create Account"
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 cursor-pointer bg-white text-black hover:bg-white/90"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating Account...
|
||||
</>
|
||||
) : (
|
||||
"Sign Up"
|
||||
)}
|
||||
</Button>
|
||||
<div className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={onToggleMode}
|
||||
className="text-sm cursor-pointer text-white/70 hover:text-white"
|
||||
>
|
||||
Already have an account? Sign in
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
157
src/components/auth/signup-page.tsx
Normal file
157
src/components/auth/signup-page.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { SignUpForm } from "./signup-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CheckCircle, ArrowRight } from "lucide-react"
|
||||
|
||||
export function SignUpPage() {
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleSignUpSuccess = () => {
|
||||
setIsSuccess(true)
|
||||
// Redirect to signin after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push("/signin?message=Please check your email to verify your account")
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-2 gap-10">
|
||||
{/* Left: Gradient panel */}
|
||||
<div className="hidden lg:block">
|
||||
<div className="relative h-full rounded-3xl overflow-hidden border border-white/10 bg-gradient-to-b from-orange-400 via-orange-500 to-black p-10">
|
||||
{/* subtle grain */}
|
||||
<div className="pointer-events-none absolute inset-0 opacity-20 [mask-image:radial-gradient(ellipse_at_center,black_70%,transparent_100%)]">
|
||||
<div className="w-full h-full bg-[radial-gradient(circle_at_1px_1px,rgba(255,255,255,0.12)_1px,transparent_0)] bg-[length:18px_18px]"></div>
|
||||
</div>
|
||||
|
||||
{/* soft circular accents */}
|
||||
<div className="pointer-events-none absolute -top-10 -right-10 w-72 h-72 rounded-full blur-3xl bg-purple-300/40"></div>
|
||||
<div className="pointer-events-none absolute top-1/3 -left-10 w-56 h-56 rounded-full blur-2xl bg-indigo-300/25"></div>
|
||||
<div className="pointer-events-none absolute -bottom-12 left-1/3 w-64 h-64 rounded-full blur-3xl bg-purple-400/20"></div>
|
||||
|
||||
<div className="relative z-10 flex h-full flex-col justify-center">
|
||||
<div className="mb-12">
|
||||
<p className="text-5xl font-bold text-center text-white/80">Codenuk</p>
|
||||
</div>
|
||||
<h2 className="text-3xl text-center font-bold mb-3">Account Created!</h2>
|
||||
<p className="text-white/80 text-center mb-10">Your account has been successfully created. Please verify your email to continue.</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Step 1 - Completed */}
|
||||
<div className="bg-white text-black rounded-xl p-4 flex items-center">
|
||||
<div className="w-9 h-9 rounded-full bg-green-500 text-white flex items-center justify-center mr-4 text-sm font-semibold">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="font-medium">Sign up completed</span>
|
||||
</div>
|
||||
{/* Step 2 - Next */}
|
||||
<div className="bg-white/10 text-white border border-white/20 rounded-xl p-4 flex items-center">
|
||||
<div className="w-9 h-9 rounded-full bg-white/20 text-white flex items-center justify-center mr-4 text-sm font-semibold">2</div>
|
||||
<span className="font-medium">Verify email & sign in</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Success message */}
|
||||
<div className="flex items-center">
|
||||
<div className="w-full max-w-md ml-auto text-center">
|
||||
<div className="mb-8">
|
||||
<div className="w-20 h-20 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">Account Created Successfully!</h1>
|
||||
<p className="text-white/60 mb-6">We've sent a verification email to your inbox. Please check your email and click the verification link to activate your account.</p>
|
||||
<div className="bg-orange-500/10 border border-orange-500/30 rounded-lg p-4 mb-6">
|
||||
<p className="text-orange-300 text-sm">
|
||||
<strong>Next step:</strong> Check your email and click the verification link, then sign in to your account.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push("/signin")}
|
||||
className="bg-gradient-to-r from-orange-500 to-orange-600 text-white hover:from-orange-600 hover:to-orange-700"
|
||||
>
|
||||
Go to Sign In
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-2 gap-10">
|
||||
{/* Left: Gradient panel with steps */}
|
||||
<div className="hidden lg:block">
|
||||
<div className="relative h-full rounded-3xl overflow-hidden border border-white/10 bg-gradient-to-b from-orange-400 via-orange-500 to-black p-10">
|
||||
{/* subtle grain */}
|
||||
<div className="pointer-events-none absolute inset-0 opacity-20 [mask-image:radial-gradient(ellipse_at_center,black_70%,transparent_100%)]">
|
||||
<div className="w-full h-full bg-[radial-gradient(circle_at_1px_1px,rgba(255,255,255,0.12)_1px,transparent_0)] bg-[length:18px_18px]"></div>
|
||||
</div>
|
||||
|
||||
{/* soft circular accents */}
|
||||
<div className="pointer-events-none absolute -top-10 -right-10 w-72 h-72 rounded-full blur-3xl bg-purple-300/40"></div>
|
||||
<div className="pointer-events-none absolute top-1/3 -left-10 w-56 h-56 rounded-full blur-2xl bg-indigo-300/25"></div>
|
||||
<div className="pointer-events-none absolute -bottom-12 left-1/3 w-64 h-64 rounded-full blur-3xl bg-purple-400/20"></div>
|
||||
|
||||
<div className="relative z-10 flex h-full flex-col justify-center">
|
||||
<div className="mb-12">
|
||||
<p className="text-5xl font-bold text-center text-white/80">Codenuk</p>
|
||||
</div>
|
||||
<h2 className="text-3xl text-center font-bold mb-3">Get Started with Us</h2>
|
||||
<p className="text-white/80 text-center mb-10">Complete these easy steps to register your account.</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Step 1 - Active */}
|
||||
<div className="bg-white text-black rounded-xl p-4 flex items-center">
|
||||
<div className="w-9 h-9 rounded-full bg-black text-white flex items-center justify-center mr-4 text-sm font-semibold">1</div>
|
||||
<span className="font-medium">Sign up your account</span>
|
||||
</div>
|
||||
{/* Step 2 - Next */}
|
||||
<div className="bg-white/10 text-white border border-white/20 rounded-xl p-4 flex items-center">
|
||||
<div className="w-9 h-9 rounded-full bg-white/20 text-white flex items-center justify-center mr-4 text-sm font-semibold">2</div>
|
||||
<span className="font-medium">Verify email & sign in</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Form area */}
|
||||
<div className="flex items-center">
|
||||
<div className="w-full max-w-md ml-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold text-white mb-1">Sign Up Account</h1>
|
||||
<p className="text-white/60">Enter your personal data to create your account.</p>
|
||||
</div>
|
||||
|
||||
<SignUpForm onSignUpSuccess={handleSignUpSuccess} />
|
||||
|
||||
<div className="text-center pt-4">
|
||||
<div className="text-white/60 text-xs mb-1">Already have an account?</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => router.push("/signin")}
|
||||
className="text-orange-400 hover:text-orange-300 font-medium transition-colors text-sm p-0 h-auto cursor-pointer"
|
||||
>
|
||||
Sign in to your account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
210
src/components/custom-template-form.tsx
Normal file
210
src/components/custom-template-form.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { DatabaseTemplate } from "@/lib/template-service"
|
||||
import { Plus, X, Save } from "lucide-react"
|
||||
|
||||
interface CustomTemplateFormProps {
|
||||
onSubmit: (templateData: Partial<DatabaseTemplate>) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function CustomTemplateForm({ onSubmit, onCancel }: CustomTemplateFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
type: "",
|
||||
title: "",
|
||||
description: "",
|
||||
category: "",
|
||||
icon: "",
|
||||
gradient: "",
|
||||
border: "",
|
||||
text: "",
|
||||
subtext: ""
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const categories = [
|
||||
"Food Delivery",
|
||||
"E-commerce",
|
||||
"SaaS Platform",
|
||||
"Mobile App",
|
||||
"Dashboard",
|
||||
"CRM System",
|
||||
"Learning Platform",
|
||||
"Healthcare",
|
||||
"Real Estate",
|
||||
"Travel",
|
||||
"Entertainment",
|
||||
"Finance",
|
||||
"Social Media",
|
||||
"Marketplace",
|
||||
"Other"
|
||||
]
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await onSubmit(formData)
|
||||
} catch (error) {
|
||||
console.error('Error creating template:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
Create Custom Template
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Template Type *</label>
|
||||
<Input
|
||||
placeholder="e.g., multi_restaurant_food_delivery"
|
||||
value={formData.type}
|
||||
onChange={(e) => handleInputChange('type', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-white/60">Unique identifier for the template</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Title *</label>
|
||||
<Input
|
||||
placeholder="e.g., Multi-Restaurant Food Delivery App"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleInputChange('title', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Description *</label>
|
||||
<Textarea
|
||||
placeholder="Describe your template and its key features..."
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40 min-h-[100px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Category *</label>
|
||||
<Select value={formData.category} onValueChange={(value) => handleInputChange('category', value)}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 border-white/10">
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category} className="text-white">
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Icon (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., restaurant, shopping-cart, users"
|
||||
value={formData.icon}
|
||||
onChange={(e) => handleInputChange('icon', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Gradient (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., from-orange-400 to-red-500"
|
||||
value={formData.gradient}
|
||||
onChange={(e) => handleInputChange('gradient', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Border (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., border-orange-500"
|
||||
value={formData.border}
|
||||
onChange={(e) => handleInputChange('border', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Text Color (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., text-orange-500"
|
||||
value={formData.text}
|
||||
onChange={(e) => handleInputChange('text', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Subtext (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., Perfect for food delivery startups"
|
||||
value={formData.subtext}
|
||||
onChange={(e) => handleInputChange('subtext', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="border-white/20 text-white hover:bg-white/10 cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-orange-500 hover:bg-orange-400 text-black font-semibold cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{loading ? "Creating..." : "Create Template"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
60
src/components/delete-confirmation-dialog.tsx
Normal file
60
src/components/delete-confirmation-dialog.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { AlertTriangle, Trash2, X } from "lucide-react"
|
||||
|
||||
interface DeleteConfirmationDialogProps {
|
||||
templateTitle: string
|
||||
onConfirm: () => Promise<void>
|
||||
onCancel: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function DeleteConfirmationDialog({
|
||||
templateTitle,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
loading = false
|
||||
}: DeleteConfirmationDialogProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<Card className="bg-white/5 border-white/10 max-w-md w-full mx-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<AlertTriangle className="mr-2 h-5 w-5 text-red-400" />
|
||||
Delete Template
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-white/80">
|
||||
Are you sure you want to delete the template <strong className="text-white">"{templateTitle}"</strong>?
|
||||
</p>
|
||||
<p className="text-white/60 text-sm">
|
||||
This action cannot be undone. The template will be permanently removed from the database.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="border-white/20 text-white hover:bg-white/10"
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
className="bg-red-500 hover:bg-red-400 text-white"
|
||||
disabled={loading}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{loading ? "Deleting..." : "Delete Template"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
225
src/components/edit-template-form.tsx
Normal file
225
src/components/edit-template-form.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { DatabaseTemplate } from "@/lib/template-service"
|
||||
import { Edit, X, Save } from "lucide-react"
|
||||
|
||||
interface EditTemplateFormProps {
|
||||
template: DatabaseTemplate
|
||||
onSubmit: (id: string, templateData: Partial<DatabaseTemplate>) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function EditTemplateForm({ template, onSubmit, onCancel }: EditTemplateFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
type: "",
|
||||
title: "",
|
||||
description: "",
|
||||
category: "",
|
||||
icon: "",
|
||||
gradient: "",
|
||||
border: "",
|
||||
text: "",
|
||||
subtext: ""
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const categories = [
|
||||
"Food Delivery",
|
||||
"E-commerce",
|
||||
"SaaS Platform",
|
||||
"Mobile App",
|
||||
"Dashboard",
|
||||
"CRM System",
|
||||
"Learning Platform",
|
||||
"Healthcare",
|
||||
"Real Estate",
|
||||
"Travel",
|
||||
"Entertainment",
|
||||
"Finance",
|
||||
"Social Media",
|
||||
"Marketplace",
|
||||
"Other"
|
||||
]
|
||||
|
||||
// Initialize form data with template values
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
type: template.type || "",
|
||||
title: template.title || "",
|
||||
description: template.description || "",
|
||||
category: template.category || "",
|
||||
icon: template.icon || "",
|
||||
gradient: template.gradient || "",
|
||||
border: template.border || "",
|
||||
text: template.text || "",
|
||||
subtext: template.subtext || ""
|
||||
})
|
||||
}, [template])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await onSubmit(template.id, formData)
|
||||
} catch (error) {
|
||||
console.error('Error updating template:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center">
|
||||
<Edit className="mr-2 h-5 w-5" />
|
||||
Edit Template: {template.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Template Type *</label>
|
||||
<Input
|
||||
placeholder="e.g., multi_restaurant_food_delivery"
|
||||
value={formData.type}
|
||||
onChange={(e) => handleInputChange('type', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-white/60">Unique identifier for the template</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Title *</label>
|
||||
<Input
|
||||
placeholder="e.g., Multi-Restaurant Food Delivery App"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleInputChange('title', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Description *</label>
|
||||
<Textarea
|
||||
placeholder="Describe your template and its key features..."
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40 min-h-[100px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Category *</label>
|
||||
<Select value={formData.category} onValueChange={(value) => handleInputChange('category', value)}>
|
||||
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 border-white/10">
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category} className="text-white">
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Icon (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., restaurant, shopping-cart, users"
|
||||
value={formData.icon}
|
||||
onChange={(e) => handleInputChange('icon', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Gradient (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., from-orange-400 to-red-500"
|
||||
value={formData.gradient}
|
||||
onChange={(e) => handleInputChange('gradient', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Border (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., border-orange-500"
|
||||
value={formData.border}
|
||||
onChange={(e) => handleInputChange('border', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Text Color (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., text-orange-500"
|
||||
value={formData.text}
|
||||
onChange={(e) => handleInputChange('text', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Subtext (optional)</label>
|
||||
<Input
|
||||
placeholder="e.g., Perfect for food delivery startups"
|
||||
value={formData.subtext}
|
||||
onChange={(e) => handleInputChange('subtext', e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="border-white/20 text-white hover:bg-white/10 cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-orange-500 hover:bg-orange-400 text-black font-semibold cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{loading ? "Updating..." : "Update Template"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
339
src/components/features/feature-submission-form.tsx
Normal file
339
src/components/features/feature-submission-form.tsx
Normal file
@ -0,0 +1,339 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
Search,
|
||||
Info
|
||||
} from 'lucide-react'
|
||||
import { featureApi } from '@/lib/api/admin'
|
||||
import { FeatureSimilarity, DuplicateCheckResult } from '@/types/admin.types'
|
||||
|
||||
interface FeatureSubmissionFormProps {
|
||||
templateId: string
|
||||
templateName?: string
|
||||
onSuccess?: (feature: any) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function FeatureSubmissionForm({
|
||||
templateId,
|
||||
templateName,
|
||||
onSuccess,
|
||||
onCancel
|
||||
}: FeatureSubmissionFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
complexity: 'medium' as 'low' | 'medium' | 'high',
|
||||
business_rules: '',
|
||||
technical_requirements: ''
|
||||
})
|
||||
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [similarFeatures, setSimilarFeatures] = useState<FeatureSimilarity[]>([])
|
||||
const [duplicateInfo, setDuplicateInfo] = useState<DuplicateCheckResult | null>(null)
|
||||
const [searchingSimilar, setSearchingSimilar] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
// Clear previous results when name changes
|
||||
if (field === 'name') {
|
||||
setSimilarFeatures([])
|
||||
setDuplicateInfo(null)
|
||||
}
|
||||
}
|
||||
|
||||
const searchSimilarFeatures = async () => {
|
||||
if (!formData.name.trim()) return
|
||||
|
||||
try {
|
||||
setSearchingSimilar(true)
|
||||
const features = await featureApi.findSimilarFeatures(formData.name, 0.7, 5)
|
||||
setSimilarFeatures(features)
|
||||
|
||||
// Check if any are potential duplicates
|
||||
const potentialDuplicate = features.find(f => f.score >= 0.8)
|
||||
if (potentialDuplicate) {
|
||||
setDuplicateInfo({
|
||||
isDuplicate: true,
|
||||
canonicalFeature: potentialDuplicate,
|
||||
similarityScore: potentialDuplicate.score,
|
||||
matchType: potentialDuplicate.match_type
|
||||
})
|
||||
} else {
|
||||
setDuplicateInfo({ isDuplicate: false })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error searching similar features:', error)
|
||||
} finally {
|
||||
setSearchingSimilar(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
setError('Feature name is required')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
const response = await featureApi.submitCustomFeature({
|
||||
template_id: templateId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
complexity: formData.complexity,
|
||||
business_rules: formData.business_rules.trim() ? JSON.parse(formData.business_rules) : undefined,
|
||||
technical_requirements: formData.technical_requirements.trim() ? JSON.parse(formData.technical_requirements) : undefined,
|
||||
created_by_user_session: 'user-session' // TODO: Get from auth context
|
||||
})
|
||||
|
||||
setSuccess(true)
|
||||
if (onSuccess) {
|
||||
onSuccess(response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to submit feature')
|
||||
console.error('Error submitting feature:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getComplexityColor = (complexity: string) => {
|
||||
switch (complexity) {
|
||||
case 'low':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'medium':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
case 'high':
|
||||
return 'bg-red-100 text-red-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<CheckCircle className="h-12 w-12 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Feature Submitted Successfully!</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Your feature "{formData.name}" has been submitted and is pending admin review.
|
||||
</p>
|
||||
{duplicateInfo?.isDuplicate && (
|
||||
<Alert className="mb-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Similar features were found. An admin will review this submission to determine if it's a duplicate.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-x-2">
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
Submit Another Feature
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Submit Custom Feature</CardTitle>
|
||||
{templateName && (
|
||||
<p className="text-sm text-gray-600">
|
||||
Adding feature to template: <strong>{templateName}</strong>
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Feature Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Feature Name *</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
placeholder="Enter feature name..."
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={searchSimilarFeatures}
|
||||
disabled={!formData.name.trim() || searchingSimilar}
|
||||
>
|
||||
{searchingSimilar ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Similar Features Alert */}
|
||||
{similarFeatures.length > 0 && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-2">
|
||||
<p>Similar features found:</p>
|
||||
<div className="space-y-1">
|
||||
{similarFeatures.map((feature) => (
|
||||
<div key={feature.id} className="flex items-center justify-between text-sm">
|
||||
<span>{feature.name}</span>
|
||||
<Badge variant="outline">
|
||||
{(feature.score * 100).toFixed(1)}% match
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{duplicateInfo?.isDuplicate && (
|
||||
<p className="text-amber-600 font-medium mt-2">
|
||||
⚠️ This may be a duplicate. Please review before submitting.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
placeholder="Describe what this feature does..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Complexity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="complexity">Complexity</Label>
|
||||
<Select value={formData.complexity} onValueChange={(value) => handleInputChange('complexity', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className="bg-green-100 text-green-800">Low</Badge>
|
||||
<span>Simple implementation</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="medium">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className="bg-yellow-100 text-yellow-800">Medium</Badge>
|
||||
<span>Moderate complexity</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="high">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className="bg-red-100 text-red-800">High</Badge>
|
||||
<span>Complex implementation</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Business Rules */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="business_rules">Business Rules (JSON)</Label>
|
||||
<Textarea
|
||||
id="business_rules"
|
||||
value={formData.business_rules}
|
||||
onChange={(e) => handleInputChange('business_rules', e.target.value)}
|
||||
placeholder='{"rule1": "description", "rule2": "description"}'
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Optional: Define business rules for this feature in JSON format
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Technical Requirements */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="technical_requirements">Technical Requirements (JSON)</Label>
|
||||
<Textarea
|
||||
id="technical_requirements"
|
||||
value={formData.technical_requirements}
|
||||
onChange={(e) => handleInputChange('technical_requirements', e.target.value)}
|
||||
placeholder='{"framework": "React", "database": "PostgreSQL"}'
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Optional: Define technical requirements in JSON format
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting || !formData.name.trim()}
|
||||
className="flex-1"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
'Submit Feature'
|
||||
)}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import Header from "@/components/navigation/header"
|
||||
import { Header } from "@/components/navigation/header"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
interface AppLayoutProps {
|
||||
@ -9,37 +9,18 @@ interface AppLayoutProps {
|
||||
}
|
||||
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
const { user } = useAuth()
|
||||
const pathname = usePathname()
|
||||
|
||||
// Don't show header on auth pages
|
||||
const isAuthPage = pathname === "/auth"
|
||||
|
||||
// Show loading state while checking auth
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-black">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-orange-500"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const isAuthPage = pathname === "/signin" || pathname === "/signup"
|
||||
|
||||
// For auth pages, don't show header
|
||||
if (isAuthPage) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// For authenticated users on other pages, show header
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// For unauthenticated users on non-auth pages, redirect to auth
|
||||
// For all other pages, show header
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -12,23 +12,41 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Bell, Settings, LogOut, User, Menu, X } from "lucide-react"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Bell, Settings, LogOut, User, Menu, X, Shield } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
|
||||
const navigation = [
|
||||
{ name: "Project Builder", href: "/", current: false },
|
||||
{ name: "Templates", href: "/templates", current: false },
|
||||
{ name: "Features", href: "/features", current: false },
|
||||
{ name: "Business Context", href: "/business-context", current: false },
|
||||
{ name: "Architecture", href: "/architecture", current: false },
|
||||
]
|
||||
{ name: "Project Builder", href: "/project-builder" },
|
||||
{ name: "Templates", href: "/templates" },
|
||||
{ name: "Features", href: "/features" },
|
||||
{ name: "Business Context", href: "/business-context" },
|
||||
{ name: "Architecture", href: "/architecture" },
|
||||
];
|
||||
|
||||
export default function Header() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const { user, logout } = useAuth()
|
||||
export function Header() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
|
||||
// Handle logout with loading state
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
setIsLoggingOut(true);
|
||||
await logout();
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Define routes where the header should not be shown
|
||||
const noHeaderRoutes = ["/signin", "/signup"];
|
||||
if (noHeaderRoutes.includes(pathname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="bg-black/80 text-white border-b border-white/10 backdrop-blur">
|
||||
@ -59,13 +77,27 @@ export default function Header() {
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
{/* Admin Navigation */}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors flex items-center space-x-1 ${
|
||||
pathname === "/admin"
|
||||
? "bg-orange-500 text-black"
|
||||
: "text-white/70 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center space-x-4 cursor-pointer">
|
||||
{/* Auth Button or User Menu */}
|
||||
{/* While loading, don't show sign-in or user menu to avoid flicker */}
|
||||
{!user ? (
|
||||
<Link href="/auth">
|
||||
<Link href="/signin">
|
||||
<Button size="sm" className="cursor-pointer">Sign In</Button>
|
||||
</Link>
|
||||
) : (
|
||||
@ -81,21 +113,29 @@ export default function Header() {
|
||||
)}
|
||||
|
||||
{/* User Menu - Only show when user is authenticated */}
|
||||
{user && (
|
||||
{user && user.email && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.avatar || "/avatars/01.png"} alt={user.name} />
|
||||
<AvatarFallback>{user.name.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
<AvatarImage src={user.avatar || "/avatars/01.png"} alt={user.name || user.email || "User"} />
|
||||
<AvatarFallback>
|
||||
{(user.name && user.name.charAt(0)) || (user.email && user.email.charAt(0)) || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 bg-black text-white border-white/10" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user.name}</p>
|
||||
<p className="text-xs leading-none text-white/60">{user.email}</p>
|
||||
<p className="text-sm font-medium leading-none">{user.name || user.email || "User"}</p>
|
||||
<p className="text-xs leading-none text-white/60">{user.email || "No email"}</p>
|
||||
{isAdmin && (
|
||||
<Badge className="w-fit bg-orange-500 text-black text-xs">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
@ -108,16 +148,21 @@ export default function Header() {
|
||||
<span>Settings</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={logout} className="hover:bg-white/5 cursor-pointer">
|
||||
<DropdownMenuItem onClick={handleLogout} className="hover:bg-white/5 cursor-pointer" disabled={isLoggingOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
<span>{isLoggingOut ? "Logging out..." : "Log out"}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<Button variant="ghost" size="sm" className="md:hidden text-white/80 hover:text-white hover:bg-white/5" onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="md:hidden"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</Button>
|
||||
</div>
|
||||
@ -125,13 +170,13 @@ export default function Header() {
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden">
|
||||
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3 border-t border-white/10">
|
||||
<div className="md:hidden py-4 border-t border-white/10">
|
||||
<nav className="flex flex-col space-y-2">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`block px-3 py-2 rounded-md text-base font-medium transition-colors ${
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
pathname === item.href
|
||||
? "bg-orange-500 text-black"
|
||||
: "text-white/70 hover:text-white hover:bg-white/5"
|
||||
@ -141,10 +186,25 @@ export default function Header() {
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{/* Admin Navigation for mobile */}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors flex items-center space-x-1 ${
|
||||
pathname === "/admin"
|
||||
? "bg-orange-500 text-black"
|
||||
: "text-white/70 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
);
|
||||
}
|
||||
250
src/components/prompt-side-panel.tsx
Normal file
250
src/components/prompt-side-panel.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { SelectDevice } from "@/components/ui/select-device"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getHealthUrl, config } from "@/lib/config"
|
||||
|
||||
export function PromptSidePanel({
|
||||
className,
|
||||
selectedDevice = 'desktop',
|
||||
onDeviceChange
|
||||
}: {
|
||||
className?: string
|
||||
selectedDevice?: 'desktop' | 'tablet' | 'mobile'
|
||||
onDeviceChange?: (device: 'desktop' | 'tablet' | 'mobile') => void
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [prompt, setPrompt] = useState(
|
||||
"Dashboard with header, left sidebar, 3 stats cards, a line chart and a data table, plus footer.",
|
||||
)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [backendStatus, setBackendStatus] = useState<'connected' | 'disconnected' | 'checking'>('checking')
|
||||
|
||||
const examples = useMemo(
|
||||
() => [
|
||||
"Landing page with header, hero, 3x2 feature grid, and footer.",
|
||||
"Settings screen: header, list of toggles, and save button.",
|
||||
"E‑commerce product page: header, 2-column gallery/details, reviews, sticky add-to-cart.",
|
||||
"Dashboard: header, left sidebar, 3 stats cards, line chart, data table, footer.",
|
||||
"Signup page: header, 2-column form, callout, submit button.",
|
||||
"Admin panel: header, left navigation, main content area with data tables, and footer.",
|
||||
"Product catalog: header, search bar, filter sidebar, 4x3 product grid, pagination.",
|
||||
"Blog layout: header, featured post hero, 2-column article list, sidebar with categories.",
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
// Check backend connection status
|
||||
useEffect(() => {
|
||||
const checkBackendStatus = async () => {
|
||||
try {
|
||||
setBackendStatus('checking')
|
||||
const response = await fetch(getHealthUrl(), {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setBackendStatus('connected')
|
||||
} else {
|
||||
setBackendStatus('disconnected')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Backend health check failed:', error)
|
||||
setBackendStatus('disconnected')
|
||||
}
|
||||
}
|
||||
|
||||
checkBackendStatus()
|
||||
|
||||
// Check status every 10 seconds
|
||||
const interval = setInterval(checkBackendStatus, config.ui.statusCheckInterval)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const dispatchGenerate = async (text: string) => {
|
||||
setIsGenerating(true)
|
||||
|
||||
// Dispatch the event for the canvas to handle with device information
|
||||
window.dispatchEvent(new CustomEvent("tldraw:generate", {
|
||||
detail: {
|
||||
prompt: text,
|
||||
device: selectedDevice
|
||||
}
|
||||
}))
|
||||
|
||||
// Wait a bit to show the loading state
|
||||
setTimeout(() => setIsGenerating(false), 1000)
|
||||
}
|
||||
|
||||
const dispatchClear = () => {
|
||||
window.dispatchEvent(new Event("tldraw:clear"))
|
||||
}
|
||||
|
||||
const handleDeviceChange = (device: 'desktop' | 'tablet' | 'mobile') => {
|
||||
console.log('DEBUG: PromptSidePanel handleDeviceChange called with:', device)
|
||||
console.log('DEBUG: Current selectedDevice prop:', selectedDevice)
|
||||
console.log('DEBUG: onDeviceChange function exists:', !!onDeviceChange)
|
||||
|
||||
if (onDeviceChange) {
|
||||
console.log('DEBUG: Calling onDeviceChange with:', device)
|
||||
onDeviceChange(device)
|
||||
} else {
|
||||
console.warn('DEBUG: onDeviceChange function not provided')
|
||||
}
|
||||
}
|
||||
|
||||
const getBackendStatusIcon = () => {
|
||||
switch (backendStatus) {
|
||||
case 'connected':
|
||||
return '🟢'
|
||||
case 'disconnected':
|
||||
return '🔴'
|
||||
case 'checking':
|
||||
return '🟡'
|
||||
default:
|
||||
return '⚪'
|
||||
}
|
||||
}
|
||||
|
||||
const getBackendStatusText = () => {
|
||||
switch (backendStatus) {
|
||||
case 'connected':
|
||||
return 'AI Backend Connected'
|
||||
case 'disconnected':
|
||||
return 'AI Backend Disconnected'
|
||||
case 'checking':
|
||||
return 'Checking Backend...'
|
||||
default:
|
||||
return 'Unknown Status'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"h-full border-l bg-white dark:bg-neutral-900 flex flex-col",
|
||||
collapsed ? "w-15" : "w-96",
|
||||
className,
|
||||
)}
|
||||
aria-label="AI prompt side panel"
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<h2 className={cn("text-sm font-medium text-balance", collapsed && "sr-only")}>AI Wireframe</h2>
|
||||
<Button variant="ghost" size="icon" onClick={() => setCollapsed((c) => !c)} aria-label="Toggle panel">
|
||||
{collapsed ? <span aria-hidden>›</span> : <span aria-hidden>‹</span>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{/* Backend Status */}
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg text-xs",
|
||||
backendStatus === 'connected' ? 'bg-green-50 text-green-700 border border-green-200' :
|
||||
backendStatus === 'disconnected' ? 'bg-red-50 text-red-700 border border-red-200' :
|
||||
'bg-yellow-50 text-yellow-700 border border-yellow-200'
|
||||
)}>
|
||||
<span>{getBackendStatusIcon()}</span>
|
||||
<span className="font-medium">{getBackendStatusText()}</span>
|
||||
</div>
|
||||
|
||||
<label className="text-xs font-medium">Prompt</label>
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Describe your screen: Landing with header, hero, 3x2 features, footer"
|
||||
className="min-h-28"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">Device Type</label>
|
||||
<SelectDevice
|
||||
value={selectedDevice}
|
||||
onValueChange={handleDeviceChange}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-medium">Current:</span>
|
||||
<span className={cn(
|
||||
"px-2 py-1 rounded-full text-xs font-medium",
|
||||
selectedDevice === "desktop" && "bg-blue-100 text-blue-700",
|
||||
selectedDevice === "tablet" && "bg-green-100 text-green-700",
|
||||
selectedDevice === "mobile" && "bg-purple-100 text-purple-700"
|
||||
)}>
|
||||
{selectedDevice.charAt(0).toUpperCase() + selectedDevice.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{selectedDevice === "desktop" && "Desktop layout with full navigation and sidebar"}
|
||||
{selectedDevice === "tablet" && "Tablet layout with responsive navigation"}
|
||||
{selectedDevice === "mobile" && "Mobile-first layout with stacked elements"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => dispatchGenerate(prompt)}
|
||||
className="flex-1"
|
||||
disabled={isGenerating || backendStatus !== 'connected'}
|
||||
>
|
||||
{isGenerating ? 'Generating...' : `Generate for ${selectedDevice}`}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={dispatchClear} disabled={isGenerating}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{backendStatus === 'disconnected' && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-xs text-amber-700">
|
||||
<strong>Backend not connected.</strong> Make sure your Flask backend is running on port 5000.
|
||||
The system will use fallback generation instead.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-1">
|
||||
<p className="text-xs font-medium mb-2">Examples</p>
|
||||
<ScrollArea className="h-40 border rounded">
|
||||
<ul className="p-2 space-y-2">
|
||||
{examples.map((ex) => (
|
||||
<li key={ex}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPrompt(ex)}
|
||||
className="text-left text-xs w-full hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded px-2 py-1"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{ex}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* AI Features Info */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
<strong>AI-Powered Generation:</strong> Claude AI analyzes your prompts and creates professional wireframe layouts with proper spacing, proportions, and UX best practices.
|
||||
</p>
|
||||
<div className="mt-2 pt-2 border-t border-blue-200">
|
||||
<p className="text-xs text-blue-600">
|
||||
<strong>Device-Specific Generation:</strong><br/>
|
||||
• <strong>Desktop:</strong> Uses single-device API for faster generation<br/>
|
||||
• <strong>Tablet/Mobile:</strong> Uses multi-device API for responsive layouts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default PromptSidePanel
|
||||
72
src/components/prompt-toolbar.tsx
Normal file
72
src/components/prompt-toolbar.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
export default function PromptToolbar({
|
||||
busy,
|
||||
onGenerate,
|
||||
onClear,
|
||||
onExample,
|
||||
}: {
|
||||
busy?: boolean
|
||||
onGenerate: (prompt: string) => void
|
||||
onClear: () => void
|
||||
onExample: (text: string) => void
|
||||
}) {
|
||||
const [prompt, setPrompt] = useState("Dashboard with header, sidebar, 3x2 cards grid, and footer")
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!prompt.trim() || busy) return
|
||||
onGenerate(prompt.trim())
|
||||
}
|
||||
|
||||
const examples = [
|
||||
"Marketing landing page with hero, 3x2 features grid, signup form, and footer",
|
||||
"Simple login screen with header and centered form",
|
||||
"Ecommerce product grid 4x2 with header, sidebar filters, and footer",
|
||||
"Admin dashboard with header, sidebar, 2x2 cards and a form",
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<form onSubmit={submit} className="flex items-center gap-2">
|
||||
<input
|
||||
aria-label="Prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="e.g., Dashboard with header, sidebar, 3x2 cards grid, and footer"
|
||||
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
|
||||
>
|
||||
{busy ? "Generating…" : "Generate"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-800 hover:bg-gray-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</form>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-gray-600">Try:</span>
|
||||
{examples.map((ex) => (
|
||||
<button
|
||||
key={ex}
|
||||
onClick={() => onExample(ex)}
|
||||
className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs text-gray-800 hover:bg-gray-50"
|
||||
>
|
||||
{ex}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
src/components/ui/accordion.tsx
Normal file
66
src/components/ui/accordion.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
157
src/components/ui/alert-dialog.tsx
Normal file
157
src/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
66
src/components/ui/alert.tsx
Normal file
66
src/components/ui/alert.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
109
src/components/ui/breadcrumb.tsx
Normal file
109
src/components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
353
src/components/ui/chart.tsx
Normal file
353
src/components/ui/chart.tsx
Normal file
@ -0,0 +1,353 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
143
src/components/ui/dialog.tsx
Normal file
143
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
58
src/components/ui/scroll-area.tsx
Normal file
58
src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
65
src/components/ui/select-device.tsx
Normal file
65
src/components/ui/select-device.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type Device = "desktop" | "tablet" | "mobile"
|
||||
|
||||
export interface SelectDeviceProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: Device
|
||||
onValueChange: (value: Device) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function SelectDevice({
|
||||
value,
|
||||
onValueChange,
|
||||
disabled,
|
||||
className,
|
||||
...props
|
||||
}: SelectDeviceProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 p-1 bg-neutral-100 rounded-lg",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onValueChange("desktop")}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-1 text-xs rounded-md transition-colors",
|
||||
value === "desktop" ? "bg-white shadow text-blue-600" : "text-neutral-600 hover:bg-white/50"
|
||||
)}
|
||||
>
|
||||
Desktop
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onValueChange("tablet")}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-1 text-xs rounded-md transition-colors",
|
||||
value === "tablet" ? "bg-white shadow text-blue-600" : "text-neutral-600 hover:bg-white/50"
|
||||
)}
|
||||
>
|
||||
Tablet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onValueChange("mobile")}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-1 text-xs rounded-md transition-colors",
|
||||
value === "mobile" ? "bg-white shadow text-blue-600" : "text-neutral-600 hover:bg-white/50"
|
||||
)}
|
||||
>
|
||||
Mobile
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
139
src/components/ui/sheet.tsx
Normal file
139
src/components/ui/sheet.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
116
src/components/ui/table.tsx
Normal file
116
src/components/ui/table.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
1394
src/components/wireframe-canvas.tsx
Normal file
1394
src/components/wireframe-canvas.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,138 +1,74 @@
|
||||
"use client"
|
||||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react"
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { safeLocalStorage } from '@/lib/utils'
|
||||
import { logout as logoutApi } from '@/components/apis/authApiClients'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
avatar?: string
|
||||
username: string
|
||||
role?: 'user' | 'admin'
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
login: (email: string, password: string) => Promise<boolean>
|
||||
signup: (name: string, email: string, password: string) => Promise<boolean>
|
||||
logout: () => void
|
||||
isAdmin: boolean
|
||||
setUserFromApi: (user: User) => void
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Check if user is logged in on mount
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
// Check localStorage for user data
|
||||
const userData = localStorage.getItem("codenuk_user")
|
||||
if (userData) {
|
||||
try {
|
||||
const user = JSON.parse(userData)
|
||||
setUser(user)
|
||||
} catch (error) {
|
||||
console.error("Error parsing user data:", error)
|
||||
localStorage.removeItem("codenuk_user")
|
||||
}
|
||||
const stored = safeLocalStorage.getItem('codenuk_user')
|
||||
if (stored) {
|
||||
try {
|
||||
const userData = JSON.parse(stored)
|
||||
setUser(userData)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored user data:', error)
|
||||
safeLocalStorage.removeItem('codenuk_user')
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
checkAuth()
|
||||
}, [])
|
||||
|
||||
const login = async (email: string, password: string): Promise<boolean> => {
|
||||
const setUserFromApi = (u: User) => {
|
||||
setUser(u)
|
||||
safeLocalStorage.setItem('codenuk_user', JSON.stringify(u))
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// For demo purposes, accept any email/password combination
|
||||
if (email && password) {
|
||||
const user: User = {
|
||||
id: "1",
|
||||
name: email.split("@")[0], // Use email prefix as name
|
||||
email: email,
|
||||
avatar: "/avatars/01.png"
|
||||
}
|
||||
|
||||
setUser(user)
|
||||
localStorage.setItem("codenuk_user", JSON.stringify(user))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
// Call the logout API to invalidate tokens on the server
|
||||
await logoutApi()
|
||||
} catch (error) {
|
||||
console.error("Login error:", error)
|
||||
return false
|
||||
console.error('Logout API call failed:', error)
|
||||
// Continue with local logout even if API call fails
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
// Always clear local data
|
||||
setUser(null)
|
||||
safeLocalStorage.removeItem('codenuk_user')
|
||||
safeLocalStorage.removeItem('accessToken')
|
||||
safeLocalStorage.removeItem('refreshToken')
|
||||
|
||||
// Redirect to signin page
|
||||
window.location.href = '/signin'
|
||||
}
|
||||
}
|
||||
|
||||
const signup = async (name: string, email: string, password: string): Promise<boolean> => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// For demo purposes, create user if all fields are provided
|
||||
if (name && email && password) {
|
||||
const user: User = {
|
||||
id: "1",
|
||||
name: name,
|
||||
email: email,
|
||||
avatar: "/avatars/01.png"
|
||||
}
|
||||
|
||||
setUser(user)
|
||||
localStorage.setItem("codenuk_user", JSON.stringify(user))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error("Signup error:", error)
|
||||
return false
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
setUser(null)
|
||||
localStorage.removeItem("codenuk_user")
|
||||
}
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
login,
|
||||
signup,
|
||||
logout
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
<AuthContext.Provider value={{ user, isAdmin: user?.role === 'admin', setUserFromApi, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
161
src/hooks/useTemplates.ts
Normal file
161
src/hooks/useTemplates.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { templateService, DatabaseTemplate, TemplatesByCategory, TemplateFeature } from '@/lib/template-service'
|
||||
|
||||
export function useTemplates() {
|
||||
const [templates, setTemplates] = useState<TemplatesByCategory>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await templateService.getTemplatesByCategory()
|
||||
setTemplates(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch templates')
|
||||
console.error('Error fetching templates:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates()
|
||||
}, [])
|
||||
|
||||
const createTemplate = async (templateData: Partial<DatabaseTemplate>) => {
|
||||
try {
|
||||
const newTemplate = await templateService.createTemplate(templateData)
|
||||
// Refresh templates after creating a new one
|
||||
await fetchTemplates()
|
||||
return newTemplate
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create template')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const updateTemplate = async (id: string, templateData: Partial<DatabaseTemplate>) => {
|
||||
try {
|
||||
const updatedTemplate = await templateService.updateTemplate(id, templateData)
|
||||
// Refresh templates after updating
|
||||
await fetchTemplates()
|
||||
return updatedTemplate
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update template')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTemplate = async (id: string) => {
|
||||
try {
|
||||
await templateService.deleteTemplate(id)
|
||||
// Refresh templates after deleting
|
||||
await fetchTemplates()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete template')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Convert database templates to the format expected by the UI
|
||||
const getTemplatesForUI = async () => {
|
||||
const allTemplates: Array<DatabaseTemplate & {
|
||||
features: string[]
|
||||
complexity: number
|
||||
timeEstimate: string
|
||||
techStack: string[]
|
||||
popularity?: number
|
||||
lastUpdated?: string
|
||||
}> = []
|
||||
|
||||
for (const [category, categoryTemplates] of Object.entries(templates)) {
|
||||
for (const template of categoryTemplates) {
|
||||
try {
|
||||
// Fetch features for this template
|
||||
const features = await templateService.getFeaturesForTemplate(template.id)
|
||||
const featureNames = features.map(f => f.name)
|
||||
|
||||
// Convert database template to UI format
|
||||
const uiTemplate = {
|
||||
...template,
|
||||
features: featureNames, // Use actual features from API
|
||||
complexity: 3, // Default complexity
|
||||
timeEstimate: "2-4 weeks", // Default time estimate
|
||||
techStack: ["Next.js", "PostgreSQL", "Tailwind CSS"], // Default tech stack
|
||||
popularity: template.avg_rating ? Math.round(template.avg_rating * 20) : 75, // Convert rating to popularity
|
||||
lastUpdated: template.updated_at ? new Date(template.updated_at).toISOString().split('T')[0] : undefined,
|
||||
featureCount: featureNames.length
|
||||
}
|
||||
allTemplates.push(uiTemplate)
|
||||
} catch (error) {
|
||||
console.error(`Error fetching features for template ${template.id}:`, error)
|
||||
// Fallback with empty features
|
||||
const uiTemplate = {
|
||||
...template,
|
||||
features: [],
|
||||
complexity: 3,
|
||||
timeEstimate: "2-4 weeks",
|
||||
techStack: ["Next.js", "PostgreSQL", "Tailwind CSS"],
|
||||
popularity: template.avg_rating ? Math.round(template.avg_rating * 20) : 75,
|
||||
lastUpdated: template.updated_at ? new Date(template.updated_at).toISOString().split('T')[0] : undefined,
|
||||
featureCount: 0
|
||||
}
|
||||
allTemplates.push(uiTemplate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allTemplates
|
||||
}
|
||||
|
||||
// Get template features when a template is selected
|
||||
const getTemplateFeatures = async (templateId: string): Promise<string[]> => {
|
||||
try {
|
||||
const features = await templateService.getFeaturesForTemplate(templateId)
|
||||
return features.map(feature => feature.name)
|
||||
} catch (error) {
|
||||
console.error('Error fetching template features:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Feature CRUD for a template
|
||||
const fetchFeatures = async (templateId: string): Promise<TemplateFeature[]> => {
|
||||
return templateService.getFeaturesForTemplate(templateId)
|
||||
}
|
||||
|
||||
const createFeature = async (templateId: string, feature: Partial<TemplateFeature>) => {
|
||||
const payload = { ...feature, template_id: templateId }
|
||||
return templateService.createFeature(payload)
|
||||
}
|
||||
|
||||
const updateFeature = async (
|
||||
featureId: string,
|
||||
updates: Partial<TemplateFeature> & { isCustom?: boolean }
|
||||
) => {
|
||||
// If updates indicate custom, route accordingly
|
||||
return templateService.updateFeature(featureId, updates)
|
||||
}
|
||||
|
||||
const deleteFeature = async (featureId: string, opts?: { isCustom?: boolean }) => {
|
||||
return templateService.deleteFeature(featureId, opts)
|
||||
}
|
||||
|
||||
return {
|
||||
templates,
|
||||
loading,
|
||||
error,
|
||||
fetchTemplates,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
getTemplatesForUI,
|
||||
getTemplateFeatures,
|
||||
fetchFeatures,
|
||||
createFeature,
|
||||
updateFeature,
|
||||
deleteFeature
|
||||
}
|
||||
}
|
||||
203
src/lib/api/admin.ts
Normal file
203
src/lib/api/admin.ts
Normal file
@ -0,0 +1,203 @@
|
||||
import {
|
||||
AdminFeature,
|
||||
AdminNotification,
|
||||
FeatureSimilarity,
|
||||
FeatureReviewData,
|
||||
AdminStats,
|
||||
FeatureSynonym,
|
||||
AdminApiResponse
|
||||
} from '@/types/admin.types';
|
||||
import { getAccessToken } from '@/components/apis/authApiClients';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_TEMPLATE_MANAGER_URL || 'http://localhost:8009';
|
||||
|
||||
// Helper function to make API calls
|
||||
async function apiCall<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<AdminApiResponse<T>> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
const token = getAccessToken();
|
||||
const defaultHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (token) {
|
||||
defaultHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Admin Feature Management API
|
||||
export const adminApi = {
|
||||
// Get pending features
|
||||
getPendingFeatures: async (limit = 50, offset = 0): Promise<AdminFeature[]> => {
|
||||
const response = await apiCall<AdminFeature[]>(`/api/admin/features/pending?limit=${limit}&offset=${offset}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get features by status
|
||||
getFeaturesByStatus: async (status: string, limit = 50, offset = 0): Promise<AdminFeature[]> => {
|
||||
const response = await apiCall<AdminFeature[]>(`/api/admin/features/status/${status}?limit=${limit}&offset=${offset}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get feature statistics
|
||||
getFeatureStats: async (): Promise<AdminStats> => {
|
||||
const response = await apiCall<AdminStats>('/api/admin/features/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Review a feature
|
||||
reviewFeature: async (featureId: string, reviewData: FeatureReviewData): Promise<AdminFeature> => {
|
||||
const response = await apiCall<AdminFeature>(`/api/admin/features/${featureId}/review`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(reviewData),
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Find similar features
|
||||
findSimilarFeatures: async (query: string, threshold = 0.7, limit = 5): Promise<FeatureSimilarity[]> => {
|
||||
const response = await apiCall<FeatureSimilarity[]>(
|
||||
`/api/admin/features/similar?q=${encodeURIComponent(query)}&threshold=${threshold}&limit=${limit}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Add feature synonym
|
||||
addFeatureSynonym: async (featureId: string, synonym: string, createdBy?: string): Promise<FeatureSynonym> => {
|
||||
const response = await apiCall<FeatureSynonym>(`/api/admin/features/${featureId}/synonyms`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ synonym, created_by: createdBy }),
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get feature synonyms
|
||||
getFeatureSynonyms: async (featureId: string): Promise<FeatureSynonym[]> => {
|
||||
const response = await apiCall<FeatureSynonym[]>(`/api/admin/features/${featureId}/synonyms`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get admin notifications
|
||||
getNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise<AdminNotification[]> => {
|
||||
const response = await apiCall<AdminNotification[]>(
|
||||
`/api/admin/notifications?unread_only=${unreadOnly}&limit=${limit}&offset=${offset}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Mark notification as read
|
||||
markNotificationAsRead: async (notificationId: string): Promise<AdminNotification> => {
|
||||
const response = await apiCall<AdminNotification>(`/api/admin/notifications/${notificationId}/read`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Mark all notifications as read
|
||||
markAllNotificationsAsRead: async (): Promise<{ count: number }> => {
|
||||
const response = await apiCall<{ count: number }>('/api/admin/notifications/read-all', {
|
||||
method: 'POST',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Feature submission API (for regular users)
|
||||
export const featureApi = {
|
||||
// Submit a new custom feature
|
||||
submitCustomFeature: async (featureData: {
|
||||
template_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
complexity: 'low' | 'medium' | 'high';
|
||||
business_rules?: any;
|
||||
technical_requirements?: any;
|
||||
created_by_user_session?: string;
|
||||
}): Promise<{ data: AdminFeature; similarityInfo?: any }> => {
|
||||
const response = await apiCall<AdminFeature>('/api/features/custom', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(featureData),
|
||||
});
|
||||
|
||||
return {
|
||||
data: response.data,
|
||||
similarityInfo: (response as any).similarityInfo,
|
||||
};
|
||||
},
|
||||
|
||||
// Find similar features (for users)
|
||||
findSimilarFeatures: async (query: string, threshold = 0.7, limit = 5): Promise<FeatureSimilarity[]> => {
|
||||
const response = await apiCall<FeatureSimilarity[]>(
|
||||
`/api/features/similar?q=${encodeURIComponent(query)}&threshold=${threshold}&limit=${limit}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Error handling utilities
|
||||
export class AdminApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status?: number,
|
||||
public code?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AdminApiError';
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
export const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
export const getStatusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'approved':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'rejected':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'duplicate':
|
||||
return 'bg-orange-100 text-orange-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
export const getComplexityColor = (complexity: string): string => {
|
||||
switch (complexity) {
|
||||
case 'low':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'medium':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'high':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
137
src/lib/config.ts
Normal file
137
src/lib/config.ts
Normal file
@ -0,0 +1,137 @@
|
||||
// Configuration for the wireframe generator
|
||||
export const config = {
|
||||
// Backend API configuration
|
||||
backend: {
|
||||
baseUrl: process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8021',
|
||||
endpoints: {
|
||||
health: '/health',
|
||||
generateWireframe: '/generate-wireframe',
|
||||
generateWireframeDesktop: '/generate-wireframe/desktop',
|
||||
generateWireframeTablet: '/generate-wireframe/tablet',
|
||||
generateWireframeMobile: '/generate-wireframe/mobile',
|
||||
generateAllDevices: '/generate-all-devices',
|
||||
wireframes: '/api/wireframes',
|
||||
wireframe: (id?: string) => id ? `/api/wireframes/${id}` : '/api/wireframes',
|
||||
},
|
||||
timeout: 30000, // 30 seconds
|
||||
},
|
||||
|
||||
// User Authentication Service
|
||||
auth: {
|
||||
baseUrl: process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8011',
|
||||
endpoints: {
|
||||
health: '/health',
|
||||
register: '/api/auth/register',
|
||||
login: '/api/auth/login',
|
||||
logout: '/api/auth/logout',
|
||||
refresh: '/api/auth/refresh',
|
||||
profile: '/api/auth/me',
|
||||
preferences: '/api/auth/preferences',
|
||||
projects: '/api/auth/projects',
|
||||
},
|
||||
tokenKey: 'auth_token',
|
||||
refreshTokenKey: 'refresh_token',
|
||||
},
|
||||
|
||||
// UI configuration
|
||||
ui: {
|
||||
maxPromptLength: 1000,
|
||||
statusCheckInterval: 10000, // 10 seconds
|
||||
generationTimeout: 30000, // 30 seconds
|
||||
},
|
||||
|
||||
// Wireframe defaults
|
||||
wireframe: {
|
||||
defaultPageSize: { width: 1200, height: 800 },
|
||||
defaultSpacing: { gap: 16, padding: 20 },
|
||||
minElementSize: { width: 80, height: 40 },
|
||||
},
|
||||
} as const
|
||||
|
||||
// Helper function to get full API URL
|
||||
export const getApiUrl = (endpoint: string): string => {
|
||||
return `${config.backend.baseUrl}${endpoint}`
|
||||
}
|
||||
|
||||
// Helper function to get full Auth Service URL
|
||||
export const getAuthUrl = (endpoint: string): string => {
|
||||
return `${config.auth.baseUrl}${endpoint}`
|
||||
}
|
||||
|
||||
// Helper function to get health check URL
|
||||
export const getHealthUrl = (): string => {
|
||||
return getApiUrl(config.backend.endpoints.health)
|
||||
}
|
||||
|
||||
// Helper function to get auth health check URL
|
||||
export const getAuthHealthUrl = (): string => {
|
||||
return getAuthUrl(config.auth.endpoints.health)
|
||||
}
|
||||
|
||||
// Helper function to get wireframe generation URL for specific device
|
||||
export const getWireframeGenerationUrl = (device: 'desktop' | 'tablet' | 'mobile' = 'desktop'): string => {
|
||||
switch (device) {
|
||||
case 'tablet':
|
||||
return getApiUrl(config.backend.endpoints.generateWireframeTablet)
|
||||
case 'mobile':
|
||||
return getApiUrl(config.backend.endpoints.generateWireframeMobile)
|
||||
case 'desktop':
|
||||
default:
|
||||
return getApiUrl(config.backend.endpoints.generateWireframeDesktop)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get universal wireframe generation URL (backward compatibility)
|
||||
export const getUniversalWireframeGenerationUrl = (): string => {
|
||||
return getApiUrl(config.backend.endpoints.generateWireframe)
|
||||
}
|
||||
|
||||
// Helper function to get all devices generation URL
|
||||
export const getAllDevicesGenerationUrl = (): string => {
|
||||
return getApiUrl(config.backend.endpoints.generateAllDevices)
|
||||
}
|
||||
|
||||
// Helper function to get wireframe persistence URLs
|
||||
export const getWireframeUrl = (id?: string): string => {
|
||||
if (id) {
|
||||
return getApiUrl(config.backend.endpoints.wireframe(id))
|
||||
}
|
||||
return getApiUrl(config.backend.endpoints.wireframes)
|
||||
}
|
||||
|
||||
// Helper function to get wireframe by ID URL
|
||||
export const getWireframeByIdUrl = (id: string): string => {
|
||||
return getApiUrl(config.backend.endpoints.wireframe(id))
|
||||
}
|
||||
|
||||
// Authentication helper functions
|
||||
export const getAuthHeaders = (): HeadersInit => {
|
||||
const token = localStorage.getItem(config.auth.tokenKey)
|
||||
return token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
export const getAuthHeadersWithContentType = (): HeadersInit => {
|
||||
const token = localStorage.getItem(config.auth.tokenKey)
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export const isAuthenticated = (): boolean => {
|
||||
const token = localStorage.getItem(config.auth.tokenKey)
|
||||
return !!token
|
||||
}
|
||||
|
||||
export const getCurrentUser = (): any => {
|
||||
try {
|
||||
const token = localStorage.getItem(config.auth.tokenKey)
|
||||
if (!token) return null
|
||||
|
||||
// Decode JWT token to get user info
|
||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||
return payload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
275
src/lib/template-service.ts
Normal file
275
src/lib/template-service.ts
Normal file
@ -0,0 +1,275 @@
|
||||
export interface DatabaseTemplate {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string | null
|
||||
category: string
|
||||
gradient: string | null
|
||||
border: string | null
|
||||
text: string | null
|
||||
subtext: string | null
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
feature_count?: number
|
||||
avg_rating?: number
|
||||
}
|
||||
|
||||
export interface TemplateFeature {
|
||||
id: string
|
||||
template_id: string
|
||||
feature_id: string
|
||||
name: string
|
||||
description: string
|
||||
feature_type: 'essential' | 'suggested' | 'custom'
|
||||
complexity: 'low' | 'medium' | 'high'
|
||||
display_order: number
|
||||
usage_count: number
|
||||
user_rating: number
|
||||
is_default: boolean
|
||||
created_by_user: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface TemplateWithFeatures extends DatabaseTemplate {
|
||||
features: TemplateFeature[]
|
||||
}
|
||||
|
||||
export interface TemplatesByCategory {
|
||||
[category: string]: DatabaseTemplate[]
|
||||
}
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8009'
|
||||
|
||||
class TemplateService {
|
||||
private async makeRequest<T>(endpoint: string): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || 'API request failed')
|
||||
}
|
||||
|
||||
return data.data
|
||||
} catch (error) {
|
||||
console.error('Template service error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getTemplatesByCategory(): Promise<TemplatesByCategory> {
|
||||
return this.makeRequest<TemplatesByCategory>('/api/templates')
|
||||
}
|
||||
|
||||
async getTemplateById(id: string): Promise<TemplateWithFeatures> {
|
||||
return this.makeRequest<TemplateWithFeatures>(`/api/templates/${id}`)
|
||||
}
|
||||
|
||||
async getTemplateByType(type: string): Promise<TemplateWithFeatures> {
|
||||
return this.makeRequest<TemplateWithFeatures>(`/api/templates/type/${type}`)
|
||||
}
|
||||
|
||||
async createTemplate(templateData: Partial<DatabaseTemplate>): Promise<DatabaseTemplate> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/templates`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(templateData),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || 'Failed to create template')
|
||||
}
|
||||
|
||||
return data.data
|
||||
} catch (error) {
|
||||
console.error('Create template error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Features API
|
||||
async getFeaturesForTemplate(templateId: string): Promise<TemplateFeature[]> {
|
||||
// Use merged endpoint to include custom features
|
||||
const dedupe = (items: TemplateFeature[]) => {
|
||||
const byKey = new Map<string, TemplateFeature>()
|
||||
|
||||
const toKey = (f: TemplateFeature) => {
|
||||
const normName = (f.name || '').trim().toLowerCase()
|
||||
// For custom features, dedupe by normalized name; for others, prefer feature_id
|
||||
if (f.feature_type === 'custom') return `custom:${normName}`
|
||||
return `std:${f.feature_id || normName}`
|
||||
}
|
||||
|
||||
const prefer = (a: TemplateFeature, b: TemplateFeature): TemplateFeature => {
|
||||
// Prefer user-created, then higher usage_count, then newer updated_at
|
||||
const aUser = !!a.created_by_user
|
||||
const bUser = !!b.created_by_user
|
||||
if (aUser !== bUser) return aUser ? a : b
|
||||
const aUsage = typeof a.usage_count === 'number' ? a.usage_count : -1
|
||||
const bUsage = typeof b.usage_count === 'number' ? b.usage_count : -1
|
||||
if (aUsage !== bUsage) return aUsage > bUsage ? a : b
|
||||
const aTime = a.updated_at ? Date.parse(a.updated_at) : 0
|
||||
const bTime = b.updated_at ? Date.parse(b.updated_at) : 0
|
||||
return aTime >= bTime ? a : b
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const key = toKey(item)
|
||||
const existing = byKey.get(key)
|
||||
if (!existing) {
|
||||
byKey.set(key, item)
|
||||
} else {
|
||||
byKey.set(key, prefer(existing, item))
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byKey.values())
|
||||
}
|
||||
|
||||
try {
|
||||
const merged = await this.makeRequest<TemplateFeature[]>(`/api/templates/${templateId}/features`)
|
||||
return dedupe(merged)
|
||||
} catch {
|
||||
// Fallback to default-only if merged endpoint unsupported
|
||||
const defaults = await this.makeRequest<TemplateFeature[]>(`/api/features/templates/${templateId}/merged`)
|
||||
return dedupe(defaults)
|
||||
}
|
||||
}
|
||||
|
||||
// Default-only features for template (does not include custom/merged)
|
||||
// async getDefaultFeaturesForTemplate(templateId: string): Promise<TemplateFeature[]> {
|
||||
// return this.makeRequest<TemplateFeature[]>(`/api/templates/${templateId}/features`)
|
||||
// }
|
||||
|
||||
async searchFeatures(searchTerm: string, templateId?: string): Promise<TemplateFeature[]> {
|
||||
const q = encodeURIComponent(searchTerm)
|
||||
const extra = templateId ? `&template_id=${encodeURIComponent(templateId)}` : ''
|
||||
return this.makeRequest<TemplateFeature[]>(`/api/features/search?q=${q}${extra}`)
|
||||
}
|
||||
|
||||
async createFeature(featureData: Partial<TemplateFeature>): Promise<TemplateFeature> {
|
||||
if (
|
||||
featureData &&
|
||||
(featureData.feature_type === 'custom' || (featureData as any).feature_type === 'custom')
|
||||
) {
|
||||
const response = await fetch(`${API_BASE_URL}/api/features/custom`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(featureData),
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||
const data = await response.json()
|
||||
if (!data.success) throw new Error(data.message || 'Failed to create custom feature')
|
||||
return data.data
|
||||
}
|
||||
const response = await fetch(`${API_BASE_URL}/api/features`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(featureData),
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||
const data = await response.json()
|
||||
if (!data.success) throw new Error(data.message || 'Failed to create feature')
|
||||
return data.data
|
||||
}
|
||||
|
||||
async updateFeature(id: string, featureData: Partial<TemplateFeature> & { isCustom?: boolean }): Promise<TemplateFeature> {
|
||||
const isCustom = (featureData as any).isCustom || featureData.feature_type === 'custom'
|
||||
const url = isCustom ? `${API_BASE_URL}/api/features/custom/${id}` : `${API_BASE_URL}/api/features/${id}`
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(featureData),
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||
const data = await response.json()
|
||||
if (!data.success) throw new Error(data.message || 'Failed to update feature')
|
||||
return data.data
|
||||
}
|
||||
|
||||
async deleteFeature(id: string, opts?: { isCustom?: boolean }): Promise<void> {
|
||||
const url = opts?.isCustom ? `${API_BASE_URL}/api/features/custom/${id}` : `${API_BASE_URL}/api/features/${id}`
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||
const data = await response.json()
|
||||
if (!data.success) throw new Error(data.message || 'Failed to delete feature')
|
||||
}
|
||||
|
||||
async updateTemplate(id: string, templateData: Partial<DatabaseTemplate>): Promise<DatabaseTemplate> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/templates/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(templateData),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || 'Failed to update template')
|
||||
}
|
||||
|
||||
return data.data
|
||||
} catch (error) {
|
||||
console.error('Update template error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTemplate(id: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/templates/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || 'Failed to delete template')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete template error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const templateService = new TemplateService()
|
||||
@ -1,6 +1,33 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
// Safe redirect function that works in both client and server environments
|
||||
export const safeRedirect = (url: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = url;
|
||||
}
|
||||
};
|
||||
|
||||
// Safe localStorage functions
|
||||
export const safeLocalStorage = {
|
||||
getItem: (key: string): string | null => {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
return window.localStorage.getItem(key);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
setItem: (key: string, value: string): void => {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
window.localStorage.setItem(key, value);
|
||||
}
|
||||
},
|
||||
removeItem: (key: string): void => {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
39
src/services/aiAnalysis.ts
Normal file
39
src/services/aiAnalysis.ts
Normal file
@ -0,0 +1,39 @@
|
||||
export type Complexity = 'low' | 'medium' | 'high'
|
||||
|
||||
export interface AIAnalysisResponse {
|
||||
complexity: Complexity
|
||||
logicRules: string[]
|
||||
}
|
||||
|
||||
export async function analyzeFeatureWithAI(
|
||||
featureName: string,
|
||||
description: string,
|
||||
requirements: string[],
|
||||
projectType?: string
|
||||
): Promise<AIAnalysisResponse> {
|
||||
try {
|
||||
const res = await fetch('/api/ai/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ featureName, description, requirements, projectType }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok || !json.success) {
|
||||
throw new Error(json.message || `AI request failed (${res.status})`)
|
||||
}
|
||||
const data = json.data as AIAnalysisResponse
|
||||
return { complexity: data.complexity, logicRules: data.logicRules }
|
||||
} catch (error) {
|
||||
// Fallback if the server route fails
|
||||
return {
|
||||
complexity: 'medium',
|
||||
logicRules: [
|
||||
'Unable to determine specific business rules due to insufficient requirements information',
|
||||
'Generic logic implementation may be required',
|
||||
'Basic validation rules should be considered',
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
121
src/types/admin.types.ts
Normal file
121
src/types/admin.types.ts
Normal file
@ -0,0 +1,121 @@
|
||||
// Admin approval workflow types
|
||||
|
||||
export interface AdminFeature {
|
||||
id: string;
|
||||
template_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
complexity: 'low' | 'medium' | 'high';
|
||||
business_rules?: any;
|
||||
technical_requirements?: any;
|
||||
approved: boolean;
|
||||
usage_count: number;
|
||||
created_by_user_session?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Admin workflow fields
|
||||
status: 'pending' | 'approved' | 'rejected' | 'duplicate';
|
||||
admin_notes?: string;
|
||||
admin_reviewed_at?: string;
|
||||
admin_reviewed_by?: string;
|
||||
canonical_feature_id?: string;
|
||||
similarity_score?: number;
|
||||
// Additional fields from joins
|
||||
template_title?: string;
|
||||
}
|
||||
|
||||
export interface AdminNotification {
|
||||
id: string;
|
||||
type: string;
|
||||
message: string;
|
||||
reference_id?: string;
|
||||
reference_type?: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
read_at?: string;
|
||||
}
|
||||
|
||||
export interface FeatureSimilarity {
|
||||
id: string;
|
||||
name: string;
|
||||
match_type: 'exact' | 'synonym' | 'fuzzy';
|
||||
score: number;
|
||||
feature_type: string;
|
||||
complexity: string;
|
||||
}
|
||||
|
||||
export interface DuplicateCheckResult {
|
||||
isDuplicate: boolean;
|
||||
canonicalFeature?: FeatureSimilarity;
|
||||
similarityScore?: number;
|
||||
matchType?: string;
|
||||
}
|
||||
|
||||
export interface FeatureReviewData {
|
||||
status: 'approved' | 'rejected' | 'duplicate';
|
||||
notes?: string;
|
||||
canonical_feature_id?: string;
|
||||
admin_reviewed_by: string;
|
||||
}
|
||||
|
||||
export interface FeatureStats {
|
||||
status: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface NotificationCounts {
|
||||
total: number;
|
||||
unread: number;
|
||||
read: number;
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
features: FeatureStats[];
|
||||
notifications: NotificationCounts;
|
||||
}
|
||||
|
||||
export interface FeatureSynonym {
|
||||
id: string;
|
||||
feature_id: string;
|
||||
synonym: string;
|
||||
created_by?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// API Response types
|
||||
export interface AdminApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
count?: number;
|
||||
message: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FeatureSubmissionResponse {
|
||||
success: boolean;
|
||||
data: AdminFeature;
|
||||
message: string;
|
||||
similarityInfo?: DuplicateCheckResult;
|
||||
}
|
||||
|
||||
// Admin dashboard state
|
||||
export interface AdminDashboardState {
|
||||
pendingFeatures: AdminFeature[];
|
||||
notifications: AdminNotification[];
|
||||
stats: AdminStats | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Admin filters and pagination
|
||||
export interface AdminFilters {
|
||||
status?: 'pending' | 'approved' | 'rejected' | 'duplicate';
|
||||
search?: string;
|
||||
complexity?: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
export interface AdminPagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
}
|
||||
6
src/types/anthropic.d.ts
vendored
Normal file
6
src/types/anthropic.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
declare module '@anthropic-ai/sdk' {
|
||||
const Anthropic: any;
|
||||
export default Anthropic;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user