frontend admin changes

This commit is contained in:
Chandini 2025-09-09 11:23:58 +05:30
parent 9d95f25f16
commit 2264f58f07
49 changed files with 7136 additions and 808 deletions

199
package-lock.json generated
View File

@ -19,6 +19,7 @@
"@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-radio-group": "^1.3.7",
"@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-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
"@tldraw/tldraw": "^3.15.4", "@tldraw/tldraw": "^3.15.4",
"axios": "^1.11.0", "axios": "^1.11.0",
@ -28,6 +29,7 @@
"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",
"socket.io-client": "^4.8.1",
"svg-path-parser": "^1.1.0", "svg-path-parser": "^1.1.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"zustand": "^5.0.8" "zustand": "^5.0.8"
@ -2572,6 +2574,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -2809,6 +2817,66 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.3",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.3",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.11",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.9.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.0",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.11", "version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
@ -4833,6 +4901,45 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.3", "version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@ -7061,7 +7168,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
@ -8318,6 +8424,68 @@
"is-arrayish": "^0.3.1" "is-arrayish": "^0.3.1"
} }
}, },
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -9074,6 +9242,35 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",

View File

@ -3,9 +3,9 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack -p 3001",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start -p 3001",
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
@ -20,6 +20,7 @@
"@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-radio-group": "^1.3.7",
"@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-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
"@tldraw/tldraw": "^3.15.4", "@tldraw/tldraw": "^3.15.4",
"axios": "^1.11.0", "axios": "^1.11.0",
@ -29,6 +30,7 @@
"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",
"socket.io-client": "^4.8.1",
"svg-path-parser": "^1.1.0", "svg-path-parser": "^1.1.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"zustand": "^5.0.8" "zustand": "^5.0.8"

View File

@ -0,0 +1,88 @@
"use client"
import { AdminSidebarLayout } from '@/components/layout/admin-sidebar-layout'
import { AdminNotificationProvider } from '@/contexts/AdminNotificationContext'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BarChart3, TrendingUp, Activity, Eye } from 'lucide-react'
export default function AdminAnalyticsPage() {
return (
<AdminNotificationProvider>
<AdminSidebarLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white">Analytics Dashboard</h1>
<p className="text-white/70">Monitor system performance and user engagement</p>
</div>
</div>
{/* Analytics Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">Page Views</CardTitle>
<Eye className="h-4 w-4 text-white/60" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">45,231</div>
<p className="text-xs text-white/60">+20.1% from last month</p>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">Active Sessions</CardTitle>
<Activity className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">2,350</div>
<p className="text-xs text-white/60">+180.1% from last month</p>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">Template Usage</CardTitle>
<BarChart3 className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">12,234</div>
<p className="text-xs text-white/60">+19% from last month</p>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">Growth Rate</CardTitle>
<TrendingUp className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">+12.5%</div>
<p className="text-xs text-white/60">+2.1% from last month</p>
</CardContent>
</Card>
</div>
{/* Analytics Content */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="text-white">Analytics Features</CardTitle>
</CardHeader>
<CardContent className="text-white/70">
<p>Advanced analytics functionality will be implemented here.</p>
<p className="mt-2">Features will include:</p>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Real-time user activity tracking</li>
<li>Template usage statistics</li>
<li>Performance metrics</li>
<li>Custom reports generation</li>
<li>Data visualization charts</li>
</ul>
</CardContent>
</Card>
</div>
</AdminSidebarLayout>
</AdminNotificationProvider>
)
}

View File

@ -1,59 +1,15 @@
"use client" "use client"
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '@/contexts/auth-context'
import { AdminDashboard } from '@/components/admin/admin-dashboard' import { AdminDashboard } from '@/components/admin/admin-dashboard'
import { AdminSidebarLayout } from '@/components/layout/admin-sidebar-layout'
import { AdminNotificationProvider } from '@/contexts/AdminNotificationContext'
export default function AdminPage() { 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 ( return (
<div className="container mx-auto px-4 py-8"> <AdminNotificationProvider>
<AdminDashboard /> <AdminSidebarLayout>
</div> <AdminDashboard />
</AdminSidebarLayout>
</AdminNotificationProvider>
) )
} }

View File

@ -0,0 +1,186 @@
"use client"
import { AdminSidebarLayout } from '@/components/layout/admin-sidebar-layout'
import { AdminNotificationProvider } from '@/contexts/AdminNotificationContext'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Settings, Save, Database, Mail, Shield } from 'lucide-react'
export default function AdminSettingsPage() {
return (
<AdminNotificationProvider>
<AdminSidebarLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white">System Settings</h1>
<p className="text-white/70">Configure system preferences and security settings</p>
</div>
<Button className="bg-orange-500 text-black hover:bg-orange-600">
<Save className="h-4 w-4 mr-2" />
Save Changes
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* General Settings */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="text-white flex items-center">
<Settings className="h-5 w-5 mr-2" />
General Settings
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="site-name" className="text-white">Site Name</Label>
<Input
id="site-name"
defaultValue="Codenuk"
className="bg-white/5 border-white/10 text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="site-description" className="text-white">Site Description</Label>
<Input
id="site-description"
defaultValue="AI-powered code generation platform"
className="bg-white/5 border-white/10 text-white"
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="maintenance-mode" className="text-white">Maintenance Mode</Label>
<Switch id="maintenance-mode" />
</div>
<div className="flex items-center justify-between">
<Label htmlFor="user-registration" className="text-white">Allow User Registration</Label>
<Switch id="user-registration" defaultChecked />
</div>
</CardContent>
</Card>
{/* Security Settings */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="text-white flex items-center">
<Shield className="h-5 w-5 mr-2" />
Security Settings
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="two-factor" className="text-white">Require 2FA for Admins</Label>
<Switch id="two-factor" defaultChecked />
</div>
<div className="flex items-center justify-between">
<Label htmlFor="session-timeout" className="text-white">Auto Session Timeout</Label>
<Switch id="session-timeout" defaultChecked />
</div>
<div className="space-y-2">
<Label htmlFor="max-login-attempts" className="text-white">Max Login Attempts</Label>
<Input
id="max-login-attempts"
type="number"
defaultValue="5"
className="bg-white/5 border-white/10 text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password-min-length" className="text-white">Min Password Length</Label>
<Input
id="password-min-length"
type="number"
defaultValue="8"
className="bg-white/5 border-white/10 text-white"
/>
</div>
</CardContent>
</Card>
{/* Email Settings */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="text-white flex items-center">
<Mail className="h-5 w-5 mr-2" />
Email Settings
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="smtp-host" className="text-white">SMTP Host</Label>
<Input
id="smtp-host"
placeholder="smtp.example.com"
className="bg-white/5 border-white/10 text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="smtp-port" className="text-white">SMTP Port</Label>
<Input
id="smtp-port"
type="number"
defaultValue="587"
className="bg-white/5 border-white/10 text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="from-email" className="text-white">From Email</Label>
<Input
id="from-email"
placeholder="noreply@codenuk.com"
className="bg-white/5 border-white/10 text-white"
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="email-notifications" className="text-white">Email Notifications</Label>
<Switch id="email-notifications" defaultChecked />
</div>
</CardContent>
</Card>
{/* Database Settings */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="text-white flex items-center">
<Database className="h-5 w-5 mr-2" />
Database Settings
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="auto-backup" className="text-white">Auto Backup</Label>
<Switch id="auto-backup" defaultChecked />
</div>
<div className="space-y-2">
<Label htmlFor="backup-frequency" className="text-white">Backup Frequency (hours)</Label>
<Input
id="backup-frequency"
type="number"
defaultValue="24"
className="bg-white/5 border-white/10 text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="retention-days" className="text-white">Backup Retention (days)</Label>
<Input
id="retention-days"
type="number"
defaultValue="30"
className="bg-white/5 border-white/10 text-white"
/>
</div>
<Button variant="outline" className="w-full border-white/20 text-white hover:bg-white/10">
<Database className="h-4 w-4 mr-2" />
Run Manual Backup
</Button>
</CardContent>
</Card>
</div>
</div>
</AdminSidebarLayout>
</AdminNotificationProvider>
)
}

View File

@ -0,0 +1,15 @@
"use client"
import { AdminSidebarLayout } from '@/components/layout/admin-sidebar-layout'
import { AdminNotificationProvider } from '@/contexts/AdminNotificationContext'
import { AdminTemplatesManager } from '@/components/admin/admin-templates-manager'
export default function AdminTemplatesPage() {
return (
<AdminNotificationProvider>
<AdminSidebarLayout>
<AdminTemplatesManager />
</AdminSidebarLayout>
</AdminNotificationProvider>
)
}

View File

@ -0,0 +1,88 @@
"use client"
import { AdminSidebarLayout } from '@/components/layout/admin-sidebar-layout'
import { AdminNotificationProvider } from '@/contexts/AdminNotificationContext'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Users, UserPlus, UserCheck, UserX } from 'lucide-react'
export default function AdminUsersPage() {
return (
<AdminNotificationProvider>
<AdminSidebarLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white">User Management</h1>
<p className="text-white/70">Manage user accounts and permissions</p>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">Total Users</CardTitle>
<Users className="h-4 w-4 text-white/60" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">1,234</div>
<p className="text-xs text-white/60">+12% from last month</p>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">New Users</CardTitle>
<UserPlus className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">89</div>
<p className="text-xs text-white/60">This month</p>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">Active Users</CardTitle>
<UserCheck className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">987</div>
<p className="text-xs text-white/60">80% of total</p>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">Inactive Users</CardTitle>
<UserX className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">247</div>
<p className="text-xs text-white/60">20% of total</p>
</CardContent>
</Card>
</div>
{/* User Management Content */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="text-white">User Management Features</CardTitle>
</CardHeader>
<CardContent className="text-white/70">
<p>User management functionality will be implemented here.</p>
<p className="mt-2">Features will include:</p>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>View all users</li>
<li>Search and filter users</li>
<li>Edit user permissions</li>
<li>Activate/deactivate accounts</li>
<li>View user activity logs</li>
</ul>
</CardContent>
</Card>
</div>
</AdminSidebarLayout>
</AdminNotificationProvider>
)
}

View File

@ -77,7 +77,8 @@ Return ONLY a JSON object in this exact format:
} }
const parsed = JSON.parse(jsonMatch[0]) const parsed = JSON.parse(jsonMatch[0])
const complexity = (parsed.complexity as 'low' | 'medium' | 'high') || 'medium' // Use the user-selected complexity if provided, otherwise use AI's suggestion
const complexity = (body.complexity || parsed.complexity as 'low' | 'medium' | 'high') || 'medium'
const logicRules = Array.isArray(parsed.logicRules) ? parsed.logicRules : [] const logicRules = Array.isArray(parsed.logicRules) ? parsed.logicRules : []
return NextResponse.json({ success: true, data: { complexity, logicRules } }) return NextResponse.json({ success: true, data: { complexity, logicRules } })

View File

@ -2,6 +2,7 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { BACKEND_URL } from "@/config/backend";
type VerifyState = "idle" | "loading" | "success" | "error"; type VerifyState = "idle" | "loading" | "success" | "error";
@ -9,14 +10,11 @@ interface VerificationResponse {
success: boolean; success: boolean;
data: { message: string; user: { email: string; username: string } }; data: { message: string; user: { email: string; username: string } };
message: string; message: string;
redirect?: string;
} }
interface ErrorResponse { // Removed unused ErrorResponse interface
success: false;
error: string;
message: string;
}
const API_BASE_URL = "http://localhost:8011";
const EmailVerification: React.FC = () => { const EmailVerification: React.FC = () => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -51,30 +49,37 @@ const EmailVerification: React.FC = () => {
try { try {
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/auth/verify-email?token=${verificationToken}`, `${BACKEND_URL}/api/auth/verify-email?token=${verificationToken}&format=json`,
{ method: "GET", headers: { "Content-Type": "application/json" }, signal: ctrl.signal } { method: "GET", headers: { "Accept": "application/json" }, signal: ctrl.signal }
); );
const txt = await res.text(); const txt = await res.text();
let data: any = {}; console.log("Raw response text:", txt);
console.log("Response status:", res.status);
console.log("Response headers:", Object.fromEntries(res.headers.entries()));
let data: Record<string, unknown> = {};
try { data = JSON.parse(txt || "{}"); } catch { /* ignore non-JSON */ } try { data = JSON.parse(txt || "{}"); } catch { /* ignore non-JSON */ }
console.log("Parsed response data:", data);
if (res.ok && (data as VerificationResponse)?.success) { if (res.ok && (data as unknown as VerificationResponse)?.success) {
router.replace("/auth?verified=1"); router.replace("/signin?verified=true");
return; return;
} }
const msg = String(data?.message || "").toLowerCase(); const msg = String(data?.message || "").toLowerCase();
if (msg.includes("already verified")) { if (msg.includes("already verified")) {
router.replace("/auth?verified=1"); // Check if there's a redirect URL in the response for already verified case too
router.replace("/signin?verified=true");
return; return;
} }
setStatus("error"); setStatus("error");
setError(data?.message || `Verification failed (HTTP ${res.status}).`); setError(String(data?.message) || `Verification failed (HTTP ${res.status}).`);
} catch (e: any) { } catch (e: unknown) {
setStatus("error"); setStatus("error");
setError(e?.name === "AbortError" ? "Request timed out. Please try again." : "Network error. Please try again."); const error = e as { name?: string };
setError(error?.name === "AbortError" ? "Request timed out. Please try again." : "Network error. Please try again.");
console.error("Email verification error:", e); console.error("Email verification error:", e);
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
@ -94,7 +99,7 @@ const EmailVerification: React.FC = () => {
return; return;
} }
const res = await fetch(`${API_BASE_URL}/api/auth/resend-verification`, { const res = await fetch(`${BACKEND_URL}/api/auth/resend-verification`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }), body: JSON.stringify({ email }),

View File

@ -3,6 +3,7 @@ import type { Metadata } from "next"
import { Poppins } from "next/font/google" import { Poppins } from "next/font/google"
import { AuthProvider } from "@/contexts/auth-context" import { AuthProvider } from "@/contexts/auth-context"
import { AppLayout } from "@/components/layout/app-layout" import { AppLayout } from "@/components/layout/app-layout"
import { ToastProvider } from "@/components/ui/toast"
import "./globals.css" import "./globals.css"
const poppins = Poppins({ const poppins = Poppins({
@ -33,11 +34,13 @@ html {
`}</style> `}</style>
</head> </head>
<body className="font-sans antialiased dark bg-black text-white"> <body className="font-sans antialiased dark bg-black text-white">
<AuthProvider> <ToastProvider>
<AppLayout> <AuthProvider>
<main>{children}</main> <AppLayout>
</AppLayout> <main>{children}</main>
</AuthProvider> </AppLayout>
</AuthProvider>
</ToastProvider>
</body> </body>
</html> </html>
) )

View File

@ -1,5 +1,10 @@
import { Suspense } from "react"
import { SignInPage } from "@/components/auth/signin-page" import { SignInPage } from "@/components/auth/signin-page"
export default function SignInPageRoute() { export default function SignInPageRoute() {
return <SignInPage /> return (
<Suspense fallback={<div>Loading...</div>}>
<SignInPage />
</Suspense>
)
} }

View File

@ -1,5 +1,10 @@
import { Suspense } from "react";
import EmailVerification from "@/app/auth/emailVerification"; import EmailVerification from "@/app/auth/emailVerification";
export default function VerifyEmailPage() { export default function VerifyEmailPage() {
return <EmailVerification />; return (
<Suspense fallback={<div>Loading...</div>}>
<EmailVerification />
</Suspense>
);
} }

View File

@ -6,34 +6,55 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { import {
AlertCircle,
CheckCircle, CheckCircle,
Clock,
XCircle, XCircle,
Copy, Clock,
Bell,
RefreshCw, RefreshCw,
AlertCircle,
Copy,
Filter,
Search, Search,
Filter Edit,
Zap,
Files,
Bell,
BarChart3
} from 'lucide-react' } from 'lucide-react'
import { adminApi, formatDate, getStatusColor, getComplexityColor } from '@/lib/api/admin' import { adminApi, formatDate, getStatusColor, getComplexityColor } from '@/lib/api/admin'
import { AdminFeature, AdminNotification, AdminStats } from '@/types/admin.types' import { AdminFeature, AdminTemplate, AdminStats } from '@/types/admin.types'
import { FeatureReviewDialog } from './feature-review-dialog' import { FeatureReviewDialog } from './feature-review-dialog'
import { AdminNotificationsPanel } from './admin-notifications-panel' import { AdminNotificationsPanel } from './admin-notifications-panel'
import { FeatureEditDialog } from './feature-edit-dialog'
import { TemplateEditDialog } from './template-edit-dialog'
import { RejectDialog } from './reject-dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { AdminNotificationProvider, useAdminNotifications } from '@/contexts/AdminNotificationContext'
import { useSearchParams } from 'next/navigation'
export function AdminDashboard() { function AdminDashboardContent() {
const searchParams = useSearchParams()
const activeTab = searchParams.get('tab') || 'dashboard'
const filterParam = searchParams.get('filter') || 'all'
const [pendingFeatures, setPendingFeatures] = useState<AdminFeature[]>([]) const [pendingFeatures, setPendingFeatures] = useState<AdminFeature[]>([])
const [notifications, setNotifications] = useState<AdminNotification[]>([]) const [customTemplates, setCustomTemplates] = useState<AdminTemplate[]>([])
const [stats, setStats] = useState<AdminStats | null>(null) const [stats, setStats] = useState<AdminStats | null>(null)
const [templateStats, setTemplateStats] = useState<AdminStats | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [selectedFeature, setSelectedFeature] = useState<AdminFeature | null>(null) const [selectedFeature, setSelectedFeature] = useState<AdminFeature | null>(null)
const [selectedTemplate, setSelectedTemplate] = useState<AdminTemplate | null>(null)
const [showReviewDialog, setShowReviewDialog] = useState(false) const [showReviewDialog, setShowReviewDialog] = useState(false)
const [showFeatureEditDialog, setShowFeatureEditDialog] = useState(false)
const [showTemplateEditDialog, setShowTemplateEditDialog] = useState(false)
const [showRejectDialog, setShowRejectDialog] = useState(false)
const [rejectItem, setRejectItem] = useState<{ id: string; name: string; type: 'feature' | 'template' } | null>(null)
const [showNotifications, setShowNotifications] = useState(false) const [showNotifications, setShowNotifications] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all') const [statusFilter, setStatusFilter] = useState<string>(filterParam)
const { unreadCount, removeByReference } = useAdminNotifications()
// Load dashboard data // Load dashboard data
const loadDashboardData = async () => { const loadDashboardData = async () => {
@ -41,15 +62,17 @@ export function AdminDashboard() {
setLoading(true) setLoading(true)
setError(null) setError(null)
const [pendingData, notificationsData, statsData] = await Promise.all([ const [featuresResponse, templatesResponse, featureStatsResponse, templateStatsResponse] = await Promise.all([
adminApi.getPendingFeatures(), adminApi.getCustomFeatures('pending'),
adminApi.getNotifications(true, 10), // Get 10 unread notifications adminApi.getCustomTemplates('pending'),
adminApi.getFeatureStats() adminApi.getCustomFeatureStats(),
adminApi.getTemplateStats()
]) ])
setPendingFeatures(pendingData) setPendingFeatures(featuresResponse || [])
setNotifications(notificationsData) setCustomTemplates(templatesResponse || [])
setStats(statsData) setStats(featureStatsResponse)
setTemplateStats(templateStatsResponse)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load dashboard data') setError(err instanceof Error ? err.message : 'Failed to load dashboard data')
console.error('Error loading dashboard data:', err) console.error('Error loading dashboard data:', err)
@ -63,12 +86,14 @@ export function AdminDashboard() {
}, []) }, [])
// Handle feature review // Handle feature review
const handleFeatureReview = async (featureId: string, reviewData: any) => { const handleFeatureReview = async (featureId: string, reviewData: { status: 'approved' | 'rejected' | 'duplicate'; admin_notes?: string; admin_reviewed_by: string }) => {
try { try {
await adminApi.reviewFeature(featureId, reviewData) await adminApi.reviewFeature(featureId, reviewData)
// Remove the reviewed feature from pending list // Update the feature in the list
setPendingFeatures(prev => prev.filter(f => f.id !== featureId)) setPendingFeatures(prev => prev.map(f =>
f.id === featureId ? { ...f, status: reviewData.status as AdminFeature['status'], admin_notes: reviewData.admin_notes } : f
))
// Reload stats // Reload stats
const newStats = await adminApi.getFeatureStats() const newStats = await adminApi.getFeatureStats()
@ -79,6 +104,141 @@ export function AdminDashboard() {
} catch (err) { } catch (err) {
console.error('Error reviewing feature:', err) console.error('Error reviewing feature:', err)
// Handle error (show toast notification, etc.) // Handle error (show toast notification, etc.)
alert('Error reviewing feature')
}
}
// Handle template review
const handleTemplateReview = async (templateId: string, reviewData: { status: 'pending' | 'approved' | 'rejected' | 'duplicate'; admin_notes?: string }) => {
try {
await adminApi.reviewTemplate(templateId, reviewData)
// Update the template in the list
setCustomTemplates(prev => prev.map(t =>
t.id === templateId ? { ...t, status: reviewData.status as AdminTemplate['status'], admin_notes: reviewData.admin_notes } : t
))
// Reload stats
const newStats = await adminApi.getTemplateStats()
setTemplateStats(newStats)
} catch (err) {
console.error('Error reviewing template:', err)
// Handle error (show toast notification, etc.)
alert('Error reviewing template')
}
}
// Handle feature update
const handleFeatureUpdate = (featureId: string, updatedFeature: AdminFeature) => {
setPendingFeatures(prev => prev.map(f =>
f.id === featureId ? updatedFeature : f
))
}
// Handle template update
const handleTemplateUpdate = (templateId: string, updatedTemplate: AdminTemplate) => {
// Hide the edited item from the pending list immediately
setCustomTemplates(prev => prev.filter(t => t.id !== templateId))
}
// Handle reject action
const handleReject = async (adminNotes: string) => {
if (!rejectItem) return
try {
if (rejectItem.type === 'feature') {
await adminApi.rejectCustomFeature(rejectItem.id, adminNotes)
setPendingFeatures(prev => prev.map(f =>
f.id === rejectItem.id ? { ...f, status: 'rejected', admin_notes: adminNotes } : f
))
// Reload feature stats
const newStats = await adminApi.getCustomFeatureStats()
setStats(newStats)
} else {
await adminApi.rejectTemplate(rejectItem.id, adminNotes)
setCustomTemplates(prev => prev.map(t =>
t.id === rejectItem.id ? { ...t, status: 'rejected', admin_notes: adminNotes } : t
))
// Reload template stats
const newStats = await adminApi.getTemplateStats()
setTemplateStats(newStats)
}
} catch (err) {
console.error(`Error rejecting ${rejectItem.type}:`, err)
// Handle error (show toast notification, etc.)
alert(`Error rejecting ${rejectItem.type}`)
}
}
// Handle approve action
const handleApprove = async (item: { id: string; type: 'feature' | 'template' }) => {
// Optimistic UI: remove immediately
if (item.type === 'feature') {
setPendingFeatures(prev => prev.filter(f => f.id !== item.id))
} else {
setCustomTemplates(prev => prev.filter(t => t.id !== item.id))
}
try {
if (item.type === 'feature') {
// Get the feature details first
const feature = pendingFeatures.find(f => f.id === item.id)
if (feature) {
// Create new approved feature in main template_features table
await adminApi.createApprovedFeature({
template_id: feature.template_id,
name: feature.name,
description: feature.description,
complexity: feature.complexity,
business_rules: feature.business_rules,
technical_requirements: feature.technical_requirements
})
// Update the custom feature status to approved
await adminApi.reviewCustomFeature(item.id, { status: 'approved', admin_notes: 'Approved and created in main templates' })
// already optimistically removed
// Remove related notifications for this feature
removeByReference('custom_feature', item.id)
}
// Reload feature stats
const newStats = await adminApi.getCustomFeatureStats()
setStats(newStats)
} else {
// Get the template details first
const template = customTemplates.find(t => t.id === item.id)
if (template) {
// Create new approved template in main templates table
await adminApi.createApprovedTemplate(item.id, {
title: template.title || '',
description: template.description,
category: template.category || '',
type: template.type || '',
icon: template.icon,
gradient: template.gradient,
border: template.border,
text: template.text,
subtext: template.subtext
})
// Update the custom template status to approved
await adminApi.reviewTemplate(item.id, { status: 'approved', admin_notes: 'Approved and created in main templates' })
// already optimistically removed
// Remove related notifications for this template
removeByReference('custom_template', item.id)
}
// Reload template stats
const newStats = await adminApi.getTemplateStats()
setTemplateStats(newStats)
}
} catch (err) {
console.error(`Error approving ${item.type}:`, err)
// Handle error (show toast notification, etc.)
// Recover UI by reloading if optimistic removal was wrong
await loadDashboardData()
} }
} }
@ -93,15 +253,17 @@ export function AdminDashboard() {
return matchesSearch && matchesStatus return matchesSearch && matchesStatus
}) })
// Get status counts // Get status counts for features
const getStatusCount = (status: string) => { const getFeatureStatusCount = (status: string) => {
return stats?.features.find(s => s.status === status)?.count || 0 return stats?.features?.find(s => s.status === status)?.count || 0
} }
const getUnreadNotificationCount = () => { // Get status counts for templates
return stats?.notifications.unread || 0 const getTemplateStatusCount = (status: string) => {
return (templateStats as AdminStats & { templates?: Array<{ status: string; count: number }> })?.templates?.find((s) => s.status === status)?.count || 0
} }
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-[400px]"> <div className="flex items-center justify-center min-h-[400px]">
@ -133,29 +295,16 @@ export function AdminDashboard() {
) )
} }
return ( const renderDashboardOverview = () => (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold">Admin Dashboard</h1> <h1 className="text-3xl font-bold text-white">Admin Dashboard</h1>
<p className="text-gray-600">Manage feature approvals and system notifications</p> <p className="text-white/70">Manage feature approvals and system notifications</p>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button onClick={loadDashboardData} className="bg-orange-500 text-black hover:bg-orange-600">
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" /> <RefreshCw className="h-4 w-4 mr-2" />
Refresh Refresh
</Button> </Button>
@ -163,70 +312,166 @@ export function AdminDashboard() {
</div> </div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card> {/* Features Stats */}
<CardContent className="pt-6"> <Card className="bg-gray-900 border-gray-800">
<div className="flex items-center space-x-2"> <CardHeader>
<Clock className="h-5 w-5 text-yellow-600" /> <CardTitle className="text-lg font-semibold text-white">Custom Features</CardTitle>
<div> </CardHeader>
<p className="text-sm font-medium text-gray-600">Pending</p> <CardContent>
<p className="text-2xl font-bold">{getStatusCount('pending')}</p> <div className="grid grid-cols-2 gap-4">
<div className="flex items-center space-x-2">
<Clock className="h-4 w-4 text-yellow-600" />
<div>
<p className="text-xs text-white/60">Pending</p>
<p className="text-xl font-bold text-white">{getFeatureStatusCount('pending')}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<CheckCircle className="h-4 w-4 text-green-500" />
<div>
<p className="text-xs text-white/60">Approved</p>
<p className="text-xl font-bold text-white">{getFeatureStatusCount('approved')}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<XCircle className="h-4 w-4 text-red-500" />
<div>
<p className="text-xs text-white/60">Rejected</p>
<p className="text-xl font-bold text-white">{getFeatureStatusCount('rejected')}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Copy className="h-4 w-4 text-orange-500" />
<div>
<p className="text-xs text-white/60">Duplicates</p>
<p className="text-xl font-bold text-white">{getFeatureStatusCount('duplicate')}</p>
</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> {/* Templates Stats */}
<CardContent className="pt-6"> <Card className="bg-gray-900 border-gray-800">
<div className="flex items-center space-x-2"> <CardHeader>
<CheckCircle className="h-5 w-5 text-green-600" /> <CardTitle className="text-lg font-semibold text-white">Custom Templates</CardTitle>
<div> </CardHeader>
<p className="text-sm font-medium text-gray-600">Approved</p> <CardContent>
<p className="text-2xl font-bold">{getStatusCount('approved')}</p> <div className="grid grid-cols-2 gap-4">
<div className="flex items-center space-x-2">
<Clock className="h-4 w-4 text-yellow-600" />
<div>
<p className="text-xs text-white/60">Pending</p>
<p className="text-xl font-bold text-white">{getTemplateStatusCount('pending')}</p>
</div>
</div> </div>
</div> <div className="flex items-center space-x-2">
</CardContent> <CheckCircle className="h-4 w-4 text-green-500" />
</Card> <div>
<p className="text-xs text-white/60">Approved</p>
<Card> <p className="text-xl font-bold text-white">{getTemplateStatusCount('approved')}</p>
<CardContent className="pt-6"> </div>
<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>
</div> <div className="flex items-center space-x-2">
</CardContent> <XCircle className="h-4 w-4 text-red-500" />
</Card> <div>
<p className="text-xs text-white/60">Rejected</p>
<Card> <p className="text-xl font-bold text-white">{getTemplateStatusCount('rejected')}</p>
<CardContent className="pt-6"> </div>
<div className="flex items-center space-x-2"> </div>
<Copy className="h-5 w-5 text-orange-600" /> <div className="flex items-center space-x-2">
<div> <Copy className="h-4 w-4 text-orange-500" />
<p className="text-sm font-medium text-gray-600">Duplicates</p> <div>
<p className="text-2xl font-bold">{getStatusCount('duplicate')}</p> <p className="text-xs text-white/60">Duplicates</p>
<p className="text-xl font-bold text-white">{getTemplateStatusCount('duplicate')}</p>
</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Main Content */} {/* Quick Actions */}
<Tabs defaultValue="pending" className="space-y-4"> <Card className="bg-gray-900 border-gray-800">
<TabsList> <CardHeader>
<TabsTrigger value="pending" className="flex items-center space-x-2"> <CardTitle className="text-lg font-semibold text-white">Quick Actions</CardTitle>
<Clock className="h-4 w-4" /> </CardHeader>
<span>Pending Review ({pendingFeatures.length})</span> <CardContent>
</TabsTrigger> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<TabsTrigger value="all" className="flex items-center space-x-2"> <Button
<Filter className="h-4 w-4" /> variant="outline"
<span>All Features</span> className="h-20 flex flex-col items-center justify-center space-y-2 border-white/20 text-white hover:bg-white/10"
</TabsTrigger> onClick={() => window.location.href = '/admin?tab=features'}
</TabsList> >
<Zap className="h-6 w-6" />
<span className="text-sm">Review Features</span>
</Button>
<Button
variant="outline"
className="h-20 flex flex-col items-center justify-center space-y-2 border-white/20 text-white hover:bg-white/10"
onClick={() => window.location.href = '/admin?tab=templates'}
>
<Files className="h-6 w-6" />
<span className="text-sm">Review Templates</span>
</Button>
<Button
variant="outline"
className="h-20 flex flex-col items-center justify-center space-y-2 border-white/20 text-white hover:bg-white/10"
onClick={() => setShowNotifications(true)}
>
<Bell className="h-6 w-6" />
<span className="text-sm">Notifications</span>
{unreadCount > 0 && (
<Badge className="bg-red-500 text-white text-xs">{unreadCount}</Badge>
)}
</Button>
<Button
variant="outline"
className="h-20 flex flex-col items-center justify-center space-y-2 border-white/20 text-white hover:bg-white/10"
>
<BarChart3 className="h-6 w-6" />
<span className="text-sm">Analytics</span>
</Button>
</div>
</CardContent>
</Card>
</div>
)
return (
<div className="space-y-6">
{activeTab === 'dashboard' && renderDashboardOverview()}
{(activeTab === 'features' || activeTab === 'templates') && (
<>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white">
{activeTab === 'features' ? 'Custom Features' : 'Custom Templates'}
</h1>
<p className="text-white/70">
{activeTab === 'features'
? 'Review and manage custom feature requests'
: 'Review and manage custom template submissions'
}
</p>
</div>
<div className="flex items-center space-x-2">
<Button onClick={loadDashboardData} className="bg-orange-500 text-black hover:bg-orange-600">
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
</>
)}
{/* Main Content - Only show for features/templates tabs */}
{activeTab === 'features' && (
<div className="space-y-4">
<TabsContent value="pending" className="space-y-4">
{/* Filters */} {/* Filters */}
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="flex-1"> <div className="flex-1">
@ -304,15 +549,38 @@ export function AdminDashboard() {
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleApprove({ id: feature.id, type: 'feature' })}
className="text-green-600 hover:text-green-700 hover:bg-green-50"
>
<CheckCircle className="h-4 w-4 mr-1" />
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setRejectItem({ id: feature.id, name: feature.name, type: 'feature' })
setShowRejectDialog(true)
}}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<XCircle className="h-4 w-4 mr-1" />
Reject
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
setSelectedFeature(feature) setSelectedFeature(feature)
setShowReviewDialog(true) setShowFeatureEditDialog(true)
}} }}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
> >
Review <Edit className="h-4 w-4 mr-1" />
Edit
</Button> </Button>
</div> </div>
</div> </div>
@ -321,40 +589,38 @@ export function AdminDashboard() {
)) ))
)} )}
</div> </div>
</TabsContent> </div>
)}
<TabsContent value="all" className="space-y-4"> {/* Feature Edit Dialog */}
<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 && ( {selectedFeature && (
<FeatureReviewDialog <FeatureEditDialog
feature={selectedFeature} feature={selectedFeature}
open={showReviewDialog} open={showFeatureEditDialog}
onOpenChange={setShowReviewDialog} onOpenChange={setShowFeatureEditDialog}
onReview={handleFeatureReview} onUpdate={handleFeatureUpdate}
/> />
)} )}
{/* Notifications Panel */} {/* Reject Dialog */}
<AdminNotificationsPanel {rejectItem && (
open={showNotifications} <RejectDialog
onOpenChange={setShowNotifications} open={showRejectDialog}
notifications={notifications} onOpenChange={setShowRejectDialog}
onNotificationRead={async (id) => { onReject={handleReject}
try { title={`Reject ${rejectItem.type === 'feature' ? 'Feature' : 'Template'}`}
await adminApi.markNotificationAsRead(id) itemName={rejectItem.name}
setNotifications(prev => prev.filter(n => n.id !== id)) itemType={rejectItem.type}
} catch (err) { />
console.error('Error marking notification as read:', err) )}
}
}}
/>
</div> </div>
) )
} }
export function AdminDashboard() {
return (
<AdminNotificationProvider>
<AdminDashboardContent />
</AdminNotificationProvider>
)
}

View File

@ -0,0 +1,305 @@
"use client"
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { ArrowLeft, Plus, Edit, Trash2 } from 'lucide-react'
import { DatabaseTemplate, TemplateFeature } from '@/lib/template-service'
import { AICustomFeatureCreator } from '@/components/ai/AICustomFeatureCreator'
import { getApiUrl } from '@/config/backend'
interface AdminFeatureSelectionProps {
template: {
id: string
title: string
description?: string
type: string
category?: string
icon?: string
gradient?: string
border?: string
text?: string
subtext?: string
}
onBack: () => void
}
export function AdminFeatureSelection({ template, onBack }: AdminFeatureSelectionProps) {
// Admin template service functions using admin API endpoints
const fetchFeatures = async (templateId: string): Promise<TemplateFeature[]> => {
const response = await fetch(getApiUrl(`api/templates/${templateId}/features`))
if (!response.ok) throw new Error('Failed to fetch features')
const data = await response.json()
// Handle different response structures
return Array.isArray(data) ? data : (data.data || data.features || [])
}
const createFeature = async (templateId: string, feature: Partial<TemplateFeature>) => {
const response = await fetch(getApiUrl('api/features'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...feature, template_id: templateId })
})
if (!response.ok) throw new Error('Failed to create feature')
return response.json()
}
const updateFeature = async (templateId: string, featureId: string, feature: Partial<TemplateFeature>) => {
const response = await fetch(getApiUrl(`api/templates/${templateId}/features/${featureId}`), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(feature)
})
if (!response.ok) throw new Error('Failed to update feature')
return response.json()
}
const deleteFeature = async (templateId: string, featureId: string) => {
const response = await fetch(getApiUrl(`api/templates/${templateId}/features/${featureId}`), {
method: 'DELETE'
})
if (!response.ok) throw new Error('Failed to delete feature')
}
const bulkCreateFeatures = async (templateId: string, features: Partial<TemplateFeature>[]) => {
const response = await fetch(getApiUrl(`api/templates/${templateId}/features/bulk`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ features })
})
if (!response.ok) throw new Error('Failed to create features')
return response.json()
}
const [features, setFeatures] = useState<TemplateFeature[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [newFeature, setNewFeature] = useState({
name: '',
description: '',
complexity: 'medium' as 'low' | 'medium' | 'high'
})
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showAIModal, setShowAIModal] = useState(false)
const load = async () => {
try {
setLoading(true)
setError(null)
const data = await fetchFeatures(template.id)
// Ensure we always have an array
setFeatures(Array.isArray(data) ? data : [])
} catch (err) {
console.error('Error loading features:', err)
const message = err instanceof Error ? err.message : 'Failed to load features'
setError(message)
setFeatures([]) // Reset to empty array on error
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [template.id])
const handleAddCustom = async () => {
if (!newFeature.name.trim()) return
// Check if template ID is valid UUID format (database templates use UUIDs)
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
if (!uuidRegex.test(template.id)) {
alert(`Cannot add features to demo templates. Template ID: ${template.id} is not a valid UUID. Please select a real template from your database.`)
return
}
try {
const created = await createFeature(template.id, {
name: newFeature.name,
description: newFeature.description,
feature_type: 'essential',
complexity: newFeature.complexity,
is_default: true,
created_by_user: true,
})
setNewFeature({ name: '', description: '', complexity: 'medium' })
setSelectedIds((prev) => {
const next = new Set(prev)
if (created?.id) next.add(created.id)
return next
})
await load()
} catch (error) {
console.error('Error creating custom feature:', error)
const errorMessage = error instanceof Error ? error.message : 'Template ID not found in database'
alert(`Failed to create custom feature: ${errorMessage}`)
}
}
const handleAddAIAnalyzed = async (payload: { name: string; description: string; complexity: 'low' | 'medium' | 'high' }) => {
// Check if template ID is valid UUID format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
if (!uuidRegex.test(template.id)) {
alert("Cannot add features to demo templates. Please select a real template from your database.")
return
}
try {
await createFeature(template.id, {
name: payload.name,
description: payload.description,
feature_type: 'essential',
complexity: payload.complexity,
is_default: true,
created_by_user: true,
})
await load()
} catch (error) {
console.error('Error creating AI-analyzed feature:', error)
const errorMessage = error instanceof Error ? error.message : 'Template ID not found in database'
alert(`Failed to create AI-analyzed feature: ${errorMessage}`)
}
}
const handleUpdate = async (f: TemplateFeature, updates: Partial<TemplateFeature>) => {
try {
await updateFeature(template.id, f.id, updates)
await load()
} catch (err) {
console.error('Error updating feature:', err)
const message = err instanceof Error ? err.message : 'Failed to update feature'
alert(`Error updating feature: ${message}`)
}
}
const handleDelete = async (f: TemplateFeature) => {
try {
await deleteFeature(template.id, f.id)
setSelectedIds((prev) => {
const next = new Set(prev)
next.delete(f.id)
return next
})
await load()
} catch (err) {
console.error('Error deleting feature:', err)
const message = err instanceof Error ? err.message : 'Failed to delete feature'
alert(`Error deleting feature: ${message}`)
}
}
const toggleSelect = (f: TemplateFeature) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(f.id)) next.delete(f.id)
else next.add(f.id)
return next
})
}
if (loading) {
return <div className="text-center py-20 text-white/60">Loading features...</div>
}
if (error) {
return <div className="text-center py-20 text-red-400">{error}</div>
}
// Ensure features is always an array before filtering
const safeFeatures = Array.isArray(features) ? features : []
const essentialFeatures = safeFeatures.filter(f => f.feature_type === 'essential')
const suggestedFeatures = safeFeatures.filter(f => f.feature_type === 'suggested')
const customFeatures = safeFeatures.filter(f => f.feature_type === 'custom')
const section = (title: string, list: TemplateFeature[]) => (
<div>
<h3 className="text-lg font-semibold text-white mb-3">{title} ({list.length})</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{list.map((f) => (
<Card key={f.id} className={`bg-white/5 ${selectedIds.has(f.id) ? 'border-orange-400' : 'border-white/10'}`}>
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedIds.has(f.id)}
onCheckedChange={() => toggleSelect(f)}
className="border-white/20 data-[state=checked]:bg-orange-500 data-[state=checked]:border-orange-500"
/>
<span>{f.name}</span>
</div>
{f.feature_type === 'custom' && (
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
className="border-blue-500 text-blue-300 hover:bg-blue-500/10"
onClick={() => {/* Edit functionality */}}
>
<Edit className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="outline"
className="border-red-500 text-red-300 hover:bg-red-500/10"
onClick={() => handleDelete(f)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</CardTitle>
</CardHeader>
<CardContent className="text-white/80 text-sm space-y-2">
<p>{f.description || 'No description provided.'}</p>
<div className="flex gap-2 text-xs">
<Badge variant="outline" className="bg-white/5 border-white/10">{f.feature_type}</Badge>
<Badge variant="outline" className="bg-white/5 border-white/10">{f.complexity}</Badge>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
return (
<div className="max-w-7xl mx-auto space-y-8">
<div className="text-center space-y-4">
<h1 className="text-4xl font-bold text-white">Select Features for {template.title}</h1>
<p className="text-xl text-white/60 max-w-3xl mx-auto">Choose defaults or add your own essential features.</p>
</div>
{section('Essential Features', essentialFeatures)}
{section('Suggested Features', suggestedFeatures)}
<div className="bg-white/5 border border-white/10 rounded-xl p-4 space-y-3">
<h3 className="text-white font-semibold">Add Essential Feature</h3>
<p className="text-white/60 text-sm">Use AI to analyze and create essential features for your project.</p>
<div className="text-center">
<Button variant="outline" onClick={() => setShowAIModal(true)} className="border-orange-500 text-orange-400 hover:bg-orange-500/10 cursor-pointer">
<Plus className="h-4 w-4 mr-2" />
Analyze with AI
</Button>
</div>
<p className="text-white/60 text-xs text-center">AI will analyze your requirements and create essential features</p>
</div>
{section('Your Custom Features', customFeatures)}
{showAIModal && (
<AICustomFeatureCreator
projectType={template.type || template.title}
onAdd={async (f) => { await handleAddAIAnalyzed(f); setShowAIModal(false) }}
onClose={() => setShowAIModal(false)}
/>
)}
<div className="text-center py-4">
<div className="space-x-4">
<Button variant="outline" onClick={onBack} className="border-white/20 text-white hover:bg-white/10 cursor-pointer">Back</Button>
</div>
<div className="text-white/60 text-sm mt-2">Selected: {selectedIds.size} | Essential: {essentialFeatures.length} | Suggested: {suggestedFeatures.length} | Custom: {customFeatures.length}</div>
</div>
</div>
)
}

View File

@ -14,34 +14,38 @@ import { Card, CardContent } from '@/components/ui/card'
import { import {
Bell, Bell,
CheckCircle, CheckCircle,
XCircle,
Copy,
Clock, Clock,
Check, Check,
Trash2 Trash2,
Wifi,
WifiOff
} from 'lucide-react' } from 'lucide-react'
import { AdminNotification } from '@/types/admin.types'
import { formatDate } from '@/lib/api/admin' import { formatDate } from '@/lib/api/admin'
import { useAdminNotifications } from '@/contexts/AdminNotificationContext'
interface AdminNotificationsPanelProps { interface AdminNotificationsPanelProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
notifications: AdminNotification[]
onNotificationRead: (id: string) => Promise<void>
} }
export function AdminNotificationsPanel({ export function AdminNotificationsPanel({
open, open,
onOpenChange, onOpenChange
notifications,
onNotificationRead
}: AdminNotificationsPanelProps) { }: AdminNotificationsPanelProps) {
const [markingAsRead, setMarkingAsRead] = useState<string | null>(null) const [markingAsRead, setMarkingAsRead] = useState<string | null>(null)
const {
notifications,
unreadCount,
isConnected,
markAsRead,
markAllAsRead,
clearAll
} = useAdminNotifications()
const handleMarkAsRead = async (id: string) => { const handleMarkAsRead = async (id: string) => {
try { try {
setMarkingAsRead(id) setMarkingAsRead(id)
await onNotificationRead(id) await markAsRead(id)
} catch (error) { } catch (error) {
console.error('Error marking notification as read:', error) console.error('Error marking notification as read:', error)
} finally { } finally {
@ -49,6 +53,22 @@ export function AdminNotificationsPanel({
} }
} }
const handleMarkAllAsRead = async () => {
try {
await markAllAsRead()
} catch (error) {
console.error('Error marking all notifications as read:', error)
}
}
const handleClearAll = async () => {
try {
await clearAll()
} catch (error) {
console.error('Error clearing all notifications:', error)
}
}
const getNotificationIcon = (type: string) => { const getNotificationIcon = (type: string) => {
switch (type) { switch (type) {
case 'new_feature': case 'new_feature':
@ -81,14 +101,27 @@ export function AdminNotificationsPanel({
<SheetTitle className="flex items-center space-x-2"> <SheetTitle className="flex items-center space-x-2">
<Bell className="h-5 w-5" /> <Bell className="h-5 w-5" />
<span>Admin Notifications</span> <span>Admin Notifications</span>
{unreadNotifications.length > 0 && ( {unreadCount > 0 && (
<Badge className="bg-red-500 text-white"> <Badge className="bg-red-500 text-white">
{unreadNotifications.length} {unreadCount}
</Badge> </Badge>
)} )}
<div className="flex items-center ml-auto">
{isConnected ? (
<div className="flex items-center text-green-600">
<Wifi className="h-4 w-4 mr-1" />
<span className="text-xs">Live</span>
</div>
) : (
<div className="flex items-center text-red-600">
<WifiOff className="h-4 w-4 mr-1" />
<span className="text-xs">Offline</span>
</div>
)}
</div>
</SheetTitle> </SheetTitle>
<SheetDescription> <SheetDescription>
System notifications and feature review updates System notifications and feature review updates Real-time updates {isConnected ? 'enabled' : 'disabled'}
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
@ -187,7 +220,7 @@ export function AdminNotificationsPanel({
<Bell className="h-12 w-12 mx-auto mb-4 text-gray-300" /> <Bell className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p className="text-gray-500">No notifications</p> <p className="text-gray-500">No notifications</p>
<p className="text-sm text-gray-400 mt-1"> <p className="text-sm text-gray-400 mt-1">
You're all caught up! New notifications will appear here. You&apos;re all caught up! New notifications will appear here.
</p> </p>
</div> </div>
)} )}
@ -203,14 +236,20 @@ export function AdminNotificationsPanel({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={handleMarkAllAsRead}
// TODO: Implement mark all as read
console.log('Mark all as read')
}}
> >
<Check className="h-4 w-4 mr-2" /> <Check className="h-4 w-4 mr-2" />
Mark All Read Mark All Read
</Button> </Button>
<Button
variant="destructive"
size="sm"
onClick={handleClearAll}
className="ml-2"
>
<Trash2 className="h-4 w-4 mr-2" />
Clear All
</Button>
</div> </div>
</div> </div>
)} )}

View File

@ -0,0 +1,657 @@
"use client"
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import {
Search,
Settings,
RefreshCw,
AlertCircle,
Zap,
Globe,
BarChart3,
Code,
ShoppingCart,
Briefcase,
GraduationCap,
Plus,
Save,
X
} from 'lucide-react'
import { adminApi, formatDate, getComplexityColor } from '@/lib/api/admin'
import { AdminTemplate, AdminStats } from '@/types/admin.types'
import { AdminFeatureSelection } from './admin-feature-selection'
interface AdminTemplatesListProps {
onTemplateSelect?: (template: AdminTemplate) => void
}
export function AdminTemplatesList({ onTemplateSelect }: AdminTemplatesListProps) {
const [templates, setTemplates] = useState<AdminTemplate[]>([])
const [stats, setStats] = useState<AdminStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('all')
const [showFeaturesManager, setShowFeaturesManager] = useState(false)
const [selectedTemplate, setSelectedTemplate] = useState<any | null>(null)
const [showFeatureSelection, setShowFeatureSelection] = useState(false)
const [showCreateModal, setShowCreateModal] = useState(false)
// Create template form state
const [newTemplate, setNewTemplate] = useState({
type: '',
title: '',
description: '',
category: '',
icon: '',
gradient: '',
border: '',
text: '',
subtext: ''
})
const categories = [
"Food Delivery",
"E-commerce",
"SaaS Platform",
"Mobile App",
"Dashboard",
"CRM System",
"Learning Platform",
"Healthcare",
"Real Estate",
"Travel",
"Entertainment",
"Finance",
"Social Media",
"Marketplace",
"Other"
]
// Load templates and stats
const loadData = async () => {
try {
setLoading(true)
setError(null)
console.log('Loading admin templates data...')
const [templatesResponse, statsResponse] = await Promise.all([
adminApi.getAdminTemplates(50, 0, categoryFilter, searchQuery),
adminApi.getAdminTemplateStats()
])
console.log('Admin templates response:', templatesResponse)
console.log('Admin template stats response:', statsResponse)
setTemplates(templatesResponse || [])
setStats(statsResponse)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load admin templates')
console.error('Error loading admin templates:', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
loadData()
}, [categoryFilter, searchQuery])
// Handle template selection for features
const handleManageFeatures = (template: AdminTemplate) => {
// Convert AdminTemplate to Template format for feature management
const templateForFeatures = {
id: template.id,
title: template.title,
description: template.description,
type: template.type,
category: template.category,
icon: template.icon,
gradient: template.gradient,
border: template.border,
text: template.text,
subtext: template.subtext
}
setSelectedTemplate(templateForFeatures)
setShowFeatureSelection(true)
}
// Handle create template form submission
const handleCreateTemplate = async (e: React.FormEvent) => {
e.preventDefault()
try {
setLoading(true)
// Create template payload
const templateData = {
...newTemplate,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
// Call API to create template (you'll need to implement this endpoint)
const response = await fetch('/api/templates', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(templateData)
})
if (!response.ok) {
throw new Error('Failed to create template')
}
// Reset form and hide it
setNewTemplate({
type: '',
title: '',
description: '',
category: '',
icon: '',
gradient: '',
border: '',
text: '',
subtext: ''
})
setShowCreateModal(false)
// Reload data to show new template
await loadData()
} catch (error) {
console.error('Error creating template:', error)
setError(error instanceof Error ? error.message : 'Failed to create template')
} finally {
setLoading(false)
}
}
// Get category icon
const getCategoryIcon = (category: string) => {
switch (category.toLowerCase()) {
case 'marketing':
return Zap
case 'software':
return Code
case 'seo':
return BarChart3
case 'ecommerce':
return ShoppingCart
case 'portfolio':
return Briefcase
case 'business':
return Briefcase
case 'education':
return GraduationCap
default:
return Globe
}
}
// Get category stats for filters
const getCategoryStats = () => {
const categoryStats = [
{ id: 'all', name: 'All Templates', count: templates.length, icon: Globe },
{ id: 'marketing', name: 'Marketing', count: 0, icon: Zap },
{ id: 'software', name: 'Software', count: 0, icon: Code },
{ id: 'seo', name: 'SEO', count: 0, icon: BarChart3 },
{ id: 'ecommerce', name: 'E-commerce', count: 0, icon: ShoppingCart },
{ id: 'portfolio', name: 'Portfolio', count: 0, icon: Briefcase },
{ id: 'business', name: 'Business', count: 0, icon: Briefcase },
{ id: 'education', name: 'Education', count: 0, icon: GraduationCap }
]
// Count templates by category
templates.forEach(template => {
const categoryId = template.category?.toLowerCase()
const categoryItem = categoryStats.find(cat => cat.id === categoryId)
if (categoryItem) {
categoryItem.count++
}
})
return categoryStats
}
// Filter templates based on search
const filteredTemplates = templates.filter(template => {
const matchesSearch = !searchQuery ||
template.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.type?.toLowerCase().includes(searchQuery.toLowerCase())
return matchesSearch
})
const TemplateCard = ({ template }: { template: AdminTemplate }) => (
<Card className="group hover:shadow-md transition-all bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
<CardTitle className="text-lg text-white group-hover:text-orange-400 transition-colors">
{template.title}
</CardTitle>
<div className="flex items-center space-x-2">
<Badge variant="outline" className="text-xs bg-white/5 border-white/10 text-white/80">
{template.type}
</Badge>
<Badge variant="outline" className="text-xs bg-white/5 border-white/10 text-white/70">
{template.category}
</Badge>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleManageFeatures(template)}
className="text-green-400 hover:text-green-300 border-green-400/30 hover:border-green-300/50"
>
<Settings className="h-4 w-4 mr-1" />
Features
</Button>
</div>
{template.description && (
<p className="text-white/70 text-sm">{template.description}</p>
)}
</CardHeader>
<CardContent className="space-y-3 text-white/80">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center space-x-1">
<Zap className="h-4 w-4 text-orange-400" />
<span>{(template as any).feature_count || 0} features</span>
</div>
<div className="text-white/60">
{template.created_at && formatDate(template.created_at)}
</div>
</div>
{template.gradient && (
<div className="text-xs text-white/60">
<span className="font-medium">Style:</span> {template.gradient}
</div>
)}
</CardContent>
</Card>
)
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex items-center space-x-2">
<RefreshCw className="h-6 w-6 animate-spin text-orange-400" />
<span className="text-white">Loading admin templates...</span>
</div>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Card className="w-full max-w-md bg-gray-900 border-gray-800">
<CardContent className="pt-6">
<div className="flex items-center space-x-2 text-red-400">
<AlertCircle className="h-6 w-6" />
<span>Error loading admin templates</span>
</div>
<p className="mt-2 text-sm text-gray-400">{error}</p>
<Button onClick={loadData} className="mt-4 bg-orange-500 text-black hover:bg-orange-600">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</CardContent>
</Card>
</div>
)
}
// Show feature selection view if a template is selected
if (showFeatureSelection && selectedTemplate) {
return (
<AdminFeatureSelection
template={selectedTemplate}
onBack={() => {
setShowFeatureSelection(false)
setSelectedTemplate(null)
}}
/>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white">Admin Templates</h1>
<p className="text-white/70">Manage features for your templates</p>
</div>
<div className="flex items-center space-x-2">
<Button
onClick={() => setShowCreateModal(true)}
className="bg-green-500 text-black hover:bg-green-600"
>
<Plus className="h-4 w-4 mr-2" />
Create Template
</Button>
<Button onClick={loadData} className="bg-orange-500 text-black hover:bg-orange-600">
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">Total Templates</CardTitle>
<Globe className="h-4 w-4 text-white/60" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{(stats as any).total_templates || templates.length}</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">Categories</CardTitle>
<BarChart3 className="h-4 w-4 text-white/60" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{(stats as any).total_categories || categories.length - 1}</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">Avg Features</CardTitle>
<Zap className="h-4 w-4 text-orange-400" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">
{Math.round((stats as any).avg_features_per_template || 0)}
</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">With Features</CardTitle>
<Settings className="h-4 w-4 text-green-400" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{(stats as any).templates_with_features || 0}</div>
</CardContent>
</Card>
</div>
)}
{/* Filters */}
<div className="flex items-center space-x-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search templates..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-white/5 border-white/10 text-white"
/>
</div>
</div>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-48 bg-white/5 border-white/10 text-white">
<SelectValue placeholder="Filter by category" />
</SelectTrigger>
<SelectContent>
{getCategoryStats().map((category) => (
<SelectItem key={category.id} value={category.id}>
<div className="flex items-center space-x-2">
<category.icon className="h-4 w-4" />
<span>{category.name} ({category.count})</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Category Filters */}
<div className="flex flex-wrap gap-4">
{getCategoryStats().map((category) => {
const Icon = category.icon
const active = categoryFilter === category.id
return (
<button
key={category.id}
onClick={() => setCategoryFilter(category.id)}
className={`flex items-center space-x-2 px-4 py-2 rounded-lg border transition-all ${
active
? "bg-orange-500 text-black border-orange-500"
: "bg-white/5 text-white/80 border-white/10 hover:bg-white/10"
}`}
>
<Icon className="h-4 w-4" />
<span className="font-medium">{category.name}</span>
<Badge variant="secondary" className="ml-1 bg-white/10 text-white">
{category.count}
</Badge>
</button>
)
})}
</div>
{/* Create Template Modal */}
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
<DialogContent className="bg-gray-900 border-gray-800 text-white max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white">Create New Template</DialogTitle>
</DialogHeader>
<form onSubmit={handleCreateTemplate} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-white">Template Type *</label>
<Input
placeholder="e.g., multi_restaurant_food_delivery"
value={newTemplate.type}
onChange={(e) => setNewTemplate(prev => ({ ...prev, type: e.target.value }))}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
required
/>
<p className="text-xs text-white/60">Unique identifier for the template</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Title *</label>
<Input
placeholder="e.g., Multi-Restaurant Food Delivery App"
value={newTemplate.title}
onChange={(e) => setNewTemplate(prev => ({ ...prev, title: e.target.value }))}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Description *</label>
<textarea
placeholder="Describe your template and its key features..."
value={newTemplate.description}
onChange={(e) => setNewTemplate(prev => ({ ...prev, description: e.target.value }))}
className="w-full px-3 py-2 bg-white/5 border border-white/10 text-white placeholder:text-white/40 rounded-md min-h-[100px]"
required
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-white">Category *</label>
<Select value={newTemplate.category} onValueChange={(value) => setNewTemplate(prev => ({ ...prev, category: value }))}>
<SelectTrigger className="bg-white/5 border-white/10 text-white">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent className="bg-gray-900 border-white/10">
{categories.map((category, index) => (
<SelectItem key={`category-${index}`} value={category} className="text-white">
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Icon (optional)</label>
<Input
placeholder="e.g., restaurant, shopping-cart, users"
value={newTemplate.icon}
onChange={(e) => setNewTemplate(prev => ({ ...prev, icon: e.target.value }))}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-white">Gradient (optional)</label>
<Input
placeholder="e.g., from-orange-400 to-red-500"
value={newTemplate.gradient}
onChange={(e) => setNewTemplate(prev => ({ ...prev, gradient: e.target.value }))}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Border (optional)</label>
<Input
placeholder="e.g., border-orange-500"
value={newTemplate.border}
onChange={(e) => setNewTemplate(prev => ({ ...prev, border: e.target.value }))}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Text Color (optional)</label>
<Input
placeholder="e.g., text-orange-500"
value={newTemplate.text}
onChange={(e) => setNewTemplate(prev => ({ ...prev, text: e.target.value }))}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Subtext (optional)</label>
<Input
placeholder="e.g., Perfect for food delivery startups"
value={newTemplate.subtext}
onChange={(e) => setNewTemplate(prev => ({ ...prev, subtext: e.target.value }))}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setShowCreateModal(false)}
className="border-white/20 text-white hover:bg-white/10 cursor-pointer"
disabled={loading}
>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button
type="submit"
className="bg-orange-500 hover:bg-orange-400 text-black font-semibold cursor-pointer"
disabled={loading}
>
<Save className="mr-2 h-4 w-4" />
{loading ? "Creating..." : "Create Template"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* Feature Selection View */}
{showFeatureSelection && selectedTemplate && (
<AdminFeatureSelection
template={selectedTemplate}
onBack={() => {
setShowFeatureSelection(false)
setSelectedTemplate(null)
}}
/>
)}
{/* Features Manager Modal */}
{showFeaturesManager && selectedTemplate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-gray-900 rounded-lg max-w-6xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-white">Manage Features - {selectedTemplate.title}</h2>
<Button
onClick={() => {
setShowFeaturesManager(false)
setSelectedTemplate(null)
}}
variant="outline"
className="text-white border-gray-600 hover:bg-gray-800"
>
Close
</Button>
</div>
<AdminFeatureSelection
template={selectedTemplate}
onBack={() => {
setShowFeaturesManager(false)
setSelectedTemplate(null)
}}
/>
</div>
</div>
</div>
)}
{/* Templates Grid */}
<div className="space-y-4">
{filteredTemplates.length === 0 ? (
<Card className="bg-gray-900 border-gray-800">
<CardContent className="pt-6">
<div className="text-center text-gray-400">
<Globe className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<p>No templates found</p>
<p className="text-sm">Try adjusting your search or filters.</p>
</div>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredTemplates.map((template) => (
<TemplateCard key={template.id} template={template} />
))}
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,647 @@
"use client"
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
CheckCircle,
XCircle,
Clock,
RefreshCw,
AlertCircle,
Copy,
Filter,
Search,
Edit,
Plus,
Save,
Files,
Settings
} from 'lucide-react'
import { adminApi, formatDate, getStatusColor, getComplexityColor } from '@/lib/api/admin'
import { AdminTemplate, AdminStats } from '@/types/admin.types'
import { TemplateEditDialog } from './template-edit-dialog'
import { RejectDialog } from './reject-dialog'
import { TemplateFeaturesManager } from './template-features-manager'
import { AdminTemplatesList } from './admin-templates-list'
import { useAdminNotifications } from '@/contexts/AdminNotificationContext'
export function AdminTemplatesManager() {
const [activeTab, setActiveTab] = useState("admin-templates")
const [customTemplates, setCustomTemplates] = useState<AdminTemplate[]>([])
const [templateStats, setTemplateStats] = useState<AdminStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedTemplate, setSelectedTemplate] = useState<AdminTemplate | null>(null)
const [showTemplateEditDialog, setShowTemplateEditDialog] = useState(false)
const [showRejectDialog, setShowRejectDialog] = useState(false)
const [rejectItem, setRejectItem] = useState<{ id: string; name: string; type: 'template' } | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [showFeaturesManager, setShowFeaturesManager] = useState(false)
const [selectedTemplateForFeatures, setSelectedTemplateForFeatures] = useState<AdminTemplate | null>(null)
// Create template form state
const [newTemplate, setNewTemplate] = useState({
title: '',
description: '',
category: '',
type: '',
complexity: 'low',
icon: '',
gradient: '',
border: '',
text: '',
subtext: ''
})
const { removeByReference } = useAdminNotifications()
// Load templates data
const loadTemplatesData = async () => {
try {
setLoading(true)
setError(null)
console.log('Loading templates data...')
const [templatesResponse, templateStatsResponse] = await Promise.all([
adminApi.getCustomTemplates(), // Try without status filter first
adminApi.getTemplateStats()
])
console.log('Templates response:', templatesResponse)
console.log('Template stats response:', templateStatsResponse)
setCustomTemplates(templatesResponse || [])
setTemplateStats(templateStatsResponse)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load templates data')
console.error('Error loading templates data:', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
loadTemplatesData()
}, [])
// Handle template review
const handleTemplateReview = async (templateId: string, reviewData: { status: 'pending' | 'approved' | 'rejected' | 'duplicate'; admin_notes?: string }) => {
try {
await adminApi.reviewTemplate(templateId, reviewData)
// Update the template in the list
setCustomTemplates(prev => prev.map(t =>
t.id === templateId ? { ...t, status: reviewData.status as AdminTemplate['status'], admin_notes: reviewData.admin_notes } : t
))
// Reload stats
const newStats = await adminApi.getTemplateStats()
setTemplateStats(newStats)
} catch (err) {
console.error('Error reviewing template:', err)
alert('Error reviewing template')
}
}
// Handle template update
const handleTemplateUpdate = (templateId: string, updatedTemplate: AdminTemplate) => {
setCustomTemplates(prev => prev.filter(t => t.id !== templateId))
}
// Handle reject action
const handleReject = async (adminNotes: string) => {
if (!rejectItem) return
try {
await adminApi.rejectTemplate(rejectItem.id, adminNotes)
setCustomTemplates(prev => prev.map(t =>
t.id === rejectItem.id ? { ...t, status: 'rejected', admin_notes: adminNotes } : t
))
// Reload template stats
const newStats = await adminApi.getTemplateStats()
setTemplateStats(newStats)
} catch (err) {
console.error('Error rejecting template:', err)
alert('Error rejecting template')
}
}
// Handle approve action
const handleApprove = async (templateId: string) => {
// Optimistic UI: remove immediately
setCustomTemplates(prev => prev.filter(t => t.id !== templateId))
try {
// Get the template details first
const template = customTemplates.find(t => t.id === templateId)
if (template) {
// Create new approved template in main templates table
await adminApi.createApprovedTemplate(templateId, {
title: template.title || '',
description: template.description,
category: template.category || '',
type: template.type || '',
icon: template.icon,
gradient: template.gradient,
border: template.border,
text: template.text,
subtext: template.subtext
})
// Update the custom template status to approved
await adminApi.reviewTemplate(templateId, { status: 'approved', admin_notes: 'Approved and created in main templates' })
// Remove related notifications for this template
removeByReference('custom_template', templateId)
}
// Reload template stats
const newStats = await adminApi.getTemplateStats()
setTemplateStats(newStats)
} catch (err) {
console.error('Error approving template:', err)
// Recover UI by reloading if optimistic removal was wrong
await loadTemplatesData()
}
}
// Handle create template
const handleCreateTemplate = async (e: React.FormEvent) => {
e.preventDefault()
try {
// Validate required fields
if (!newTemplate.title || !newTemplate.description || !newTemplate.category || !newTemplate.type) {
alert('Please fill in all required fields (Title, Description, Category, Type)')
return
}
// Call API to create new custom template using the correct endpoint
await adminApi.createCustomTemplate({
title: newTemplate.title,
description: newTemplate.description,
category: newTemplate.category,
type: newTemplate.type,
complexity: newTemplate.complexity,
icon: newTemplate.icon,
gradient: newTemplate.gradient,
border: newTemplate.border,
text: newTemplate.text,
subtext: newTemplate.subtext
})
// Reset form
setNewTemplate({
title: '',
description: '',
category: '',
type: '',
complexity: 'low',
icon: '',
gradient: '',
border: '',
text: '',
subtext: ''
})
// Reload templates
await loadTemplatesData()
// Switch to manage tab
setActiveTab("manage")
alert('Template created successfully!')
} catch (err) {
console.error('Error creating template:', err)
const errorMessage = err instanceof Error ? err.message : 'Failed to create template'
alert(`Error creating template: ${errorMessage}`)
}
}
// Filter templates based on search and status
const filteredTemplates = customTemplates.filter(template => {
const matchesSearch = template.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.category?.toLowerCase().includes(searchQuery.toLowerCase())
const matchesStatus = statusFilter === 'all' || template.status === statusFilter
return matchesSearch && matchesStatus
})
// Get status counts for templates
const getTemplateStatusCount = (status: string) => {
return (templateStats as AdminStats & { templates?: Array<{ status: string; count: number }> })?.templates?.find((s) => s.status === status)?.count || 0
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex items-center space-x-2">
<RefreshCw className="h-6 w-6 animate-spin" />
<span>Loading templates...</span>
</div>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Card className="w-full max-w-md">
<CardContent className="pt-6">
<div className="flex items-center space-x-2 text-red-600">
<AlertCircle className="h-6 w-6" />
<span>Error loading templates</span>
</div>
<p className="mt-2 text-sm text-gray-600">{error}</p>
<Button onClick={loadTemplatesData} className="mt-4">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white">Templates Management</h1>
<p className="text-white/70">Manage templates and create new ones</p>
</div>
<div className="flex items-center space-x-2">
<Button onClick={loadTemplatesData} className="bg-orange-500 text-black hover:bg-orange-600">
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">Total Templates</CardTitle>
<Files className="h-4 w-4 text-white/60" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{customTemplates.length}</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">Pending</CardTitle>
<Clock className="h-4 w-4 text-yellow-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{getTemplateStatusCount('pending')}</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">Approved</CardTitle>
<CheckCircle className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{getTemplateStatusCount('approved')}</div>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">Rejected</CardTitle>
<XCircle className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{getTemplateStatusCount('rejected')}</div>
</CardContent>
</Card>
</div>
{/* Main Content */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList className="bg-gray-900 border-gray-800">
<TabsTrigger value="admin-templates" className="flex items-center space-x-2 data-[state=active]:bg-orange-500 data-[state=active]:text-black text-white/70">
<Settings className="h-4 w-4" />
<span>Admin Templates</span>
</TabsTrigger>
<TabsTrigger value="manage" className="flex items-center space-x-2 data-[state=active]:bg-orange-500 data-[state=active]:text-black text-white/70">
<Filter className="h-4 w-4" />
<span>Custom Templates ({customTemplates.length})</span>
</TabsTrigger>
</TabsList>
<TabsContent value="admin-templates" className="space-y-4">
<AdminTemplatesList />
</TabsContent>
<TabsContent value="manage" className="space-y-4">
{/* Filters */}
<div className="flex items-center space-x-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search templates..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
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>
{/* Templates List */}
<div className="space-y-4">
{filteredTemplates.length === 0 ? (
<Card>
<CardContent className="pt-6">
<div className="text-center text-gray-500">
<Copy className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>No templates found</p>
<p className="text-sm">All templates have been reviewed or no templates match your filters.</p>
</div>
</CardContent>
</Card>
) : (
filteredTemplates.map((template) => (
<Card key={template.id} className="hover:shadow-md transition-shadow">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-2 mb-2">
<h3 className="text-lg font-semibold">{template.title}</h3>
<Badge className={getStatusColor(template.status)}>
{template.status}
</Badge>
<Badge className={getComplexityColor(template.complexity)}>
{template.complexity}
</Badge>
{template.category && (
<Badge variant="outline">{template.category}</Badge>
)}
</div>
{template.description && (
<p className="text-gray-600 mb-2">{template.description}</p>
)}
<div className="flex items-center space-x-4 text-sm text-gray-500">
<span>Type: {template.type || 'Unknown'}</span>
<span>Submitted: {formatDate(template.created_at)}</span>
<span>Usage: {template.usage_count || 0}</span>
</div>
{template.admin_notes && (
<div className="mt-2 p-2 bg-gray-50 rounded">
<p className="text-sm text-gray-600">
<strong>Admin Notes:</strong> {template.admin_notes}
</p>
</div>
)}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleApprove(template.id)}
className="text-green-600 hover:text-green-700 hover:bg-green-50"
>
<CheckCircle className="h-4 w-4 mr-1" />
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setRejectItem({ id: template.id, name: template.title, type: 'template' })
setShowRejectDialog(true)
}}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<XCircle className="h-4 w-4 mr-1" />
Reject
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedTemplateForFeatures(template)
setShowFeaturesManager(true)
}}
className="text-green-600 hover:text-green-700 hover:bg-green-50"
>
<Settings className="h-4 w-4 mr-1" />
Features
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedTemplate(template)
setShowTemplateEditDialog(true)
}}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</Button>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</TabsContent>
<TabsContent value="create" className="space-y-4">
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="text-white">Create New Template</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleCreateTemplate} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="title" className="text-white">Template Title</Label>
<Input
id="title"
value={newTemplate.title}
onChange={(e) => setNewTemplate(prev => ({ ...prev, title: e.target.value }))}
className="bg-white/5 border-white/10 text-white"
placeholder="Enter template title"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="category" className="text-white">Category</Label>
<Select value={newTemplate.category} onValueChange={(value) => setNewTemplate(prev => ({ ...prev, category: value }))}>
<SelectTrigger className="bg-white/5 border-white/10 text-white">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="marketing">Marketing</SelectItem>
<SelectItem value="software">Software</SelectItem>
<SelectItem value="seo">SEO</SelectItem>
<SelectItem value="ecommerce">E-commerce</SelectItem>
<SelectItem value="portfolio">Portfolio</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-white">Description</Label>
<Textarea
id="description"
value={newTemplate.description}
onChange={(e) => setNewTemplate(prev => ({ ...prev, description: e.target.value }))}
className="bg-white/5 border-white/10 text-white"
placeholder="Enter template description"
rows={3}
required
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="type" className="text-white">Type</Label>
<Select value={newTemplate.type} onValueChange={(value) => setNewTemplate(prev => ({ ...prev, type: value }))}>
<SelectTrigger className="bg-white/5 border-white/10 text-white">
<SelectValue placeholder="Select template type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Business Website">Business Website</SelectItem>
<SelectItem value="E-commerce Store">E-commerce Store</SelectItem>
<SelectItem value="Landing Page">Landing Page</SelectItem>
<SelectItem value="Blog Platform">Blog Platform</SelectItem>
<SelectItem value="Portfolio Site">Portfolio Site</SelectItem>
<SelectItem value="SaaS Platform">SaaS Platform</SelectItem>
<SelectItem value="Mobile App">Mobile App</SelectItem>
<SelectItem value="Web Application">Web Application</SelectItem>
<SelectItem value="Marketing Site">Marketing Site</SelectItem>
<SelectItem value="Corporate Website">Corporate Website</SelectItem>
<SelectItem value="Educational Platform">Educational Platform</SelectItem>
<SelectItem value="Social Media App">Social Media App</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="complexity" className="text-white">Complexity</Label>
<Select value={newTemplate.complexity} onValueChange={(value) => setNewTemplate(prev => ({ ...prev, complexity: value }))}>
<SelectTrigger className="bg-white/5 border-white/10 text-white">
<SelectValue placeholder="Select complexity" />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="icon" className="text-white">Icon</Label>
<Input
id="icon"
value={newTemplate.icon}
onChange={(e) => setNewTemplate(prev => ({ ...prev, icon: e.target.value }))}
className="bg-white/5 border-white/10 text-white"
placeholder="Icon name or URL"
/>
</div>
<div className="space-y-2">
<Label htmlFor="gradient" className="text-white">Gradient</Label>
<Input
id="gradient"
value={newTemplate.gradient}
onChange={(e) => setNewTemplate(prev => ({ ...prev, gradient: e.target.value }))}
className="bg-white/5 border-white/10 text-white"
placeholder="CSS gradient value"
/>
</div>
</div>
<div className="flex justify-end space-x-2">
<Button type="button" variant="outline" onClick={() => setActiveTab("manage")}>
Cancel
</Button>
<Button type="submit" className="bg-orange-500 text-black hover:bg-orange-600">
<Save className="h-4 w-4 mr-2" />
Create Template
</Button>
</div>
</form>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Template Edit Dialog */}
{selectedTemplate && (
<TemplateEditDialog
template={selectedTemplate}
open={showTemplateEditDialog}
onOpenChange={setShowTemplateEditDialog}
onUpdate={handleTemplateUpdate}
/>
)}
{/* Reject Dialog */}
{rejectItem && (
<RejectDialog
open={showRejectDialog}
onOpenChange={setShowRejectDialog}
onReject={handleReject}
title="Reject Template"
itemName={rejectItem.name}
itemType={rejectItem.type}
/>
)}
{/* Template Features Manager */}
{selectedTemplateForFeatures && (
<TemplateFeaturesManager
template={selectedTemplateForFeatures}
open={showFeaturesManager}
onOpenChange={setShowFeaturesManager}
/>
)}
</div>
)
}

View File

@ -0,0 +1,257 @@
"use client"
import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Loader2, Save, AlertTriangle } from 'lucide-react'
import { adminApi } from '@/lib/api/admin'
import { AdminFeature } from '@/types/admin.types'
interface FeatureEditDialogProps {
feature: AdminFeature
open: boolean
onOpenChange: (open: boolean) => void
onUpdate: (featureId: string, updatedFeature: AdminFeature) => void
}
export function FeatureEditDialog({
feature,
open,
onOpenChange,
onUpdate
}: FeatureEditDialogProps) {
const [formData, setFormData] = useState({
name: '',
description: '',
complexity: 'medium' as 'low' | 'medium' | 'high',
business_rules: '',
technical_requirements: ''
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Initialize form data when feature changes
useEffect(() => {
if (feature) {
setFormData({
name: feature.name || '',
description: feature.description || '',
complexity: feature.complexity || 'medium',
business_rules: feature.business_rules ? JSON.stringify(feature.business_rules, null, 2) : '',
technical_requirements: feature.technical_requirements ? JSON.stringify(feature.technical_requirements, null, 2) : ''
})
setError(null)
}
}, [feature])
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.name.trim()) {
setError('Feature name is required')
return
}
try {
setLoading(true)
setError(null)
// Prepare update data
const updateData: Record<string, unknown> = {
name: formData.name.trim(),
description: formData.description.trim() || undefined,
complexity: formData.complexity
}
// Parse JSON fields if they have content
if (formData.business_rules.trim()) {
try {
updateData.business_rules = JSON.parse(formData.business_rules)
} catch (err) {
setError('Invalid JSON format in business rules')
return
}
}
if (formData.technical_requirements.trim()) {
try {
updateData.technical_requirements = JSON.parse(formData.technical_requirements)
} catch (err) {
setError('Invalid JSON format in technical requirements')
return
}
}
const updatedFeature = await adminApi.updateCustomFeature(feature.id, updateData)
// Update the feature in the parent component
onUpdate(feature.id, { ...feature, ...updatedFeature })
// Close dialog
onOpenChange(false)
} catch (error) {
console.error('Error updating feature:', error)
} finally {
setLoading(false)
}
}
const getComplexityColor = (complexity: string) => {
switch (complexity) {
case 'low':
return 'bg-green-100 text-green-800'
case 'medium':
return 'bg-yellow-100 text-yellow-800'
case 'high':
return 'bg-red-100 text-red-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Custom Feature</DialogTitle>
<p className="text-sm text-gray-600">
Template: <strong>{feature.template_title || 'Unknown'}</strong>
</p>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Feature Name */}
<div className="space-y-2">
<Label htmlFor="name">Feature Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
placeholder="Enter feature name..."
required
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Describe what this feature does..."
rows={3}
/>
</div>
{/* Complexity */}
<div className="space-y-2">
<Label htmlFor="complexity">Complexity</Label>
<Select value={formData.complexity} onValueChange={(value) => handleInputChange('complexity', value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">
<div className="flex items-center space-x-2">
<Badge className="bg-green-100 text-green-800">Low</Badge>
<span>Simple implementation</span>
</div>
</SelectItem>
<SelectItem value="medium">
<div className="flex items-center space-x-2">
<Badge className="bg-yellow-100 text-yellow-800">Medium</Badge>
<span>Moderate complexity</span>
</div>
</SelectItem>
<SelectItem value="high">
<div className="flex items-center space-x-2">
<Badge className="bg-red-100 text-red-800">High</Badge>
<span>Complex implementation</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Business Rules */}
<div className="space-y-2">
<Label htmlFor="business_rules">Business Rules (JSON)</Label>
<Textarea
id="business_rules"
value={formData.business_rules}
onChange={(e) => handleInputChange('business_rules', e.target.value)}
placeholder='{"rule1": "description", "rule2": "description"}'
rows={4}
/>
<p className="text-xs text-gray-500">
Optional: Define business rules for this feature in JSON format
</p>
</div>
{/* Technical Requirements */}
<div className="space-y-2">
<Label htmlFor="technical_requirements">Technical Requirements (JSON)</Label>
<Textarea
id="technical_requirements"
value={formData.technical_requirements}
onChange={(e) => handleInputChange('technical_requirements', e.target.value)}
placeholder='{"framework": "React", "database": "PostgreSQL"}'
rows={4}
/>
<p className="text-xs text-gray-500">
Optional: Define technical requirements in JSON format
</p>
</div>
{/* Error Display */}
{error && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Action Buttons */}
<div className="flex justify-end space-x-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
disabled={loading || !formData.name.trim()}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Updating...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Update Feature
</>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,140 @@
"use client"
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Loader2, XCircle, AlertTriangle } from 'lucide-react'
interface RejectDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onReject: (adminNotes: string) => Promise<void>
title: string
itemName: string
itemType: 'feature' | 'template'
}
export function RejectDialog({
open,
onOpenChange,
onReject,
title,
itemName,
itemType
}: RejectDialogProps) {
const [adminNotes, setAdminNotes] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!adminNotes.trim()) {
setError('Please provide a reason for rejection')
return
}
try {
setLoading(true)
setError(null)
await onReject(adminNotes.trim())
// Reset form and close dialog
setAdminNotes('')
onOpenChange(false)
} catch (error) {
setError(error instanceof Error ? error.message : `Failed to reject ${itemType}`)
console.error(`Error rejecting ${itemType}:`, error)
} finally {
setLoading(false)
}
}
const handleCancel = () => {
setAdminNotes('')
setError(null)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center text-red-600">
<XCircle className="h-5 w-5 mr-2" />
{title}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-800">
You are about to reject: <strong>{itemName}</strong>
</p>
<p className="text-xs text-red-600 mt-1">
This action will mark the {itemType} as rejected and notify the submitter.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="adminNotes">Reason for Rejection *</Label>
<Textarea
id="adminNotes"
value={adminNotes}
onChange={(e) => setAdminNotes(e.target.value)}
placeholder={`Explain why this ${itemType} is being rejected...`}
rows={4}
required
/>
<p className="text-xs text-gray-500">
This message will be visible to the submitter
</p>
</div>
{/* Error Display */}
{error && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Action Buttons */}
<div className="flex justify-end space-x-2 pt-2">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
disabled={loading || !adminNotes.trim()}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Rejecting...
</>
) : (
<>
<XCircle className="h-4 w-4 mr-2" />
Reject {itemType === 'feature' ? 'Feature' : 'Template'}
</>
)}
</Button>
</div>
</form>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,319 @@
"use client"
import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Loader2, Save, AlertTriangle } from 'lucide-react'
import { adminApi, AdminApiError } from '@/lib/api/admin'
import { AdminTemplate } from '@/types/admin.types'
import { useToast } from '@/components/ui/toast'
interface TemplateEditDialogProps {
template: AdminTemplate
open: boolean
onOpenChange: (open: boolean) => void
onUpdate: (templateId: string, updatedTemplate: AdminTemplate) => void
}
export function TemplateEditDialog({
template,
open,
onOpenChange,
onUpdate
}: TemplateEditDialogProps) {
const { show } = useToast()
const [formData, setFormData] = useState({
title: '',
description: '',
category: '',
type: '',
icon: '',
gradient: '',
border: '',
text: '',
subtext: ''
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [conflictInfo, setConflictInfo] = useState<{ title?: string; type?: string } | null>(null)
const categories = [
"Food Delivery",
"E-commerce",
"SaaS Platform",
"Mobile App",
"Dashboard",
"CRM System",
"Learning Platform",
"Healthcare",
"Real Estate",
"Travel",
"Entertainment",
"Finance",
"Social Media",
"Marketplace",
"Other"
]
// Initialize form data when template changes
useEffect(() => {
if (template) {
setFormData({
title: template.title || '',
description: template.description || '',
category: template.category || '',
type: template.type || '',
icon: template.icon || '',
gradient: template.gradient || '',
border: template.border || '',
text: template.text || '',
subtext: template.subtext || ''
})
setError(null)
setConflictInfo(null)
}
}, [template])
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.title.trim()) {
setError('Template title is required')
return
}
if (!formData.type.trim()) {
setError('Template type is required')
return
}
try {
setLoading(true)
setError(null)
setConflictInfo(null)
// Prepare update data
const updateData = {
title: formData.title.trim(),
description: formData.description.trim() || undefined,
category: formData.category || undefined,
type: formData.type.trim(),
icon: formData.icon.trim() || undefined,
gradient: formData.gradient.trim() || undefined,
border: formData.border.trim() || undefined,
text: formData.text.trim() || undefined,
subtext: formData.subtext.trim() || undefined
}
const newTemplate = await adminApi.createTemplateFromEdit(template.id, updateData)
// Update the template in the parent component with the new template data
onUpdate(template.id, { ...template, ...newTemplate, status: 'pending' })
// Close dialog
onOpenChange(false)
} catch (error) {
if (error instanceof AdminApiError) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: any = (error as any).data || {}
if (data?.existing_template) {
setConflictInfo({ title: data.existing_template.title, type: data.existing_template.type })
}
const message = data?.message || error.message || 'Failed to create template'
setError(message)
show({
title: 'Template creation failed',
description: data?.existing_template
? `${message} — Existing: ${data.existing_template.title} (${data.existing_template.type}).`
: message,
variant: 'error',
})
} else {
const message = error instanceof Error ? error.message : 'Failed to create template'
setError(message)
show({ title: 'Template creation failed', description: message, variant: 'error' })
}
console.error('Error creating template:', error)
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New Template from Edit</DialogTitle>
<p className="text-sm text-gray-600">
We&apos;ll help you create a comprehensive template. based on: <strong>{template.title}</strong>
</p>
<p className="text-xs text-gray-500">
The new template will be created with &apos;pending&apos; status for review.
</p>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="type">Template Type *</Label>
<Input
id="type"
placeholder="e.g., multi_restaurant_food_delivery"
value={formData.type}
onChange={(e) => handleInputChange('type', e.target.value)}
required
/>
<p className="text-xs text-gray-500">Unique identifier for the template</p>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
placeholder="e.g., Multi-Restaurant Food Delivery App"
value={formData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description *</Label>
<Textarea
id="description"
placeholder="Describe your template and its key features..."
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
className="min-h-[100px]"
required
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="category">Category *</Label>
<Select value={formData.category} onValueChange={(value) => handleInputChange('category', value)}>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="icon">Icon (optional)</Label>
<Input
id="icon"
placeholder="e.g., restaurant, shopping-cart, users"
value={formData.icon}
onChange={(e) => handleInputChange('icon', e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="gradient">Gradient (optional)</Label>
<Input
id="gradient"
placeholder="e.g., from-orange-400 to-red-500"
value={formData.gradient}
onChange={(e) => handleInputChange('gradient', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="border">Border (optional)</Label>
<Input
id="border"
placeholder="e.g., border-orange-500"
value={formData.border}
onChange={(e) => handleInputChange('border', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="text">Text Color (optional)</Label>
<Input
id="text"
placeholder="e.g., text-orange-500"
value={formData.text}
onChange={(e) => handleInputChange('text', e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="subtext">Subtext (optional)</Label>
<Input
id="subtext"
placeholder="e.g., Perfect for food delivery startups"
value={formData.subtext}
onChange={(e) => handleInputChange('subtext', e.target.value)}
/>
</div>
{/* Error Display */}
{error && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
{error}
{conflictInfo && (
<span className="block mt-1 text-xs text-gray-600">
Existing: {conflictInfo.title} ({conflictInfo.type}). Try a different title.
</span>
)}
</AlertDescription>
</Alert>
)}
{/* Action Buttons */}
<div className="flex justify-end space-x-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
disabled={loading || !formData.title.trim() || !formData.type.trim()}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Creating...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Create New Template
</>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,490 @@
"use client"
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import {
Plus,
Edit,
Trash2,
Save,
X,
Settings,
Zap,
AlertCircle,
CheckCircle,
RefreshCw
} from 'lucide-react'
import { adminApi, getComplexityColor } from '@/lib/api/admin'
import { AdminFeature, AdminTemplate } from '@/types/admin.types'
interface TemplateFeature {
id: string
name: string
description?: string
complexity: 'low' | 'medium' | 'high'
feature_type?: string
business_rules?: Record<string, unknown>
technical_requirements?: Record<string, unknown>
created_at?: string
updated_at?: string
}
interface TemplateFeaturesManagerProps {
template: AdminTemplate
open: boolean
onOpenChange: (open: boolean) => void
}
export function TemplateFeaturesManager({ template, open, onOpenChange }: TemplateFeaturesManagerProps) {
const [features, setFeatures] = useState<TemplateFeature[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showAddFeature, setShowAddFeature] = useState(false)
const [editingFeature, setEditingFeature] = useState<TemplateFeature | null>(null)
// New feature form state
const [newFeature, setNewFeature] = useState({
name: '',
description: '',
complexity: 'low' as 'low' | 'medium' | 'high',
feature_type: '',
business_rules: '',
technical_requirements: ''
})
// Load template features
const loadFeatures = async () => {
if (!template.id) return
try {
setLoading(true)
setError(null)
const featuresData = await adminApi.getTemplateFeatures(template.id)
setFeatures(featuresData as TemplateFeature[])
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load features')
console.error('Error loading template features:', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (open && template.id) {
loadFeatures()
}
}, [open, template.id])
// Handle add feature
const handleAddFeature = async (e: React.FormEvent) => {
e.preventDefault()
if (!newFeature.name.trim()) {
alert('Feature name is required')
return
}
try {
const featureData = {
name: newFeature.name,
description: newFeature.description || undefined,
complexity: newFeature.complexity,
feature_type: newFeature.feature_type || undefined,
business_rules: newFeature.business_rules ? JSON.parse(newFeature.business_rules) : undefined,
technical_requirements: newFeature.technical_requirements ? JSON.parse(newFeature.technical_requirements) : undefined
}
const addedFeature = await adminApi.addFeatureToTemplate(template.id, featureData)
setFeatures(prev => [...prev, addedFeature as TemplateFeature])
// Reset form
setNewFeature({
name: '',
description: '',
complexity: 'low',
feature_type: '',
business_rules: '',
technical_requirements: ''
})
setShowAddFeature(false)
} catch (err) {
console.error('Error adding feature:', err)
alert('Failed to add feature: ' + (err instanceof Error ? err.message : 'Unknown error'))
}
}
// Handle update feature
const handleUpdateFeature = async (featureId: string, updateData: Partial<TemplateFeature>) => {
try {
const updatedFeature = await adminApi.updateTemplateFeature(template.id, featureId, updateData)
setFeatures(prev => prev.map(f => f.id === featureId ? updatedFeature as TemplateFeature : f))
setEditingFeature(null)
} catch (err) {
console.error('Error updating feature:', err)
alert('Failed to update feature: ' + (err instanceof Error ? err.message : 'Unknown error'))
}
}
// Handle delete feature
const handleDeleteFeature = async (featureId: string) => {
if (!confirm('Are you sure you want to delete this feature?')) return
try {
await adminApi.removeFeatureFromTemplate(template.id, featureId)
setFeatures(prev => prev.filter(f => f.id !== featureId))
} catch (err) {
console.error('Error deleting feature:', err)
alert('Failed to delete feature: ' + (err instanceof Error ? err.message : 'Unknown error'))
}
}
// Handle bulk add common features
const handleBulkAddCommonFeatures = async () => {
const commonFeatures = [
{ name: 'User Authentication', description: 'Login and registration system', complexity: 'medium' as const },
{ name: 'Responsive Design', description: 'Mobile-friendly layout', complexity: 'low' as const },
{ name: 'SEO Optimization', description: 'Search engine optimization features', complexity: 'low' as const },
{ name: 'Analytics Integration', description: 'Google Analytics or similar tracking', complexity: 'low' as const },
{ name: 'Contact Form', description: 'Contact form with validation', complexity: 'low' as const },
{ name: 'Content Management', description: 'CMS functionality for content updates', complexity: 'high' as const }
]
try {
const addedFeatures = await adminApi.bulkAddFeaturesToTemplate(template.id, commonFeatures)
setFeatures(prev => [...prev, ...addedFeatures as TemplateFeature[]])
} catch (err) {
console.error('Error bulk adding features:', err)
alert('Failed to add common features: ' + (err instanceof Error ? err.message : 'Unknown error'))
}
}
const FeatureCard = ({ feature }: { feature: TemplateFeature }) => (
<Card className="bg-gray-900 border-gray-800">
<CardContent className="pt-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-2 mb-2">
<h4 className="font-medium text-white">{feature.name}</h4>
<Badge className={getComplexityColor(feature.complexity)}>
{feature.complexity}
</Badge>
{feature.feature_type && (
<Badge variant="outline" className="text-xs">
{feature.feature_type}
</Badge>
)}
</div>
{feature.description && (
<p className="text-gray-400 text-sm mb-2">{feature.description}</p>
)}
<div className="text-xs text-gray-500">
{feature.created_at && `Added: ${new Date(feature.created_at).toLocaleDateString()}`}
</div>
</div>
<div className="flex items-center space-x-1">
<Button
variant="outline"
size="sm"
onClick={() => setEditingFeature(feature)}
className="text-blue-400 hover:text-blue-300"
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteFeature(feature.id)}
className="text-red-400 hover:text-red-300"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</CardContent>
</Card>
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto bg-black border-gray-800">
<DialogHeader>
<DialogTitle className="text-white flex items-center space-x-2">
<Settings className="h-5 w-5" />
<span>Manage Features - {template.title}</span>
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Header Actions */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Zap className="h-5 w-5 text-orange-400" />
<span className="text-white font-medium">Template Features ({features.length})</span>
</div>
<div className="flex items-center space-x-2">
<Button
onClick={handleBulkAddCommonFeatures}
variant="outline"
size="sm"
className="text-white border-gray-600 hover:bg-gray-800"
>
<Plus className="h-4 w-4 mr-1" />
Add Common Features
</Button>
<Button
onClick={() => setShowAddFeature(true)}
className="bg-orange-500 text-black hover:bg-orange-600"
>
<Plus className="h-4 w-4 mr-1" />
Add Feature
</Button>
<Button
onClick={loadFeatures}
variant="outline"
size="sm"
className="text-white border-gray-600 hover:bg-gray-800"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin text-orange-400" />
<span className="ml-2 text-white">Loading features...</span>
</div>
)}
{/* Error State */}
{error && (
<Card className="bg-red-900/20 border-red-800">
<CardContent className="pt-4">
<div className="flex items-center space-x-2 text-red-400">
<AlertCircle className="h-5 w-5" />
<span>Error: {error}</span>
</div>
</CardContent>
</Card>
)}
{/* Features List */}
{!loading && !error && (
<div className="space-y-4">
{features.length === 0 ? (
<Card className="bg-gray-900 border-gray-800">
<CardContent className="pt-6">
<div className="text-center text-gray-400">
<Zap className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<p>No features added yet</p>
<p className="text-sm">Add features to make this template more comprehensive.</p>
</div>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{features.map((feature) => (
<FeatureCard key={feature.id} feature={feature} />
))}
</div>
)}
</div>
)}
{/* Add Feature Form */}
{showAddFeature && (
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="text-white flex items-center justify-between">
<span>Add New Feature</span>
<Button
variant="outline"
size="sm"
onClick={() => setShowAddFeature(false)}
className="text-gray-400 hover:text-white"
>
<X className="h-4 w-4" />
</Button>
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleAddFeature} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="feature-name" className="text-white">Feature Name *</Label>
<Input
id="feature-name"
value={newFeature.name}
onChange={(e) => setNewFeature(prev => ({ ...prev, name: e.target.value }))}
className="bg-white/5 border-white/10 text-white"
placeholder="e.g., User Authentication"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="feature-type" className="text-white">Feature Type</Label>
<Input
id="feature-type"
value={newFeature.feature_type}
onChange={(e) => setNewFeature(prev => ({ ...prev, feature_type: e.target.value }))}
className="bg-white/5 border-white/10 text-white"
placeholder="e.g., Authentication, UI, Backend"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="feature-description" className="text-white">Description</Label>
<Textarea
id="feature-description"
value={newFeature.description}
onChange={(e) => setNewFeature(prev => ({ ...prev, description: e.target.value }))}
className="bg-white/5 border-white/10 text-white"
placeholder="Describe what this feature does..."
rows={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="feature-complexity" className="text-white">Complexity</Label>
<Select value={newFeature.complexity} onValueChange={(value) => setNewFeature(prev => ({ ...prev, complexity: value as 'low' | 'medium' | 'high' }))}>
<SelectTrigger className="bg-white/5 border-white/10 text-white">
<SelectValue placeholder="Select complexity" />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="outline"
onClick={() => setShowAddFeature(false)}
className="text-white border-gray-600 hover:bg-gray-800"
>
Cancel
</Button>
<Button type="submit" className="bg-orange-500 text-black hover:bg-orange-600">
<Save className="h-4 w-4 mr-2" />
Add Feature
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Edit Feature Dialog */}
{editingFeature && (
<Card className="bg-gray-900 border-gray-800">
<CardHeader>
<CardTitle className="text-white flex items-center justify-between">
<span>Edit Feature</span>
<Button
variant="outline"
size="sm"
onClick={() => setEditingFeature(null)}
className="text-gray-400 hover:text-white"
>
<X className="h-4 w-4" />
</Button>
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
handleUpdateFeature(editingFeature.id, {
name: formData.get('name') as string,
description: formData.get('description') as string,
complexity: formData.get('complexity') as 'low' | 'medium' | 'high',
feature_type: formData.get('feature_type') as string
})
}} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-name" className="text-white">Feature Name</Label>
<Input
id="edit-name"
name="name"
defaultValue={editingFeature.name}
className="bg-white/5 border-white/10 text-white"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-type" className="text-white">Feature Type</Label>
<Input
id="edit-type"
name="feature_type"
defaultValue={editingFeature.feature_type || ''}
className="bg-white/5 border-white/10 text-white"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description" className="text-white">Description</Label>
<Textarea
id="edit-description"
name="description"
defaultValue={editingFeature.description || ''}
className="bg-white/5 border-white/10 text-white"
rows={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-complexity" className="text-white">Complexity</Label>
<Select name="complexity" defaultValue={editingFeature.complexity}>
<SelectTrigger className="bg-white/5 border-white/10 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="outline"
onClick={() => setEditingFeature(null)}
className="text-white border-gray-600 hover:bg-gray-800"
>
Cancel
</Button>
<Button type="submit" className="bg-orange-500 text-black hover:bg-orange-600">
<Save className="h-4 w-4 mr-2" />
Update Feature
</Button>
</div>
</form>
</CardContent>
</Card>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -4,6 +4,13 @@ import { useState } from 'react'
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 { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { analyzeFeatureWithAI } from '@/services/aiAnalysis' import { analyzeFeatureWithAI } from '@/services/aiAnalysis'
type Complexity = 'low' | 'medium' | 'high' type Complexity = 'low' | 'medium' | 'high'
@ -26,11 +33,12 @@ export function AICustomFeatureCreator({
onClose, onClose,
}: { }: {
projectType?: string projectType?: string
onAdd: (feature: { name: string; description: string; complexity: Complexity }) => void onAdd: (feature: { name: string; description: string; complexity: Complexity; logic_rules?: string[] }) => void
onClose: () => void onClose: () => void
}) { }) {
const [featureName, setFeatureName] = useState('') const [featureName, setFeatureName] = useState('')
const [featureDescription, setFeatureDescription] = useState('') const [featureDescription, setFeatureDescription] = useState('')
const [selectedComplexity, setSelectedComplexity] = useState<Complexity | undefined>(undefined)
const [isAnalyzing, setIsAnalyzing] = useState(false) const [isAnalyzing, setIsAnalyzing] = useState(false)
const [aiAnalysis, setAiAnalysis] = useState<AIAnalysisResult | null>(null) const [aiAnalysis, setAiAnalysis] = useState<AIAnalysisResult | null>(null)
const [analysisError, setAnalysisError] = useState<string | null>(null) const [analysisError, setAnalysisError] = useState<string | null>(null)
@ -55,10 +63,10 @@ export function AICustomFeatureCreator({
setAiAnalysis({ setAiAnalysis({
suggested_name: featureName, suggested_name: featureName,
complexity: overall.complexity, complexity: overall.complexity, // Using the complexity from the API response
implementation_details: [], implementation_details: [],
technical_requirements: [], technical_requirements: [],
estimated_effort: 'Medium', estimated_effort: overall.complexity === 'high' ? 'High' : overall.complexity === 'low' ? 'Low' : 'Medium',
dependencies: [], dependencies: [],
api_endpoints: [], api_endpoints: [],
database_tables: [], database_tables: [],
@ -83,7 +91,8 @@ export function AICustomFeatureCreator({
onAdd({ onAdd({
name: aiAnalysis.suggested_name || featureName.trim() || 'Custom Feature', name: aiAnalysis.suggested_name || featureName.trim() || 'Custom Feature',
description: featureDescription.trim(), description: featureDescription.trim(),
complexity: aiAnalysis.complexity || 'medium', complexity: selectedComplexity || aiAnalysis.complexity || 'medium',
logic_rules: logicRules,
}) })
onClose() onClose()
} }
@ -109,6 +118,24 @@ export function AICustomFeatureCreator({
<p className="text-xs text-white/50 mt-1">Be as detailed as possible. The AI will analyze and break down your requirements.</p> <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> </div>
<div>
<label className="block text-sm text-white/70 mb-1">Complexity Level</label>
<Select
value={selectedComplexity || ''}
onValueChange={(value: Complexity) => setSelectedComplexity(value)}
>
<SelectTrigger className="w-full bg-white/10 border-white/20 text-white">
<SelectValue placeholder="Select complexity" />
</SelectTrigger>
<SelectContent className="bg-gray-900 border-white/10">
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-white/50 mt-1">Choose the complexity level for this feature</p>
</div>
{/* Dynamic Requirements List */} {/* Dynamic Requirements List */}
<div className="space-y-2"> <div className="space-y-2">
<div className="text-white font-medium">Detailed Requirements (Add one by one)</div> <div className="text-white font-medium">Detailed Requirements (Add one by one)</div>
@ -218,21 +245,21 @@ export function AICustomFeatureCreator({
</Button> </Button>
</div> </div>
)} )}
{/* Form Actions */}
<div className="flex gap-3 flex-wrap items-center pt-4 border-t border-white/10">
{aiAnalysis && (
<div className="flex-1 text-white/80 text-sm">
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 || !selectedComplexity} className="bg-orange-500 hover:bg-orange-400 text-black">
{aiAnalysis ? 'Add Feature with Tagged Rules' : 'Analyze & Add Feature'}
</Button>
</div>
</form> </form>
</div> </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>
</div> </div>
) )

View File

@ -1,7 +1,6 @@
import axios from "axios"; import axios from "axios";
import { safeLocalStorage, safeRedirect } from "@/lib/utils"; import { safeLocalStorage } from "@/lib/utils";
import { BACKEND_URL } from "@/config/backend";
const API_BASE_URL = "http://localhost:8011";
let accessToken = safeLocalStorage.getItem('accessToken'); let accessToken = safeLocalStorage.getItem('accessToken');
let refreshToken = safeLocalStorage.getItem('refreshToken'); let refreshToken = safeLocalStorage.getItem('refreshToken');
@ -20,8 +19,43 @@ export const clearTokens = () => {
safeLocalStorage.removeItem('refreshToken'); safeLocalStorage.removeItem('refreshToken');
}; };
export const getAccessToken = () => accessToken; export const getAccessToken = () => {
export const getRefreshToken = () => refreshToken; // Always get the latest token from localStorage
const token = safeLocalStorage.getItem('accessToken');
console.log('🔐 [getAccessToken] Token check:', {
hasToken: !!token,
tokenLength: token?.length || 0,
tokenStart: token?.substring(0, 20) + '...' || 'No token',
timestamp: new Date().toISOString(),
localStorageToken: token,
moduleToken: accessToken
});
if (token) {
accessToken = token; // Update the module variable
console.log('🔐 [getAccessToken] Updated module token from localStorage');
return token; // Return the fresh token from localStorage
} else {
console.log('🔐 [getAccessToken] No token found in localStorage');
return null; // Return null if no token found
}
};
// Helper function to clear all authentication data
// export const clearAllAuthData = () => {
// console.log('🧹 Clearing all authentication data...');
// clearTokens();
// safeLocalStorage.removeItem('codenuk_user');
// console.log('✅ All authentication data cleared');
// };
export const getRefreshToken = () => {
// Always get the latest token from localStorage
const token = safeLocalStorage.getItem('refreshToken');
if (token) {
refreshToken = token; // Update the module variable
}
return refreshToken;
};
// Logout function that calls the backend API // Logout function that calls the backend API
export const logout = async () => { export const logout = async () => {
@ -42,7 +76,7 @@ export const logout = async () => {
}; };
export const authApiClient = axios.create({ export const authApiClient = axios.create({
baseURL: API_BASE_URL, baseURL: BACKEND_URL,
withCredentials: true, withCredentials: true,
}); });

View File

@ -1,8 +1,16 @@
import { authApiClient } from "./authApiClients"; import { authApiClient } from "./authApiClients";
import { safeLocalStorage } from "@/lib/utils"; import { safeLocalStorage } from "@/lib/utils";
import { BACKEND_URL } from "@/config/backend";
interface ApiResponse {
data?: {
message?: string;
error?: string;
};
}
interface ApiError extends Error { interface ApiError extends Error {
response?: any; response?: ApiResponse;
} }
export const registerUser = async ( export const registerUser = async (
@ -17,19 +25,37 @@ export const registerUser = async (
) => { ) => {
console.log("Registering user with data:", data); console.log("Registering user with data:", data);
try { try {
const response = await authApiClient.post( // Using centralized backend URL
"/api/auth/register", const response = await fetch(`${BACKEND_URL}/api/auth/register`, {
data method: 'POST',
); headers: {
return response.data; 'Content-Type': 'application/json',
} catch (error: any) { },
console.error("Error registering user:", error.response?.data || error.message); credentials: 'include',
body: JSON.stringify(data)
});
// Create a proper error object that preserves the original response const responseData = await response.json();
if (!response.ok) {
// Extract error message from server response
const errorMessage = responseData?.message || responseData?.error || responseData?.detail || `Registration failed (${response.status})`;
throw new Error(errorMessage);
}
return responseData;
} catch (error: unknown) {
console.error("Error registering user:", error);
// If it's already our enhanced error with proper message, re-throw it
if (error instanceof Error) {
throw error;
}
// Create a proper error object for unexpected errors
const enhancedError: ApiError = new Error( const enhancedError: ApiError = new Error(
error.response?.data?.message || error.response?.data?.error || "Failed to register user. Please try again." "Failed to register user. Please try again."
); );
enhancedError.response = error.response;
throw enhancedError; throw enhancedError;
} }
}; };
@ -37,19 +63,37 @@ export const registerUser = async (
export const loginUser = async (email: string, password: string) => { export const loginUser = async (email: string, password: string) => {
console.log("Logging in user with email:", email); console.log("Logging in user with email:", email);
try { try {
const response = await authApiClient.post( // Using centralized backend URL
"/api/auth/login", const response = await fetch(`${BACKEND_URL}/api/auth/login`, {
{ email, password } method: 'POST',
); headers: {
return response.data; 'Content-Type': 'application/json',
} catch (error: any) { },
console.error("Error logging in user:", error.response?.data || error.message); credentials: 'include',
body: JSON.stringify({ email, password })
});
// Create a proper error object that preserves the original response const data = await response.json();
if (!response.ok) {
// Extract error message from server response
const errorMessage = data?.message || data?.error || data?.detail || `Login failed (${response.status})`;
throw new Error(errorMessage);
}
return data;
} catch (error: unknown) {
console.error("Error logging in user:", error);
// If it's already our enhanced error with proper message, re-throw it
if (error instanceof Error) {
throw error;
}
// Create a proper error object for unexpected errors
const enhancedError: ApiError = new Error( const enhancedError: ApiError = new Error(
error.response?.data?.message || error.response?.data?.error || "Failed to log in. Please check your credentials." "Failed to log in. Please check your credentials."
); );
enhancedError.response = error.response;
throw enhancedError; throw enhancedError;
} }
} }
@ -59,17 +103,28 @@ export const logoutUser = async () => {
try { try {
const refreshToken = safeLocalStorage.getItem('refreshToken'); const refreshToken = safeLocalStorage.getItem('refreshToken');
if (refreshToken) { if (refreshToken) {
await authApiClient.post("/api/auth/logout", { refreshToken }); // Using centralized backend URL
const response = await fetch(`${BACKEND_URL}/api/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ refreshToken })
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
} }
return { success: true, message: "Logged out successfully" }; return { success: true, message: "Logged out successfully" };
} catch (error: any) { } catch (error: unknown) {
console.error("Error logging out user:", error.response?.data || error.message); console.error("Error logging out user:", error);
// Create a proper error object that preserves the original response // Create a proper error object
const enhancedError: ApiError = new Error( const enhancedError: ApiError = new Error(
error.response?.data?.message || error.response?.data?.error || "Failed to log out. Please try again." error instanceof Error ? error.message : "Failed to log out. Please try again."
); );
enhancedError.response = error.response;
throw enhancedError; throw enhancedError;
} }
} }

View File

@ -12,7 +12,11 @@ import { useAuth } from "@/contexts/auth-context"
import { loginUser } from "@/components/apis/authenticationHandler" import { loginUser } from "@/components/apis/authenticationHandler"
import { setTokens } from "@/components/apis/authApiClients" import { setTokens } from "@/components/apis/authApiClients"
export function SignInForm() { interface SignInFormProps {
onToggleMode?: () => void
}
export function SignInForm({ }: 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("")
@ -53,16 +57,17 @@ export function SignInForm() {
} else { } else {
setError("Invalid response from server. Please try again.") setError("Invalid response from server. Please try again.")
} }
} catch (err: any) { } catch (err: unknown) {
console.error('Login error:', err) console.error('Login error:', err)
// Handle different types of errors // Handle different types of errors
if (err.response?.data?.message) { const error = err as { response?: { data?: { message?: string; error?: string } }; message?: string };
setError(err.response.data.message) if (error.response?.data?.message) {
} else if (err.response?.data?.error) { setError(error.response.data.message)
setError(err.response.data.error) } else if (error.response?.data?.error) {
} else if (err.message) { setError(error.response.data.error)
setError(err.message) } else if (error.message) {
setError(error.message)
} else { } else {
setError("An error occurred during login. Please try again.") setError("An error occurred during login. Please try again.")
} }

View File

@ -14,27 +14,29 @@ export function SignInPage() {
useEffect(() => { useEffect(() => {
// Check for verification messages in URL // Check for verification messages in URL
const verified = searchParams.get('verified') if (typeof window !== 'undefined') {
const message = searchParams.get('message') const verified = searchParams.get('verified')
const error = searchParams.get('error') const message = searchParams.get('message')
const error = searchParams.get('error')
if (verified === 'true') { if (verified === 'true') {
setVerificationMessage('Email verified successfully! You can now sign in to your account.') setVerificationMessage('Email verified successfully! You can now sign in to your account.')
setVerificationType('success') setVerificationType('success')
} else if (message) { } else if (message) {
setVerificationMessage(decodeURIComponent(message)) setVerificationMessage(decodeURIComponent(message))
setVerificationType('success') setVerificationType('success')
} else if (error) { } else if (error) {
setVerificationMessage(decodeURIComponent(error)) setVerificationMessage(decodeURIComponent(error))
setVerificationType('error') setVerificationType('error')
} }
// Clear the message after 5 seconds // Clear the message after 5 seconds
if (verified || message || error) { if (verified || message || error) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setVerificationMessage(null) setVerificationMessage(null)
}, 5000) }, 5000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}
} }
}, [searchParams]) }, [searchParams])
@ -106,7 +108,7 @@ export function SignInPage() {
<SignInForm /> <SignInForm />
<div className="text-center pt-4"> <div className="text-center pt-4">
<div className="text-white/60 text-xs mb-1">Don't have an account?</div> <div className="text-white/60 text-xs mb-1">Don&apos;t have an account?</div>
<Button <Button
type="button" type="button"
variant="link" variant="link"

View File

@ -7,13 +7,13 @@ 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" // Removed unused Card components
import { Eye, EyeOff, Loader2, User, Mail, Lock, Shield } from "lucide-react" import { Eye, EyeOff, Loader2, User, Mail, Lock, Shield } from "lucide-react"
import { useAuth } from "@/contexts/auth-context"
import { registerUser } from "../apis/authenticationHandler" import { registerUser } from "../apis/authenticationHandler"
interface SignUpFormProps { interface SignUpFormProps {
onSignUpSuccess?: () => void onSignUpSuccess?: () => void
onToggleMode?: () => void
} }
export function SignUpForm({ onSignUpSuccess }: SignUpFormProps) { export function SignUpForm({ onSignUpSuccess }: SignUpFormProps) {
@ -31,6 +31,7 @@ export function SignUpForm({ onSignUpSuccess }: SignUpFormProps) {
role: "user" // default role, adjust as needed role: "user" // default role, adjust as needed
}) })
// const { signup } = useAuth() // Unused variable
const router = useRouter() const router = useRouter()
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@ -70,18 +71,18 @@ export function SignUpForm({ onSignUpSuccess }: SignUpFormProps) {
onSignUpSuccess() onSignUpSuccess()
} else { } else {
// Default behavior - redirect to signin with message // Default behavior - redirect to signin with message
router.push("/signin?message=Account created successfully! Please check your email to verify your account.") if (typeof window !== 'undefined') {
router.push("/signin?message=Account created successfully! Please check your email to verify your account.")
}
} }
} else { } else {
setError(response.message || "Failed to create account. Please try again.") setError(response.message || "Failed to create account. Please try again.")
} }
} catch (err: any) { } catch (err: unknown) {
console.error('Signup error:', err) console.error('Signup error:', err)
// Handle different types of errors // The authentication handler now properly extracts error messages from server responses
if (err.response?.data?.message) { if (err instanceof Error) {
setError(err.response.data.message)
} else if (err.message) {
setError(err.message) setError(err.message)
} else { } else {
setError("An error occurred during registration. Please try again.") setError("An error occurred during registration. Please try again.")

View File

@ -14,8 +14,10 @@ export function SignUpPage() {
setIsSuccess(true) setIsSuccess(true)
// Redirect to signin after 3 seconds // Redirect to signin after 3 seconds
setTimeout(() => { setTimeout(() => {
router.push("/signin?message=Please check your email to verify your account") if (typeof window !== 'undefined') {
}, 3000) router.push("/signin?message=Please check your email to verify your account")
}
}, 3001)
} }
if (isSuccess) { if (isSuccess) {
@ -68,7 +70,7 @@ export function SignUpPage() {
<CheckCircle className="h-10 w-10 text-white" /> <CheckCircle className="h-10 w-10 text-white" />
</div> </div>
<h1 className="text-2xl font-semibold text-white mb-2">Account Created Successfully!</h1> <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> <p className="text-white/60 mb-6">We&apos;ve sent a verification email to your inbox. Please check your email and click the verification link to activate your account.</p>
<div className="bg-orange-500/10 border border-orange-500/30 rounded-lg p-4 mb-6"> <div className="bg-orange-500/10 border border-orange-500/30 rounded-lg p-4 mb-6">
<p className="text-orange-300 text-sm"> <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. <strong>Next step:</strong> Check your email and click the verification link, then sign in to your account.

View File

@ -0,0 +1,123 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { TemplateFeature } from "@/lib/template-service"
interface EditFeatureFormProps {
feature: TemplateFeature
onSubmit: (feature: Partial<TemplateFeature>) => Promise<void>
onCancel: () => void
isOpen: boolean
}
export function EditFeatureForm({ feature, onSubmit, onCancel, isOpen }: EditFeatureFormProps) {
const [formData, setFormData] = useState({
name: feature.name || "",
description: feature.description || "",
complexity: feature.complexity || "medium",
})
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
try {
await onSubmit(formData)
onCancel() // Close the dialog on success
} catch (error) {
console.error('Error updating feature:', error)
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={isOpen} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px] bg-gray-900 text-white">
<DialogHeader>
<DialogTitle>Edit Feature</DialogTitle>
<DialogDescription>
Make changes to your feature here. Click save when you're done.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Feature Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Enter feature name"
required
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Enter description"
rows={3}
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
/>
</div>
<div className="space-y-2">
<Label>Complexity</Label>
<Select
value={formData.complexity}
onValueChange={(value: "low" | "medium" | "high") =>
setFormData({ ...formData, complexity: value })
}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-white">
<SelectValue placeholder="Select complexity" />
</SelectTrigger>
<SelectContent className="bg-gray-900 border-white/10">
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="border-white/20 text-white hover:bg-white/10"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="bg-orange-500 hover:bg-orange-400 text-black"
>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,156 @@
"use client"
import { useAuth } from "@/contexts/auth-context"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Badge } from "@/components/ui/badge"
import { Bell, Settings, LogOut, User, Shield, ArrowLeft } from "lucide-react"
import { useState } from "react"
import { AdminNotificationsPanel } from "@/components/admin/admin-notifications-panel"
import { useAdminNotifications } from "@/contexts/AdminNotificationContext"
interface AdminLayoutProps {
children: React.ReactNode
}
export function AdminLayout({ children }: AdminLayoutProps) {
const [isLoggingOut, setIsLoggingOut] = useState(false)
const [showNotifications, setShowNotifications] = useState(false)
// const pathname = usePathname()
const { user, logout, isAdmin } = useAuth()
const { unreadCount } = useAdminNotifications()
// Handle logout with loading state
const handleLogout = async () => {
try {
setIsLoggingOut(true)
await logout()
} catch (error) {
console.error('Logout failed:', error)
setIsLoggingOut(false)
}
}
// Redirect non-admin users
if (!isAdmin) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600 mb-4">Access Denied</h1>
<p className="text-gray-600 mb-4">You don&apos;t have permission to access the admin panel.</p>
<Link href="/">
<Button>Go to Home</Button>
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-black text-white">
{/* Admin Header */}
<header className="bg-black/90 text-white border-b border-white/10 backdrop-blur">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 justify-between items-center">
{/* Logo and Admin Title */}
<div className="flex items-center space-x-4">
<Link href="/" className="flex items-center space-x-2">
<ArrowLeft className="h-5 w-5 text-white/70 hover:text-white" />
</Link>
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-orange-500 rounded-lg flex items-center justify-center">
<span className="text-black font-bold text-sm">C</span>
</div>
<span className="text-xl font-bold">Codenuk</span>
<Badge className="bg-orange-500 text-black">
<Shield className="h-3 w-3 mr-1" />
Admin
</Badge>
</div>
</div>
{/* Right side */}
<div className="flex items-center space-x-4">
{/* Notifications */}
<Button
variant="ghost"
size="sm"
className="relative text-white/80 hover:text-white hover:bg-white/5"
onClick={() => setShowNotifications(true)}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 flex items-center justify-center text-xs bg-orange-500 text-black">
{unreadCount}
</Badge>
)}
</Button>
{/* User Menu */}
{user && user.email && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5">
<Avatar className="h-8 w-8">
<AvatarImage src="/avatars/01.png" alt={user.username || user.email || "User"} />
<AvatarFallback>
{(user.username && user.username.charAt(0)) || (user.email && user.email.charAt(0)) || "U"}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 bg-black text-white border-white/10" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user.username || user.email || "User"}</p>
<p className="text-xs leading-none text-white/60">{user.email || "No email"}</p>
<Badge className="w-fit bg-orange-500 text-black text-xs">
<Shield className="h-3 w-3 mr-1" />
Admin
</Badge>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="hover:bg-white/5 cursor-pointer">
<User className="mr-2 h-4 w-4" />
<span>Profile</span>
</DropdownMenuItem>
<DropdownMenuItem className="hover:bg-white/5 cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="hover:bg-white/5 cursor-pointer" disabled={isLoggingOut}>
<LogOut className="mr-2 h-4 w-4" />
<span>{isLoggingOut ? "Logging out..." : "Log out"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</div>
</header>
{/* Admin Content */}
<main className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
{/* Notifications Panel */}
<AdminNotificationsPanel
open={showNotifications}
onOpenChange={setShowNotifications}
/>
</div>
)
}

View File

@ -0,0 +1,348 @@
"use client"
import { useAuth } from "@/contexts/auth-context"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Badge } from "@/components/ui/badge"
import {
Bell,
Settings,
LogOut,
User,
Shield,
ArrowLeft,
LayoutDashboard,
Files,
Zap,
Users,
BarChart3,
FileText,
Cog,
HelpCircle,
ChevronLeft,
ChevronRight
} from "lucide-react"
import { useState } from "react"
import { AdminNotificationsPanel } from "@/components/admin/admin-notifications-panel"
import { useAdminNotifications } from "@/contexts/AdminNotificationContext"
import { cn } from "@/lib/utils"
interface AdminSidebarLayoutProps {
children: React.ReactNode
}
interface SidebarItem {
id: string
label: string
icon: React.ComponentType<{ className?: string }>
href?: string
badge?: number
subItems?: SidebarItem[]
}
export function AdminSidebarLayout({ children }: AdminSidebarLayoutProps) {
const [isLoggingOut, setIsLoggingOut] = useState(false)
const [showNotifications, setShowNotifications] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const pathname = usePathname()
const { user, logout, isAdmin } = useAuth()
const { unreadCount } = useAdminNotifications()
// Handle logout with loading state
const handleLogout = async () => {
try {
setIsLoggingOut(true)
await logout()
} catch (error) {
console.error('Logout failed:', error)
setIsLoggingOut(false)
}
}
// Sidebar navigation items
const sidebarItems: SidebarItem[] = [
{
id: "dashboard",
label: "Dashboard",
icon: LayoutDashboard,
href: "/admin"
},
{
id: "features",
label: "Custom Features",
icon: Zap,
href: "/admin?tab=features",
badge: 0 // This will be updated dynamically
},
{
id: "templates",
label: "Templates",
icon: Files,
href: "/admin/templates"
},
{
id: "users",
label: "User Management",
icon: Users,
href: "/admin/users"
},
{
id: "analytics",
label: "Analytics",
icon: BarChart3,
href: "/admin/analytics"
},
{
id: "settings",
label: "Settings",
icon: Cog,
href: "/admin/settings"
},
{
id: "help",
label: "Help & Support",
icon: HelpCircle,
href: "/admin/help"
}
]
// Redirect non-admin users
if (!isAdmin) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600 mb-4">Access Denied</h1>
<p className="text-gray-600 mb-4">You don&apos;t have permission to access the admin panel.</p>
<Link href="/">
<Button>Go to Home</Button>
</Link>
</div>
</div>
)
}
const SidebarItem = ({ item, level = 0 }: { item: SidebarItem; level?: number }) => {
const [isExpanded, setIsExpanded] = useState(false)
const hasSubItems = item.subItems && item.subItems.length > 0
const isActive = pathname === item.href || (item.href && pathname.startsWith(item.href))
return (
<div>
<Link
href={item.href || "#"}
onClick={hasSubItems ? (e) => {
e.preventDefault()
setIsExpanded(!isExpanded)
} : undefined}
className={cn(
"flex items-center justify-between w-full px-3 py-2 text-sm rounded-lg transition-colors",
level > 0 && "ml-4 pl-6",
isActive
? "bg-orange-500 text-black font-medium"
: "text-white/80 hover:bg-white/10 hover:text-white",
sidebarCollapsed && level === 0 && "justify-center px-2"
)}
>
<div className="flex items-center space-x-3">
<item.icon className={cn("h-5 w-5", sidebarCollapsed && level === 0 && "h-6 w-6")} />
{(!sidebarCollapsed || level > 0) && (
<span className="truncate">{item.label}</span>
)}
</div>
{!sidebarCollapsed && (
<div className="flex items-center space-x-2">
{item.badge !== undefined && item.badge > 0 && (
<Badge className="bg-red-500 text-white text-xs h-5 w-5 rounded-full p-0 flex items-center justify-center">
{item.badge}
</Badge>
)}
{hasSubItems && (
<ChevronRight className={cn("h-4 w-4 transition-transform", isExpanded && "rotate-90")} />
)}
</div>
)}
</Link>
{hasSubItems && isExpanded && !sidebarCollapsed && (
<div className="mt-1 space-y-1">
{item.subItems!.map((subItem) => (
<SidebarItem key={subItem.id} item={subItem} level={level + 1} />
))}
</div>
)}
</div>
)
}
return (
<div className="min-h-screen bg-black text-white flex">
{/* Sidebar */}
<div className={cn(
"bg-gray-900 border-r border-white/10 transition-all duration-300 flex flex-col",
sidebarCollapsed ? "w-16" : "w-64"
)}>
{/* Sidebar Header */}
<div className="p-4 border-b border-white/10">
<div className="flex items-center justify-between">
{!sidebarCollapsed && (
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-orange-500 rounded-lg flex items-center justify-center">
<span className="text-black font-bold text-sm">C</span>
</div>
<span className="text-lg font-bold">Admin</span>
<Badge className="bg-orange-500 text-black text-xs">
<Shield className="h-3 w-3 mr-1" />
Panel
</Badge>
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="text-white/70 hover:text-white hover:bg-white/10"
>
{sidebarCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</Button>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-2">
{sidebarItems.map((item) => (
<SidebarItem key={item.id} item={item} />
))}
</nav>
{/* Sidebar Footer */}
<div className="p-4 border-t border-white/10">
{!sidebarCollapsed ? (
<div className="flex items-center space-x-3">
<Avatar className="h-8 w-8">
<AvatarImage src="/avatars/01.png" alt={user?.username || user?.email || "User"} />
<AvatarFallback className="bg-orange-500 text-black">
{(user?.username && user.username.charAt(0)) || (user?.email && user.email.charAt(0)) || "U"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{user?.username || user?.email || "User"}
</p>
<p className="text-xs text-white/60 truncate">Administrator</p>
</div>
</div>
) : (
<div className="flex justify-center">
<Avatar className="h-8 w-8">
<AvatarImage src="/avatars/01.png" alt={user?.username || user?.email || "User"} />
<AvatarFallback className="bg-orange-500 text-black">
{(user?.username && user.username.charAt(0)) || (user?.email && user.email.charAt(0)) || "U"}
</AvatarFallback>
</Avatar>
</div>
)}
</div>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col">
{/* Top Header */}
<header className="bg-black/90 text-white border-b border-white/10 backdrop-blur">
<div className="px-6 py-4">
<div className="flex h-8 justify-between items-center">
{/* Back to Main Site */}
<div className="flex items-center space-x-4">
<Link href="/" className="flex items-center space-x-2 text-white/70 hover:text-white">
<ArrowLeft className="h-5 w-5" />
<span className="text-sm">Back to Site</span>
</Link>
</div>
{/* Right side */}
<div className="flex items-center space-x-4">
{/* Notifications */}
<Button
variant="ghost"
size="sm"
className="relative text-white/80 hover:text-white hover:bg-white/5"
onClick={() => setShowNotifications(true)}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 flex items-center justify-center text-xs bg-orange-500 text-black">
{unreadCount}
</Badge>
)}
</Button>
{/* User Menu */}
{user && user.email && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5">
<Avatar className="h-8 w-8">
<AvatarImage src="/avatars/01.png" alt={user.username || user.email || "User"} />
<AvatarFallback>
{(user.username && user.username.charAt(0)) || (user.email && user.email.charAt(0)) || "U"}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 bg-black text-white border-white/10" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user.username || user.email || "User"}</p>
<p className="text-xs leading-none text-white/60">{user.email || "No email"}</p>
<Badge className="w-fit bg-orange-500 text-black text-xs">
<Shield className="h-3 w-3 mr-1" />
Admin
</Badge>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="hover:bg-white/5 cursor-pointer">
<User className="mr-2 h-4 w-4" />
<span>Profile</span>
</DropdownMenuItem>
<DropdownMenuItem className="hover:bg-white/5 cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="hover:bg-white/5 cursor-pointer" disabled={isLoggingOut}>
<LogOut className="mr-2 h-4 w-4" />
<span>{isLoggingOut ? "Logging out..." : "Log out"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</div>
</header>
{/* Main Content Area */}
<main className="flex-1 px-6 py-8 overflow-auto">
{children}
</main>
</div>
{/* Notifications Panel */}
<AdminNotificationsPanel
open={showNotifications}
onOpenChange={setShowNotifications}
/>
</div>
)
}

View File

@ -1,6 +1,5 @@
"use client" "use client"
import { useAuth } from "@/contexts/auth-context"
import { Header } from "@/components/navigation/header" import { Header } from "@/components/navigation/header"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
@ -9,14 +8,15 @@ interface AppLayoutProps {
} }
export function AppLayout({ children }: AppLayoutProps) { export function AppLayout({ children }: AppLayoutProps) {
const { user } = useAuth() // const { user } = useAuth()
const pathname = usePathname() const pathname = usePathname()
// Don't show header on auth pages // Don't show header on auth pages or admin pages
const isAuthPage = pathname === "/signin" || pathname === "/signup" const isAuthPage = pathname === "/signin" || pathname === "/signup"
const isAdminPage = pathname?.startsWith("/admin")
// For auth pages, don't show header // For auth pages and admin pages, don't show header
if (isAuthPage) { if (isAuthPage || isAdminPage) {
return <>{children}</> return <>{children}</>
} }

File diff suppressed because it is too large Load Diff

View File

@ -17,14 +17,6 @@ import { Badge } from "@/components/ui/badge";
import { Bell, Settings, LogOut, User, Menu, X, Shield } 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 = [
{ name: "Project Builder", href: "/project-builder" },
{ name: "Templates", href: "/templates" },
{ name: "Features", href: "/features" },
{ name: "Business Context", href: "/business-context" },
{ name: "Architecture", href: "/architecture" },
];
export function Header() { export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false); const [isLoggingOut, setIsLoggingOut] = useState(false);
@ -64,19 +56,20 @@ export function Header() {
{/* Desktop Navigation */} {/* Desktop Navigation */}
<nav className="hidden md:flex space-x-2"> <nav className="hidden md:flex space-x-2">
{navigation.map((item) => ( {/* Hide Project Builder and other user nav when admin is logged in */}
{!isAdmin && (
<Link <Link
key={item.name} key="project-builder"
href={item.href} href="/project-builder"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${ className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
pathname === item.href pathname === "/project-builder"
? "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"
}`} }`}
> >
{item.name} Project Builder
</Link> </Link>
))} )}
{/* Admin Navigation */} {/* Admin Navigation */}
{isAdmin && ( {isAdmin && (
<Link <Link
@ -118,9 +111,9 @@ export function Header() {
<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 || user.email || "User"} /> <AvatarImage src="/avatars/01.png" alt={user.username || user.email || "User"} />
<AvatarFallback> <AvatarFallback>
{(user.name && user.name.charAt(0)) || (user.email && user.email.charAt(0)) || "U"} {(user.username && user.username.charAt(0)) || (user.email && user.email.charAt(0)) || "U"}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
</Button> </Button>
@ -128,7 +121,7 @@ export function Header() {
<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 || user.email || "User"}</p> <p className="text-sm font-medium leading-none">{user.username || user.email || "User"}</p>
<p className="text-xs leading-none text-white/60">{user.email || "No email"}</p> <p className="text-xs leading-none text-white/60">{user.email || "No email"}</p>
{isAdmin && ( {isAdmin && (
<Badge className="w-fit bg-orange-500 text-black text-xs"> <Badge className="w-fit bg-orange-500 text-black text-xs">
@ -172,20 +165,20 @@ export function Header() {
{mobileMenuOpen && ( {mobileMenuOpen && (
<div className="md:hidden py-4 border-t border-white/10"> <div className="md:hidden py-4 border-t border-white/10">
<nav className="flex flex-col space-y-2"> <nav className="flex flex-col space-y-2">
{navigation.map((item) => ( {!isAdmin && (
<Link <Link
key={item.name} key="project-builder-mobile"
href={item.href} href="/project-builder"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${ className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
pathname === item.href pathname === "/project-builder"
? "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"
}`} }`}
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
> >
{item.name} Project Builder
</Link> </Link>
))} )}
{/* Admin Navigation for mobile */} {/* Admin Navigation for mobile */}
{isAdmin && ( {isAdmin && (
<Link <Link

View File

@ -6,7 +6,6 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
<input <input
type={type} type={type}
data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",

View File

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

View File

@ -0,0 +1,92 @@
"use client"
import React, { createContext, useCallback, useContext, useMemo, useState } from "react"
type ToastVariant = "success" | "error" | "info" | "warning"
type Toast = {
id: string
title?: string
description?: string
variant?: ToastVariant
durationMs?: number
}
type ToastContextValue = {
toasts: Toast[]
show: (toast: Omit<Toast, "id">) => void
dismiss: (id: string) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const show = useCallback((toast: Omit<Toast, "id">) => {
const id = Math.random().toString(36).slice(2)
const duration = toast.durationMs ?? 4000
const next: Toast = { id, ...toast }
setToasts((prev) => [...prev, next])
if (duration > 0) {
setTimeout(() => dismiss(id), duration)
}
}, [dismiss])
const value = useMemo(() => ({ toasts, show, dismiss }), [toasts, show, dismiss])
return (
<ToastContext.Provider value={value}>
{children}
<Toaster />
</ToastContext.Provider>
)
}
export function useToast() {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error("useToast must be used within <ToastProvider>")
return ctx
}
export function Toaster() {
const ctx = useContext(ToastContext)
if (!ctx) return null
const { toasts, dismiss } = ctx
return (
<div className="fixed top-4 right-4 z-50 space-y-2">
{toasts.map((t) => (
<div
key={t.id}
className={[
"min-w-[280px] max-w-sm rounded-md border px-4 py-3 shadow-lg",
t.variant === "success" && "bg-green-50 border-green-300 text-green-900",
t.variant === "error" && "bg-red-50 border-red-300 text-red-900",
t.variant === "warning" && "bg-yellow-50 border-yellow-300 text-yellow-900",
(!t.variant || t.variant === "info") && "bg-white border-gray-200 text-gray-900",
].filter(Boolean).join(" ")}
>
<div className="flex items-start justify-between gap-4">
<div>
{t.title && <div className="font-semibold">{t.title}</div>}
{t.description && <div className="text-sm opacity-90 mt-0.5">{t.description}</div>}
</div>
<button
onClick={() => dismiss(t.id)}
className="text-sm opacity-60 hover:opacity-100"
aria-label="Dismiss"
>
</button>
</div>
</div>
))}
</div>
)
}

View File

@ -0,0 +1,33 @@
"use client";
import React from "react";
type TooltipProps = {
content: React.ReactNode;
children: React.ReactNode;
side?: "top" | "right" | "bottom" | "left";
};
export function Tooltip({ content, children, side = "top" }: TooltipProps) {
const sideClasses: Record<string, string> = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
left: "right-full top-1/2 -translate-y-1/2 mr-2",
right: "left-full top-1/2 -translate-y-1/2 ml-2",
};
return (
<span className="relative inline-flex group">
{children}
<span
className={`pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-150 absolute z-50 ${sideClasses[side]} max-w-xs`}
>
<span className="block rounded-md bg-black text-white text-xs px-2 py-1 shadow-lg border border-white/10 whitespace-normal break-words">
{content}
</span>
</span>
</span>
);
}

25
src/config/backend.ts Normal file
View File

@ -0,0 +1,25 @@
/**
* Centralized Backend Configuration
* Single source of truth for all backend URLs
*/
// Main backend URL - change this to update all API calls
export const BACKEND_URL = 'http://localhost:8000';
// export const BACKEND_URL = 'https://backend.codenuk.com';
// Realtime notifications socket URL (Template Manager emits notifications)
// Prefer env override if present; fallback to local Template Manager port
export const SOCKET_URL = process.env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:8009';
// Export for backward compatibility
export const API_BASE_URL = BACKEND_URL;
// Helper function to get full API endpoint URL
export const getApiUrl = (endpoint: string): string => {
// Remove leading slash if present to avoid double slashes
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
return `${BACKEND_URL}/${cleanEndpoint}`;
};

View File

@ -0,0 +1,141 @@
"use client"
import React, { createContext, useContext, useEffect, useState } from 'react';
import { AdminNotification } from '@/types/admin.types';
import { useNotificationSocket } from '@/hooks/useNotificationSocket';
import { adminApi } from '@/lib/api/admin';
interface AdminNotificationContextType {
notifications: AdminNotification[];
unreadCount: number;
isConnected: boolean;
refreshNotifications: () => Promise<void>;
markAsRead: (id: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
clearAll: () => Promise<void>;
removeByReference: (referenceType: string, referenceId: string) => void;
}
const AdminNotificationContext = createContext<AdminNotificationContextType | undefined>(undefined);
export function AdminNotificationProvider({ children }: { children: React.ReactNode }) {
const [notifications, setNotifications] = useState<AdminNotification[]>([]);
const [, setIsLoading] = useState(false);
const {
isConnected,
notificationCounts,
onNewNotification,
onNotificationRead,
onAllNotificationsRead
} = useNotificationSocket();
// Load initial notifications
const refreshNotifications = async () => {
try {
setIsLoading(true);
const data = await adminApi.getNotifications(false, 100, 0);
setNotifications(data);
} catch (error) {
console.error('Error fetching notifications:', error);
} finally {
setIsLoading(false);
}
};
// Mark single notification as read
const markAsRead = async (id: string) => {
try {
await adminApi.markNotificationAsRead(id);
setNotifications(prev =>
prev.map(n => n.id === id ? { ...n, is_read: true, read_at: new Date().toISOString() } : n)
);
} catch (error) {
console.error('Error marking notification as read:', error);
throw error;
}
};
// Mark all notifications as read
const markAllAsRead = async () => {
try {
await adminApi.markAllNotificationsAsRead();
setNotifications(prev =>
prev.map(n => ({ ...n, is_read: true, read_at: new Date().toISOString() }))
);
} catch (error) {
console.error('Error marking all notifications as read:', error);
throw error;
}
};
// Clear all notifications from UI (after marking as read server-side)
const clearAll = async () => {
await markAllAsRead();
setNotifications([]);
};
// Remove notifications tied to a specific backend reference (e.g., feature/template id)
const removeByReference = (referenceType: string, referenceId: string) => {
setNotifications(prev => prev.filter(n => !(n.reference_type === referenceType && n.reference_id === referenceId)));
};
// Set up WebSocket event handlers
useEffect(() => {
onNewNotification((notification: AdminNotification) => {
setNotifications(prev => [notification, ...prev]);
});
onNotificationRead(({ id }: { id: string }) => {
setNotifications(prev =>
prev.map(n => n.id === id ? { ...n, is_read: true, read_at: new Date().toISOString() } : n)
);
});
onAllNotificationsRead(() => {
setNotifications(prev =>
prev.map(n => ({ ...n, is_read: true, read_at: new Date().toISOString() }))
);
});
}, [onNewNotification, onNotificationRead, onAllNotificationsRead]);
// Load notifications on mount
useEffect(() => {
refreshNotifications();
}, []);
// When socket connects (e.g., navigating to admin), refresh to include any
// notifications created before the connection was established
useEffect(() => {
if (isConnected) {
refreshNotifications();
}
}, [isConnected]);
const value: AdminNotificationContextType = {
notifications,
unreadCount: notificationCounts.unread > 0
? notificationCounts.unread
: notifications.reduce((acc, n) => acc + (n.is_read ? 0 : 1), 0),
isConnected,
refreshNotifications,
markAsRead,
markAllAsRead,
clearAll,
removeByReference,
};
return (
<AdminNotificationContext.Provider value={value}>
{children}
</AdminNotificationContext.Provider>
);
}
export function useAdminNotifications() {
const context = useContext(AdminNotificationContext);
if (context === undefined) {
throw new Error('useAdminNotifications must be used within an AdminNotificationProvider');
}
return context;
}

View File

@ -1,4 +1,4 @@
'use client' "use client"
import React, { createContext, useContext, useEffect, useState } from 'react' import React, { createContext, useContext, useEffect, useState } from 'react'
import { safeLocalStorage } from '@/lib/utils' import { safeLocalStorage } from '@/lib/utils'
@ -14,6 +14,7 @@ interface User {
interface AuthContextValue { interface AuthContextValue {
user: User | null user: User | null
isAdmin: boolean isAdmin: boolean
isLoading: boolean
setUserFromApi: (user: User) => void setUserFromApi: (user: User) => void
logout: () => Promise<void> logout: () => Promise<void>
} }
@ -22,23 +23,78 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => { useEffect(() => {
const stored = safeLocalStorage.getItem('codenuk_user') const stored = safeLocalStorage.getItem('codenuk_user')
if (stored) { const accessToken = safeLocalStorage.getItem('accessToken')
const refreshToken = safeLocalStorage.getItem('refreshToken')
console.log('🔐 [AuthContext] Checking localStorage for user data...')
console.log('🔐 [AuthContext] Stored user data:', stored)
console.log('🔐 [AuthContext] Access token exists:', !!accessToken)
console.log('🔐 [AuthContext] Refresh token exists:', !!refreshToken)
console.log('🔐 [AuthContext] Token details:', {
accessTokenLength: accessToken?.length || 0,
refreshTokenLength: refreshToken?.length || 0,
timestamp: new Date().toISOString()
})
// Only restore user if we have both user data AND at least one token
if (stored && (accessToken || refreshToken)) {
try { try {
const userData = JSON.parse(stored) const userData = JSON.parse(stored)
console.log('🔐 [AuthContext] Restoring user session:', userData?.username)
console.log('🔐 [AuthContext] User ID from localStorage:', userData?.id)
setUser(userData) setUser(userData)
} catch (error) { } catch (error) {
console.error('Failed to parse stored user data:', error) console.error('Failed to parse stored user data:', error)
safeLocalStorage.removeItem('codenuk_user') safeLocalStorage.removeItem('codenuk_user')
} }
} else {
console.log('🔐 [AuthContext] No valid user session found')
// Clear any partial data
if (stored && !accessToken && !refreshToken) {
console.log('🔐 [AuthContext] Clearing orphaned user data without tokens')
safeLocalStorage.removeItem('codenuk_user')
}
} }
setIsLoading(false)
}, [])
// Listen for storage changes to sync auth state across tabs
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'codenuk_user' || e.key === 'accessToken' || e.key === 'refreshToken') {
console.log('🔐 [AuthContext] Storage changed, re-checking auth state')
const stored = safeLocalStorage.getItem('codenuk_user')
const accessToken = safeLocalStorage.getItem('accessToken')
const refreshToken = safeLocalStorage.getItem('refreshToken')
if (stored && (accessToken || refreshToken)) {
try {
const userData = JSON.parse(stored)
setUser(userData)
} catch (error) {
console.error('Failed to parse user data after storage change:', error)
setUser(null)
}
} else {
setUser(null)
}
}
}
window.addEventListener('storage', handleStorageChange)
return () => window.removeEventListener('storage', handleStorageChange)
}, []) }, [])
const setUserFromApi = (u: User) => { const setUserFromApi = (u: User) => {
console.log('🔐 [AuthContext] Setting user from API:', u)
console.log('🔐 [AuthContext] User ID being set:', u?.id)
setUser(u) setUser(u)
safeLocalStorage.setItem('codenuk_user', JSON.stringify(u)) safeLocalStorage.setItem('codenuk_user', JSON.stringify(u))
console.log('🔐 [AuthContext] User data saved to localStorage')
} }
const logout = async () => { const logout = async () => {
@ -61,7 +117,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
return ( return (
<AuthContext.Provider value={{ user, isAdmin: user?.role === 'admin', setUserFromApi, logout }}> <AuthContext.Provider value={{ user, isAdmin: user?.role === 'admin', isLoading, setUserFromApi, logout }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )
@ -71,4 +127,4 @@ export function useAuth() {
const ctx = useContext(AuthContext) const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider') if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx return ctx
} }

View File

@ -0,0 +1,123 @@
import { useEffect, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { AdminNotification } from '@/types/admin.types';
import { SOCKET_URL } from '@/config/backend';
import { getAccessToken } from '@/components/apis/authApiClients';
interface NotificationCounts {
total: number;
unread: number;
read: number;
}
interface UseNotificationSocketReturn {
socket: Socket | null;
isConnected: boolean;
notificationCounts: NotificationCounts;
onNewNotification: (callback: (notification: AdminNotification) => void) => void;
onNotificationRead: (callback: (data: { id: string }) => void) => void;
onAllNotificationsRead: (callback: () => void) => void;
}
export function useNotificationSocket(): UseNotificationSocketReturn {
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [notificationCounts, setNotificationCounts] = useState<NotificationCounts>({
total: 0,
unread: 0,
read: 0
});
const newNotificationCallbackRef = useRef<((notification: AdminNotification) => void) | null>(null);
const notificationReadCallbackRef = useRef<((data: { id: string }) => void) | null>(null);
const allNotificationsReadCallbackRef = useRef<(() => void) | null>(null);
useEffect(() => {
// Initialize socket connection
const templateManagerUrl = SOCKET_URL; // connect directly to template-manager socket server
const token = getAccessToken();
console.log('[useNotificationSocket] Initializing socket', {
url: templateManagerUrl,
hasToken: !!token,
tokenPreview: token ? token.substring(0, 12) + '...' : null
});
const newSocket = io(templateManagerUrl, {
transports: ['websocket', 'polling'],
timeout: 20000,
path: '/socket.io/',
auth: token ? { token } : undefined,
});
newSocket.on('connect', () => {
console.log('🔌 Connected to notification socket');
setIsConnected(true);
});
newSocket.on('disconnect', () => {
console.log('🔌 Disconnected from notification socket');
setIsConnected(false);
});
newSocket.on('notification-count', (counts: NotificationCounts) => {
console.log('📊 Notification counts updated:', counts);
setNotificationCounts(counts);
});
newSocket.on('new-notification', (notification: AdminNotification) => {
console.log('🔔 New notification received:', notification);
if (newNotificationCallbackRef.current) {
newNotificationCallbackRef.current(notification);
}
});
newSocket.on('notification-read', (data: { id: string }) => {
console.log('✅ Notification marked as read:', data.id);
if (notificationReadCallbackRef.current) {
notificationReadCallbackRef.current(data);
}
});
newSocket.on('all-notifications-read', () => {
console.log('✅ All notifications marked as read');
if (allNotificationsReadCallbackRef.current) {
allNotificationsReadCallbackRef.current();
}
});
newSocket.on('connect_error', (error: any) => {
console.error('🔌 Socket connection error:', {
message: error?.message,
name: error?.name,
data: error?.data
});
setIsConnected(false);
});
setSocket(newSocket);
return () => {
newSocket.close();
};
}, []);
const onNewNotification = (callback: (notification: AdminNotification) => void) => {
newNotificationCallbackRef.current = callback;
};
const onNotificationRead = (callback: (data: { id: string }) => void) => {
notificationReadCallbackRef.current = callback;
};
const onAllNotificationsRead = (callback: () => void) => {
allNotificationsReadCallbackRef.current = callback;
};
return {
socket,
isConnected,
notificationCounts,
onNewNotification,
onNotificationRead,
onAllNotificationsRead,
};
}

View File

@ -1,161 +1,351 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback } from 'react';
import { templateService, DatabaseTemplate, TemplatesByCategory, TemplateFeature } from '@/lib/template-service' import { useToast } from '@/components/ui/toast';
import { templateService, DatabaseTemplate, TemplateFeature, TemplatesByCategory } from '@/lib/template-service';
import { useAuth } from '@/contexts/auth-context';
export function useTemplates() { export function useTemplates() {
const [templates, setTemplates] = useState<TemplatesByCategory>({}) const { user, isLoading: authLoading } = useAuth();
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)
}
}
// Log user ID for debugging
useEffect(() => { useEffect(() => {
fetchTemplates() console.log('🔍 [useTemplates] Current user ID:', user?.id || 'No user ID')
}, []) console.log('👤 [useTemplates] User object:', user)
console.log('🔐 [useTemplates] Auth state - user exists:', !!user)
console.log('🔐 [useTemplates] Auth state - user.id exists:', !!user?.id)
console.log('🔐 [useTemplates] User type:', typeof user)
console.log('🔐 [useTemplates] User keys:', user ? Object.keys(user) : 'No user object')
console.log('🔐 [useTemplates] Auth loading:', authLoading)
console.log('🔐 [useTemplates] Timestamp:', new Date().toISOString())
// Check if we have tokens available
const accessToken = localStorage.getItem('accessToken')
const refreshToken = localStorage.getItem('refreshToken')
console.log('🔐 [useTemplates] Token availability:', {
hasAccessToken: !!accessToken,
hasRefreshToken: !!refreshToken,
accessTokenLength: accessToken?.length || 0,
refreshTokenLength: refreshToken?.length || 0
})
}, [user, authLoading])
const createTemplate = async (templateData: Partial<DatabaseTemplate>) => { const [loading, setLoading] = useState(true);
try { const [error, setError] = useState<string | null>(null);
const newTemplate = await templateService.createTemplate(templateData) const [combined, setCombined] = useState<{
// Refresh templates after creating a new one data: DatabaseTemplate[];
await fetchTemplates() count?: number;
return newTemplate pagination?: { total?: number; limit?: number; offset?: number; hasMore?: boolean };
} catch (err) { } | null>(null);
setError(err instanceof Error ? err.message : 'Failed to create template')
throw err // Stable categories fetched once, independent of paginated data
const [categories, setCategories] = useState<Array<{ id: string; name: string; count: number }>>([]);
const [paginationState, setPaginationState] = useState({
currentPage: 0,
pageSize: 6, // Respect backend pagination: 6 items per page
total: 0,
hasMore: false,
loading: false,
searchQuery: '',
selectedCategory: 'all',
});
const { show } = useToast();
// Fetch templates with pagination, category, and search
const fetchTemplatesWithPagination = useCallback(async (opts?: {
page?: number;
pageSize?: number;
category?: string;
search?: string;
resetPagination?: boolean;
}) => {
// Don't fetch if auth is still loading
if (authLoading) {
console.log('[useTemplates] Auth still loading, skipping fetch')
return;
} }
}
const updateTemplate = async (id: string, templateData: Partial<DatabaseTemplate>) => { // Check if we have authentication tokens available
try { const accessToken = localStorage.getItem('accessToken')
const updatedTemplate = await templateService.updateTemplate(id, templateData) const refreshToken = localStorage.getItem('refreshToken')
// Refresh templates after updating const hasTokens = !!(accessToken || refreshToken)
await fetchTemplates()
return updatedTemplate console.log('[useTemplates] Token check before API call:', {
} catch (err) { hasAccessToken: !!accessToken,
setError(err instanceof Error ? err.message : 'Failed to update template') hasRefreshToken: !!refreshToken,
throw err hasAnyToken: hasTokens,
} userExists: !!user,
} userId: user?.id
})
const deleteTemplate = async (id: string) => { // If user exists but no tokens, wait a bit for tokens to be loaded
try { if (user && !hasTokens) {
await templateService.deleteTemplate(id) console.log('[useTemplates] User exists but no tokens found, waiting for token loading...')
// Refresh templates after deleting // Wait a short time for tokens to be loaded from localStorage
await fetchTemplates() await new Promise(resolve => setTimeout(resolve, 100))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete template') // Check again after waiting
throw err const retryAccessToken = localStorage.getItem('accessToken')
} const retryRefreshToken = localStorage.getItem('refreshToken')
} const retryHasTokens = !!(retryAccessToken || retryRefreshToken)
// Convert database templates to the format expected by the UI console.log('[useTemplates] Token check after waiting:', {
const getTemplatesForUI = async () => { hasAccessToken: !!retryAccessToken,
const allTemplates: Array<DatabaseTemplate & { hasRefreshToken: !!retryRefreshToken,
features: string[] hasAnyToken: retryHasTokens
complexity: number })
timeEstimate: string
techStack: string[] if (!retryHasTokens) {
popularity?: number console.log('[useTemplates] Still no tokens after waiting, skipping authenticated request')
lastUpdated?: string return;
}> = []
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 try {
} const { page = 0, pageSize = paginationState.pageSize, category = paginationState.selectedCategory, search = paginationState.searchQuery, resetPagination = false } = opts || {};
const offset = resetPagination ? 0 : page * pageSize;
const limit = pageSize; // Use pageSize for limit
setPaginationState((prev) => ({ ...prev, loading: true }));
setError(null);
setLoading(true);
console.log('[useTemplates] Fetching templates with params:', { page, pageSize, category, search, offset });
console.log('[useTemplates] User ID for API call:', user?.id);
console.log('[useTemplates] User object:', user);
console.log('[useTemplates] includeOthers will be:', !user?.id);
// Only pass userId if it is a valid UUID v4; otherwise, omit and include others
const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const validUserId = typeof user?.id === 'string' && uuidV4Regex.test(user.id) ? user.id : undefined;
if (!validUserId && user?.id) {
console.warn('[useTemplates] user.id is not a valid UUID v4; omitting userId to avoid backend filter. user.id:', user.id)
}
const res = await templateService.getCombinedTemplates({
userId: validUserId,
includeOthers: true, // always include custom templates from others + defaults
limit: pageSize,
offset,
category: category === 'all' ? undefined : category,
search: search || undefined,
});
console.log('[useTemplates] API call parameters:', {
userId: user?.id,
includeOthers: !user?.id,
userExists: !!user,
userType: typeof user
});
console.log('[useTemplates] API response received:', {
hasData: !!res?.data,
dataLength: res?.data?.length || 0,
count: res?.count,
pagination: res?.pagination,
fullResponse: res,
templates: res?.data?.map(t => ({ id: t.id, title: t.title, type: t.type, category: t.category }))
});
setPaginationState((prev) => ({
...prev,
currentPage: resetPagination ? 0 : page,
pageSize,
total: res.pagination?.total || 0,
hasMore: res.pagination?.hasMore || false,
loading: false,
searchQuery: search,
selectedCategory: category,
}));
setCombined(res);
setLoading(false);
return res;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch templates';
console.error('[useTemplates] Error fetching templates:', err);
// Handle authentication errors gracefully without redirecting
if (errorMessage.includes('Authentication required') || errorMessage.includes('Access denied')) {
setError('Please sign in to view your templates. You can still browse public templates.');
// Don't throw the error to prevent further processing
} else {
setError(errorMessage);
throw err;
}
setPaginationState((prev) => ({ ...prev, loading: false }));
setLoading(false);
}
}, [paginationState.pageSize, paginationState.selectedCategory, paginationState.searchQuery, user?.id, authLoading]);
// Load more templates for pagination
const loadMoreTemplates = useCallback(async () => {
if (paginationState.loading || !paginationState.hasMore) {
console.log('[useTemplates] Skipping loadMoreTemplates:', { loading: paginationState.loading, hasMore: paginationState.hasMore });
return;
}
try {
const nextPage = paginationState.currentPage + 1;
const res = await fetchTemplatesWithPagination({
page: nextPage,
pageSize: paginationState.pageSize,
category: paginationState.selectedCategory,
search: paginationState.searchQuery,
});
return res;
} catch (err) {
console.error('[useTemplates] Error loading more templates:', err);
throw err;
}
}, [user, paginationState.loading, paginationState.hasMore, paginationState.currentPage, paginationState.pageSize, paginationState.selectedCategory, paginationState.searchQuery, fetchTemplatesWithPagination]);
// Initial fetch on mount - wait for user to be loaded or confirmed as null
useEffect(() => {
if (authLoading) {
console.log('[useTemplates] Auth still loading, waiting...')
return;
}
console.log('[useTemplates] Initiating fetch, user:', user?.id || 'anonymous')
// Determine what to fetch based on user state
let fetchParams = {
page: 0,
pageSize: paginationState.pageSize,
category: paginationState.selectedCategory,
search: paginationState.searchQuery,
resetPagination: true,
};
// If no user, only fetch public templates
if (!user?.id) {
console.log('[useTemplates] No user, fetching only public templates')
fetchParams = {
...fetchParams,
category: 'all', // Force 'all' category for public templates
search: '',
};
}
fetchTemplatesWithPagination(fetchParams).catch((err) => {
console.error('[useTemplates] Fetch failed:', err)
setLoading(false)
});
}, [authLoading, user?.id]); // Depend on both authLoading and user?.id
// Fetch categories once (does not affect pagination)
useEffect(() => {
const loadCategories = async () => {
try {
const byCat: TemplatesByCategory = await templateService.getTemplatesByCategory();
const entries = Object.entries(byCat || {});
const total = entries.reduce((sum, [, arr]) => sum + (Array.isArray(arr) ? arr.length : 0), 0);
const cats = [
{ id: 'all', name: 'All Templates', count: total },
...entries.map(([key, arr]) => ({ id: key, name: key, count: (arr || []).length })),
];
setCategories(cats);
} catch (e) {
console.warn('[useTemplates] Failed to load categories; falling back to derived list.', e);
setCategories([{ id: 'all', name: 'All Templates', count: combined?.pagination?.total || 0 }]);
}
};
loadCategories();
}, [combined?.pagination?.total]);
// CRUD operations
const createTemplate = async (templateData: Partial<DatabaseTemplate>) => {
try {
const newTemplate = await templateService.createTemplate(templateData);
// Refresh templates after creation
await fetchTemplatesWithPagination({ resetPagination: true });
return newTemplate;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create template';
// Emit a browser toast using the simplest possible fallback
show({ title: 'Duplicate template', description: errorMessage, variant: 'error' });
throw err;
}
};
const updateTemplate = async (id: string, templateData: Partial<DatabaseTemplate>, isCustom: boolean = false) => {
try {
const updatedTemplate = await templateService.updateTemplate(id, templateData, isCustom);
// Refresh templates after update
await fetchTemplatesWithPagination({ resetPagination: true });
show({
title: 'Template updated',
description: (templateData as Record<string, unknown>)?.title ? `Updated: ${(templateData as Record<string, unknown>).title}` : undefined,
variant: 'success'
});
return updatedTemplate;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update template';
show({ title: 'Update failed', description: errorMessage, variant: 'error' });
throw err;
}
};
const deleteTemplate = async (id: string, isCustom: boolean = false) => {
try {
await templateService.deleteTemplate(id, isCustom);
// Refresh templates after deletion
await fetchTemplatesWithPagination({ resetPagination: true });
show({ title: 'Template deleted', variant: 'success' });
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete template';
show({ title: 'Delete failed', description: errorMessage, variant: 'error' });
throw err;
}
};
// Get template features when a template is selected
const getTemplateFeatures = async (templateId: string): Promise<string[]> => { const getTemplateFeatures = async (templateId: string): Promise<string[]> => {
try { try {
const features = await templateService.getFeaturesForTemplate(templateId) const features = await templateService.getFeaturesForTemplate(templateId);
return features.map(feature => feature.name) return features.map((feature) => feature.name);
} catch (error) { } catch (error) {
console.error('Error fetching template features:', error) console.error('[useTemplates] Error fetching template features:', error);
return [] return [];
} }
} };
// Feature CRUD for a template
const fetchFeatures = async (templateId: string): Promise<TemplateFeature[]> => { const fetchFeatures = async (templateId: string): Promise<TemplateFeature[]> => {
return templateService.getFeaturesForTemplate(templateId) return templateService.getFeaturesForTemplate(templateId);
} };
const createFeature = async (templateId: string, feature: Partial<TemplateFeature>) => { const createFeature = async (templateId: string, feature: Partial<TemplateFeature>) => {
const payload = { ...feature, template_id: templateId } const payload = { ...feature, template_id: templateId };
return templateService.createFeature(payload) return templateService.createFeature(payload);
} };
const updateFeature = async ( const updateFeature = async (featureId: string, updates: Partial<TemplateFeature> & { isCustom?: boolean }) => {
featureId: string, return templateService.updateFeature(featureId, updates);
updates: Partial<TemplateFeature> & { isCustom?: boolean } };
) => {
// If updates indicate custom, route accordingly
return templateService.updateFeature(featureId, updates)
}
const deleteFeature = async (featureId: string, opts?: { isCustom?: boolean }) => { const deleteFeature = async (featureId: string, opts?: { isCustom?: boolean }) => {
return templateService.deleteFeature(featureId, opts) return templateService.deleteFeature(featureId, opts);
} };
return { return {
templates, user,
loading, combined,
loading: loading || authLoading,
error, error,
fetchTemplates, paginationState,
categories,
fetchTemplatesWithPagination,
loadMoreTemplates,
createTemplate, createTemplate,
updateTemplate, updateTemplate,
deleteTemplate, deleteTemplate,
getTemplatesForUI,
getTemplateFeatures, getTemplateFeatures,
fetchFeatures, fetchFeatures,
createFeature, createFeature,
updateFeature, updateFeature,
deleteFeature deleteFeature,
} };
} }

192
src/lib/api-config.ts Normal file
View File

@ -0,0 +1,192 @@
/**
* Centralized API configuration for the CodeNuk frontend
* All API base URLs are managed from this single location
*/
// Default API base URL - can be overridden by environment variables
const DEFAULT_API_BASE_URL = 'http://localhost:8000';
// Environment-based configuration
export const API_CONFIG = {
// Main API base URL
BASE_URL: process.env.NEXT_PUBLIC_API_URL || DEFAULT_API_BASE_URL,
// Specific service endpoints (if needed in the future)
AUTH_SERVICE: process.env.NEXT_PUBLIC_AUTH_API_URL || DEFAULT_API_BASE_URL,
TEMPLATE_SERVICE: process.env.NEXT_PUBLIC_TEMPLATE_MANAGER_URL || DEFAULT_API_BASE_URL,
GENERATION_SERVICE: process.env.NEXT_PUBLIC_GENERATION_SERVICE_URL || DEFAULT_API_BASE_URL,
SELECTION_SERVICE: process.env.NEXT_PUBLIC_SELECTION_SERVICE_URL || DEFAULT_API_BASE_URL,
// AI Mockup Wireframe Service via API Gateway
// Route through gateway on port 8000 to unify CORS/auth and service access
AI_MOCKUP_SERVICE: process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000/api/mockup',
USER_AUTH_SERVICE: process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8000/api/auth',
} as const;
// AI Mockup Configuration
export const AI_MOCKUP_CONFIG = {
// Backend API configuration
backend: {
baseUrl: API_CONFIG.AI_MOCKUP_SERVICE,
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: API_CONFIG.USER_AUTH_SERVICE,
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;
// Export the main API base URL for backward compatibility
export const API_BASE_URL = API_CONFIG.BASE_URL;
// Temporary debug logging
console.log('🔧 API Config Debug:', {
'process.env.NEXT_PUBLIC_API_URL': process.env.NEXT_PUBLIC_API_URL,
'process.env.NEXT_PUBLIC_AUTH_API_URL': process.env.NEXT_PUBLIC_AUTH_API_URL,
'DEFAULT_API_BASE_URL': DEFAULT_API_BASE_URL,
'API_CONFIG.BASE_URL': API_CONFIG.BASE_URL,
'API_CONFIG.AUTH_SERVICE': API_CONFIG.AUTH_SERVICE,
'API_BASE_URL': API_BASE_URL
});
// Helper function to get full API endpoint URL
export const getApiUrl = (endpoint: string): string => {
// Remove leading slash if present to avoid double slashes
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
return `${API_CONFIG.BASE_URL}/${cleanEndpoint}`;
};
// Helper function to get service-specific URL
export const getServiceUrl = (service: keyof typeof API_CONFIG, endpoint: string): string => {
const baseUrl = API_CONFIG[service];
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
return `${baseUrl}/${cleanEndpoint}`;
};
// AI Mockup Helper Functions
export const getAIMockupApiUrl = (endpoint: string): string => {
return `${AI_MOCKUP_CONFIG.backend.baseUrl}${endpoint}`;
};
export const getAIMockupAuthUrl = (endpoint: string): string => {
return `${AI_MOCKUP_CONFIG.auth.baseUrl}${endpoint}`;
};
// Helper function to get health check URL
export const getAIMockupHealthUrl = (): string => {
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.health);
};
// Helper function to get auth health check URL
export const getAIMockupAuthHealthUrl = (): string => {
return getAIMockupAuthUrl(AI_MOCKUP_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 getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.generateWireframeTablet);
case 'mobile':
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.generateWireframeMobile);
case 'desktop':
default:
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.generateWireframeDesktop);
}
};
// Helper function to get universal wireframe generation URL (backward compatibility)
export const getUniversalWireframeGenerationUrl = (): string => {
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.generateWireframe);
};
// Helper function to get all devices generation URL
export const getAllDevicesGenerationUrl = (): string => {
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.generateAllDevices);
};
// Helper function to get wireframe persistence URLs
export const getWireframeUrl = (id?: string): string => {
if (id) {
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.wireframe(id));
}
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.wireframes);
};
// Helper function to get wireframe by ID URL
export const getWireframeByIdUrl = (id: string): string => {
return getAIMockupApiUrl(AI_MOCKUP_CONFIG.backend.endpoints.wireframe(id));
};
// Authentication helper functions - Use consistent token retrieval
export const getAIMockupAuthHeaders = (): HeadersInit => {
// Use the main auth system's token instead of separate token
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
return token ? { 'Authorization': `Bearer ${token}` } : {};
};
export const getAIMockupAuthHeadersWithContentType = (): HeadersInit => {
// Use the main auth system's token instead of separate token
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
return {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
};
};
export const isAIMockupAuthenticated = (): boolean => {
// Use the main auth system's token instead of separate token
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
return !!token;
};
export const getCurrentAIMockupUser = (): any => {
try {
// Use the main auth system's token instead of separate token
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
if (!token) return null;
// Decode JWT token to get user info
const payload = JSON.parse(atob(token.split('.')[1]));
return payload;
} catch {
return null;
}
};

View File

@ -5,18 +5,23 @@ import {
FeatureReviewData, FeatureReviewData,
AdminStats, AdminStats,
FeatureSynonym, FeatureSynonym,
AdminApiResponse AdminApiResponse,
AdminTemplate
} from '@/types/admin.types'; } from '@/types/admin.types';
import { getAccessToken } from '@/components/apis/authApiClients'; import { getAccessToken } from '@/components/apis/authApiClients';
import { BACKEND_URL } from '@/config/backend';
const API_BASE_URL = process.env.NEXT_PUBLIC_TEMPLATE_MANAGER_URL || 'http://localhost:8009'; const API_BASE_URL = BACKEND_URL;
const AUTH_API_BASE_URL = BACKEND_URL;
// Helper function to make API calls // Helper function to make API calls
async function apiCall<T>( async function apiCall<T>(
endpoint: string, endpoint: string,
options: RequestInit = {} options: RequestInit = {},
useAuthAPI = false
): Promise<AdminApiResponse<T>> { ): Promise<AdminApiResponse<T>> {
const url = `${API_BASE_URL}${endpoint}`; const baseUrl = useAuthAPI ? AUTH_API_BASE_URL : API_BASE_URL;
const url = `${baseUrl}${endpoint}`;
const token = getAccessToken(); const token = getAccessToken();
const defaultHeaders: Record<string, string> = { const defaultHeaders: Record<string, string> = {
@ -36,7 +41,14 @@ async function apiCall<T>(
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP error! status: ${response.status}`); const err = new AdminApiError(
errorData.message || `HTTP error! status: ${response.status}`,
response.status,
errorData.code
);
// Attach full server payload for consumers that need richer context
(err as AdminApiError & { data: Record<string, unknown> }).data = errorData;
throw err;
} }
return response.json(); return response.json();
@ -46,42 +58,42 @@ async function apiCall<T>(
export const adminApi = { export const adminApi = {
// Get pending features // Get pending features
getPendingFeatures: async (limit = 50, offset = 0): Promise<AdminFeature[]> => { getPendingFeatures: async (limit = 50, offset = 0): Promise<AdminFeature[]> => {
const response = await apiCall<AdminFeature[]>(`/api/admin/features/pending?limit=${limit}&offset=${offset}`); const response = await apiCall<AdminFeature[]>(`/api/auth/admin/custom-features?status=pending&limit=${limit}&offset=${offset}`, {}, true);
return response.data; return response.data;
}, },
// Get features by status // Get features by status
getFeaturesByStatus: async (status: string, limit = 50, offset = 0): Promise<AdminFeature[]> => { getFeaturesByStatus: async (status: string, limit = 50, offset = 0): Promise<AdminFeature[]> => {
const response = await apiCall<AdminFeature[]>(`/api/admin/features/status/${status}?limit=${limit}&offset=${offset}`); const response = await apiCall<AdminFeature[]>(`/api/auth/admin/custom-features?status=${status}&limit=${limit}&offset=${offset}`, {}, true);
return response.data; return response.data;
}, },
// Get feature statistics // Get feature statistics
getFeatureStats: async (): Promise<AdminStats> => { getFeatureStats: async (): Promise<AdminStats> => {
const response = await apiCall<AdminStats>('/api/admin/features/stats'); const response = await apiCall<AdminStats>('/api/auth/admin/custom-features', {}, true);
return response.data; return response.data;
}, },
// Review a feature // Review a feature
reviewFeature: async (featureId: string, reviewData: FeatureReviewData): Promise<AdminFeature> => { reviewFeature: async (featureId: string, reviewData: FeatureReviewData): Promise<AdminFeature> => {
const response = await apiCall<AdminFeature>(`/api/admin/features/${featureId}/review`, { const response = await apiCall<AdminFeature>(`/api/auth/admin/custom-features/${featureId}/review`, {
method: 'POST', method: 'PUT',
body: JSON.stringify(reviewData), body: JSON.stringify(reviewData),
}); }, true);
return response.data; return response.data;
}, },
// Find similar features // Find similar features
findSimilarFeatures: async (query: string, threshold = 0.7, limit = 5): Promise<FeatureSimilarity[]> => { findSimilarFeatures: async (query: string, threshold = 0.7, limit = 5): Promise<FeatureSimilarity[]> => {
const response = await apiCall<FeatureSimilarity[]>( const response = await apiCall<FeatureSimilarity[]>(
`/api/admin/features/similar?q=${encodeURIComponent(query)}&threshold=${threshold}&limit=${limit}` `/api/features/similar?q=${encodeURIComponent(query)}&threshold=${threshold}&limit=${limit}`
); );
return response.data; return response.data;
}, },
// Add feature synonym // Add feature synonym
addFeatureSynonym: async (featureId: string, synonym: string, createdBy?: string): Promise<FeatureSynonym> => { addFeatureSynonym: async (featureId: string, synonym: string, createdBy?: string): Promise<FeatureSynonym> => {
const response = await apiCall<FeatureSynonym>(`/api/admin/features/${featureId}/synonyms`, { const response = await apiCall<FeatureSynonym>(`/api/features/${featureId}/synonyms`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ synonym, created_by: createdBy }), body: JSON.stringify({ synonym, created_by: createdBy }),
}); });
@ -90,7 +102,7 @@ export const adminApi = {
// Get feature synonyms // Get feature synonyms
getFeatureSynonyms: async (featureId: string): Promise<FeatureSynonym[]> => { getFeatureSynonyms: async (featureId: string): Promise<FeatureSynonym[]> => {
const response = await apiCall<FeatureSynonym[]>(`/api/admin/features/${featureId}/synonyms`); const response = await apiCall<FeatureSynonym[]>(`/api/features/${featureId}/synonyms`);
return response.data; return response.data;
}, },
@ -117,6 +129,272 @@ export const adminApi = {
}); });
return response.data; return response.data;
}, },
// Get custom features (from user-auth admin API)
getCustomFeatures: async (status?: string, limit = 50, offset = 0): Promise<AdminFeature[]> => {
const params = new URLSearchParams({ limit: limit.toString(), offset: offset.toString() });
if (status) params.append('status', status);
const response = await apiCall<AdminFeature[]>(`/api/auth/admin/custom-features?${params}`, {}, true);
return response.data;
},
// Get custom templates (from user-auth admin API)
getCustomTemplates: async (status?: string, limit = 50, offset = 0): Promise<AdminTemplate[]> => {
const params = new URLSearchParams({ limit: limit.toString(), offset: offset.toString() });
if (status) params.append('status', status);
const response = await apiCall<AdminTemplate[]>(`/api/auth/admin/custom-templates?${params}`, {}, true);
return response.data;
},
// Review custom feature (from user-auth admin API)
reviewCustomFeature: async (featureId: string, reviewData: { status: string; admin_notes?: string }): Promise<AdminFeature> => {
const response = await apiCall<AdminFeature>(`/api/auth/admin/custom-features/${featureId}/review`, {
method: 'PUT',
body: JSON.stringify(reviewData),
}, true);
return response.data;
},
// Review custom template (from user-auth admin API)
reviewTemplate: async (templateId: string, reviewData: { status: string; admin_notes?: string }): Promise<Record<string, unknown>> => {
const response = await apiCall<Record<string, unknown>>(`/api/auth/admin/custom-templates/${templateId}/review`, {
method: 'PUT',
body: JSON.stringify(reviewData),
}, true);
return response.data;
},
// Reject custom feature
rejectCustomFeature: async (featureId: string, adminNotes?: string): Promise<AdminFeature> => {
const response = await apiCall<AdminFeature>(`/api/auth/admin/custom-features/${featureId}/review`, {
method: 'PUT',
body: JSON.stringify({
status: 'rejected',
admin_notes: adminNotes || 'Rejected by admin'
}),
}, true);
return response.data;
},
// Reject custom template
rejectTemplate: async (templateId: string, adminNotes?: string): Promise<Record<string, unknown>> => {
const response = await apiCall<Record<string, unknown>>(`/api/auth/admin/custom-templates/${templateId}/review`, {
method: 'PUT',
body: JSON.stringify({
status: 'rejected',
admin_notes: adminNotes || 'Rejected by admin'
}),
}, true);
return response.data;
},
// Update custom feature
updateCustomFeature: async (featureId: string, updateData: {
name?: string;
description?: string;
complexity?: 'low' | 'medium' | 'high';
business_rules?: Record<string, unknown>;
technical_requirements?: Record<string, unknown>;
}): Promise<AdminFeature> => {
const response = await apiCall<AdminFeature>(`/api/features/${featureId}`, {
method: 'PUT',
body: JSON.stringify(updateData),
});
return response.data;
},
// Create new template from edit (creates new record instead of updating)
createTemplateFromEdit: async (originalTemplateId: string, updateData: {
title?: string;
description?: string;
category?: string;
type?: string;
icon?: string;
gradient?: string;
border?: string;
text?: string;
subtext?: string;
}): Promise<Record<string, unknown>> => {
const response = await apiCall<Record<string, unknown>>(`/api/templates`, {
method: 'POST',
body: JSON.stringify({
...updateData,
is_custom: true,
isCustom: true,
status: 'pending',
created_from_edit: originalTemplateId
}),
}, false);
return response.data;
},
// Create approved template in main templates table
createApprovedTemplate: async (customTemplateId: string, templateData: {
title: string;
description?: string;
category: string;
type: string;
icon?: string;
gradient?: string;
border?: string;
text?: string;
subtext?: string;
}): Promise<Record<string, unknown>> => {
// Use dedicated endpoint that also approves the custom template server-side
const response = await apiCall<Record<string, unknown>>(`/api/templates/approve-custom`, {
method: 'POST',
body: JSON.stringify({
custom_template_id: customTemplateId,
template: {
...templateData,
is_custom: false,
isCustom: false
}
}),
}, false);
return response.data;
},
// Create feature directly in template_features table
createApprovedFeature: async (featureData: {
template_id: string;
name: string;
description?: string;
complexity: 'low' | 'medium' | 'high';
business_rules?: Record<string, unknown>;
technical_requirements?: Record<string, unknown>;
feature_type?: string;
}): Promise<AdminFeature> => {
const response = await apiCall<AdminFeature>(`/api/features`, {
method: 'POST',
body: JSON.stringify(featureData),
}, false);
return response.data;
},
// Get single custom feature by ID
getCustomFeature: async (featureId: string): Promise<AdminFeature> => {
const response = await apiCall<AdminFeature>(`/api/features/${featureId}`);
return response.data;
},
// Get single custom template by ID
getCustomTemplate: async (templateId: string): Promise<Record<string, unknown>> => {
const response = await apiCall<Record<string, unknown>>(`/api/templates/${templateId}`);
return response.data;
},
// Get custom feature stats
getCustomFeatureStats: async (): Promise<AdminStats> => {
const response = await apiCall<AdminStats>('/api/admin/features/stats');
return response.data;
},
// Get template stats
getTemplateStats: async (): Promise<AdminStats> => {
const response = await apiCall<AdminStats>('/api/admin/templates/stats');
return response.data;
},
// Create new custom template
createCustomTemplate: async (templateData: {
title: string;
description: string;
category: string;
type: string;
complexity: string;
icon?: string;
gradient?: string;
border?: string;
text?: string;
subtext?: string;
}): Promise<Record<string, unknown>> => {
const response = await apiCall<Record<string, unknown>>('/api/templates', {
method: 'POST',
body: JSON.stringify({
...templateData
}),
}, false);
return response.data;
},
// Template Features Management
// Get features for a specific template
getTemplateFeatures: async (templateId: string): Promise<AdminFeature[]> => {
const response = await apiCall<AdminFeature[]>(`/api/templates/${templateId}/features`);
return response.data;
},
// Add feature to template
addFeatureToTemplate: async (templateId: string, featureData: {
name: string;
description?: string;
complexity: 'low' | 'medium' | 'high';
business_rules?: Record<string, unknown>;
technical_requirements?: Record<string, unknown>;
feature_type?: string;
}): Promise<AdminFeature> => {
const response = await apiCall<AdminFeature>(`/api/templates/${templateId}/features`, {
method: 'POST',
body: JSON.stringify(featureData),
});
return response.data;
},
// Update feature in template
updateTemplateFeature: async (templateId: string, featureId: string, updateData: {
name?: string;
description?: string;
complexity?: 'low' | 'medium' | 'high';
business_rules?: Record<string, unknown>;
technical_requirements?: Record<string, unknown>;
feature_type?: string;
}): Promise<AdminFeature> => {
const response = await apiCall<AdminFeature>(`/api/templates/${templateId}/features/${featureId}`, {
method: 'PUT',
body: JSON.stringify(updateData),
});
return response.data;
},
// Remove feature from template
removeFeatureFromTemplate: async (templateId: string, featureId: string): Promise<void> => {
await apiCall<void>(`/api/templates/${templateId}/features/${featureId}`, {
method: 'DELETE',
});
},
// Bulk add features to template
bulkAddFeaturesToTemplate: async (templateId: string, features: Array<{
name: string;
description?: string;
complexity: 'low' | 'medium' | 'high';
business_rules?: Record<string, unknown>;
technical_requirements?: Record<string, unknown>;
feature_type?: string;
}>): Promise<AdminFeature[]> => {
const response = await apiCall<AdminFeature[]>(`/api/templates/${templateId}/features/bulk`, {
method: 'POST',
body: JSON.stringify({ features }),
});
return response.data;
},
// Admin Templates Management (from main templates table)
// Get all admin templates for management
getAdminTemplates: async (limit = 50, offset = 0, category?: string, search?: string): Promise<AdminTemplate[]> => {
const params = new URLSearchParams({ limit: limit.toString(), offset: offset.toString() });
if (category && category !== 'all') params.append('category', category);
if (search) params.append('search', search);
const response = await apiCall<AdminTemplate[]>(`/api/admin/templates?${params}`);
return response.data;
},
// Get admin template statistics
getAdminTemplateStats: async (): Promise<AdminStats> => {
const response = await apiCall<AdminStats>('/api/admin/templates/stats');
return response.data;
},
}; };
// Feature submission API (for regular users) // Feature submission API (for regular users)
@ -127,10 +405,10 @@ export const featureApi = {
name: string; name: string;
description?: string; description?: string;
complexity: 'low' | 'medium' | 'high'; complexity: 'low' | 'medium' | 'high';
business_rules?: any; business_rules?: Record<string, unknown>;
technical_requirements?: any; technical_requirements?: Record<string, unknown>;
created_by_user_session?: string; created_by_user_session?: string;
}): Promise<{ data: AdminFeature; similarityInfo?: any }> => { }): Promise<{ data: AdminFeature; similarityInfo?: Record<string, unknown> }> => {
const response = await apiCall<AdminFeature>('/api/features/custom', { const response = await apiCall<AdminFeature>('/api/features/custom', {
method: 'POST', method: 'POST',
body: JSON.stringify(featureData), body: JSON.stringify(featureData),
@ -138,7 +416,7 @@ export const featureApi = {
return { return {
data: response.data, data: response.data,
similarityInfo: (response as any).similarityInfo, similarityInfo: (response as AdminApiResponse<AdminFeature> & { similarityInfo?: Record<string, unknown> }).similarityInfo,
}; };
}, },

View File

@ -18,7 +18,7 @@ export const config = {
// User Authentication Service // User Authentication Service
auth: { auth: {
baseUrl: process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8011', baseUrl: process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8000',
endpoints: { endpoints: {
health: '/health', health: '/health',
register: '/api/auth/register', register: '/api/auth/register',

View File

@ -1,3 +1,6 @@
import { getAccessToken } from '@/components/apis/authApiClients'
import { BACKEND_URL } from '@/config/backend'
export interface DatabaseTemplate { export interface DatabaseTemplate {
id: string id: string
type: string type: string
@ -14,6 +17,8 @@ export interface DatabaseTemplate {
updated_at: string updated_at: string
feature_count?: number feature_count?: number
avg_rating?: number avg_rating?: number
is_custom?: boolean // Add this field to identify custom templates
created_by_user?: string // Add this field for user-created templates
} }
export interface TemplateFeature { export interface TemplateFeature {
@ -41,19 +46,36 @@ export interface TemplatesByCategory {
[category: string]: DatabaseTemplate[] [category: string]: DatabaseTemplate[]
} }
const API_BASE_URL = 'http://localhost:8009'
class TemplateService { class TemplateService {
private async makeRequest<T>(endpoint: string): Promise<T> { private async makeRequest<T>(endpoint: string, requireAuth: boolean = false): Promise<T> {
try { try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, { const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
// Add authentication header if required and token exists
if (requireAuth) {
const token = getAccessToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
}
const response = await fetch(`${BACKEND_URL}${endpoint}`, {
method: 'GET', method: 'GET',
headers: { headers,
'Content-Type': 'application/json',
},
}) })
if (!response.ok) { if (!response.ok) {
// Handle authentication errors gracefully
if (response.status === 401) {
throw new Error('Authentication required. Please sign in to continue.')
}
if (response.status === 403) {
throw new Error('Access denied. You do not have permission to view this resource.')
}
throw new Error(`HTTP error! status: ${response.status}`) throw new Error(`HTTP error! status: ${response.status}`)
} }
@ -71,29 +93,274 @@ class TemplateService {
} }
async getTemplatesByCategory(): Promise<TemplatesByCategory> { async getTemplatesByCategory(): Promise<TemplatesByCategory> {
return this.makeRequest<TemplatesByCategory>('/api/templates') return this.makeRequest<TemplatesByCategory>('/api/templates', false)
} }
async getTemplateById(id: string): Promise<TemplateWithFeatures> { async getTemplateById(id: string): Promise<TemplateWithFeatures> {
return this.makeRequest<TemplateWithFeatures>(`/api/templates/${id}`) return this.makeRequest<TemplateWithFeatures>(`/api/templates/${id}`, false)
} }
async getTemplateByType(type: string): Promise<TemplateWithFeatures> { async getTemplateByType(type: string): Promise<TemplateWithFeatures> {
return this.makeRequest<TemplateWithFeatures>(`/api/templates/type/${type}`) return this.makeRequest<TemplateWithFeatures>(`/api/templates/type/${type}`, false)
}
async getCombinedTemplates(params?: {
userId?: string
status?: string
limit?: number
offset?: number
category?: string | null
search?: string | null
includeOthers?: boolean
}): Promise<{ data: DatabaseTemplate[]; count?: number; pagination?: { total?: number; limit?: number; offset?: number; hasMore?: boolean } }> {
const limit = params?.limit ?? 3
const offset = params?.offset ?? 0
const qp: string[] = [
`limit=${encodeURIComponent(String(limit))}`,
`offset=${encodeURIComponent(String(offset))}`,
]
// Use the /merged endpoint for templates
const endpoint = '/api/templates/merged'
if (params?.category && params.category !== 'all') {
qp.push(`category=${encodeURIComponent(params.category)}`)
}
if (params?.search && params.search.trim()) {
qp.push(`search=${encodeURIComponent(params.search.trim())}`)
}
if (params?.userId) {
qp.push(`userId=${encodeURIComponent(params.userId)}`)
} else if (params?.includeOthers === true) {
qp.push(`includeOthers=true`)
}
const url = `${BACKEND_URL}${endpoint}?${qp.join('&')}`
console.log('[templateService.getCombinedTemplates] Making request to URL:', url)
console.log('[templateService.getCombinedTemplates] Request params:', params)
console.log('🆔 [templateService] User ID being sent:', params?.userId || 'No user ID')
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
// Add authentication header if user ID is provided (authenticated request)
if (params?.userId) {
const token = getAccessToken()
console.log('🔐 [templateService] Token check:', {
hasUserId: !!params.userId,
hasToken: !!token,
tokenLength: token?.length || 0,
tokenStart: token?.substring(0, 20) + '...' || 'No token',
timestamp: new Date().toISOString(),
url: url
})
if (token) {
headers['Authorization'] = `Bearer ${token}`
console.log('🔐 [templateService] Authorization header added with token')
console.log('🔐 [templateService] Token format check:', {
tokenType: typeof token,
tokenLength: token.length,
startsWithBearer: token.startsWith('Bearer'),
firstChars: token.substring(0, 10),
lastChars: token.substring(token.length - 10)
})
} else {
console.log('❌ [templateService] No token available for authenticated request')
}
} else {
console.log('🔐 [templateService] No userId provided, making public request')
}
console.log('🔐 [templateService] Final headers being sent:', headers)
console.log('🔐 [templateService] Authorization header value:', headers['Authorization'])
const resp = await fetch(url, { headers })
console.log('[templateService.getCombinedTemplates] Response status:', resp.status, resp.statusText)
if (!resp.ok) {
const text = await resp.text().catch(() => '')
console.error('[templateService.getCombinedTemplates] HTTP error', resp.status, text)
// Handle authentication errors gracefully
if (resp.status === 401) {
throw new Error('Authentication required. Please sign in to continue.')
}
if (resp.status === 403) {
throw new Error('Access denied. You do not have permission to view this resource.')
}
// If combined endpoint returns 404, throw error
if (resp.status === 404) {
throw new Error(`/api/templates/combined endpoint not found. Please ensure this endpoint exists and is working.`)
}
throw new Error(`HTTP error! status: ${resp.status}`)
}
const json = await resp.json()
console.log('[templateService.getCombinedTemplates] Raw JSON:', json)
// Handle merged endpoint response
console.log('[templateService.getCombinedTemplates] Response structure:', {
success: json?.success,
hasData: !!json?.data,
dataLength: json?.data?.length,
hasPagination: !!json?.pagination,
pagination: json?.pagination
})
// Extract data from the response
let templates = []
if (json?.success && json?.data) {
if (Array.isArray(json.data)) {
// If data is already an array
templates = json.data
} else if (typeof json.data === 'object') {
// If data is an object with categories (like { "System": [...], "Marketing": [...] })
// Flatten all categories into a single array
for (const category in json.data) {
if (Array.isArray(json.data[category])) {
templates.push(...json.data[category])
}
}
}
} else if (Array.isArray(json)) {
// Fallback: if response is directly an array
templates = json
}
// Extract pagination info
let paginationInfo = json?.pagination || {}
if (!paginationInfo.total && templates.length > 0) {
// If no pagination info, calculate it from the data
paginationInfo = {
total: templates.length,
offset,
limit,
hasMore: false // Since we got all data
}
}
const result = {
data: templates,
count: templates.length,
pagination: paginationInfo
}
console.log('[templateService.getCombinedTemplates] Processed result:', result)
console.log('[templateService.getCombinedTemplates] Templates array:', templates)
console.log('[templateService.getCombinedTemplates] Pagination info:', paginationInfo)
return result
} catch (error) {
console.error('[templateService.getCombinedTemplates] Error:', error)
throw error
}
}
async getTemplatesByUser(params: {
userId: string
status?: string
limit?: number
offset?: number
}): Promise<{
defaultTemplates: DatabaseTemplate[]
customTemplates: DatabaseTemplate[]
}> {
const { userId, status, limit = 20, offset = 0 } = params
const queryParts: string[] = []
if (typeof status === 'string' && status.length > 0) {
queryParts.push(`status=${encodeURIComponent(status)}`)
}
queryParts.push(`limit=${encodeURIComponent(String(limit))}`)
queryParts.push(`offset=${encodeURIComponent(String(offset))}`)
const query = queryParts.join('&')
const url = `${BACKEND_URL}/api/templates/by-user/${encodeURIComponent(userId)}?${query}`
try {
console.log('[templateService.getTemplatesByUser] URL (matching curl):', url)
const resp = await fetch(url, { headers: { 'Content-Type': 'application/json' } })
if (!resp.ok) throw new Error(`HTTP error! status: ${resp.status}`)
const json = await resp.json()
console.log('[templateService.getTemplatesByUser] Raw JSON:', json)
// Support both wrapped {success,data} and direct payloads
const payload = (json && json.success !== undefined) ? json.data : json
// If payload is an array, partition by flags
let defaultTemplates: DatabaseTemplate[] = []
let customTemplates: DatabaseTemplate[] = []
if (Array.isArray(payload)) {
const arr = payload as DatabaseTemplate[]
defaultTemplates = arr.filter((t: DatabaseTemplate) => !(t?.is_custom || t?.created_by_user))
customTemplates = arr.filter((t: DatabaseTemplate) => (t?.is_custom || t?.created_by_user))
} else {
// Normalize likely object shapes
defaultTemplates = payload?.defaultTemplates || payload?.default_templates || payload?.defaults || []
customTemplates = payload?.customTemplates || payload?.custom_templates || payload?.customs || []
}
const normalized = { defaultTemplates, customTemplates }
console.log('[templateService.getTemplatesByUser] Normalized:', normalized)
return normalized
} catch (err) {
console.error('[templateService.getTemplatesByUser] Error:', err)
throw err
}
} }
async createTemplate(templateData: Partial<DatabaseTemplate>): Promise<DatabaseTemplate> { async createTemplate(templateData: Partial<DatabaseTemplate>): Promise<DatabaseTemplate> {
console.log(templateData, "templateData___");
try { try {
const response = await fetch(`${API_BASE_URL}/api/templates`, { const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
// Add authentication header
const token = getAccessToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
// Add is_custom flag to ensure custom templates are stored in custom_templates table
const payload = {
...templateData,
is_custom: true,
isCustom: true
};
const response = await fetch(`${BACKEND_URL}/api/templates`, {
method: 'POST', method: 'POST',
headers: { headers,
'Content-Type': 'application/json', body: JSON.stringify(payload),
},
body: JSON.stringify(templateData),
}) })
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`) // Try to extract backend error message
let backendMessage = ''
let backendError = ''
try {
const errJson = await response.json()
backendMessage = errJson?.message || ''
backendError = errJson?.error || ''
} catch {
try {
backendMessage = await response.text()
} catch {}
}
// Handle specific error cases
if (response.status === 401) {
throw new Error(backendMessage || 'Authentication required. Please sign in to create templates.')
}
if (response.status === 403) {
throw new Error(backendMessage || 'Access denied. You do not have permission to create templates.')
}
if (response.status === 409) {
// Surface duplicate-specific message from backend
throw new Error(backendMessage || backendError || 'Template already exists with same name or type.')
}
throw new Error(backendMessage || `HTTP error! status: ${response.status}`)
} }
const data = await response.json() const data = await response.json()
@ -111,7 +378,7 @@ class TemplateService {
// Features API // Features API
async getFeaturesForTemplate(templateId: string): Promise<TemplateFeature[]> { async getFeaturesForTemplate(templateId: string): Promise<TemplateFeature[]> {
// Use merged endpoint to include custom features // Use merged endpoint to include custom features (returns custom_feature.id for custom items)
const dedupe = (items: TemplateFeature[]) => { const dedupe = (items: TemplateFeature[]) => {
const byKey = new Map<string, TemplateFeature>() const byKey = new Map<string, TemplateFeature>()
@ -152,8 +419,8 @@ class TemplateService {
const merged = await this.makeRequest<TemplateFeature[]>(`/api/templates/${templateId}/features`) const merged = await this.makeRequest<TemplateFeature[]>(`/api/templates/${templateId}/features`)
return dedupe(merged) return dedupe(merged)
} catch { } catch {
// Fallback to default-only if merged endpoint unsupported // Fallback to compatible route in features router
const defaults = await this.makeRequest<TemplateFeature[]>(`/api/features/templates/${templateId}/merged`) const defaults = await this.makeRequest<TemplateFeature[]>(`/api/features/templates/${templateId}/features`)
return dedupe(defaults) return dedupe(defaults)
} }
} }
@ -172,11 +439,16 @@ class TemplateService {
async createFeature(featureData: Partial<TemplateFeature>): Promise<TemplateFeature> { async createFeature(featureData: Partial<TemplateFeature>): Promise<TemplateFeature> {
if ( if (
featureData && featureData &&
(featureData.feature_type === 'custom' || (featureData as any).feature_type === 'custom') (featureData.feature_type === 'custom')
) { ) {
const response = await fetch(`${API_BASE_URL}/api/features/custom`, { const customHeaders: Record<string, string> = { 'Content-Type': 'application/json' }
const customToken = getAccessToken()
if (customToken) {
customHeaders['Authorization'] = `Bearer ${customToken}`
}
const response = await fetch(`${BACKEND_URL}/api/features/custom`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: customHeaders,
body: JSON.stringify(featureData), body: JSON.stringify(featureData),
}) })
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
@ -184,9 +456,14 @@ class TemplateService {
if (!data.success) throw new Error(data.message || 'Failed to create custom feature') if (!data.success) throw new Error(data.message || 'Failed to create custom feature')
return data.data return data.data
} }
const response = await fetch(`${API_BASE_URL}/api/features`, { const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const token = getAccessToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const response = await fetch(`${BACKEND_URL}/api/features`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers,
body: JSON.stringify(featureData), body: JSON.stringify(featureData),
}) })
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
@ -196,11 +473,14 @@ class TemplateService {
} }
async updateFeature(id: string, featureData: Partial<TemplateFeature> & { isCustom?: boolean }): Promise<TemplateFeature> { async updateFeature(id: string, featureData: Partial<TemplateFeature> & { isCustom?: boolean }): Promise<TemplateFeature> {
const isCustom = (featureData as any).isCustom || featureData.feature_type === 'custom' const isCustom = featureData.isCustom || featureData.feature_type === 'custom'
const url = isCustom ? `${API_BASE_URL}/api/features/custom/${id}` : `${API_BASE_URL}/api/features/${id}` const url = isCustom ? `${BACKEND_URL}/api/features/custom/${id}` : `${BACKEND_URL}/api/features/${id}`
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const token = getAccessToken()
if (token) headers['Authorization'] = `Bearer ${token}`
const response = await fetch(url, { const response = await fetch(url, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers,
body: JSON.stringify(featureData), body: JSON.stringify(featureData),
}) })
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
@ -210,28 +490,62 @@ class TemplateService {
} }
async deleteFeature(id: string, opts?: { isCustom?: boolean }): Promise<void> { 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 url = opts?.isCustom ? `${BACKEND_URL}/api/features/custom/${id}` : `${BACKEND_URL}/api/features/${id}`
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const token = getAccessToken()
if (token) headers['Authorization'] = `Bearer ${token}`
const response = await fetch(url, { const response = await fetch(url, {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers,
}) })
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
const data = await response.json() const data = await response.json()
if (!data.success) throw new Error(data.message || 'Failed to delete feature') if (!data.success) throw new Error(data.message || 'Failed to delete feature')
} }
async updateTemplate(id: string, templateData: Partial<DatabaseTemplate>): Promise<DatabaseTemplate> { async updateTemplate(id: string, templateData: Partial<DatabaseTemplate>, isCustom: boolean = false): Promise<DatabaseTemplate> {
try { try {
const response = await fetch(`${API_BASE_URL}/api/templates/${id}`, { const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
// Add authentication header
const token = getAccessToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
// Single endpoint with query flag
const endpoint = `/api/templates/${id}?isCustom=${encodeURIComponent(String(!!isCustom))}`
const response = await fetch(`${BACKEND_URL}${endpoint}`, {
method: 'PUT', method: 'PUT',
headers: { headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(templateData), body: JSON.stringify(templateData),
}) })
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`) // Try to surface backend error message
let backendMessage = ''
try {
const errJson = await response.json()
backendMessage = errJson?.message || errJson?.error || ''
} catch {
try {
backendMessage = await response.text()
} catch {}
}
// Handle specific error cases
if (response.status === 401) {
throw new Error(backendMessage || 'Authentication required. Please sign in to update templates.')
}
if (response.status === 403) {
throw new Error(backendMessage || 'Access denied. You do not have permission to update this template.')
}
if (response.status === 404) {
throw new Error(backendMessage || 'Template not found. It may have been deleted or you may not have access to it.')
}
throw new Error(backendMessage || `HTTP error! status: ${response.status}`)
} }
const data = await response.json() const data = await response.json()
@ -247,17 +561,48 @@ class TemplateService {
} }
} }
async deleteTemplate(id: string): Promise<void> { async deleteTemplate(id: string, isCustom: boolean = false): Promise<void> {
try { try {
const response = await fetch(`${API_BASE_URL}/api/templates/${id}`, { const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
// Add authentication header
const token = getAccessToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
// Single endpoint with query flag
const endpoint = `/api/templates/${id}?isCustom=${encodeURIComponent(String(!!isCustom))}`
const response = await fetch(`${BACKEND_URL}${endpoint}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers,
'Content-Type': 'application/json',
},
}) })
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`) // Try to surface backend error message
let backendMessage = ''
try {
const errJson = await response.json()
backendMessage = errJson?.message || errJson?.error || ''
} catch {
try {
backendMessage = await response.text()
} catch {}
}
// Handle specific error cases
if (response.status === 401) {
throw new Error(backendMessage || 'Authentication required. Please sign in to delete templates.')
}
if (response.status === 403) {
throw new Error(backendMessage || 'Access denied. You do not have permission to delete this template.')
}
if (response.status === 404) {
throw new Error(backendMessage || 'Template not found. It may have been deleted or you may not have access to it.')
}
throw new Error(backendMessage || `HTTP error! status: ${response.status}`)
} }
const data = await response.json() const data = await response.json()

View File

@ -9,13 +9,14 @@ export async function analyzeFeatureWithAI(
featureName: string, featureName: string,
description: string, description: string,
requirements: string[], requirements: string[],
projectType?: string projectType?: string,
userSelectedComplexity?: Complexity
): Promise<AIAnalysisResponse> { ): Promise<AIAnalysisResponse> {
try { try {
const res = await fetch('/api/ai/analyze', { const res = await fetch('/api/ai/analyze', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ featureName, description, requirements, projectType }), body: JSON.stringify({ featureName, description, requirements, projectType, complexity: userSelectedComplexity }),
}) })
const json = await res.json() const json = await res.json()
if (!res.ok || !json.success) { if (!res.ok || !json.success) {

View File

@ -119,3 +119,33 @@ export interface AdminPagination {
limit: number; limit: number;
total: number; total: number;
} }
export interface AdminTemplate {
id: string;
type: string;
title: string;
description?: string;
icon?: string;
category?: string;
gradient?: string;
border?: string;
text?: string;
subtext?: string;
complexity: 'low' | 'medium' | 'high';
business_rules?: Record<string, unknown>;
technical_requirements?: Record<string, unknown>;
approved: boolean;
usage_count: number;
created_by_user_session?: string;
created_at: string;
updated_at: string;
is_custom: boolean;
// Admin approval workflow fields
status: 'pending' | 'approved' | 'rejected' | 'duplicate';
admin_notes?: string;
admin_reviewed_at?: string;
admin_reviewed_by?: string;
canonical_template_id?: string;
similarity_score?: number;
user_id?: string;
}