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:
tejas.prakash 2025-09-04 03:49:12 +00:00
commit 9d95f25f16
54 changed files with 11039 additions and 572 deletions

231
AUTH_IMPLEMENTATION.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -9,9 +9,11 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.57.0",
"@next/font": "^14.2.15", "@next/font": "^14.2.15",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2", "@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-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-progress": "^1.1.7",
@ -19,13 +21,17 @@
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
"@tldraw/tldraw": "^3.15.4",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"next": "15.4.6", "next": "15.4.6",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "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": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
@ -33,6 +39,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/svg-path-parser": "^1.1.6",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.4.6", "eslint-config-next": "15.4.6",
"tailwindcss": "^4", "tailwindcss": "^4",

59
src/app/admin/page.tsx Normal file
View 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>
)
}

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

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

View File

@ -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() { 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
View 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
View File

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

View File

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

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

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

View File

@ -0,0 +1,311 @@
"use client"
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import {
CheckCircle,
XCircle,
Copy,
AlertTriangle,
Search,
ExternalLink
} from 'lucide-react'
import { AdminFeature, FeatureSimilarity, FeatureReviewData } from '@/types/admin.types'
import { adminApi, formatDate, getStatusColor, getComplexityColor } from '@/lib/api/admin'
interface FeatureReviewDialogProps {
feature: AdminFeature
open: boolean
onOpenChange: (open: boolean) => void
onReview: (featureId: string, reviewData: FeatureReviewData) => Promise<void>
}
export function FeatureReviewDialog({
feature,
open,
onOpenChange,
onReview
}: FeatureReviewDialogProps) {
const [status, setStatus] = useState<'approved' | 'rejected' | 'duplicate'>('approved')
const [notes, setNotes] = useState('')
const [canonicalFeatureId, setCanonicalFeatureId] = useState('')
const [similarFeatures, setSimilarFeatures] = useState<FeatureSimilarity[]>([])
const [loadingSimilar, setLoadingSimilar] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
// Load similar features when dialog opens
useEffect(() => {
if (open && feature.name) {
loadSimilarFeatures(feature.name)
}
}, [open, feature.name])
const loadSimilarFeatures = async (query: string) => {
try {
setLoadingSimilar(true)
const features = await adminApi.findSimilarFeatures(query, 0.7, 5)
setSimilarFeatures(features)
} catch (error) {
console.error('Error loading similar features:', error)
} finally {
setLoadingSimilar(false)
}
}
const handleSubmit = async () => {
if (!status) return
const reviewData: FeatureReviewData = {
status,
notes: notes.trim() || undefined,
canonical_feature_id: status === 'duplicate' ? canonicalFeatureId : undefined,
admin_reviewed_by: 'admin' // TODO: Get from auth context
}
try {
setSubmitting(true)
await onReview(feature.id, reviewData)
} catch (error) {
console.error('Error reviewing feature:', error)
} finally {
setSubmitting(false)
}
}
const handleStatusChange = (newStatus: string) => {
setStatus(newStatus as 'approved' | 'rejected' | 'duplicate')
if (newStatus !== 'duplicate') {
setCanonicalFeatureId('')
}
}
const filteredSimilarFeatures = similarFeatures.filter(f =>
f.name.toLowerCase().includes(searchQuery.toLowerCase())
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Review Feature: {feature.name}</DialogTitle>
<DialogDescription>
Review and approve, reject, or mark as duplicate this custom feature submission.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Feature Details */}
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex items-center space-x-2">
<h3 className="text-lg font-semibold">{feature.name}</h3>
<Badge className={getStatusColor(feature.status)}>
{feature.status}
</Badge>
<Badge className={getComplexityColor(feature.complexity)}>
{feature.complexity}
</Badge>
</div>
{feature.description && (
<p className="text-gray-600">{feature.description}</p>
)}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">Template:</span> {feature.template_title || 'Unknown'}
</div>
<div>
<span className="font-medium">Submitted:</span> {formatDate(feature.created_at)}
</div>
{feature.similarity_score && (
<div>
<span className="font-medium">Similarity Score:</span> {(feature.similarity_score * 100).toFixed(1)}%
</div>
)}
<div>
<span className="font-medium">Usage Count:</span> {feature.usage_count}
</div>
</div>
{feature.business_rules && (
<div>
<h4 className="font-medium mb-2">Business Rules:</h4>
<pre className="text-sm bg-gray-50 p-2 rounded">
{JSON.stringify(feature.business_rules, null, 2)}
</pre>
</div>
)}
{feature.technical_requirements && (
<div>
<h4 className="font-medium mb-2">Technical Requirements:</h4>
<pre className="text-sm bg-gray-50 p-2 rounded">
{JSON.stringify(feature.technical_requirements, null, 2)}
</pre>
</div>
)}
</div>
</CardContent>
</Card>
{/* Similar Features */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<h4 className="font-medium">Similar Features</h4>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Search similar features..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-4 py-2 border rounded-md text-sm"
/>
</div>
</div>
{loadingSimilar ? (
<div className="text-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900 mx-auto"></div>
<p className="text-sm text-gray-500 mt-2">Loading similar features...</p>
</div>
) : filteredSimilarFeatures.length > 0 ? (
<div className="space-y-2">
{filteredSimilarFeatures.map((similar) => (
<div
key={similar.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
>
<div className="flex-1">
<div className="flex items-center space-x-2">
<span className="font-medium">{similar.name}</span>
<Badge className={getComplexityColor(similar.complexity)}>
{similar.complexity}
</Badge>
<Badge variant="outline">
{similar.match_type} ({(similar.score * 100).toFixed(1)}%)
</Badge>
</div>
<p className="text-sm text-gray-500">{similar.feature_type}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCanonicalFeatureId(similar.id)}
disabled={status !== 'duplicate'}
>
<ExternalLink className="h-4 w-4 mr-1" />
Select as Duplicate
</Button>
</div>
))}
</div>
) : (
<div className="text-center py-4 text-gray-500">
<AlertTriangle className="h-8 w-8 mx-auto mb-2 text-gray-300" />
<p>No similar features found</p>
</div>
)}
</CardContent>
</Card>
{/* Review Form */}
<div className="space-y-4">
<div>
<Label htmlFor="status">Review Decision</Label>
<Select value={status} onValueChange={handleStatusChange}>
<SelectTrigger>
<SelectValue placeholder="Select review decision" />
</SelectTrigger>
<SelectContent>
<SelectItem value="approved">
<div className="flex items-center space-x-2">
<CheckCircle className="h-4 w-4 text-green-600" />
<span>Approve</span>
</div>
</SelectItem>
<SelectItem value="rejected">
<div className="flex items-center space-x-2">
<XCircle className="h-4 w-4 text-red-600" />
<span>Reject</span>
</div>
</SelectItem>
<SelectItem value="duplicate">
<div className="flex items-center space-x-2">
<Copy className="h-4 w-4 text-orange-600" />
<span>Mark as Duplicate</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{status === 'duplicate' && (
<div>
<Label htmlFor="canonical">Canonical Feature ID</Label>
<input
id="canonical"
type="text"
value={canonicalFeatureId}
onChange={(e) => setCanonicalFeatureId(e.target.value)}
placeholder="Enter the ID of the canonical feature"
className="w-full px-3 py-2 border rounded-md"
/>
<p className="text-sm text-gray-500 mt-1">
Select a similar feature above or enter the canonical feature ID manually
</p>
</div>
)}
<div>
<Label htmlFor="notes">Admin Notes (Optional)</Label>
<Textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add notes about your decision..."
rows={3}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={submitting || (status === 'duplicate' && !canonicalFeatureId)}
>
{submitting ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Submitting...
</>
) : (
`Submit ${status.charAt(0).toUpperCase() + status.slice(1)}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,215 @@
"use client"
import { useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils"
type Block = {
id: string
label: string
x: number
y: number
w: number
h: number
}
export type WireframePlan = {
version: number
blocks: Block[]
}
export function AISidePanel({
onGenerate,
onClear,
className,
}: {
onGenerate: (plan: WireframePlan) => void
onClear: () => void
className?: string
}) {
const [collapsed, setCollapsed] = useState(false)
const [prompt, setPrompt] = useState(
"Dashboard with header, left sidebar, 3 stats cards, a line chart and a data table, plus footer.",
)
const [version, setVersion] = useState(1)
const examples = useMemo(
() => [
"Landing page with header, hero, 3x2 feature grid, and footer.",
"Settings screen: header, list of toggles, and save button.",
"Ecommerce product page: header, 2-column gallery/details, reviews, sticky add-to-cart.",
"Dashboard: header, left sidebar, 3 stats cards, line chart, data table, footer.",
"Signup page: header, 2-column form, callout, submit button.",
],
[],
)
function parsePromptToPlan(input: string): WireframePlan {
const W = 1200
const H = 800
const P = 24
const blocks: Block[] = []
let y = P
let x = P
let width = W - 2 * P
const lower = input.toLowerCase()
const addBlock = (label: string, bx: number, by: number, bw: number, bh: number) => {
blocks.push({
id: `${label}-${Math.random().toString(36).slice(2, 8)}`,
label,
x: Math.round(bx),
y: Math.round(by),
w: Math.round(bw),
h: Math.round(bh),
})
}
if (/\bheader\b/.test(lower) || /\bnavbar\b/.test(lower)) {
addBlock("Header", x, y, width, 72)
y += 72 + P
}
let hasSidebar = false
if (/\bsidebar\b/.test(lower) || /\bleft sidebar\b/.test(lower)) {
hasSidebar = true
addBlock("Sidebar", x, y, 260, H - y - P)
x += 260 + P
width = W - x - P
}
if (/\bhero\b/.test(lower)) {
addBlock("Hero", x, y, width, 200)
y += 200 + P
}
if (/stats?\b/.test(lower) || /\bcards?\b/.test(lower)) {
const cols = /4/.test(lower) ? 4 : 3
const gap = P
const cardW = (width - gap * (cols - 1)) / cols
const cardH = 100
for (let i = 0; i < cols; i++) {
addBlock(`Card ${i + 1}`, x + i * (cardW + gap), y, cardW, cardH)
}
y += cardH + P
}
const gridMatch = lower.match(/(\d)\s*x\s*(\d)/)
if (gridMatch) {
const cols = Number.parseInt(gridMatch[1], 10)
const rows = Number.parseInt(gridMatch[2], 10)
const gap = P
const cellW = (width - gap * (cols - 1)) / cols
const cellH = 120
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
addBlock(`Feature ${r * cols + c + 1}`, x + c * (cellW + gap), y + r * (cellH + gap), cellW, cellH)
}
}
y += rows * cellH + (rows - 1) * gap + P
}
if (/\b2[-\s]?column\b/.test(lower) || /gallery\/details/.test(lower) || /gallery/.test(lower)) {
const gap = P
const colW = (width - gap) / 2
const colH = 260
addBlock("Left Column", x, y, colW, colH)
addBlock("Right Column", x + colW + gap, y, colW, colH)
y += colH + P
}
if (/\bchart\b/.test(lower) || /\bline chart\b/.test(lower)) {
addBlock("Chart", x, y, width, 220)
y += 220 + P
}
if (/\btable\b/.test(lower)) {
addBlock("Data Table", x, y, width, 260)
y += 260 + P
}
if (/\bform\b/.test(lower) || /\bsignup\b/.test(lower) || /\blogin\b/.test(lower)) {
const gap = P
const twoCol = /\b2[-\s]?column\b/.test(lower)
if (twoCol) {
const colW = (width - gap) / 2
addBlock("Form Left", x, y, colW, 260)
addBlock("Form Right", x + colW + gap, y, colW, 260)
y += 260 + P
} else {
addBlock("Form", x, y, width, 220)
y += 220 + P
}
}
if (/\bfooter\b/.test(lower)) {
addBlock("Footer", P, H - 80 - P, W - 2 * P, 80)
}
return { version: Date.now() + version, blocks }
}
const handleGenerate = () => {
const plan = parsePromptToPlan(prompt)
setVersion((v) => v + 1)
onGenerate(plan)
}
return (
<aside
className={cn(
"h-full border-l bg-white dark:bg-neutral-900 flex flex-col",
collapsed ? "w-12" : "w-96",
className,
)}
aria-label="AI prompt side panel"
>
<div className="flex items-center justify-between px-3 py-2 border-b">
<h2 className={cn("text-sm font-medium text-balance", collapsed && "sr-only")}>AI Wireframe</h2>
<Button variant="ghost" size="icon" onClick={() => setCollapsed((c) => !c)} aria-label="Toggle panel">
{collapsed ? <span aria-hidden></span> : <span aria-hidden></span>}
</Button>
</div>
{!collapsed && (
<div className="flex flex-col gap-3 p-3">
<label className="text-xs font-medium">Prompt</label>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe your screen: Landing with header, hero, 3x2 features, footer"
className="min-h-28"
/>
<div className="flex gap-2">
<Button onClick={handleGenerate} className="flex-1">
Generate
</Button>
<Button variant="secondary" onClick={onClear}>
Clear
</Button>
</div>
<div className="pt-1">
<p className="text-xs font-medium mb-2">Examples</p>
<ScrollArea className="h-40 border rounded">
<ul className="p-2 space-y-2">
{examples.map((ex) => (
<li key={ex}>
<button
type="button"
onClick={() => setPrompt(ex)}
className="text-left text-xs w-full hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded px-2 py-1"
>
{ex}
</button>
</li>
))}
</ul>
</ScrollArea>
</div>
</div>
)}
</aside>
)
}

View File

@ -0,0 +1,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

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

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

View File

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

View File

@ -7,14 +7,12 @@ import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" 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 { useAuth } from "@/contexts/auth-context"
import { loginUser } from "@/components/apis/authenticationHandler"
import { setTokens } from "@/components/apis/authApiClients"
interface SignInFormProps { export function SignInForm() {
onToggleMode: () => void
}
export function SignInForm({ onToggleMode }: SignInFormProps) {
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
@ -23,23 +21,51 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
password: "", password: "",
}) })
const { login } = useAuth()
const router = useRouter() const router = useRouter()
const { setUserFromApi } = useAuth()
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError("") setError("")
setIsLoading(true)
if (!formData.email || !formData.password) {
setError("Please fill in all required fields.")
return
}
setIsLoading(true)
try { try {
const success = await login(formData.email, formData.password) const response = await loginUser(formData.email, formData.password)
if (success) {
router.push("/") 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 { } 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 { } finally {
setIsLoading(false) setIsLoading(false)
} }
@ -47,7 +73,7 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
return ( return (
<div className="w-full"> <div className="w-full">
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email" className="text-white text-sm">Email</Label> <Label htmlFor="email" className="text-white text-sm">Email</Label>
<Input <Input
@ -57,7 +83,7 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
value={formData.email} value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required 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>
<div className="space-y-2"> <div className="space-y-2">
@ -70,13 +96,13 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
value={formData.password} value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })} onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required 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 <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" 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)} onClick={() => setShowPassword(!showPassword)}
> >
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
@ -86,11 +112,18 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
{error && ( {error && (
<div className="text-red-300 text-sm text-center bg-red-900/20 p-3 rounded-md border border-red-500/30"> <div className="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> </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 ? ( {isLoading ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -100,11 +133,6 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
"Sign In" "Sign In"
)} )}
</Button> </Button>
<div className="text-center">
<Button type="button" variant="link" onClick={onToggleMode} className="text-sm cursor-pointer text-white/70 hover:text-white">
Don&apos;t have an account? Sign up
</Button>
</div>
</form> </form>
</div> </div>
) )

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

View File

@ -8,165 +8,257 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 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 { useAuth } from "@/contexts/auth-context"
import { registerUser } from "../apis/authenticationHandler"
interface SignUpFormProps { interface SignUpFormProps {
onToggleMode: () => void onSignUpSuccess?: () => void
} }
export function SignUpForm({ onToggleMode }: SignUpFormProps) { export function SignUpForm({ onSignUpSuccess }: SignUpFormProps) {
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false) const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", username: "",
first_name: "",
last_name: "",
email: "", email: "",
password: "", password: "",
confirmPassword: "", confirmPassword: "",
role: "user" // default role, adjust as needed
}) })
const { signup } = useAuth()
const router = useRouter() const router = useRouter()
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError("") setError("")
// Validation
if (formData.password !== formData.confirmPassword) { if (formData.password !== formData.confirmPassword) {
setError("Passwords don't match") setError("Passwords don't match")
return 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) setIsLoading(true)
try { try {
const success = await signup(formData.name, formData.email, formData.password) const response = await registerUser({
if (success) { username: formData.username,
router.push("/") 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 { } 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 { } finally {
setIsLoading(false) setIsLoading(false)
} }
} }
return ( return (
<Card className="w-full max-w-md bg-transparent border-0 shadow-none"> <div className="w-full max-w-md">
<CardHeader className="space-y-1 p-0 mb-2"> <form onSubmit={handleSubmit} className="space-y-4">
<CardTitle className="sr-only">Sign Up</CardTitle> {/* Personal Information Section */}
<CardDescription className="sr-only">Create your account to get started</CardDescription> <div className="space-y-3">
</CardHeader> <div className="flex items-center space-x-2 mb-2">
<CardContent className="p-0"> <User className="h-4 w-4 text-orange-400" />
<form onSubmit={handleSubmit} className="space-y-5"> <h3 className="text-base font-semibold text-white">Personal Information</h3>
<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> </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 <Input
id="email" id="email"
type="email" type="email"
placeholder="Enter your email" placeholder="john.doe@example.com"
value={formData.email} value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required 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>
<div className="space-y-2"> </div>
<Label htmlFor="password" className="text-white text-sm">Password</Label>
<div className="relative"> {/* Security Section */}
<Input <div className="space-y-3">
id="password" <div className="flex items-center space-x-2 mb-2">
type={showPassword ? "text" : "password"} <Lock className="h-4 w-4 text-orange-400" />
placeholder="Enter your password" <h3 className="text-base font-semibold text-white">Security</h3>
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> </div>
{error && ( <div className="grid grid-cols-2 gap-3">
<div className="text-red-300 text-sm text-center bg-red-900/20 p-3 rounded-md border border-red-500/30"> <div className="space-y-1">
{error} <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>
<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>
<Button </form>
type="submit" </div>
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>
) )
} }

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

View File

@ -0,0 +1,210 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { DatabaseTemplate } from "@/lib/template-service"
import { Plus, X, Save } from "lucide-react"
interface CustomTemplateFormProps {
onSubmit: (templateData: Partial<DatabaseTemplate>) => Promise<void>
onCancel: () => void
}
export function CustomTemplateForm({ onSubmit, onCancel }: CustomTemplateFormProps) {
const [formData, setFormData] = useState({
type: "",
title: "",
description: "",
category: "",
icon: "",
gradient: "",
border: "",
text: "",
subtext: ""
})
const [loading, setLoading] = useState(false)
const categories = [
"Food Delivery",
"E-commerce",
"SaaS Platform",
"Mobile App",
"Dashboard",
"CRM System",
"Learning Platform",
"Healthcare",
"Real Estate",
"Travel",
"Entertainment",
"Finance",
"Social Media",
"Marketplace",
"Other"
]
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
await onSubmit(formData)
} catch (error) {
console.error('Error creating template:', error)
} finally {
setLoading(false)
}
}
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
}))
}
return (
<Card className="bg-white/5 border-white/10">
<CardHeader>
<CardTitle className="text-white flex items-center">
<Plus className="mr-2 h-5 w-5" />
Create Custom Template
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-white">Template Type *</label>
<Input
placeholder="e.g., multi_restaurant_food_delivery"
value={formData.type}
onChange={(e) => handleInputChange('type', e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
required
/>
<p className="text-xs text-white/60">Unique identifier for the template</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Title *</label>
<Input
placeholder="e.g., Multi-Restaurant Food Delivery App"
value={formData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Description *</label>
<Textarea
placeholder="Describe your template and its key features..."
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40 min-h-[100px]"
required
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-white">Category *</label>
<Select value={formData.category} onValueChange={(value) => handleInputChange('category', value)}>
<SelectTrigger className="bg-white/5 border-white/10 text-white">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent className="bg-gray-900 border-white/10">
{categories.map((category) => (
<SelectItem key={category} value={category} className="text-white">
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Icon (optional)</label>
<Input
placeholder="e.g., restaurant, shopping-cart, users"
value={formData.icon}
onChange={(e) => handleInputChange('icon', e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-white">Gradient (optional)</label>
<Input
placeholder="e.g., from-orange-400 to-red-500"
value={formData.gradient}
onChange={(e) => handleInputChange('gradient', e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Border (optional)</label>
<Input
placeholder="e.g., border-orange-500"
value={formData.border}
onChange={(e) => handleInputChange('border', e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Text Color (optional)</label>
<Input
placeholder="e.g., text-orange-500"
value={formData.text}
onChange={(e) => handleInputChange('text', e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Subtext (optional)</label>
<Input
placeholder="e.g., Perfect for food delivery startups"
value={formData.subtext}
onChange={(e) => handleInputChange('subtext', e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="border-white/20 text-white hover:bg-white/10 cursor-pointer"
disabled={loading}
>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button
type="submit"
className="bg-orange-500 hover:bg-orange-400 text-black font-semibold cursor-pointer"
disabled={loading}
>
<Save className="mr-2 h-4 w-4" />
{loading ? "Creating..." : "Create Template"}
</Button>
</div>
</form>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,60 @@
"use client"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { AlertTriangle, Trash2, X } from "lucide-react"
interface DeleteConfirmationDialogProps {
templateTitle: string
onConfirm: () => Promise<void>
onCancel: () => void
loading?: boolean
}
export function DeleteConfirmationDialog({
templateTitle,
onConfirm,
onCancel,
loading = false
}: DeleteConfirmationDialogProps) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="bg-white/5 border-white/10 max-w-md w-full mx-4">
<CardHeader>
<CardTitle className="text-white flex items-center">
<AlertTriangle className="mr-2 h-5 w-5 text-red-400" />
Delete Template
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-white/80">
Are you sure you want to delete the template <strong className="text-white">"{templateTitle}"</strong>?
</p>
<p className="text-white/60 text-sm">
This action cannot be undone. The template will be permanently removed from the database.
</p>
<div className="flex justify-end space-x-3 pt-4">
<Button
variant="outline"
onClick={onCancel}
className="border-white/20 text-white hover:bg-white/10"
disabled={loading}
>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button
onClick={onConfirm}
className="bg-red-500 hover:bg-red-400 text-white"
disabled={loading}
>
<Trash2 className="mr-2 h-4 w-4" />
{loading ? "Deleting..." : "Delete Template"}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,225 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { DatabaseTemplate } from "@/lib/template-service"
import { Edit, X, Save } from "lucide-react"
interface EditTemplateFormProps {
template: DatabaseTemplate
onSubmit: (id: string, templateData: Partial<DatabaseTemplate>) => Promise<void>
onCancel: () => void
}
export function EditTemplateForm({ template, onSubmit, onCancel }: EditTemplateFormProps) {
const [formData, setFormData] = useState({
type: "",
title: "",
description: "",
category: "",
icon: "",
gradient: "",
border: "",
text: "",
subtext: ""
})
const [loading, setLoading] = useState(false)
const categories = [
"Food Delivery",
"E-commerce",
"SaaS Platform",
"Mobile App",
"Dashboard",
"CRM System",
"Learning Platform",
"Healthcare",
"Real Estate",
"Travel",
"Entertainment",
"Finance",
"Social Media",
"Marketplace",
"Other"
]
// Initialize form data with template values
useEffect(() => {
setFormData({
type: template.type || "",
title: template.title || "",
description: template.description || "",
category: template.category || "",
icon: template.icon || "",
gradient: template.gradient || "",
border: template.border || "",
text: template.text || "",
subtext: template.subtext || ""
})
}, [template])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
await onSubmit(template.id, formData)
} catch (error) {
console.error('Error updating template:', error)
} finally {
setLoading(false)
}
}
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
}))
}
return (
<Card className="bg-white/5 border-white/10">
<CardHeader>
<CardTitle className="text-white flex items-center">
<Edit className="mr-2 h-5 w-5" />
Edit Template: {template.title}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-white">Template Type *</label>
<Input
placeholder="e.g., multi_restaurant_food_delivery"
value={formData.type}
onChange={(e) => handleInputChange('type', e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
required
/>
<p className="text-xs text-white/60">Unique identifier for the template</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Title *</label>
<Input
placeholder="e.g., Multi-Restaurant Food Delivery App"
value={formData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Description *</label>
<Textarea
placeholder="Describe your template and its key features..."
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40 min-h-[100px]"
required
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-white">Category *</label>
<Select value={formData.category} onValueChange={(value) => handleInputChange('category', value)}>
<SelectTrigger className="bg-white/5 border-white/10 text-white">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent className="bg-gray-900 border-white/10">
{categories.map((category) => (
<SelectItem key={category} value={category} className="text-white">
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Icon (optional)</label>
<Input
placeholder="e.g., restaurant, shopping-cart, users"
value={formData.icon}
onChange={(e) => handleInputChange('icon', e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-white">Gradient (optional)</label>
<Input
placeholder="e.g., from-orange-400 to-red-500"
value={formData.gradient}
onChange={(e) => handleInputChange('gradient', e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Border (optional)</label>
<Input
placeholder="e.g., border-orange-500"
value={formData.border}
onChange={(e) => handleInputChange('border', e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Text Color (optional)</label>
<Input
placeholder="e.g., text-orange-500"
value={formData.text}
onChange={(e) => handleInputChange('text', e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Subtext (optional)</label>
<Input
placeholder="e.g., Perfect for food delivery startups"
value={formData.subtext}
onChange={(e) => handleInputChange('subtext', e.target.value)}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="border-white/20 text-white hover:bg-white/10 cursor-pointer"
disabled={loading}
>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button
type="submit"
className="bg-orange-500 hover:bg-orange-400 text-black font-semibold cursor-pointer"
disabled={loading}
>
<Save className="mr-2 h-4 w-4" />
{loading ? "Updating..." : "Update Template"}
</Button>
</div>
</form>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,339 @@
"use client"
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
CheckCircle,
AlertTriangle,
Loader2,
Search,
Info
} from 'lucide-react'
import { featureApi } from '@/lib/api/admin'
import { FeatureSimilarity, DuplicateCheckResult } from '@/types/admin.types'
interface FeatureSubmissionFormProps {
templateId: string
templateName?: string
onSuccess?: (feature: any) => void
onCancel?: () => void
}
export function FeatureSubmissionForm({
templateId,
templateName,
onSuccess,
onCancel
}: FeatureSubmissionFormProps) {
const [formData, setFormData] = useState({
name: '',
description: '',
complexity: 'medium' as 'low' | 'medium' | 'high',
business_rules: '',
technical_requirements: ''
})
const [submitting, setSubmitting] = useState(false)
const [similarFeatures, setSimilarFeatures] = useState<FeatureSimilarity[]>([])
const [duplicateInfo, setDuplicateInfo] = useState<DuplicateCheckResult | null>(null)
const [searchingSimilar, setSearchingSimilar] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }))
// Clear previous results when name changes
if (field === 'name') {
setSimilarFeatures([])
setDuplicateInfo(null)
}
}
const searchSimilarFeatures = async () => {
if (!formData.name.trim()) return
try {
setSearchingSimilar(true)
const features = await featureApi.findSimilarFeatures(formData.name, 0.7, 5)
setSimilarFeatures(features)
// Check if any are potential duplicates
const potentialDuplicate = features.find(f => f.score >= 0.8)
if (potentialDuplicate) {
setDuplicateInfo({
isDuplicate: true,
canonicalFeature: potentialDuplicate,
similarityScore: potentialDuplicate.score,
matchType: potentialDuplicate.match_type
})
} else {
setDuplicateInfo({ isDuplicate: false })
}
} catch (error) {
console.error('Error searching similar features:', error)
} finally {
setSearchingSimilar(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.name.trim()) {
setError('Feature name is required')
return
}
try {
setSubmitting(true)
setError(null)
const response = await featureApi.submitCustomFeature({
template_id: templateId,
name: formData.name.trim(),
description: formData.description.trim() || undefined,
complexity: formData.complexity,
business_rules: formData.business_rules.trim() ? JSON.parse(formData.business_rules) : undefined,
technical_requirements: formData.technical_requirements.trim() ? JSON.parse(formData.technical_requirements) : undefined,
created_by_user_session: 'user-session' // TODO: Get from auth context
})
setSuccess(true)
if (onSuccess) {
onSuccess(response.data)
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to submit feature')
console.error('Error submitting feature:', error)
} finally {
setSubmitting(false)
}
}
const getComplexityColor = (complexity: string) => {
switch (complexity) {
case 'low':
return 'bg-green-100 text-green-800'
case 'medium':
return 'bg-yellow-100 text-yellow-800'
case 'high':
return 'bg-red-100 text-red-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
if (success) {
return (
<Card>
<CardContent className="pt-6">
<div className="text-center">
<CheckCircle className="h-12 w-12 text-green-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">Feature Submitted Successfully!</h3>
<p className="text-gray-600 mb-4">
Your feature "{formData.name}" has been submitted and is pending admin review.
</p>
{duplicateInfo?.isDuplicate && (
<Alert className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Similar features were found. An admin will review this submission to determine if it's a duplicate.
</AlertDescription>
</Alert>
)}
<div className="space-x-2">
<Button onClick={() => window.location.reload()}>
Submit Another Feature
</Button>
{onCancel && (
<Button variant="outline" onClick={onCancel}>
Close
</Button>
)}
</div>
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Submit Custom Feature</CardTitle>
{templateName && (
<p className="text-sm text-gray-600">
Adding feature to template: <strong>{templateName}</strong>
</p>
)}
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Feature Name */}
<div className="space-y-2">
<Label htmlFor="name">Feature Name *</Label>
<div className="flex space-x-2">
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
placeholder="Enter feature name..."
required
/>
<Button
type="button"
variant="outline"
onClick={searchSimilarFeatures}
disabled={!formData.name.trim() || searchingSimilar}
>
{searchingSimilar ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* Similar Features Alert */}
{similarFeatures.length > 0 && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<div className="space-y-2">
<p>Similar features found:</p>
<div className="space-y-1">
{similarFeatures.map((feature) => (
<div key={feature.id} className="flex items-center justify-between text-sm">
<span>{feature.name}</span>
<Badge variant="outline">
{(feature.score * 100).toFixed(1)}% match
</Badge>
</div>
))}
</div>
{duplicateInfo?.isDuplicate && (
<p className="text-amber-600 font-medium mt-2">
This may be a duplicate. Please review before submitting.
</p>
)}
</div>
</AlertDescription>
</Alert>
)}
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Describe what this feature does..."
rows={3}
/>
</div>
{/* Complexity */}
<div className="space-y-2">
<Label htmlFor="complexity">Complexity</Label>
<Select value={formData.complexity} onValueChange={(value) => handleInputChange('complexity', value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">
<div className="flex items-center space-x-2">
<Badge className="bg-green-100 text-green-800">Low</Badge>
<span>Simple implementation</span>
</div>
</SelectItem>
<SelectItem value="medium">
<div className="flex items-center space-x-2">
<Badge className="bg-yellow-100 text-yellow-800">Medium</Badge>
<span>Moderate complexity</span>
</div>
</SelectItem>
<SelectItem value="high">
<div className="flex items-center space-x-2">
<Badge className="bg-red-100 text-red-800">High</Badge>
<span>Complex implementation</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Business Rules */}
<div className="space-y-2">
<Label htmlFor="business_rules">Business Rules (JSON)</Label>
<Textarea
id="business_rules"
value={formData.business_rules}
onChange={(e) => handleInputChange('business_rules', e.target.value)}
placeholder='{"rule1": "description", "rule2": "description"}'
rows={3}
/>
<p className="text-xs text-gray-500">
Optional: Define business rules for this feature in JSON format
</p>
</div>
{/* Technical Requirements */}
<div className="space-y-2">
<Label htmlFor="technical_requirements">Technical Requirements (JSON)</Label>
<Textarea
id="technical_requirements"
value={formData.technical_requirements}
onChange={(e) => handleInputChange('technical_requirements', e.target.value)}
placeholder='{"framework": "React", "database": "PostgreSQL"}'
rows={3}
/>
<p className="text-xs text-gray-500">
Optional: Define technical requirements in JSON format
</p>
</div>
{/* Error Display */}
{error && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Submit Button */}
<div className="flex space-x-2">
<Button
type="submit"
disabled={submitting || !formData.name.trim()}
className="flex-1"
>
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Submitting...
</>
) : (
'Submit Feature'
)}
</Button>
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
)}
</div>
</form>
</CardContent>
</Card>
)
}

View File

@ -1,7 +1,7 @@
"use client" "use client"
import { useAuth } from "@/contexts/auth-context" import { useAuth } from "@/contexts/auth-context"
import Header from "@/components/navigation/header" import { Header } from "@/components/navigation/header"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
interface AppLayoutProps { interface AppLayoutProps {
@ -9,37 +9,18 @@ interface AppLayoutProps {
} }
export function AppLayout({ children }: AppLayoutProps) { export function AppLayout({ children }: AppLayoutProps) {
const { isAuthenticated, isLoading } = useAuth() const { user } = useAuth()
const pathname = usePathname() const pathname = usePathname()
// Don't show header on auth pages // Don't show header on auth pages
const isAuthPage = pathname === "/auth" const isAuthPage = pathname === "/signin" || pathname === "/signup"
// 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>
)
}
// For auth pages, don't show header // For auth pages, don't show header
if (isAuthPage) { if (isAuthPage) {
return <>{children}</> return <>{children}</>
} }
// For authenticated users on other pages, show header // For all other pages, show header
if (isAuthenticated) {
return (
<>
<Header />
{children}
</>
)
}
// For unauthenticated users on non-auth pages, redirect to auth
return ( return (
<> <>
<Header /> <Header />

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,10 @@
"use client" "use client";
import { useState } from "react" import { useState } from "react";
import Link from "next/link" import Link from "next/link";
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -12,23 +12,41 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge";
import { Bell, Settings, LogOut, User, Menu, X } from "lucide-react" import { Bell, Settings, LogOut, User, Menu, X, Shield } from "lucide-react";
import { useAuth } from "@/contexts/auth-context" import { useAuth } from "@/contexts/auth-context";
const navigation = [ const navigation = [
{ name: "Project Builder", href: "/", current: false }, { name: "Project Builder", href: "/project-builder" },
{ name: "Templates", href: "/templates", current: false }, { name: "Templates", href: "/templates" },
{ name: "Features", href: "/features", current: false }, { name: "Features", href: "/features" },
{ name: "Business Context", href: "/business-context", current: false }, { name: "Business Context", href: "/business-context" },
{ name: "Architecture", href: "/architecture", current: false }, { name: "Architecture", href: "/architecture" },
] ];
export default function Header() { export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const pathname = usePathname() const [isLoggingOut, setIsLoggingOut] = useState(false);
const { user, logout } = useAuth() 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 ( return (
<header className="bg-black/80 text-white border-b border-white/10 backdrop-blur"> <header className="bg-black/80 text-white border-b border-white/10 backdrop-blur">
@ -59,13 +77,27 @@ export default function Header() {
{item.name} {item.name}
</Link> </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> </nav>
{/* Right side */} {/* Right side */}
<div className="flex items-center space-x-4 cursor-pointer"> <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 ? ( {!user ? (
<Link href="/auth"> <Link href="/signin">
<Button size="sm" className="cursor-pointer">Sign In</Button> <Button size="sm" className="cursor-pointer">Sign In</Button>
</Link> </Link>
) : ( ) : (
@ -81,21 +113,29 @@ export default function Header() {
)} )}
{/* User Menu - Only show when user is authenticated */} {/* User Menu - Only show when user is authenticated */}
{user && ( {user && user.email && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5"> <Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarImage src={user.avatar || "/avatars/01.png"} alt={user.name} /> <AvatarImage src={user.avatar || "/avatars/01.png"} alt={user.name || user.email || "User"} />
<AvatarFallback>{user.name.charAt(0).toUpperCase()}</AvatarFallback> <AvatarFallback>
{(user.name && user.name.charAt(0)) || (user.email && user.email.charAt(0)) || "U"}
</AvatarFallback>
</Avatar> </Avatar>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-56 bg-black text-white border-white/10" align="end" forceMount> <DropdownMenuContent className="w-56 bg-black text-white border-white/10" align="end" forceMount>
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user.name}</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}</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> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@ -108,16 +148,21 @@ export default function Header() {
<span>Settings</span> <span>Settings</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <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" /> <LogOut className="mr-2 h-4 w-4" />
<span>Log out</span> <span>{isLoggingOut ? "Logging out..." : "Log out"}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
{/* Mobile menu button */} {/* 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" />} {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button> </Button>
</div> </div>
@ -125,13 +170,13 @@ export default function Header() {
{/* Mobile Navigation */} {/* Mobile Navigation */}
{mobileMenuOpen && ( {mobileMenuOpen && (
<div className="md:hidden"> <div className="md:hidden py-4 border-t border-white/10">
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3 border-t border-white/10"> <nav className="flex flex-col space-y-2">
{navigation.map((item) => ( {navigation.map((item) => (
<Link <Link
key={item.name} key={item.name}
href={item.href} 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 pathname === item.href
? "bg-orange-500 text-black" ? "bg-orange-500 text-black"
: "text-white/70 hover:text-white hover:bg-white/5" : "text-white/70 hover:text-white hover:bg-white/5"
@ -141,10 +186,25 @@ export default function Header() {
{item.name} {item.name}
</Link> </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>
)} )}
</div> </div>
</header> </header>
) );
} }

View 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.",
"Ecommerce product page: header, 2-column gallery/details, reviews, sticky add-to-cart.",
"Dashboard: header, left sidebar, 3 stats cards, line chart, data table, footer.",
"Signup page: header, 2-column form, callout, submit button.",
"Admin panel: header, left navigation, main content area with data tables, and footer.",
"Product catalog: header, search bar, filter sidebar, 4x3 product grid, pagination.",
"Blog layout: header, featured post hero, 2-column article list, sidebar with categories.",
],
[],
)
// Check backend connection status
useEffect(() => {
const checkBackendStatus = async () => {
try {
setBackendStatus('checking')
const response = await fetch(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

View File

@ -0,0 +1,72 @@
"use client"
import type React from "react"
import { useState } from "react"
export default function PromptToolbar({
busy,
onGenerate,
onClear,
onExample,
}: {
busy?: boolean
onGenerate: (prompt: string) => void
onClear: () => void
onExample: (text: string) => void
}) {
const [prompt, setPrompt] = useState("Dashboard with header, sidebar, 3x2 cards grid, and footer")
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!prompt.trim() || busy) return
onGenerate(prompt.trim())
}
const examples = [
"Marketing landing page with hero, 3x2 features grid, signup form, and footer",
"Simple login screen with header and centered form",
"Ecommerce product grid 4x2 with header, sidebar filters, and footer",
"Admin dashboard with header, sidebar, 2x2 cards and a form",
]
return (
<div className="flex flex-col gap-3">
<form onSubmit={submit} className="flex items-center gap-2">
<input
aria-label="Prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="e.g., Dashboard with header, sidebar, 3x2 cards grid, and footer"
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500"
/>
<button
type="submit"
disabled={busy}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
>
{busy ? "Generating…" : "Generate"}
</button>
<button
type="button"
onClick={onClear}
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-800 hover:bg-gray-50"
>
Clear
</button>
</form>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-gray-600">Try:</span>
{examples.map((ex) => (
<button
key={ex}
onClick={() => onExample(ex)}
className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs text-gray-800 hover:bg-gray-50"
>
{ex}
</button>
))}
</div>
</div>
)
}

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

View File

@ -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 { interface User {
id: string id: string
name: string
email: string email: string
avatar?: string username: string
role?: 'user' | 'admin'
} }
interface AuthContextType { interface AuthContextValue {
user: User | null user: User | null
isAuthenticated: boolean isAdmin: boolean
isLoading: boolean setUserFromApi: (user: User) => void
login: (email: string, password: string) => Promise<boolean> logout: () => Promise<void>
signup: (name: string, email: string, password: string) => Promise<boolean>
logout: () => void
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined) const AuthContext = createContext<AuthContextValue | undefined>(undefined)
export function useAuth() { export function AuthProvider({ children }: { children: React.ReactNode }) {
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) {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Check if user is logged in on mount
useEffect(() => { useEffect(() => {
const checkAuth = () => { const stored = safeLocalStorage.getItem('codenuk_user')
// Check localStorage for user data if (stored) {
const userData = localStorage.getItem("codenuk_user") try {
if (userData) { const userData = JSON.parse(stored)
try { setUser(userData)
const user = JSON.parse(userData) } catch (error) {
setUser(user) console.error('Failed to parse stored user data:', error)
} catch (error) { safeLocalStorage.removeItem('codenuk_user')
console.error("Error parsing user data:", error)
localStorage.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 { try {
setIsLoading(true) // Call the logout API to invalidate tokens on the server
await logoutApi()
// 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
} catch (error) { } catch (error) {
console.error("Login error:", error) console.error('Logout API call failed:', error)
return false // Continue with local logout even if API call fails
} finally { } 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 ( return (
<AuthContext.Provider value={value}> <AuthContext.Provider value={{ user, isAdmin: user?.role === 'admin', setUserFromApi, logout }}>
{children} {children}
</AuthContext.Provider> </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
View 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
View 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
View 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
View 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()

View File

@ -1,6 +1,33 @@
import { clsx, type ClassValue } from "clsx" import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) 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);
}
}
};

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

@ -0,0 +1,6 @@
declare module '@anthropic-ai/sdk' {
const Anthropic: any;
export default Anthropic;
}