Spaces:
Build error
Build error
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +1 -0
- Dockerfile +50 -0
- README.md +26 -10
- components.json +22 -0
- jsconfig.json +10 -0
- next.config.ts +18 -0
- package-lock.json +0 -0
- package.json +60 -0
- postcss.config.mjs +7 -0
- public/.nojekyll +0 -0
- public/file.svg +1 -0
- public/globe.svg +1 -0
- public/logo.png +0 -0
- public/next.svg +1 -0
- public/vercel.svg +1 -0
- public/window.svg +1 -0
- src/app/admin/agent-monitoring/page.tsx +17 -0
- src/app/admin/analytics/page.tsx +364 -0
- src/app/admin/blog/page.tsx +1007 -0
- src/app/admin/knowledge/page.tsx +279 -0
- src/app/admin/models/page.tsx +327 -0
- src/app/admin/page.tsx +1144 -0
- src/app/admin/rag/page.tsx +344 -0
- src/app/admin/settings/page.tsx +303 -0
- src/app/admin/training/page.tsx +273 -0
- src/app/admin/users/page.tsx +711 -0
- src/app/api/admin/agent-metrics/route.ts +37 -0
- src/app/api/admin/query-logs/route.ts +41 -0
- src/app/api/admin/retrain/route.ts +38 -0
- src/app/api/admin/review-queue/[id]/approve/route.ts +45 -0
- src/app/api/admin/review-queue/[id]/reject/route.ts +47 -0
- src/app/api/admin/review-queue/route.ts +37 -0
- src/app/api/cache/admin/analytics/route.ts +48 -0
- src/app/api/cache/admin/config/route.ts +63 -0
- src/app/api/cache/admin/database-storage/route.ts +48 -0
- src/app/api/cache/admin/databases/route.ts +48 -0
- src/app/api/cache/admin/documents/route.ts +51 -0
- src/app/api/cache/admin/stats/route.ts +48 -0
- src/app/api/cache/admin/users/route.ts +56 -0
- src/app/api/cache/sessions/[sessionId]/messages/route.ts +79 -0
- src/app/api/cache/sessions/route.ts +73 -0
- src/app/api/livekit-token/route.ts +133 -0
- src/app/auth/forgot-password/page.tsx +342 -0
- src/app/auth/reset-password/page.tsx +221 -0
- src/app/auth/signin/page.tsx +104 -0
- src/app/auth/signup/page.tsx +255 -0
- src/app/blog/[slug]/page.tsx +243 -0
- src/app/blog/page.tsx +395 -0
- src/app/chat/page.tsx +145 -0
- src/app/developers/constants.ts +158 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
src/app/favicon.ico filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AmaniQuery Frontend - Hugging Face Spaces Dockerfile
|
| 2 |
+
# Next.js frontend
|
| 3 |
+
|
| 4 |
+
# Build stage
|
| 5 |
+
FROM node:20-alpine AS builder
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
# Environment for build
|
| 10 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 11 |
+
ENV NODE_ENV=production
|
| 12 |
+
|
| 13 |
+
# Copy package files
|
| 14 |
+
COPY package*.json ./
|
| 15 |
+
|
| 16 |
+
# Install dependencies
|
| 17 |
+
RUN npm ci
|
| 18 |
+
|
| 19 |
+
# Copy source
|
| 20 |
+
COPY . .
|
| 21 |
+
|
| 22 |
+
# Build Next.js app
|
| 23 |
+
RUN npm run build
|
| 24 |
+
|
| 25 |
+
# Production stage
|
| 26 |
+
FROM node:20-alpine AS runner
|
| 27 |
+
|
| 28 |
+
WORKDIR /app
|
| 29 |
+
|
| 30 |
+
ENV NODE_ENV=production
|
| 31 |
+
ENV NEXT_TELEMETRY_DISABLED=1
|
| 32 |
+
ENV PORT=3000
|
| 33 |
+
|
| 34 |
+
# Create non-root user
|
| 35 |
+
RUN addgroup --system --gid 1001 nodejs
|
| 36 |
+
RUN adduser --system --uid 1001 nextjs
|
| 37 |
+
|
| 38 |
+
# Copy built assets
|
| 39 |
+
COPY --from=builder /app/public ./public
|
| 40 |
+
COPY --from=builder /app/.next/standalone ./
|
| 41 |
+
COPY --from=builder /app/.next/static ./.next/static
|
| 42 |
+
|
| 43 |
+
USER nextjs
|
| 44 |
+
|
| 45 |
+
# HF Spaces PORT
|
| 46 |
+
EXPOSE 3000
|
| 47 |
+
|
| 48 |
+
ENV HOSTNAME="0.0.0.0"
|
| 49 |
+
|
| 50 |
+
CMD ["node", "server.js"]
|
README.md
CHANGED
|
@@ -1,10 +1,26 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AmaniQuery
|
| 3 |
+
emoji: 🔍
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 3000
|
| 8 |
+
pinned: true
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# AmaniQuery Frontend
|
| 12 |
+
|
| 13 |
+
AI-Powered Legal & Policy Research Assistant for Kenya.
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
|
| 17 |
+
- 🔍 Natural language search for Kenyan laws and bills
|
| 18 |
+
- 💬 Chat with AI about legal documents
|
| 19 |
+
- 🎤 Voice interface for queries
|
| 20 |
+
- 📊 Research reports and analysis
|
| 21 |
+
- 🔐 User authentication and saved chats
|
| 22 |
+
|
| 23 |
+
## API Backend
|
| 24 |
+
|
| 25 |
+
This frontend connects to:
|
| 26 |
+
- Backend API: `https://amaniquery-backend.hf.space`
|
components.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "new-york",
|
| 4 |
+
"rsc": true,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "tailwind.config.js",
|
| 8 |
+
"css": "src/app/globals.css",
|
| 9 |
+
"baseColor": "zinc",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"iconLibrary": "lucide",
|
| 14 |
+
"aliases": {
|
| 15 |
+
"components": "@/components",
|
| 16 |
+
"utils": "@/lib/utils",
|
| 17 |
+
"ui": "@/components/ui",
|
| 18 |
+
"lib": "@/lib",
|
| 19 |
+
"hooks": "@/hooks"
|
| 20 |
+
},
|
| 21 |
+
"registries": {}
|
| 22 |
+
}
|
jsconfig.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"baseUrl": ".",
|
| 4 |
+
"paths": {
|
| 5 |
+
"@/*": ["./src/*"]
|
| 6 |
+
}
|
| 7 |
+
},
|
| 8 |
+
"include": ["src/**/*"]
|
| 9 |
+
}
|
| 10 |
+
|
next.config.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
const nextConfig: NextConfig = {
|
| 5 |
+
output: 'standalone',
|
| 6 |
+
images: {
|
| 7 |
+
remotePatterns: [
|
| 8 |
+
{
|
| 9 |
+
protocol: 'https',
|
| 10 |
+
hostname: 'res.cloudinary.com',
|
| 11 |
+
pathname: '/**',
|
| 12 |
+
},
|
| 13 |
+
],
|
| 14 |
+
},
|
| 15 |
+
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export default nextConfig;
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"version": "0.3.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build --webpack",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@radix-ui/react-checkbox": "^1.3.3",
|
| 13 |
+
"@radix-ui/react-dialog": "^1.1.15",
|
| 14 |
+
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
| 15 |
+
"@radix-ui/react-label": "^2.1.8",
|
| 16 |
+
"@radix-ui/react-popover": "^1.1.15",
|
| 17 |
+
"@radix-ui/react-progress": "^1.1.8",
|
| 18 |
+
"@radix-ui/react-scroll-area": "^1.2.10",
|
| 19 |
+
"@radix-ui/react-select": "^2.2.6",
|
| 20 |
+
"@radix-ui/react-slot": "^1.2.4",
|
| 21 |
+
"@radix-ui/react-switch": "^1.2.6",
|
| 22 |
+
"@radix-ui/react-tabs": "^1.1.13",
|
| 23 |
+
"@radix-ui/react-tooltip": "^1.2.8",
|
| 24 |
+
"@tailwindcss/postcss": "^4.1.17",
|
| 25 |
+
"@upstash/redis": "^1.35.7",
|
| 26 |
+
"@vercel/analytics": "^1.6.1",
|
| 27 |
+
"@vercel/speed-insights": "^1.2.0",
|
| 28 |
+
"class-variance-authority": "^0.7.1",
|
| 29 |
+
"clsx": "^2.1.1",
|
| 30 |
+
"framer-motion": "^12.23.26",
|
| 31 |
+
"livekit-client": "^2.16.0",
|
| 32 |
+
"livekit-server-sdk": "^2.14.2",
|
| 33 |
+
"lucide-react": "^0.554.0",
|
| 34 |
+
"next": "16.0.10",
|
| 35 |
+
"next-themes": "^0.4.6",
|
| 36 |
+
"react": "19.2.3",
|
| 37 |
+
"react-diff-viewer-continued": "^3.4.0",
|
| 38 |
+
"react-dom": "19.2.3",
|
| 39 |
+
"react-markdown": "^10.1.0",
|
| 40 |
+
"rehype-highlight": "^7.0.2",
|
| 41 |
+
"rehype-raw": "^7.0.0",
|
| 42 |
+
"remark-gfm": "^4.0.1",
|
| 43 |
+
"sonner": "^2.0.7",
|
| 44 |
+
"tailwind-merge": "^3.4.0",
|
| 45 |
+
"tailwindcss": "^4.1.17"
|
| 46 |
+
},
|
| 47 |
+
"devDependencies": {
|
| 48 |
+
"@types/node": "^22",
|
| 49 |
+
"@types/react": "^19",
|
| 50 |
+
"@types/react-dom": "^19",
|
| 51 |
+
"autoprefixer": "^10.4.20",
|
| 52 |
+
"baseline-browser-mapping": "^2.9.4",
|
| 53 |
+
"eslint": "^9",
|
| 54 |
+
"eslint-config-next": "16.0.10",
|
| 55 |
+
"postcss": "^8.4.49",
|
| 56 |
+
"tailwindcss": "^4.1.17",
|
| 57 |
+
"tw-animate-css": "^1.4.0",
|
| 58 |
+
"typescript": "^5"
|
| 59 |
+
}
|
| 60 |
+
}
|
postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
'@tailwindcss/postcss': {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
public/.nojekyll
ADDED
|
File without changes
|
public/file.svg
ADDED
|
|
public/globe.svg
ADDED
|
|
public/logo.png
ADDED
|
public/next.svg
ADDED
|
|
public/vercel.svg
ADDED
|
|
public/window.svg
ADDED
|
|
src/app/admin/agent-monitoring/page.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { AgentMonitoring } from "@/components/AgentMonitoring"
|
| 4 |
+
import { AdminSidebar } from "@/components/admin-sidebar"
|
| 5 |
+
|
| 6 |
+
export default function AgentMonitoringPage() {
|
| 7 |
+
return (
|
| 8 |
+
<>
|
| 9 |
+
<AdminSidebar />
|
| 10 |
+
<div className="ml-0 md:ml-5 transition-all duration-300">
|
| 11 |
+
<div className="container mx-auto py-6 px-4">
|
| 12 |
+
<AgentMonitoring />
|
| 13 |
+
</div>
|
| 14 |
+
</div>
|
| 15 |
+
</>
|
| 16 |
+
)
|
| 17 |
+
}
|
src/app/admin/analytics/page.tsx
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react"
|
| 4 |
+
import { useRouter } from "next/navigation"
|
| 5 |
+
import { useAuth } from "@/lib/auth-context"
|
| 6 |
+
import { AdminSidebar } from "@/components/admin-sidebar"
|
| 7 |
+
import { ThemeToggle } from "@/components/theme-toggle"
|
| 8 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
| 9 |
+
import { Badge } from "@/components/ui/badge"
|
| 10 |
+
import { toast } from "sonner"
|
| 11 |
+
import {
|
| 12 |
+
Activity,
|
| 13 |
+
Users,
|
| 14 |
+
BarChart3,
|
| 15 |
+
Loader2,
|
| 16 |
+
Globe,
|
| 17 |
+
Key,
|
| 18 |
+
} from "lucide-react"
|
| 19 |
+
|
| 20 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 21 |
+
|
| 22 |
+
interface AnalyticsData {
|
| 23 |
+
total_users: number
|
| 24 |
+
total_integrations: number
|
| 25 |
+
total_api_keys: number
|
| 26 |
+
total_requests_today: number
|
| 27 |
+
total_requests_this_week: number
|
| 28 |
+
total_requests_this_month: number
|
| 29 |
+
total_cost_today: number
|
| 30 |
+
total_cost_this_week: number
|
| 31 |
+
total_cost_this_month: number
|
| 32 |
+
top_users: Array<{ user_id: string; email: string; request_count: number }>
|
| 33 |
+
top_integrations: Array<{ integration_id: string; name: string; request_count: number }>
|
| 34 |
+
top_endpoints: Array<{ endpoint: string; request_count: number }>
|
| 35 |
+
requests_over_time: Array<{ date: string; count: number }>
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function FeedbackTrainingMetrics() {
|
| 39 |
+
const [metrics, setMetrics] = useState<any>(null)
|
| 40 |
+
const [loading, setLoading] = useState(true)
|
| 41 |
+
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
const fetchMetrics = async () => {
|
| 44 |
+
try {
|
| 45 |
+
const [feedbackRes, trainingRes, clusterRes] = await Promise.all([
|
| 46 |
+
fetch("/api/v1/feedback/analytics"),
|
| 47 |
+
fetch("/api/v1/finetuning/stats"),
|
| 48 |
+
fetch("/api/v1/clusters/stats")
|
| 49 |
+
])
|
| 50 |
+
|
| 51 |
+
const data = {
|
| 52 |
+
feedback: feedbackRes.ok ? await feedbackRes.json() : null,
|
| 53 |
+
training: trainingRes.ok ? await trainingRes.json() : null,
|
| 54 |
+
clusters: clusterRes.ok ? await clusterRes.json() : null
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
setMetrics(data)
|
| 58 |
+
} catch (error) {
|
| 59 |
+
console.error("Failed to fetch continuous learning metrics:", error)
|
| 60 |
+
} finally {
|
| 61 |
+
setLoading(false)
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
fetchMetrics()
|
| 66 |
+
}, [])
|
| 67 |
+
|
| 68 |
+
if (loading) {
|
| 69 |
+
return (
|
| 70 |
+
<div className="flex justify-center py-6">
|
| 71 |
+
<Loader2 className="w-6 h-6 animate-spin" />
|
| 72 |
+
</div>
|
| 73 |
+
)
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
if (!metrics) return null
|
| 77 |
+
|
| 78 |
+
return (
|
| 79 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 80 |
+
<Card className="border-l-4 border-l-blue-500">
|
| 81 |
+
<CardHeader className="pb-2">
|
| 82 |
+
<CardTitle className="text-sm font-medium">Total Feedback</CardTitle>
|
| 83 |
+
</CardHeader>
|
| 84 |
+
<CardContent>
|
| 85 |
+
<div className="text-2xl font-bold">
|
| 86 |
+
{metrics.feedback?.feedback_distribution?.total || 0}
|
| 87 |
+
</div>
|
| 88 |
+
<p className="text-xs text-muted-foreground">
|
| 89 |
+
{metrics.feedback?.feedback_distribution?.positive_rate?.toFixed(1)}% positive
|
| 90 |
+
</p>
|
| 91 |
+
</CardContent>
|
| 92 |
+
</Card>
|
| 93 |
+
|
| 94 |
+
<Card className="border-l-4 border-l-green-500">
|
| 95 |
+
<CardHeader className="pb-2">
|
| 96 |
+
<CardTitle className="text-sm font-medium">Training Ready</CardTitle>
|
| 97 |
+
</CardHeader>
|
| 98 |
+
<CardContent>
|
| 99 |
+
<div className="text-2xl font-bold">
|
| 100 |
+
{metrics.training?.kept_for_training || 0}
|
| 101 |
+
</div>
|
| 102 |
+
<p className="text-xs text-muted-foreground">
|
| 103 |
+
Avg: {metrics.training?.average_score?.toFixed(2) || "N/A"}
|
| 104 |
+
</p>
|
| 105 |
+
</CardContent>
|
| 106 |
+
</Card>
|
| 107 |
+
|
| 108 |
+
<Card className="border-l-4 border-l-orange-500">
|
| 109 |
+
<CardHeader className="pb-2">
|
| 110 |
+
<CardTitle className="text-sm font-medium">Pending Export</CardTitle>
|
| 111 |
+
</CardHeader>
|
| 112 |
+
<CardContent>
|
| 113 |
+
<div className="text-2xl font-bold">
|
| 114 |
+
{metrics.training?.awaiting_export || 0}
|
| 115 |
+
</div>
|
| 116 |
+
<p className="text-xs text-muted-foreground">Ready for fine-tuning</p>
|
| 117 |
+
</CardContent>
|
| 118 |
+
</Card>
|
| 119 |
+
|
| 120 |
+
<Card className="border-l-4 border-l-purple-500">
|
| 121 |
+
<CardHeader className="pb-2">
|
| 122 |
+
<CardTitle className="text-sm font-medium">Task Clusters</CardTitle>
|
| 123 |
+
</CardHeader>
|
| 124 |
+
<CardContent>
|
| 125 |
+
<div className="text-2xl font-bold">
|
| 126 |
+
{metrics.clusters?.active_clusters || 0}
|
| 127 |
+
</div>
|
| 128 |
+
<p className="text-xs text-muted-foreground">
|
| 129 |
+
{metrics.clusters?.total_queries_classified || 0} queries classified
|
| 130 |
+
</p>
|
| 131 |
+
</CardContent>
|
| 132 |
+
</Card>
|
| 133 |
+
</div>
|
| 134 |
+
)
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
export default function AdminAnalyticsPage() {
|
| 139 |
+
const { isAuthenticated, isAdmin, loading } = useAuth()
|
| 140 |
+
const router = useRouter()
|
| 141 |
+
const [analytics, setAnalytics] = useState<AnalyticsData | null>(null)
|
| 142 |
+
const [loadingAnalytics, setLoadingAnalytics] = useState(true)
|
| 143 |
+
|
| 144 |
+
useEffect(() => {
|
| 145 |
+
if (!loading && !isAuthenticated) {
|
| 146 |
+
router.push("/auth/signin?redirect=/admin/analytics")
|
| 147 |
+
} else if (!loading && isAuthenticated && !isAdmin) {
|
| 148 |
+
router.push("/chat")
|
| 149 |
+
}
|
| 150 |
+
}, [isAuthenticated, isAdmin, loading, router])
|
| 151 |
+
|
| 152 |
+
useEffect(() => {
|
| 153 |
+
if (isAuthenticated && isAdmin) {
|
| 154 |
+
fetchAnalytics()
|
| 155 |
+
}
|
| 156 |
+
}, [isAuthenticated, isAdmin])
|
| 157 |
+
|
| 158 |
+
const fetchAnalytics = async () => {
|
| 159 |
+
setLoadingAnalytics(true)
|
| 160 |
+
try {
|
| 161 |
+
const sessionToken = localStorage.getItem("session_token")
|
| 162 |
+
const response = await fetch(`/api/cache/admin/analytics`, {
|
| 163 |
+
headers: {
|
| 164 |
+
"X-Session-Token": sessionToken || "",
|
| 165 |
+
},
|
| 166 |
+
})
|
| 167 |
+
|
| 168 |
+
if (response.ok) {
|
| 169 |
+
const data = await response.json()
|
| 170 |
+
setAnalytics(data)
|
| 171 |
+
const cacheStatus = response.headers.get("X-Cache")
|
| 172 |
+
if (cacheStatus) console.log(`Analytics cache: ${cacheStatus}`)
|
| 173 |
+
} else {
|
| 174 |
+
toast.error("Failed to fetch analytics")
|
| 175 |
+
}
|
| 176 |
+
} catch {
|
| 177 |
+
toast.error("Failed to fetch analytics")
|
| 178 |
+
} finally {
|
| 179 |
+
setLoadingAnalytics(false)
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
if (loading || !isAuthenticated || !isAdmin) {
|
| 184 |
+
return (
|
| 185 |
+
<div className="min-h-screen flex items-center justify-center">
|
| 186 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
| 187 |
+
</div>
|
| 188 |
+
)
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
return (
|
| 192 |
+
<div className="min-h-screen bg-background flex">
|
| 193 |
+
<AdminSidebar />
|
| 194 |
+
<div className="flex-1 ml-0 md:ml-[20px] p-4 md:p-6">
|
| 195 |
+
<div className="absolute top-4 right-4 z-10">
|
| 196 |
+
<ThemeToggle />
|
| 197 |
+
</div>
|
| 198 |
+
<div className="max-w-7xl mx-auto space-y-6">
|
| 199 |
+
<div>
|
| 200 |
+
<h1 className="text-3xl font-bold flex items-center gap-2">
|
| 201 |
+
<BarChart3 className="w-8 h-8" />
|
| 202 |
+
Analytics Dashboard
|
| 203 |
+
</h1>
|
| 204 |
+
<p className="text-muted-foreground">System usage and performance metrics</p>
|
| 205 |
+
</div>
|
| 206 |
+
|
| 207 |
+
{loadingAnalytics ? (
|
| 208 |
+
<div className="flex items-center justify-center py-12">
|
| 209 |
+
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
| 210 |
+
</div>
|
| 211 |
+
) : analytics ? (
|
| 212 |
+
<>
|
| 213 |
+
{/* AmaniQ Continuous Learning Metrics */}
|
| 214 |
+
<div className="mb-6">
|
| 215 |
+
<h2 className="text-xl font-semibold mb-4">AmaniQ Continuous Learning</h2>
|
| 216 |
+
<FeedbackTrainingMetrics />
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
{/* Original System Analytics */}
|
| 220 |
+
{/* Overview Stats */}
|
| 221 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 222 |
+
<Card>
|
| 223 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
| 224 |
+
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
| 225 |
+
<Users className="h-4 w-4 text-muted-foreground" />
|
| 226 |
+
</CardHeader>
|
| 227 |
+
<CardContent>
|
| 228 |
+
<div className="text-2xl font-bold">{analytics.total_users}</div>
|
| 229 |
+
</CardContent>
|
| 230 |
+
</Card>
|
| 231 |
+
|
| 232 |
+
<Card>
|
| 233 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
| 234 |
+
<CardTitle className="text-sm font-medium">Integrations</CardTitle>
|
| 235 |
+
<Globe className="h-4 w-4 text-muted-foreground" />
|
| 236 |
+
</CardHeader>
|
| 237 |
+
<CardContent>
|
| 238 |
+
<div className="text-2xl font-bold">{analytics.total_integrations}</div>
|
| 239 |
+
</CardContent>
|
| 240 |
+
</Card>
|
| 241 |
+
|
| 242 |
+
<Card>
|
| 243 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
| 244 |
+
<CardTitle className="text-sm font-medium">API Keys</CardTitle>
|
| 245 |
+
<Key className="h-4 w-4 text-muted-foreground" />
|
| 246 |
+
</CardHeader>
|
| 247 |
+
<CardContent>
|
| 248 |
+
<div className="text-2xl font-bold">{analytics.total_api_keys}</div>
|
| 249 |
+
</CardContent>
|
| 250 |
+
</Card>
|
| 251 |
+
|
| 252 |
+
<Card>
|
| 253 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
| 254 |
+
<CardTitle className="text-sm font-medium">Requests Today</CardTitle>
|
| 255 |
+
<Activity className="h-4 w-4 text-muted-foreground" />
|
| 256 |
+
</CardHeader>
|
| 257 |
+
<CardContent>
|
| 258 |
+
<div className="text-2xl font-bold">{analytics.total_requests_today}</div>
|
| 259 |
+
</CardContent>
|
| 260 |
+
</Card>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
{/* Request Stats */}
|
| 264 |
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
| 265 |
+
<Card>
|
| 266 |
+
<CardHeader>
|
| 267 |
+
<CardTitle className="text-sm font-medium">This Week</CardTitle>
|
| 268 |
+
</CardHeader>
|
| 269 |
+
<CardContent>
|
| 270 |
+
<div className="text-2xl font-bold">{analytics.total_requests_this_week}</div>
|
| 271 |
+
<p className="text-xs text-muted-foreground">Requests</p>
|
| 272 |
+
</CardContent>
|
| 273 |
+
</Card>
|
| 274 |
+
|
| 275 |
+
<Card>
|
| 276 |
+
<CardHeader>
|
| 277 |
+
<CardTitle className="text-sm font-medium">This Month</CardTitle>
|
| 278 |
+
</CardHeader>
|
| 279 |
+
<CardContent>
|
| 280 |
+
<div className="text-2xl font-bold">{analytics.total_requests_this_month}</div>
|
| 281 |
+
<p className="text-xs text-muted-foreground">Requests</p>
|
| 282 |
+
</CardContent>
|
| 283 |
+
</Card>
|
| 284 |
+
|
| 285 |
+
<Card>
|
| 286 |
+
<CardHeader>
|
| 287 |
+
<CardTitle className="text-sm font-medium">Cost This Month</CardTitle>
|
| 288 |
+
</CardHeader>
|
| 289 |
+
<CardContent>
|
| 290 |
+
<div className="text-2xl font-bold">
|
| 291 |
+
${analytics.total_cost_this_month.toFixed(2)}
|
| 292 |
+
</div>
|
| 293 |
+
<p className="text-xs text-muted-foreground">Total cost</p>
|
| 294 |
+
</CardContent>
|
| 295 |
+
</Card>
|
| 296 |
+
</div>
|
| 297 |
+
|
| 298 |
+
{/* Top Users */}
|
| 299 |
+
{analytics.top_users && analytics.top_users.length > 0 && (
|
| 300 |
+
<Card>
|
| 301 |
+
<CardHeader>
|
| 302 |
+
<CardTitle>Top Users</CardTitle>
|
| 303 |
+
</CardHeader>
|
| 304 |
+
<CardContent>
|
| 305 |
+
<div className="space-y-2">
|
| 306 |
+
{analytics.top_users.slice(0, 10).map((user, index) => (
|
| 307 |
+
<div
|
| 308 |
+
key={user.user_id}
|
| 309 |
+
className="flex items-center justify-between p-2 rounded-lg hover:bg-accent"
|
| 310 |
+
>
|
| 311 |
+
<div className="flex items-center gap-3">
|
| 312 |
+
<Badge variant="outline">{index + 1}</Badge>
|
| 313 |
+
<span className="font-medium">{user.email}</span>
|
| 314 |
+
</div>
|
| 315 |
+
<span className="text-sm text-muted-foreground">
|
| 316 |
+
{user.request_count} requests
|
| 317 |
+
</span>
|
| 318 |
+
</div>
|
| 319 |
+
))}
|
| 320 |
+
</div>
|
| 321 |
+
</CardContent>
|
| 322 |
+
</Card>
|
| 323 |
+
)}
|
| 324 |
+
|
| 325 |
+
{/* Top Endpoints */}
|
| 326 |
+
{analytics.top_endpoints && analytics.top_endpoints.length > 0 && (
|
| 327 |
+
<Card>
|
| 328 |
+
<CardHeader>
|
| 329 |
+
<CardTitle>Top Endpoints</CardTitle>
|
| 330 |
+
</CardHeader>
|
| 331 |
+
<CardContent>
|
| 332 |
+
<div className="space-y-2">
|
| 333 |
+
{analytics.top_endpoints.slice(0, 10).map((endpoint, index) => (
|
| 334 |
+
<div
|
| 335 |
+
key={endpoint.endpoint}
|
| 336 |
+
className="flex items-center justify-between p-2 rounded-lg hover:bg-accent"
|
| 337 |
+
>
|
| 338 |
+
<div className="flex items-center gap-3">
|
| 339 |
+
<Badge variant="outline">{index + 1}</Badge>
|
| 340 |
+
<code className="text-sm font-mono">{endpoint.endpoint}</code>
|
| 341 |
+
</div>
|
| 342 |
+
<span className="text-sm text-muted-foreground">
|
| 343 |
+
{endpoint.request_count} requests
|
| 344 |
+
</span>
|
| 345 |
+
</div>
|
| 346 |
+
))}
|
| 347 |
+
</div>
|
| 348 |
+
</CardContent>
|
| 349 |
+
</Card>
|
| 350 |
+
)}
|
| 351 |
+
</>
|
| 352 |
+
) : (
|
| 353 |
+
<Card>
|
| 354 |
+
<CardContent className="py-12 text-center text-muted-foreground">
|
| 355 |
+
No analytics data available
|
| 356 |
+
</CardContent>
|
| 357 |
+
</Card>
|
| 358 |
+
)}
|
| 359 |
+
</div>
|
| 360 |
+
</div>
|
| 361 |
+
</div>
|
| 362 |
+
)
|
| 363 |
+
}
|
| 364 |
+
|
src/app/admin/blog/page.tsx
ADDED
|
@@ -0,0 +1,1007 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react"
|
| 4 |
+
import { useRouter } from "next/navigation"
|
| 5 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
| 6 |
+
import { Button } from "@/components/ui/button"
|
| 7 |
+
import { Badge } from "@/components/ui/badge"
|
| 8 |
+
import { Input } from "@/components/ui/input"
|
| 9 |
+
import { Textarea } from "@/components/ui/textarea"
|
| 10 |
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
| 11 |
+
import { AdminSidebar } from "@/components/admin-sidebar"
|
| 12 |
+
import { ThemeToggle } from "@/components/theme-toggle"
|
| 13 |
+
import { BlogEditor } from "@/components/blog-editor"
|
| 14 |
+
import { useAuth } from "@/lib/auth-context"
|
| 15 |
+
import {
|
| 16 |
+
Plus,
|
| 17 |
+
Edit,
|
| 18 |
+
Trash2,
|
| 19 |
+
Eye,
|
| 20 |
+
Save,
|
| 21 |
+
X,
|
| 22 |
+
Upload,
|
| 23 |
+
FileText,
|
| 24 |
+
Tag,
|
| 25 |
+
Folder,
|
| 26 |
+
} from "lucide-react"
|
| 27 |
+
import Link from "next/link"
|
| 28 |
+
|
| 29 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 30 |
+
|
| 31 |
+
const getAuthHeaders = (): Record<string, string> => {
|
| 32 |
+
const token = localStorage.getItem("session_token")
|
| 33 |
+
const headers: Record<string, string> = {}
|
| 34 |
+
if (token) {
|
| 35 |
+
headers["X-Session-Token"] = token
|
| 36 |
+
}
|
| 37 |
+
return headers
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* Removes keys with empty string values from the payload.
|
| 42 |
+
* This ensures optional fields like slug and featured_image_url
|
| 43 |
+
* are not sent as empty strings, which would fail backend validation.
|
| 44 |
+
*/
|
| 45 |
+
const cleanPostPayload = (payload: Record<string, unknown>): Record<string, unknown> => {
|
| 46 |
+
const cleaned: Record<string, unknown> = {}
|
| 47 |
+
for (const [key, value] of Object.entries(payload)) {
|
| 48 |
+
// Keep the key if it's not an empty string
|
| 49 |
+
// Arrays are always included (even if empty)
|
| 50 |
+
if (value !== "" || Array.isArray(value)) {
|
| 51 |
+
cleaned[key] = value
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
return cleaned
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
interface BlogPost {
|
| 58 |
+
id: string
|
| 59 |
+
title: string
|
| 60 |
+
slug: string
|
| 61 |
+
markdown_content?: string
|
| 62 |
+
html_content?: string
|
| 63 |
+
excerpt?: string
|
| 64 |
+
post_type: string
|
| 65 |
+
status: string
|
| 66 |
+
featured_image_url?: string
|
| 67 |
+
author: {
|
| 68 |
+
id: string
|
| 69 |
+
name?: string
|
| 70 |
+
email: string
|
| 71 |
+
}
|
| 72 |
+
categories: Array<{ id: string; name: string; slug: string }>
|
| 73 |
+
tags: Array<{ id: string; name: string; slug: string }>
|
| 74 |
+
published_at?: string
|
| 75 |
+
created_at: string
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
interface Category {
|
| 79 |
+
id: string
|
| 80 |
+
name: string
|
| 81 |
+
slug: string
|
| 82 |
+
description?: string
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
interface Tag {
|
| 86 |
+
id: string
|
| 87 |
+
name: string
|
| 88 |
+
slug: string
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
export default function AdminBlogPage() {
|
| 92 |
+
const { isAuthenticated, isAdmin, loading } = useAuth()
|
| 93 |
+
const router = useRouter()
|
| 94 |
+
const [posts, setPosts] = useState<BlogPost[]>([])
|
| 95 |
+
const [categories, setCategories] = useState<Category[]>([])
|
| 96 |
+
const [tags, setTags] = useState<Tag[]>([])
|
| 97 |
+
const [loadingData, setLoadingData] = useState(true)
|
| 98 |
+
const [activeTab, setActiveTab] = useState<"posts" | "categories" | "tags">("posts")
|
| 99 |
+
const [editingPost, setEditingPost] = useState<BlogPost | null>(null)
|
| 100 |
+
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
| 101 |
+
const [editingTag, setEditingTag] = useState<Tag | null>(null)
|
| 102 |
+
const [showPostForm, setShowPostForm] = useState(false)
|
| 103 |
+
const [postSubmitting, setPostSubmitting] = useState(false) // loading state for create/update
|
| 104 |
+
|
| 105 |
+
// Post form state
|
| 106 |
+
const [postForm, setPostForm] = useState({
|
| 107 |
+
title: "",
|
| 108 |
+
slug: "",
|
| 109 |
+
markdown_content: "",
|
| 110 |
+
html_content: "",
|
| 111 |
+
excerpt: "",
|
| 112 |
+
post_type: "news",
|
| 113 |
+
status: "draft",
|
| 114 |
+
featured_image_url: "",
|
| 115 |
+
category_ids: [] as string[],
|
| 116 |
+
tag_ids: [] as string[],
|
| 117 |
+
})
|
| 118 |
+
|
| 119 |
+
// Category form state
|
| 120 |
+
const [categoryForm, setCategoryForm] = useState({
|
| 121 |
+
name: "",
|
| 122 |
+
slug: "",
|
| 123 |
+
description: "",
|
| 124 |
+
})
|
| 125 |
+
|
| 126 |
+
// Tag form state
|
| 127 |
+
const [tagForm, setTagForm] = useState({
|
| 128 |
+
name: "",
|
| 129 |
+
slug: "",
|
| 130 |
+
})
|
| 131 |
+
|
| 132 |
+
useEffect(() => {
|
| 133 |
+
if (!loading && !isAuthenticated) {
|
| 134 |
+
router.push("/auth/signin?redirect=/admin/blog")
|
| 135 |
+
} else if (!loading && isAuthenticated && !isAdmin) {
|
| 136 |
+
router.push("/chat")
|
| 137 |
+
}
|
| 138 |
+
}, [isAuthenticated, isAdmin, loading, router])
|
| 139 |
+
|
| 140 |
+
useEffect(() => {
|
| 141 |
+
if (isAuthenticated && isAdmin) {
|
| 142 |
+
fetchPosts()
|
| 143 |
+
fetchCategories()
|
| 144 |
+
fetchTags()
|
| 145 |
+
}
|
| 146 |
+
}, [isAuthenticated, isAdmin])
|
| 147 |
+
|
| 148 |
+
const fetchPosts = async () => {
|
| 149 |
+
try {
|
| 150 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts?page=1&page_size=100`, {
|
| 151 |
+
headers: getAuthHeaders(),
|
| 152 |
+
})
|
| 153 |
+
if (response.ok) {
|
| 154 |
+
const data = await response.json()
|
| 155 |
+
setPosts(data.posts || [])
|
| 156 |
+
}
|
| 157 |
+
} catch (error) {
|
| 158 |
+
console.error("Failed to fetch posts:", error)
|
| 159 |
+
} finally {
|
| 160 |
+
setLoadingData(false)
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
const fetchCategories = async () => {
|
| 165 |
+
try {
|
| 166 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/categories`)
|
| 167 |
+
if (response.ok) {
|
| 168 |
+
const data = await response.json()
|
| 169 |
+
setCategories(data)
|
| 170 |
+
}
|
| 171 |
+
} catch (error) {
|
| 172 |
+
console.error("Failed to fetch categories:", error)
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
const fetchTags = async () => {
|
| 177 |
+
try {
|
| 178 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/tags`)
|
| 179 |
+
if (response.ok) {
|
| 180 |
+
const data = await response.json()
|
| 181 |
+
setTags(data)
|
| 182 |
+
}
|
| 183 |
+
} catch (error) {
|
| 184 |
+
console.error("Failed to fetch tags:", error)
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
const handleCreatePost = async () => {
|
| 189 |
+
setPostSubmitting(true)
|
| 190 |
+
try {
|
| 191 |
+
const cleanedPayload = cleanPostPayload(postForm)
|
| 192 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts`, {
|
| 193 |
+
method: "POST",
|
| 194 |
+
headers: {
|
| 195 |
+
"Content-Type": "application/json",
|
| 196 |
+
...getAuthHeaders(),
|
| 197 |
+
},
|
| 198 |
+
body: JSON.stringify(cleanedPayload),
|
| 199 |
+
})
|
| 200 |
+
if (response.ok) {
|
| 201 |
+
await fetchPosts()
|
| 202 |
+
resetPostForm()
|
| 203 |
+
setShowPostForm(false)
|
| 204 |
+
} else {
|
| 205 |
+
const error = await response.json()
|
| 206 |
+
console.error('Create post error response:', error)
|
| 207 |
+
alert(`Failed to create post: ${error.detail || "Unknown error"}`)
|
| 208 |
+
}
|
| 209 |
+
} catch (error) {
|
| 210 |
+
console.error("Failed to create post:", error)
|
| 211 |
+
alert("Failed to create post")
|
| 212 |
+
} finally {
|
| 213 |
+
setPostSubmitting(false)
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
const handleUpdatePost = async () => {
|
| 218 |
+
if (!editingPost) return
|
| 219 |
+
try {
|
| 220 |
+
const cleanedPayload = cleanPostPayload(postForm)
|
| 221 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts/${editingPost.id}`, {
|
| 222 |
+
method: "PUT",
|
| 223 |
+
headers: {
|
| 224 |
+
"Content-Type": "application/json",
|
| 225 |
+
...getAuthHeaders(),
|
| 226 |
+
},
|
| 227 |
+
body: JSON.stringify(cleanedPayload),
|
| 228 |
+
})
|
| 229 |
+
if (response.ok) {
|
| 230 |
+
await fetchPosts()
|
| 231 |
+
setEditingPost(null)
|
| 232 |
+
resetPostForm()
|
| 233 |
+
} else {
|
| 234 |
+
const error = await response.json()
|
| 235 |
+
alert(`Failed to update post: ${error.detail || "Unknown error"}`)
|
| 236 |
+
}
|
| 237 |
+
} catch (error) {
|
| 238 |
+
console.error("Failed to update post:", error)
|
| 239 |
+
alert("Failed to update post")
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
const handleDeletePost = async (postId: string) => {
|
| 244 |
+
if (!confirm("Are you sure you want to delete this post?")) return
|
| 245 |
+
try {
|
| 246 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts/${postId}`, {
|
| 247 |
+
method: "DELETE",
|
| 248 |
+
headers: getAuthHeaders(),
|
| 249 |
+
})
|
| 250 |
+
if (response.ok) {
|
| 251 |
+
await fetchPosts()
|
| 252 |
+
} else {
|
| 253 |
+
alert("Failed to delete post")
|
| 254 |
+
}
|
| 255 |
+
} catch (error) {
|
| 256 |
+
console.error("Failed to delete post:", error)
|
| 257 |
+
alert("Failed to delete post")
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
const handleUploadFeaturedImage = async (postId: string, file: File) => {
|
| 262 |
+
const formData = new FormData()
|
| 263 |
+
formData.append("file", file)
|
| 264 |
+
try {
|
| 265 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts/${postId}/featured-image`, {
|
| 266 |
+
method: "POST",
|
| 267 |
+
headers: getAuthHeaders(),
|
| 268 |
+
body: formData,
|
| 269 |
+
})
|
| 270 |
+
if (response.ok) {
|
| 271 |
+
const data = await response.json()
|
| 272 |
+
setPostForm({ ...postForm, featured_image_url: data.featured_image_url })
|
| 273 |
+
await fetchPosts()
|
| 274 |
+
} else {
|
| 275 |
+
alert("Failed to upload image")
|
| 276 |
+
}
|
| 277 |
+
} catch (error) {
|
| 278 |
+
console.error("Failed to upload image:", error)
|
| 279 |
+
alert("Failed to upload image")
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
const resetPostForm = () => {
|
| 284 |
+
setPostForm({
|
| 285 |
+
title: "",
|
| 286 |
+
slug: "",
|
| 287 |
+
markdown_content: "",
|
| 288 |
+
html_content: "",
|
| 289 |
+
excerpt: "",
|
| 290 |
+
post_type: "news",
|
| 291 |
+
status: "draft",
|
| 292 |
+
featured_image_url: "",
|
| 293 |
+
category_ids: [],
|
| 294 |
+
tag_ids: [],
|
| 295 |
+
})
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
const startEditPost = (post: BlogPost) => {
|
| 299 |
+
setEditingPost(post)
|
| 300 |
+
setPostForm({
|
| 301 |
+
title: post.title,
|
| 302 |
+
slug: post.slug,
|
| 303 |
+
markdown_content: post.markdown_content || "",
|
| 304 |
+
html_content: post.html_content || "",
|
| 305 |
+
excerpt: post.excerpt || "",
|
| 306 |
+
post_type: post.post_type,
|
| 307 |
+
status: post.status,
|
| 308 |
+
featured_image_url: post.featured_image_url || "",
|
| 309 |
+
category_ids: post.categories.map((c) => c.id),
|
| 310 |
+
tag_ids: post.tags.map((t) => t.id),
|
| 311 |
+
})
|
| 312 |
+
setShowPostForm(true)
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
const handleCreateCategory = async () => {
|
| 316 |
+
try {
|
| 317 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/categories`, {
|
| 318 |
+
method: "POST",
|
| 319 |
+
headers: {
|
| 320 |
+
"Content-Type": "application/json",
|
| 321 |
+
...getAuthHeaders(),
|
| 322 |
+
},
|
| 323 |
+
body: JSON.stringify(categoryForm),
|
| 324 |
+
})
|
| 325 |
+
if (response.ok) {
|
| 326 |
+
await fetchCategories()
|
| 327 |
+
setCategoryForm({ name: "", slug: "", description: "" })
|
| 328 |
+
setEditingCategory(null)
|
| 329 |
+
} else {
|
| 330 |
+
const error = await response.json()
|
| 331 |
+
alert(`Failed to create category: ${error.detail || "Unknown error"}`)
|
| 332 |
+
}
|
| 333 |
+
} catch (error) {
|
| 334 |
+
console.error("Failed to create category:", error)
|
| 335 |
+
alert("Failed to create category")
|
| 336 |
+
}
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
const handleUpdateCategory = async () => {
|
| 340 |
+
if (!editingCategory) return
|
| 341 |
+
try {
|
| 342 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/categories/${editingCategory.id}`, {
|
| 343 |
+
method: "PUT",
|
| 344 |
+
headers: {
|
| 345 |
+
"Content-Type": "application/json",
|
| 346 |
+
...getAuthHeaders(),
|
| 347 |
+
},
|
| 348 |
+
body: JSON.stringify(categoryForm),
|
| 349 |
+
})
|
| 350 |
+
if (response.ok) {
|
| 351 |
+
await fetchCategories()
|
| 352 |
+
setEditingCategory(null)
|
| 353 |
+
setCategoryForm({ name: "", slug: "", description: "" })
|
| 354 |
+
} else {
|
| 355 |
+
const error = await response.json()
|
| 356 |
+
alert(`Failed to update category: ${error.detail || "Unknown error"}`)
|
| 357 |
+
}
|
| 358 |
+
} catch (error) {
|
| 359 |
+
console.error("Failed to update category:", error)
|
| 360 |
+
alert("Failed to update category")
|
| 361 |
+
}
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
const handleDeleteCategory = async (categoryId: string) => {
|
| 365 |
+
if (!confirm("Are you sure you want to delete this category?")) return
|
| 366 |
+
try {
|
| 367 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/categories/${categoryId}`, {
|
| 368 |
+
method: "DELETE",
|
| 369 |
+
headers: getAuthHeaders(),
|
| 370 |
+
})
|
| 371 |
+
if (response.ok) {
|
| 372 |
+
await fetchCategories()
|
| 373 |
+
} else {
|
| 374 |
+
alert("Failed to delete category")
|
| 375 |
+
}
|
| 376 |
+
} catch (error) {
|
| 377 |
+
console.error("Failed to delete category:", error)
|
| 378 |
+
alert("Failed to delete category")
|
| 379 |
+
}
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
const handleCreateTag = async () => {
|
| 383 |
+
try {
|
| 384 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/tags`, {
|
| 385 |
+
method: "POST",
|
| 386 |
+
headers: {
|
| 387 |
+
"Content-Type": "application/json",
|
| 388 |
+
...getAuthHeaders(),
|
| 389 |
+
},
|
| 390 |
+
body: JSON.stringify(tagForm),
|
| 391 |
+
})
|
| 392 |
+
if (response.ok) {
|
| 393 |
+
await fetchTags()
|
| 394 |
+
setTagForm({ name: "", slug: "" })
|
| 395 |
+
setEditingTag(null)
|
| 396 |
+
} else {
|
| 397 |
+
const error = await response.json()
|
| 398 |
+
alert(`Failed to create tag: ${error.detail || "Unknown error"}`)
|
| 399 |
+
}
|
| 400 |
+
} catch (error) {
|
| 401 |
+
console.error("Failed to create tag:", error)
|
| 402 |
+
alert("Failed to create tag")
|
| 403 |
+
}
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
const handleUpdateTag = async () => {
|
| 407 |
+
if (!editingTag) return
|
| 408 |
+
try {
|
| 409 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/tags/${editingTag.id}`, {
|
| 410 |
+
method: "PUT",
|
| 411 |
+
headers: {
|
| 412 |
+
"Content-Type": "application/json",
|
| 413 |
+
...getAuthHeaders(),
|
| 414 |
+
},
|
| 415 |
+
body: JSON.stringify(tagForm),
|
| 416 |
+
})
|
| 417 |
+
if (response.ok) {
|
| 418 |
+
await fetchTags()
|
| 419 |
+
setEditingTag(null)
|
| 420 |
+
setTagForm({ name: "", slug: "" })
|
| 421 |
+
} else {
|
| 422 |
+
const error = await response.json()
|
| 423 |
+
alert(`Failed to update tag: ${error.detail || "Unknown error"}`)
|
| 424 |
+
}
|
| 425 |
+
} catch (error) {
|
| 426 |
+
console.error("Failed to update tag:", error)
|
| 427 |
+
alert("Failed to update tag")
|
| 428 |
+
}
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
const handleDeleteTag = async (tagId: string) => {
|
| 432 |
+
if (!confirm("Are you sure you want to delete this tag?")) return
|
| 433 |
+
try {
|
| 434 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/tags/${tagId}`, {
|
| 435 |
+
method: "DELETE",
|
| 436 |
+
headers: getAuthHeaders(),
|
| 437 |
+
})
|
| 438 |
+
if (response.ok) {
|
| 439 |
+
await fetchTags()
|
| 440 |
+
} else {
|
| 441 |
+
alert("Failed to delete tag")
|
| 442 |
+
}
|
| 443 |
+
} catch (error) {
|
| 444 |
+
console.error("Failed to delete tag:", error)
|
| 445 |
+
alert("Failed to delete tag")
|
| 446 |
+
}
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
if (loading || !isAuthenticated || !isAdmin) {
|
| 450 |
+
return (
|
| 451 |
+
<div className="min-h-screen flex items-center justify-center">
|
| 452 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
| 453 |
+
</div>
|
| 454 |
+
)
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
return (
|
| 458 |
+
<div className="min-h-screen bg-background flex">
|
| 459 |
+
<AdminSidebar />
|
| 460 |
+
<div className="flex-1 ml-0 md:ml-[20px] p-4 md:p-6">
|
| 461 |
+
<div className="absolute top-4 right-4 z-10">
|
| 462 |
+
<ThemeToggle />
|
| 463 |
+
</div>
|
| 464 |
+
<div className="max-w-7xl mx-auto space-y-6">
|
| 465 |
+
<div className="flex items-center justify-between">
|
| 466 |
+
<div>
|
| 467 |
+
<h1 className="text-3xl font-bold">Blog Management</h1>
|
| 468 |
+
<p className="text-muted-foreground">Manage blog posts, categories, and tags</p>
|
| 469 |
+
</div>
|
| 470 |
+
<Link href="/blog">
|
| 471 |
+
<Button variant="outline">
|
| 472 |
+
<Eye className="w-4 h-4 mr-2" />
|
| 473 |
+
View Public Blog
|
| 474 |
+
</Button>
|
| 475 |
+
</Link>
|
| 476 |
+
</div>
|
| 477 |
+
|
| 478 |
+
{/* Tabs */}
|
| 479 |
+
<div className="flex gap-2 border-b">
|
| 480 |
+
<button
|
| 481 |
+
onClick={() => setActiveTab("posts")}
|
| 482 |
+
className={`px-4 py-2 font-medium ${
|
| 483 |
+
activeTab === "posts"
|
| 484 |
+
? "border-b-2 border-primary text-primary"
|
| 485 |
+
: "text-muted-foreground"
|
| 486 |
+
}`}
|
| 487 |
+
>
|
| 488 |
+
<FileText className="w-4 h-4 inline mr-2" />
|
| 489 |
+
Posts
|
| 490 |
+
</button>
|
| 491 |
+
<button
|
| 492 |
+
onClick={() => setActiveTab("categories")}
|
| 493 |
+
className={`px-4 py-2 font-medium ${
|
| 494 |
+
activeTab === "categories"
|
| 495 |
+
? "border-b-2 border-primary text-primary"
|
| 496 |
+
: "text-muted-foreground"
|
| 497 |
+
}`}
|
| 498 |
+
>
|
| 499 |
+
<Folder className="w-4 h-4 inline mr-2" />
|
| 500 |
+
Categories
|
| 501 |
+
</button>
|
| 502 |
+
<button
|
| 503 |
+
onClick={() => setActiveTab("tags")}
|
| 504 |
+
className={`px-4 py-2 font-medium ${
|
| 505 |
+
activeTab === "tags"
|
| 506 |
+
? "border-b-2 border-primary text-primary"
|
| 507 |
+
: "text-muted-foreground"
|
| 508 |
+
}`}
|
| 509 |
+
>
|
| 510 |
+
<Tag className="w-4 h-4 inline mr-2" />
|
| 511 |
+
Tags
|
| 512 |
+
</button>
|
| 513 |
+
</div>
|
| 514 |
+
|
| 515 |
+
{/* Posts Tab */}
|
| 516 |
+
{activeTab === "posts" && (
|
| 517 |
+
<div className="space-y-4">
|
| 518 |
+
{!showPostForm ? (
|
| 519 |
+
<>
|
| 520 |
+
<div className="flex justify-end">
|
| 521 |
+
<Button onClick={() => setShowPostForm(true)}>
|
| 522 |
+
<Plus className="w-4 h-4 mr-2" />
|
| 523 |
+
New Post
|
| 524 |
+
</Button>
|
| 525 |
+
</div>
|
| 526 |
+
<Card>
|
| 527 |
+
<CardContent className="p-0">
|
| 528 |
+
<Table>
|
| 529 |
+
<TableHeader>
|
| 530 |
+
<TableRow>
|
| 531 |
+
<TableHead>Title</TableHead>
|
| 532 |
+
<TableHead>Type</TableHead>
|
| 533 |
+
<TableHead>Status</TableHead>
|
| 534 |
+
<TableHead>Author</TableHead>
|
| 535 |
+
<TableHead>Published</TableHead>
|
| 536 |
+
<TableHead>Actions</TableHead>
|
| 537 |
+
</TableRow>
|
| 538 |
+
</TableHeader>
|
| 539 |
+
<TableBody>
|
| 540 |
+
{posts.map((post) => (
|
| 541 |
+
<TableRow key={post.id}>
|
| 542 |
+
<TableCell className="font-medium">{post.title}</TableCell>
|
| 543 |
+
<TableCell>
|
| 544 |
+
<Badge variant="outline">{post.post_type}</Badge>
|
| 545 |
+
</TableCell>
|
| 546 |
+
<TableCell>
|
| 547 |
+
<Badge
|
| 548 |
+
variant={post.status === "published" ? "default" : "secondary"}
|
| 549 |
+
>
|
| 550 |
+
{post.status}
|
| 551 |
+
</Badge>
|
| 552 |
+
</TableCell>
|
| 553 |
+
<TableCell>{post.author.name || post.author.email}</TableCell>
|
| 554 |
+
<TableCell>
|
| 555 |
+
{post.published_at
|
| 556 |
+
? new Date(post.published_at).toLocaleDateString()
|
| 557 |
+
: "-"}
|
| 558 |
+
</TableCell>
|
| 559 |
+
<TableCell>
|
| 560 |
+
<div className="flex gap-2">
|
| 561 |
+
<Button
|
| 562 |
+
variant="ghost"
|
| 563 |
+
size="sm"
|
| 564 |
+
onClick={() => startEditPost(post)}
|
| 565 |
+
>
|
| 566 |
+
<Edit className="w-4 h-4" />
|
| 567 |
+
</Button>
|
| 568 |
+
<Link href={`/blog/${post.slug}`} target="_blank">
|
| 569 |
+
<Button variant="ghost" size="sm">
|
| 570 |
+
<Eye className="w-4 h-4" />
|
| 571 |
+
</Button>
|
| 572 |
+
</Link>
|
| 573 |
+
<Button
|
| 574 |
+
variant="ghost"
|
| 575 |
+
size="sm"
|
| 576 |
+
onClick={() => handleDeletePost(post.id)}
|
| 577 |
+
>
|
| 578 |
+
<Trash2 className="w-4 h-4 text-destructive" />
|
| 579 |
+
</Button>
|
| 580 |
+
</div>
|
| 581 |
+
</TableCell>
|
| 582 |
+
</TableRow>
|
| 583 |
+
))}
|
| 584 |
+
</TableBody>
|
| 585 |
+
</Table>
|
| 586 |
+
</CardContent>
|
| 587 |
+
</Card>
|
| 588 |
+
</>
|
| 589 |
+
) : (
|
| 590 |
+
<Card>
|
| 591 |
+
<CardHeader>
|
| 592 |
+
<div className="flex items-center justify-between">
|
| 593 |
+
<CardTitle>
|
| 594 |
+
{editingPost ? "Edit Post" : "Create New Post"}
|
| 595 |
+
</CardTitle>
|
| 596 |
+
<Button variant="ghost" onClick={() => {
|
| 597 |
+
setShowPostForm(false)
|
| 598 |
+
setEditingPost(null)
|
| 599 |
+
resetPostForm()
|
| 600 |
+
}}>
|
| 601 |
+
<X className="w-4 h-4" />
|
| 602 |
+
</Button>
|
| 603 |
+
</div>
|
| 604 |
+
</CardHeader>
|
| 605 |
+
<CardContent className="space-y-4">
|
| 606 |
+
<div className="grid grid-cols-2 gap-4">
|
| 607 |
+
<div>
|
| 608 |
+
<label className="text-sm font-medium mb-2 block">Title</label>
|
| 609 |
+
<Input
|
| 610 |
+
value={postForm.title}
|
| 611 |
+
onChange={(e) => setPostForm({ ...postForm, title: e.target.value })}
|
| 612 |
+
placeholder="Post title"
|
| 613 |
+
/>
|
| 614 |
+
</div>
|
| 615 |
+
<div>
|
| 616 |
+
<label className="text-sm font-medium mb-2 block">Slug</label>
|
| 617 |
+
<Input
|
| 618 |
+
value={postForm.slug}
|
| 619 |
+
onChange={(e) => setPostForm({ ...postForm, slug: e.target.value })}
|
| 620 |
+
placeholder="url-friendly-slug"
|
| 621 |
+
/>
|
| 622 |
+
</div>
|
| 623 |
+
</div>
|
| 624 |
+
<div className="grid grid-cols-2 gap-4">
|
| 625 |
+
<div>
|
| 626 |
+
<label className="text-sm font-medium mb-2 block">Post Type</label>
|
| 627 |
+
<select
|
| 628 |
+
value={postForm.post_type}
|
| 629 |
+
title="Post Type"
|
| 630 |
+
onChange={(e) => setPostForm({ ...postForm, post_type: e.target.value })}
|
| 631 |
+
className="w-full px-3 py-2 border rounded-md"
|
| 632 |
+
>
|
| 633 |
+
<option value="news">News</option>
|
| 634 |
+
<option value="announcement">Announcement</option>
|
| 635 |
+
<option value="update">Update</option>
|
| 636 |
+
</select>
|
| 637 |
+
</div>
|
| 638 |
+
<div>
|
| 639 |
+
<label className="text-sm font-medium mb-2 block">Status</label>
|
| 640 |
+
<select
|
| 641 |
+
value={postForm.status}
|
| 642 |
+
title="Status"
|
| 643 |
+
onChange={(e) => setPostForm({ ...postForm, status: e.target.value })}
|
| 644 |
+
className="w-full px-3 py-2 border rounded-md"
|
| 645 |
+
>
|
| 646 |
+
<option value="draft">Draft</option>
|
| 647 |
+
<option value="published">Published</option>
|
| 648 |
+
</select>
|
| 649 |
+
</div>
|
| 650 |
+
</div>
|
| 651 |
+
<div>
|
| 652 |
+
<label className="text-sm font-medium mb-2 block">Excerpt</label>
|
| 653 |
+
<Textarea
|
| 654 |
+
value={postForm.excerpt}
|
| 655 |
+
onChange={(e) => setPostForm({ ...postForm, excerpt: e.target.value })}
|
| 656 |
+
placeholder="Short excerpt..."
|
| 657 |
+
rows={2}
|
| 658 |
+
/>
|
| 659 |
+
</div>
|
| 660 |
+
<div>
|
| 661 |
+
<label className="text-sm font-medium mb-2 block">Featured Image URL</label>
|
| 662 |
+
<div className="flex gap-2">
|
| 663 |
+
<Input
|
| 664 |
+
value={postForm.featured_image_url}
|
| 665 |
+
onChange={(e) =>
|
| 666 |
+
setPostForm({ ...postForm, featured_image_url: e.target.value })
|
| 667 |
+
}
|
| 668 |
+
placeholder="Image URL or upload file"
|
| 669 |
+
/>
|
| 670 |
+
{editingPost && (
|
| 671 |
+
<input
|
| 672 |
+
type="file"
|
| 673 |
+
title="Upload Featured Image"
|
| 674 |
+
accept="image/*"
|
| 675 |
+
onChange={(e) => {
|
| 676 |
+
const file = e.target.files?.[0]
|
| 677 |
+
if (file && editingPost) {
|
| 678 |
+
handleUploadFeaturedImage(editingPost.id, file)
|
| 679 |
+
}
|
| 680 |
+
}}
|
| 681 |
+
className="hidden"
|
| 682 |
+
id="image-upload"
|
| 683 |
+
/>
|
| 684 |
+
)}
|
| 685 |
+
{editingPost && (
|
| 686 |
+
<Button
|
| 687 |
+
variant="outline"
|
| 688 |
+
onClick={() => document.getElementById("image-upload")?.click()}
|
| 689 |
+
>
|
| 690 |
+
<Upload className="w-4 h-4" />
|
| 691 |
+
</Button>
|
| 692 |
+
)}
|
| 693 |
+
</div>
|
| 694 |
+
</div>
|
| 695 |
+
<BlogEditor
|
| 696 |
+
markdownContent={postForm.markdown_content}
|
| 697 |
+
htmlContent={postForm.html_content}
|
| 698 |
+
onMarkdownChange={(value) =>
|
| 699 |
+
setPostForm({ ...postForm, markdown_content: value })
|
| 700 |
+
}
|
| 701 |
+
onHtmlChange={(value) =>
|
| 702 |
+
setPostForm({ ...postForm, html_content: value })
|
| 703 |
+
}
|
| 704 |
+
/>
|
| 705 |
+
<div>
|
| 706 |
+
<label className="text-sm font-medium mb-2 block">Categories</label>
|
| 707 |
+
<div className="flex flex-wrap gap-2">
|
| 708 |
+
{categories.map((category) => (
|
| 709 |
+
<Badge
|
| 710 |
+
key={category.id}
|
| 711 |
+
variant={
|
| 712 |
+
postForm.category_ids.includes(category.id) ? "default" : "outline"
|
| 713 |
+
}
|
| 714 |
+
className="cursor-pointer"
|
| 715 |
+
onClick={() => {
|
| 716 |
+
const newIds = postForm.category_ids.includes(category.id)
|
| 717 |
+
? postForm.category_ids.filter((id) => id !== category.id)
|
| 718 |
+
: [...postForm.category_ids, category.id]
|
| 719 |
+
setPostForm({ ...postForm, category_ids: newIds })
|
| 720 |
+
}}
|
| 721 |
+
>
|
| 722 |
+
{category.name}
|
| 723 |
+
</Badge>
|
| 724 |
+
))}
|
| 725 |
+
</div>
|
| 726 |
+
</div>
|
| 727 |
+
<div>
|
| 728 |
+
<label className="text-sm font-medium mb-2 block">Tags</label>
|
| 729 |
+
<div className="flex flex-wrap gap-2">
|
| 730 |
+
{tags.map((tag) => (
|
| 731 |
+
<Badge
|
| 732 |
+
key={tag.id}
|
| 733 |
+
variant={postForm.tag_ids.includes(tag.id) ? "default" : "outline"}
|
| 734 |
+
className="cursor-pointer"
|
| 735 |
+
onClick={() => {
|
| 736 |
+
const newIds = postForm.tag_ids.includes(tag.id)
|
| 737 |
+
? postForm.tag_ids.filter((id) => id !== tag.id)
|
| 738 |
+
: [...postForm.tag_ids, tag.id]
|
| 739 |
+
setPostForm({ ...postForm, tag_ids: newIds })
|
| 740 |
+
}}
|
| 741 |
+
>
|
| 742 |
+
{tag.name}
|
| 743 |
+
</Badge>
|
| 744 |
+
))}
|
| 745 |
+
</div>
|
| 746 |
+
</div>
|
| 747 |
+
<div className="flex justify-end gap-2">
|
| 748 |
+
<Button
|
| 749 |
+
variant="outline"
|
| 750 |
+
onClick={() => {
|
| 751 |
+
setShowPostForm(false)
|
| 752 |
+
setEditingPost(null)
|
| 753 |
+
resetPostForm()
|
| 754 |
+
}}
|
| 755 |
+
>
|
| 756 |
+
Cancel
|
| 757 |
+
</Button>
|
| 758 |
+
<Button
|
| 759 |
+
onClick={editingPost ? handleUpdatePost : handleCreatePost}
|
| 760 |
+
disabled={postSubmitting}
|
| 761 |
+
>
|
| 762 |
+
{postSubmitting ? (
|
| 763 |
+
<span className="flex items-center">
|
| 764 |
+
<svg className="animate-spin h-4 w-4 mr-2 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 765 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
| 766 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4l3-3-3-3v4a8 8 0 00-8 8z"></path>
|
| 767 |
+
</svg>
|
| 768 |
+
{editingPost ? "Updating..." : "Creating..."}
|
| 769 |
+
</span>
|
| 770 |
+
) : (
|
| 771 |
+
<>
|
| 772 |
+
<Save className="w-4 h-4 mr-2" />
|
| 773 |
+
{editingPost ? "Update" : "Create"} Post
|
| 774 |
+
</>
|
| 775 |
+
)}
|
| 776 |
+
</Button>
|
| 777 |
+
</div>
|
| 778 |
+
</CardContent>
|
| 779 |
+
</Card>
|
| 780 |
+
)}
|
| 781 |
+
</div>
|
| 782 |
+
)}
|
| 783 |
+
|
| 784 |
+
{/* Categories Tab */}
|
| 785 |
+
{activeTab === "categories" && (
|
| 786 |
+
<div className="space-y-4">
|
| 787 |
+
<div className="flex justify-end">
|
| 788 |
+
<Button
|
| 789 |
+
onClick={() => {
|
| 790 |
+
setEditingCategory(null)
|
| 791 |
+
setCategoryForm({ name: "", slug: "", description: "" })
|
| 792 |
+
}}
|
| 793 |
+
>
|
| 794 |
+
<Plus className="w-4 h-4 mr-2" />
|
| 795 |
+
New Category
|
| 796 |
+
</Button>
|
| 797 |
+
</div>
|
| 798 |
+
{(editingCategory || (!editingCategory && categoryForm.name)) && (
|
| 799 |
+
<Card>
|
| 800 |
+
<CardHeader>
|
| 801 |
+
<CardTitle>
|
| 802 |
+
{editingCategory ? "Edit Category" : "Create Category"}
|
| 803 |
+
</CardTitle>
|
| 804 |
+
</CardHeader>
|
| 805 |
+
<CardContent className="space-y-4">
|
| 806 |
+
<div>
|
| 807 |
+
<label className="text-sm font-medium mb-2 block">Name</label>
|
| 808 |
+
<Input
|
| 809 |
+
value={categoryForm.name}
|
| 810 |
+
onChange={(e) => setCategoryForm({ ...categoryForm, name: e.target.value })}
|
| 811 |
+
placeholder="Category name"
|
| 812 |
+
/>
|
| 813 |
+
</div>
|
| 814 |
+
<div>
|
| 815 |
+
<label className="text-sm font-medium mb-2 block">Slug</label>
|
| 816 |
+
<Input
|
| 817 |
+
value={categoryForm.slug}
|
| 818 |
+
onChange={(e) => setCategoryForm({ ...categoryForm, slug: e.target.value })}
|
| 819 |
+
placeholder="category-slug"
|
| 820 |
+
/>
|
| 821 |
+
</div>
|
| 822 |
+
<div>
|
| 823 |
+
<label className="text-sm font-medium mb-2 block">Description</label>
|
| 824 |
+
<Textarea
|
| 825 |
+
value={categoryForm.description}
|
| 826 |
+
onChange={(e) =>
|
| 827 |
+
setCategoryForm({ ...categoryForm, description: e.target.value })
|
| 828 |
+
}
|
| 829 |
+
placeholder="Category description"
|
| 830 |
+
rows={3}
|
| 831 |
+
/>
|
| 832 |
+
</div>
|
| 833 |
+
<div className="flex justify-end gap-2">
|
| 834 |
+
<Button
|
| 835 |
+
variant="outline"
|
| 836 |
+
onClick={() => {
|
| 837 |
+
setEditingCategory(null)
|
| 838 |
+
setCategoryForm({ name: "", slug: "", description: "" })
|
| 839 |
+
}}
|
| 840 |
+
>
|
| 841 |
+
Cancel
|
| 842 |
+
</Button>
|
| 843 |
+
<Button
|
| 844 |
+
onClick={editingCategory ? handleUpdateCategory : handleCreateCategory}
|
| 845 |
+
>
|
| 846 |
+
<Save className="w-4 h-4 mr-2" />
|
| 847 |
+
{editingCategory ? "Update" : "Create"}
|
| 848 |
+
</Button>
|
| 849 |
+
</div>
|
| 850 |
+
</CardContent>
|
| 851 |
+
</Card>
|
| 852 |
+
)}
|
| 853 |
+
<Card>
|
| 854 |
+
<CardContent className="p-0">
|
| 855 |
+
<Table>
|
| 856 |
+
<TableHeader>
|
| 857 |
+
<TableRow>
|
| 858 |
+
<TableHead>Name</TableHead>
|
| 859 |
+
<TableHead>Slug</TableHead>
|
| 860 |
+
<TableHead>Description</TableHead>
|
| 861 |
+
<TableHead>Actions</TableHead>
|
| 862 |
+
</TableRow>
|
| 863 |
+
</TableHeader>
|
| 864 |
+
<TableBody>
|
| 865 |
+
{categories.map((category) => (
|
| 866 |
+
<TableRow key={category.id}>
|
| 867 |
+
<TableCell className="font-medium">{category.name}</TableCell>
|
| 868 |
+
<TableCell>{category.slug}</TableCell>
|
| 869 |
+
<TableCell>{category.description || "-"}</TableCell>
|
| 870 |
+
<TableCell>
|
| 871 |
+
<div className="flex gap-2">
|
| 872 |
+
<Button
|
| 873 |
+
variant="ghost"
|
| 874 |
+
size="sm"
|
| 875 |
+
onClick={() => {
|
| 876 |
+
setEditingCategory(category)
|
| 877 |
+
setCategoryForm({
|
| 878 |
+
name: category.name,
|
| 879 |
+
slug: category.slug,
|
| 880 |
+
description: category.description || "",
|
| 881 |
+
})
|
| 882 |
+
}}
|
| 883 |
+
>
|
| 884 |
+
<Edit className="w-4 h-4" />
|
| 885 |
+
</Button>
|
| 886 |
+
<Button
|
| 887 |
+
variant="ghost"
|
| 888 |
+
size="sm"
|
| 889 |
+
onClick={() => handleDeleteCategory(category.id)}
|
| 890 |
+
>
|
| 891 |
+
<Trash2 className="w-4 h-4 text-destructive" />
|
| 892 |
+
</Button>
|
| 893 |
+
</div>
|
| 894 |
+
</TableCell>
|
| 895 |
+
</TableRow>
|
| 896 |
+
))}
|
| 897 |
+
</TableBody>
|
| 898 |
+
</Table>
|
| 899 |
+
</CardContent>
|
| 900 |
+
</Card>
|
| 901 |
+
</div>
|
| 902 |
+
)}
|
| 903 |
+
|
| 904 |
+
{/* Tags Tab */}
|
| 905 |
+
{activeTab === "tags" && (
|
| 906 |
+
<div className="space-y-4">
|
| 907 |
+
<div className="flex justify-end">
|
| 908 |
+
<Button
|
| 909 |
+
onClick={() => {
|
| 910 |
+
setEditingTag(null)
|
| 911 |
+
setTagForm({ name: "", slug: "" })
|
| 912 |
+
}}
|
| 913 |
+
>
|
| 914 |
+
<Plus className="w-4 h-4 mr-2" />
|
| 915 |
+
New Tag
|
| 916 |
+
</Button>
|
| 917 |
+
</div>
|
| 918 |
+
{(editingTag || (!editingTag && tagForm.name)) && (
|
| 919 |
+
<Card>
|
| 920 |
+
<CardHeader>
|
| 921 |
+
<CardTitle>{editingTag ? "Edit Tag" : "Create Tag"}</CardTitle>
|
| 922 |
+
</CardHeader>
|
| 923 |
+
<CardContent className="space-y-4">
|
| 924 |
+
<div>
|
| 925 |
+
<label className="text-sm font-medium mb-2 block">Name</label>
|
| 926 |
+
<Input
|
| 927 |
+
value={tagForm.name}
|
| 928 |
+
onChange={(e) => setTagForm({ ...tagForm, name: e.target.value })}
|
| 929 |
+
placeholder="Tag name"
|
| 930 |
+
/>
|
| 931 |
+
</div>
|
| 932 |
+
<div>
|
| 933 |
+
<label className="text-sm font-medium mb-2 block">Slug</label>
|
| 934 |
+
<Input
|
| 935 |
+
value={tagForm.slug}
|
| 936 |
+
onChange={(e) => setTagForm({ ...tagForm, slug: e.target.value })}
|
| 937 |
+
placeholder="tag-slug"
|
| 938 |
+
/>
|
| 939 |
+
</div>
|
| 940 |
+
<div className="flex justify-end gap-2">
|
| 941 |
+
<Button
|
| 942 |
+
variant="outline"
|
| 943 |
+
onClick={() => {
|
| 944 |
+
setEditingTag(null)
|
| 945 |
+
setTagForm({ name: "", slug: "" })
|
| 946 |
+
}}
|
| 947 |
+
>
|
| 948 |
+
Cancel
|
| 949 |
+
</Button>
|
| 950 |
+
<Button onClick={editingTag ? handleUpdateTag : handleCreateTag}>
|
| 951 |
+
<Save className="w-4 h-4 mr-2" />
|
| 952 |
+
{editingTag ? "Update" : "Create"}
|
| 953 |
+
</Button>
|
| 954 |
+
</div>
|
| 955 |
+
</CardContent>
|
| 956 |
+
</Card>
|
| 957 |
+
)}
|
| 958 |
+
<Card>
|
| 959 |
+
<CardContent className="p-0">
|
| 960 |
+
<Table>
|
| 961 |
+
<TableHeader>
|
| 962 |
+
<TableRow>
|
| 963 |
+
<TableHead>Name</TableHead>
|
| 964 |
+
<TableHead>Slug</TableHead>
|
| 965 |
+
<TableHead>Actions</TableHead>
|
| 966 |
+
</TableRow>
|
| 967 |
+
</TableHeader>
|
| 968 |
+
<TableBody>
|
| 969 |
+
{tags.map((tag) => (
|
| 970 |
+
<TableRow key={tag.id}>
|
| 971 |
+
<TableCell className="font-medium">{tag.name}</TableCell>
|
| 972 |
+
<TableCell>{tag.slug}</TableCell>
|
| 973 |
+
<TableCell>
|
| 974 |
+
<div className="flex gap-2">
|
| 975 |
+
<Button
|
| 976 |
+
variant="ghost"
|
| 977 |
+
size="sm"
|
| 978 |
+
onClick={() => {
|
| 979 |
+
setEditingTag(tag)
|
| 980 |
+
setTagForm({ name: tag.name, slug: tag.slug })
|
| 981 |
+
}}
|
| 982 |
+
>
|
| 983 |
+
<Edit className="w-4 h-4" />
|
| 984 |
+
</Button>
|
| 985 |
+
<Button
|
| 986 |
+
variant="ghost"
|
| 987 |
+
size="sm"
|
| 988 |
+
onClick={() => handleDeleteTag(tag.id)}
|
| 989 |
+
>
|
| 990 |
+
<Trash2 className="w-4 h-4 text-destructive" />
|
| 991 |
+
</Button>
|
| 992 |
+
</div>
|
| 993 |
+
</TableCell>
|
| 994 |
+
</TableRow>
|
| 995 |
+
))}
|
| 996 |
+
</TableBody>
|
| 997 |
+
</Table>
|
| 998 |
+
</CardContent>
|
| 999 |
+
</Card>
|
| 1000 |
+
</div>
|
| 1001 |
+
)}
|
| 1002 |
+
</div>
|
| 1003 |
+
</div>
|
| 1004 |
+
</div>
|
| 1005 |
+
)
|
| 1006 |
+
}
|
| 1007 |
+
|
src/app/admin/knowledge/page.tsx
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Knowledge Base Settings - Admin Page
|
| 5 |
+
*
|
| 6 |
+
* WeKnora-style knowledge base management for admins
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import { useState } from "react"
|
| 10 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
| 11 |
+
import { Button } from "@/components/ui/button"
|
| 12 |
+
import { Badge } from "@/components/ui/badge"
|
| 13 |
+
import { Input } from "@/components/ui/input"
|
| 14 |
+
import { Label } from "@/components/ui/label"
|
| 15 |
+
import { Textarea } from "@/components/ui/textarea"
|
| 16 |
+
import {
|
| 17 |
+
Table,
|
| 18 |
+
TableBody,
|
| 19 |
+
TableCell,
|
| 20 |
+
TableHead,
|
| 21 |
+
TableHeader,
|
| 22 |
+
TableRow,
|
| 23 |
+
} from "@/components/ui/table"
|
| 24 |
+
import {
|
| 25 |
+
Dialog,
|
| 26 |
+
DialogContent,
|
| 27 |
+
DialogDescription,
|
| 28 |
+
DialogFooter,
|
| 29 |
+
DialogHeader,
|
| 30 |
+
DialogTitle,
|
| 31 |
+
DialogTrigger,
|
| 32 |
+
} from "@/components/ui/dialog"
|
| 33 |
+
import {
|
| 34 |
+
Database,
|
| 35 |
+
Plus,
|
| 36 |
+
Upload,
|
| 37 |
+
RefreshCw,
|
| 38 |
+
Trash2,
|
| 39 |
+
Edit,
|
| 40 |
+
FileText,
|
| 41 |
+
Scale,
|
| 42 |
+
Newspaper,
|
| 43 |
+
CheckCircle,
|
| 44 |
+
XCircle,
|
| 45 |
+
Clock,
|
| 46 |
+
Settings,
|
| 47 |
+
Search
|
| 48 |
+
} from "lucide-react"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
interface KnowledgeBase {
|
| 52 |
+
id: string
|
| 53 |
+
name: string
|
| 54 |
+
description: string
|
| 55 |
+
type: 'legal' | 'news' | 'general' | 'custom'
|
| 56 |
+
documentCount: number
|
| 57 |
+
lastUpdated: string
|
| 58 |
+
status: 'active' | 'indexing' | 'error'
|
| 59 |
+
chunkSize: number
|
| 60 |
+
embeddingModel: string
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
const MOCK_KNOWLEDGE_BASES: KnowledgeBase[] = [
|
| 64 |
+
{
|
| 65 |
+
id: "kenya_law",
|
| 66 |
+
name: "Kenya Law",
|
| 67 |
+
description: "Constitution, Acts, Bills, Case Law from Kenya Law Reports",
|
| 68 |
+
type: "legal",
|
| 69 |
+
documentCount: 5420,
|
| 70 |
+
lastUpdated: "2025-12-14T10:30:00Z",
|
| 71 |
+
status: "active",
|
| 72 |
+
chunkSize: 512,
|
| 73 |
+
embeddingModel: "text-embedding-3-small"
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
id: "kenya_news",
|
| 77 |
+
name: "Kenya News",
|
| 78 |
+
description: "Current affairs from major Kenyan news outlets",
|
| 79 |
+
type: "news",
|
| 80 |
+
documentCount: 12350,
|
| 81 |
+
lastUpdated: "2025-12-15T08:00:00Z",
|
| 82 |
+
status: "active",
|
| 83 |
+
chunkSize: 256,
|
| 84 |
+
embeddingModel: "text-embedding-3-small"
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
id: "parliament",
|
| 88 |
+
name: "Parliament Records",
|
| 89 |
+
description: "Bills, Hansard, Committee Reports",
|
| 90 |
+
type: "legal",
|
| 91 |
+
documentCount: 2100,
|
| 92 |
+
lastUpdated: "2025-12-13T14:20:00Z",
|
| 93 |
+
status: "indexing",
|
| 94 |
+
chunkSize: 512,
|
| 95 |
+
embeddingModel: "text-embedding-3-small"
|
| 96 |
+
}
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
export default function KnowledgeBaseSettingsPage() {
|
| 100 |
+
const [knowledgeBases, setKnowledgeBases] = useState(MOCK_KNOWLEDGE_BASES)
|
| 101 |
+
const [selectedKB, setSelectedKB] = useState<KnowledgeBase | null>(null)
|
| 102 |
+
const [isCreating, setIsCreating] = useState(false)
|
| 103 |
+
|
| 104 |
+
const getTypeIcon = (type: string) => {
|
| 105 |
+
switch (type) {
|
| 106 |
+
case 'legal': return <Scale className="h-4 w-4" />
|
| 107 |
+
case 'news': return <Newspaper className="h-4 w-4" />
|
| 108 |
+
default: return <Database className="h-4 w-4" />
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
const getStatusBadge = (status: string) => {
|
| 113 |
+
switch (status) {
|
| 114 |
+
case 'active':
|
| 115 |
+
return <Badge variant="default" className="bg-green-500"><CheckCircle className="h-3 w-3 mr-1" />Active</Badge>
|
| 116 |
+
case 'indexing':
|
| 117 |
+
return <Badge variant="secondary"><Clock className="h-3 w-3 mr-1 animate-spin" />Indexing</Badge>
|
| 118 |
+
case 'error':
|
| 119 |
+
return <Badge variant="destructive"><XCircle className="h-3 w-3 mr-1" />Error</Badge>
|
| 120 |
+
default:
|
| 121 |
+
return <Badge variant="outline">{status}</Badge>
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
return (
|
| 126 |
+
<div className="container mx-auto py-6 space-y-6">
|
| 127 |
+
<div className="flex items-center justify-between">
|
| 128 |
+
<div>
|
| 129 |
+
<h1 className="text-3xl font-bold">Knowledge Base Management</h1>
|
| 130 |
+
<p className="text-muted-foreground">Manage your document collections and search indices</p>
|
| 131 |
+
</div>
|
| 132 |
+
<Dialog open={isCreating} onOpenChange={setIsCreating}>
|
| 133 |
+
<DialogTrigger asChild>
|
| 134 |
+
<Button>
|
| 135 |
+
<Plus className="h-4 w-4 mr-2" />
|
| 136 |
+
New Knowledge Base
|
| 137 |
+
</Button>
|
| 138 |
+
</DialogTrigger>
|
| 139 |
+
<DialogContent className="max-w-2xl">
|
| 140 |
+
<DialogHeader>
|
| 141 |
+
<DialogTitle>Create Knowledge Base</DialogTitle>
|
| 142 |
+
<DialogDescription>
|
| 143 |
+
Create a new knowledge base for document retrieval
|
| 144 |
+
</DialogDescription>
|
| 145 |
+
</DialogHeader>
|
| 146 |
+
<div className="space-y-4 py-4">
|
| 147 |
+
<div className="grid grid-cols-2 gap-4">
|
| 148 |
+
<div className="space-y-2">
|
| 149 |
+
<Label htmlFor="name">Name</Label>
|
| 150 |
+
<Input id="name" placeholder="e.g., Kenya Law" />
|
| 151 |
+
</div>
|
| 152 |
+
<div className="space-y-2">
|
| 153 |
+
<Label htmlFor="type">Type</Label>
|
| 154 |
+
<select className="w-full p-2 border rounded-md">
|
| 155 |
+
<option value="legal">Legal</option>
|
| 156 |
+
<option value="news">News</option>
|
| 157 |
+
<option value="general">General</option>
|
| 158 |
+
<option value="custom">Custom</option>
|
| 159 |
+
</select>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
<div className="space-y-2">
|
| 163 |
+
<Label htmlFor="description">Description</Label>
|
| 164 |
+
<Textarea id="description" placeholder="Describe this knowledge base..." />
|
| 165 |
+
</div>
|
| 166 |
+
<div className="grid grid-cols-2 gap-4">
|
| 167 |
+
<div className="space-y-2">
|
| 168 |
+
<Label htmlFor="chunkSize">Chunk Size</Label>
|
| 169 |
+
<Input id="chunkSize" type="number" defaultValue={512} />
|
| 170 |
+
</div>
|
| 171 |
+
<div className="space-y-2">
|
| 172 |
+
<Label htmlFor="embeddingModel">Embedding Model</Label>
|
| 173 |
+
<select className="w-full p-2 border rounded-md">
|
| 174 |
+
<option value="text-embedding-3-small">text-embedding-3-small</option>
|
| 175 |
+
<option value="text-embedding-3-large">text-embedding-3-large</option>
|
| 176 |
+
<option value="text-embedding-ada-002">text-embedding-ada-002</option>
|
| 177 |
+
</select>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
<DialogFooter>
|
| 182 |
+
<Button variant="outline" onClick={() => setIsCreating(false)}>Cancel</Button>
|
| 183 |
+
<Button onClick={() => setIsCreating(false)}>Create</Button>
|
| 184 |
+
</DialogFooter>
|
| 185 |
+
</DialogContent>
|
| 186 |
+
</Dialog>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
<div className="grid gap-6 md:grid-cols-3">
|
| 190 |
+
<Card>
|
| 191 |
+
<CardHeader className="pb-2">
|
| 192 |
+
<CardTitle className="text-sm font-medium">Total Knowledge Bases</CardTitle>
|
| 193 |
+
</CardHeader>
|
| 194 |
+
<CardContent>
|
| 195 |
+
<div className="text-2xl font-bold">{knowledgeBases.length}</div>
|
| 196 |
+
</CardContent>
|
| 197 |
+
</Card>
|
| 198 |
+
<Card>
|
| 199 |
+
<CardHeader className="pb-2">
|
| 200 |
+
<CardTitle className="text-sm font-medium">Total Documents</CardTitle>
|
| 201 |
+
</CardHeader>
|
| 202 |
+
<CardContent>
|
| 203 |
+
<div className="text-2xl font-bold">
|
| 204 |
+
{knowledgeBases.reduce((sum, kb) => sum + kb.documentCount, 0).toLocaleString()}
|
| 205 |
+
</div>
|
| 206 |
+
</CardContent>
|
| 207 |
+
</Card>
|
| 208 |
+
<Card>
|
| 209 |
+
<CardHeader className="pb-2">
|
| 210 |
+
<CardTitle className="text-sm font-medium">Active Indices</CardTitle>
|
| 211 |
+
</CardHeader>
|
| 212 |
+
<CardContent>
|
| 213 |
+
<div className="text-2xl font-bold">
|
| 214 |
+
{knowledgeBases.filter(kb => kb.status === 'active').length}
|
| 215 |
+
</div>
|
| 216 |
+
</CardContent>
|
| 217 |
+
</Card>
|
| 218 |
+
</div>
|
| 219 |
+
|
| 220 |
+
<Card>
|
| 221 |
+
<CardHeader>
|
| 222 |
+
<CardTitle>Knowledge Bases</CardTitle>
|
| 223 |
+
<CardDescription>Manage document collections and search indices</CardDescription>
|
| 224 |
+
</CardHeader>
|
| 225 |
+
<CardContent>
|
| 226 |
+
<Table>
|
| 227 |
+
<TableHeader>
|
| 228 |
+
<TableRow>
|
| 229 |
+
<TableHead>Name</TableHead>
|
| 230 |
+
<TableHead>Type</TableHead>
|
| 231 |
+
<TableHead>Documents</TableHead>
|
| 232 |
+
<TableHead>Status</TableHead>
|
| 233 |
+
<TableHead>Last Updated</TableHead>
|
| 234 |
+
<TableHead className="text-right">Actions</TableHead>
|
| 235 |
+
</TableRow>
|
| 236 |
+
</TableHeader>
|
| 237 |
+
<TableBody>
|
| 238 |
+
{knowledgeBases.map((kb) => (
|
| 239 |
+
<TableRow key={kb.id}>
|
| 240 |
+
<TableCell>
|
| 241 |
+
<div className="flex items-center gap-2">
|
| 242 |
+
{getTypeIcon(kb.type)}
|
| 243 |
+
<div>
|
| 244 |
+
<div className="font-medium">{kb.name}</div>
|
| 245 |
+
<div className="text-xs text-muted-foreground">{kb.description}</div>
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
</TableCell>
|
| 249 |
+
<TableCell>
|
| 250 |
+
<Badge variant="outline">{kb.type}</Badge>
|
| 251 |
+
</TableCell>
|
| 252 |
+
<TableCell>{kb.documentCount.toLocaleString()}</TableCell>
|
| 253 |
+
<TableCell>{getStatusBadge(kb.status)}</TableCell>
|
| 254 |
+
<TableCell>{new Date(kb.lastUpdated).toLocaleDateString()}</TableCell>
|
| 255 |
+
<TableCell className="text-right">
|
| 256 |
+
<div className="flex justify-end gap-2">
|
| 257 |
+
<Button variant="ghost" size="icon">
|
| 258 |
+
<Upload className="h-4 w-4" />
|
| 259 |
+
</Button>
|
| 260 |
+
<Button variant="ghost" size="icon">
|
| 261 |
+
<RefreshCw className="h-4 w-4" />
|
| 262 |
+
</Button>
|
| 263 |
+
<Button variant="ghost" size="icon">
|
| 264 |
+
<Settings className="h-4 w-4" />
|
| 265 |
+
</Button>
|
| 266 |
+
<Button variant="ghost" size="icon">
|
| 267 |
+
<Trash2 className="h-4 w-4 text-red-500" />
|
| 268 |
+
</Button>
|
| 269 |
+
</div>
|
| 270 |
+
</TableCell>
|
| 271 |
+
</TableRow>
|
| 272 |
+
))}
|
| 273 |
+
</TableBody>
|
| 274 |
+
</Table>
|
| 275 |
+
</CardContent>
|
| 276 |
+
</Card>
|
| 277 |
+
</div>
|
| 278 |
+
)
|
| 279 |
+
}
|
src/app/admin/models/page.tsx
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Model Settings - Admin Page
|
| 5 |
+
*
|
| 6 |
+
* WeKnora-style AI model configuration for admins
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import { useState, useEffect, ReactNode } from "react"
|
| 10 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
| 11 |
+
import { Button } from "@/components/ui/button"
|
| 12 |
+
import { Badge } from "@/components/ui/badge"
|
| 13 |
+
import { Input } from "@/components/ui/input"
|
| 14 |
+
import { Label } from "@/components/ui/label"
|
| 15 |
+
import { Switch } from "@/components/ui/switch"
|
| 16 |
+
import { Slider } from "@/components/ui/slider"
|
| 17 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
| 18 |
+
import {
|
| 19 |
+
Table,
|
| 20 |
+
TableBody,
|
| 21 |
+
TableCell,
|
| 22 |
+
TableHead,
|
| 23 |
+
TableHeader,
|
| 24 |
+
TableRow,
|
| 25 |
+
} from "@/components/ui/table"
|
| 26 |
+
import {
|
| 27 |
+
Dialog,
|
| 28 |
+
DialogContent,
|
| 29 |
+
DialogDescription,
|
| 30 |
+
DialogFooter,
|
| 31 |
+
DialogHeader,
|
| 32 |
+
DialogTitle,
|
| 33 |
+
DialogTrigger,
|
| 34 |
+
} from "@/components/ui/dialog"
|
| 35 |
+
import {
|
| 36 |
+
Brain,
|
| 37 |
+
Sparkles,
|
| 38 |
+
Zap,
|
| 39 |
+
Rocket,
|
| 40 |
+
Plus,
|
| 41 |
+
Settings,
|
| 42 |
+
CheckCircle,
|
| 43 |
+
XCircle,
|
| 44 |
+
Key,
|
| 45 |
+
RefreshCw,
|
| 46 |
+
Loader2
|
| 47 |
+
} from "lucide-react"
|
| 48 |
+
|
| 49 |
+
interface ModelConfig {
|
| 50 |
+
apiKey?: string
|
| 51 |
+
maxTokens?: number
|
| 52 |
+
topP?: number
|
| 53 |
+
isDefault?: boolean
|
| 54 |
+
id: string
|
| 55 |
+
name: string
|
| 56 |
+
provider: string
|
| 57 |
+
enabled: boolean
|
| 58 |
+
is_default?: boolean
|
| 59 |
+
status: string
|
| 60 |
+
temperature: number
|
| 61 |
+
max_tokens?: number
|
| 62 |
+
top_p?: number
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 66 |
+
|
| 67 |
+
export default function ModelSettingsPage() {
|
| 68 |
+
const [models, setModels] = useState<ModelConfig[]>([])
|
| 69 |
+
const [loading, setLoading] = useState(true)
|
| 70 |
+
const [error, setError] = useState<string | null>(null)
|
| 71 |
+
const [selectedModel, setSelectedModel] = useState<ModelConfig | null>(null)
|
| 72 |
+
|
| 73 |
+
useEffect(() => {
|
| 74 |
+
fetchModels()
|
| 75 |
+
}, [])
|
| 76 |
+
|
| 77 |
+
const fetchModels = async () => {
|
| 78 |
+
try {
|
| 79 |
+
setLoading(true)
|
| 80 |
+
const response = await fetch(`${API_URL}/api/v1/admin/models`)
|
| 81 |
+
if (!response.ok) throw new Error("Failed to fetch models")
|
| 82 |
+
const data = await response.json()
|
| 83 |
+
setModels(data)
|
| 84 |
+
} catch (err) {
|
| 85 |
+
setError(err instanceof Error ? err.message : "Failed to load models")
|
| 86 |
+
// Fallback to default models
|
| 87 |
+
setModels([
|
| 88 |
+
{
|
| 89 |
+
id: "moonshot-v1-8k", name: "Moonshot V1", provider: "Moonshot", enabled: true, is_default: true, status: "connected", temperature: 0.7, max_tokens: 4096,
|
| 90 |
+
apiKey: undefined,
|
| 91 |
+
maxTokens: undefined,
|
| 92 |
+
topP: undefined,
|
| 93 |
+
isDefault: undefined
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", provider: "Google", enabled: true, is_default: false, status: "connected", temperature: 0.7, max_tokens: 4096,
|
| 97 |
+
apiKey: undefined,
|
| 98 |
+
maxTokens: undefined,
|
| 99 |
+
topP: undefined,
|
| 100 |
+
isDefault: undefined
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
id: "gemini-1.5-pro", name: "Gemini 1.5 Pro", provider: "Google", enabled: true, is_default: false, status: "connected", temperature: 0.7, max_tokens: 8192,
|
| 104 |
+
apiKey: undefined,
|
| 105 |
+
maxTokens: undefined,
|
| 106 |
+
topP: undefined,
|
| 107 |
+
isDefault: undefined
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
id: "gpt-4o-mini", name: "GPT-4o Mini", provider: "OpenAI", enabled: false, is_default: false, status: "unconfigured", temperature: 0.7, max_tokens: 4096,
|
| 111 |
+
apiKey: undefined,
|
| 112 |
+
maxTokens: undefined,
|
| 113 |
+
topP: undefined,
|
| 114 |
+
isDefault: undefined
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
id: "claude-3.5-sonnet", name: "Claude 3.5 Sonnet", provider: "Anthropic", enabled: false, is_default: false, status: "unconfigured", temperature: 0.7, max_tokens: 4096,
|
| 118 |
+
apiKey: undefined,
|
| 119 |
+
maxTokens: undefined,
|
| 120 |
+
topP: undefined,
|
| 121 |
+
isDefault: undefined
|
| 122 |
+
}
|
| 123 |
+
])
|
| 124 |
+
} finally {
|
| 125 |
+
setLoading(false)
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
const getProviderIcon = (provider: string) => {
|
| 130 |
+
switch (provider) {
|
| 131 |
+
case 'Google': return <Sparkles className="h-4 w-4" />
|
| 132 |
+
case 'Moonshot': return <Zap className="h-4 w-4" />
|
| 133 |
+
case 'OpenAI': return <Brain className="h-4 w-4" />
|
| 134 |
+
case 'Anthropic': return <Rocket className="h-4 w-4" />
|
| 135 |
+
default: return <Brain className="h-4 w-4" />
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const getStatusBadge = (status: string) => {
|
| 140 |
+
switch (status) {
|
| 141 |
+
case 'connected':
|
| 142 |
+
return <Badge variant="default" className="bg-green-500"><CheckCircle className="h-3 w-3 mr-1" />Connected</Badge>
|
| 143 |
+
case 'error':
|
| 144 |
+
return <Badge variant="destructive"><XCircle className="h-3 w-3 mr-1" />Error</Badge>
|
| 145 |
+
case 'unconfigured':
|
| 146 |
+
return <Badge variant="secondary"><Key className="h-3 w-3 mr-1" />Unconfigured</Badge>
|
| 147 |
+
default:
|
| 148 |
+
return <Badge variant="outline">{status}</Badge>
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
const updateModel = (id: string, updates: Partial<ModelConfig>) => {
|
| 153 |
+
setModels(prev => prev.map(m => m.id === id ? { ...m, ...updates } : m))
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const setAsDefault = (id: string) => {
|
| 157 |
+
setModels(prev => prev.map(m => ({ ...m, isDefault: m.id === id })))
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
return (
|
| 161 |
+
<div className="container mx-auto py-6 space-y-6">
|
| 162 |
+
<div className="flex items-center justify-between">
|
| 163 |
+
<div>
|
| 164 |
+
<h1 className="text-3xl font-bold">Model Settings</h1>
|
| 165 |
+
<p className="text-muted-foreground">Configure AI models and their parameters</p>
|
| 166 |
+
</div>
|
| 167 |
+
<Button variant="outline">
|
| 168 |
+
<RefreshCw className="h-4 w-4 mr-2" />
|
| 169 |
+
Test All Connections
|
| 170 |
+
</Button>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
<div className="grid gap-6 md:grid-cols-4">
|
| 174 |
+
<Card>
|
| 175 |
+
<CardHeader className="pb-2">
|
| 176 |
+
<CardTitle className="text-sm font-medium">Configured Models</CardTitle>
|
| 177 |
+
</CardHeader>
|
| 178 |
+
<CardContent>
|
| 179 |
+
<div className="text-2xl font-bold">{models.filter(m => m.status === 'connected').length}</div>
|
| 180 |
+
</CardContent>
|
| 181 |
+
</Card>
|
| 182 |
+
<Card>
|
| 183 |
+
<CardHeader className="pb-2">
|
| 184 |
+
<CardTitle className="text-sm font-medium">Enabled Models</CardTitle>
|
| 185 |
+
</CardHeader>
|
| 186 |
+
<CardContent>
|
| 187 |
+
<div className="text-2xl font-bold">{models.filter(m => m.enabled).length}</div>
|
| 188 |
+
</CardContent>
|
| 189 |
+
</Card>
|
| 190 |
+
<Card>
|
| 191 |
+
<CardHeader className="pb-2">
|
| 192 |
+
<CardTitle className="text-sm font-medium">Default Model</CardTitle>
|
| 193 |
+
</CardHeader>
|
| 194 |
+
<CardContent>
|
| 195 |
+
<div className="text-lg font-bold">{models.find(m => m.isDefault)?.name || 'None'}</div>
|
| 196 |
+
</CardContent>
|
| 197 |
+
</Card>
|
| 198 |
+
<Card>
|
| 199 |
+
<CardHeader className="pb-2">
|
| 200 |
+
<CardTitle className="text-sm font-medium">Providers</CardTitle>
|
| 201 |
+
</CardHeader>
|
| 202 |
+
<CardContent>
|
| 203 |
+
<div className="text-2xl font-bold">{new Set(models.map(m => m.provider)).size}</div>
|
| 204 |
+
</CardContent>
|
| 205 |
+
</Card>
|
| 206 |
+
</div>
|
| 207 |
+
|
| 208 |
+
<Card>
|
| 209 |
+
<CardHeader>
|
| 210 |
+
<CardTitle>AI Models</CardTitle>
|
| 211 |
+
<CardDescription>Configure model access and parameters</CardDescription>
|
| 212 |
+
</CardHeader>
|
| 213 |
+
<CardContent>
|
| 214 |
+
<Table>
|
| 215 |
+
<TableHeader>
|
| 216 |
+
<TableRow>
|
| 217 |
+
<TableHead>Model</TableHead>
|
| 218 |
+
<TableHead>Provider</TableHead>
|
| 219 |
+
<TableHead>Status</TableHead>
|
| 220 |
+
<TableHead>Enabled</TableHead>
|
| 221 |
+
<TableHead>Default</TableHead>
|
| 222 |
+
<TableHead className="text-right">Actions</TableHead>
|
| 223 |
+
</TableRow>
|
| 224 |
+
</TableHeader>
|
| 225 |
+
<TableBody>
|
| 226 |
+
{models.map((model) => (
|
| 227 |
+
<TableRow key={model.id}>
|
| 228 |
+
<TableCell>
|
| 229 |
+
<div className="flex items-center gap-2">
|
| 230 |
+
{getProviderIcon(model.provider)}
|
| 231 |
+
<div>
|
| 232 |
+
<div className="font-medium">{model.name}</div>
|
| 233 |
+
<div className="text-xs text-muted-foreground font-mono">{model.id}</div>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
</TableCell>
|
| 237 |
+
<TableCell>
|
| 238 |
+
<Badge variant="outline">{model.provider}</Badge>
|
| 239 |
+
</TableCell>
|
| 240 |
+
<TableCell>{getStatusBadge(model.status)}</TableCell>
|
| 241 |
+
<TableCell>
|
| 242 |
+
<Switch
|
| 243 |
+
checked={model.enabled}
|
| 244 |
+
onCheckedChange={(checked: any) => updateModel(model.id, { enabled: checked })}
|
| 245 |
+
disabled={model.status !== 'connected'}
|
| 246 |
+
/>
|
| 247 |
+
</TableCell>
|
| 248 |
+
<TableCell>
|
| 249 |
+
<Switch
|
| 250 |
+
checked={model.isDefault ?? model.is_default ?? false}
|
| 251 |
+
onCheckedChange={() => setAsDefault(model.id)}
|
| 252 |
+
disabled={!model.enabled}
|
| 253 |
+
/>
|
| 254 |
+
</TableCell>
|
| 255 |
+
<TableCell className="text-right">
|
| 256 |
+
<div className="flex justify-end gap-2">
|
| 257 |
+
<Dialog>
|
| 258 |
+
<DialogTrigger asChild>
|
| 259 |
+
<Button
|
| 260 |
+
variant="ghost"
|
| 261 |
+
size="icon"
|
| 262 |
+
onClick={() => setSelectedModel(model)}
|
| 263 |
+
>
|
| 264 |
+
<Settings className="h-4 w-4" />
|
| 265 |
+
</Button>
|
| 266 |
+
</DialogTrigger>
|
| 267 |
+
<DialogContent className="max-w-lg">
|
| 268 |
+
<DialogHeader>
|
| 269 |
+
<DialogTitle>Configure {model.name}</DialogTitle>
|
| 270 |
+
<DialogDescription>
|
| 271 |
+
Adjust model parameters and API settings
|
| 272 |
+
</DialogDescription>
|
| 273 |
+
</DialogHeader>
|
| 274 |
+
<div className="space-y-4 py-4">
|
| 275 |
+
<div className="space-y-2">
|
| 276 |
+
<Label htmlFor="apiKey">API Key</Label>
|
| 277 |
+
<Input
|
| 278 |
+
id="apiKey"
|
| 279 |
+
type="password"
|
| 280 |
+
placeholder="Enter API key..."
|
| 281 |
+
defaultValue={model.apiKey}
|
| 282 |
+
/>
|
| 283 |
+
</div>
|
| 284 |
+
<div className="space-y-2">
|
| 285 |
+
<Label>Temperature: {model.temperature}</Label>
|
| 286 |
+
<Slider
|
| 287 |
+
defaultValue={[model.temperature]}
|
| 288 |
+
max={1}
|
| 289 |
+
step={0.1}
|
| 290 |
+
onValueChange={(v) => updateModel(model.id, { temperature: v[0] })}
|
| 291 |
+
/>
|
| 292 |
+
</div>
|
| 293 |
+
<div className="space-y-2">
|
| 294 |
+
<Label>Max Tokens: {model.maxTokens ?? model.max_tokens ?? 4096}</Label>
|
| 295 |
+
<Slider
|
| 296 |
+
defaultValue={[model.maxTokens ?? model.max_tokens ?? 4096]}
|
| 297 |
+
max={16384}
|
| 298 |
+
step={256}
|
| 299 |
+
onValueChange={(v) => updateModel(model.id, { maxTokens: v[0] })}
|
| 300 |
+
/>
|
| 301 |
+
</div>
|
| 302 |
+
<div className="space-y-2">
|
| 303 |
+
<Label>Top P: {model.topP ?? model.top_p ?? 0.7}</Label>
|
| 304 |
+
<Slider
|
| 305 |
+
defaultValue={[model.topP ?? model.top_p ?? 0.7]}
|
| 306 |
+
max={1}
|
| 307 |
+
step={0.05}
|
| 308 |
+
onValueChange={(v) => updateModel(model.id, { topP: v[0] })}
|
| 309 |
+
/>
|
| 310 |
+
</div>
|
| 311 |
+
</div>
|
| 312 |
+
<DialogFooter>
|
| 313 |
+
<Button>Save Changes</Button>
|
| 314 |
+
</DialogFooter>
|
| 315 |
+
</DialogContent>
|
| 316 |
+
</Dialog>
|
| 317 |
+
</div>
|
| 318 |
+
</TableCell>
|
| 319 |
+
</TableRow>
|
| 320 |
+
))}
|
| 321 |
+
</TableBody>
|
| 322 |
+
</Table>
|
| 323 |
+
</CardContent>
|
| 324 |
+
</Card>
|
| 325 |
+
</div>
|
| 326 |
+
)
|
| 327 |
+
}
|
src/app/admin/page.tsx
ADDED
|
@@ -0,0 +1,1144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect, useCallback, useRef } from "react"
|
| 4 |
+
import { useRouter } from "next/navigation"
|
| 5 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
| 6 |
+
import { Button } from "@/components/ui/button"
|
| 7 |
+
import { Badge } from "@/components/ui/badge"
|
| 8 |
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
| 9 |
+
import { Input } from "@/components/ui/input"
|
| 10 |
+
import { AdminSidebar } from "@/components/admin-sidebar"
|
| 11 |
+
import { ThemeToggle } from "@/components/theme-toggle"
|
| 12 |
+
import { useAuth } from "@/lib/auth-context"
|
| 13 |
+
import {
|
| 14 |
+
Database,
|
| 15 |
+
Activity,
|
| 16 |
+
Clock,
|
| 17 |
+
CheckCircle,
|
| 18 |
+
XCircle,
|
| 19 |
+
Play,
|
| 20 |
+
Search,
|
| 21 |
+
BarChart3,
|
| 22 |
+
FileText,
|
| 23 |
+
Globe,
|
| 24 |
+
RefreshCw,
|
| 25 |
+
Square
|
| 26 |
+
} from "lucide-react"
|
| 27 |
+
import Link from "next/link"
|
| 28 |
+
|
| 29 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 30 |
+
|
| 31 |
+
// Helper function to get auth headers
|
| 32 |
+
const getAuthHeaders = (): Record<string, string> => {
|
| 33 |
+
const token = localStorage.getItem("session_token")
|
| 34 |
+
const headers: Record<string, string> = {}
|
| 35 |
+
if (token) {
|
| 36 |
+
headers["X-Session-Token"] = token
|
| 37 |
+
}
|
| 38 |
+
return headers
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
interface Stats {
|
| 42 |
+
total_chunks: number
|
| 43 |
+
categories: Record<string, number>
|
| 44 |
+
sources: string[]
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
interface Health {
|
| 48 |
+
status: string
|
| 49 |
+
database_chunks: number
|
| 50 |
+
embedding_model: string
|
| 51 |
+
llm_provider: string
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
interface Crawler {
|
| 55 |
+
status: "idle" | "running" | "failed"
|
| 56 |
+
last_run: string | null
|
| 57 |
+
logs: string[]
|
| 58 |
+
pid?: number
|
| 59 |
+
start_time?: string
|
| 60 |
+
}
|
| 61 |
+
interface CommandResult {
|
| 62 |
+
command: string
|
| 63 |
+
exit_code: number
|
| 64 |
+
stdout: string
|
| 65 |
+
stderr: string
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
interface Document {
|
| 69 |
+
id: string
|
| 70 |
+
content: string
|
| 71 |
+
metadata: {
|
| 72 |
+
title: string
|
| 73 |
+
url: string
|
| 74 |
+
source: string
|
| 75 |
+
category: string
|
| 76 |
+
date: string
|
| 77 |
+
author?: string
|
| 78 |
+
sentiment_polarity?: number
|
| 79 |
+
sentiment_label?: string
|
| 80 |
+
}
|
| 81 |
+
score: number
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
interface Database {
|
| 85 |
+
name: string
|
| 86 |
+
type: string
|
| 87 |
+
status: string
|
| 88 |
+
total_chunks: number
|
| 89 |
+
categories: Record<string, number>
|
| 90 |
+
persist_directory: string
|
| 91 |
+
elasticsearch_docs: number
|
| 92 |
+
elasticsearch_enabled: boolean
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
interface DatabaseStorageStats {
|
| 96 |
+
raw_documents: {
|
| 97 |
+
total: number
|
| 98 |
+
processed: number
|
| 99 |
+
unprocessed: number
|
| 100 |
+
categories: Record<string, number>
|
| 101 |
+
}
|
| 102 |
+
processed_chunks: {
|
| 103 |
+
total: number
|
| 104 |
+
categories: Record<string, number>
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
interface ConfigData {
|
| 109 |
+
[key: string]: {
|
| 110 |
+
has_value: boolean
|
| 111 |
+
description?: string
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
interface DatabaseStats {
|
| 116 |
+
total_databases: number
|
| 117 |
+
active_databases: number
|
| 118 |
+
databases: Database[]
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
export default function AdminDashboard() {
|
| 122 |
+
const { isAuthenticated, isAdmin, loading } = useAuth()
|
| 123 |
+
const router = useRouter()
|
| 124 |
+
const [stats, setStats] = useState<Stats | null>(null)
|
| 125 |
+
const [health, setHealth] = useState<Health | null>(null)
|
| 126 |
+
const [crawlers, setCrawlers] = useState<Record<string, Crawler>>({})
|
| 127 |
+
const [documents, setDocuments] = useState<Document[]>([])
|
| 128 |
+
const [searchQuery, setSearchQuery] = useState("")
|
| 129 |
+
const [command, setCommand] = useState("")
|
| 130 |
+
const [commandHistory, setCommandHistory] = useState<CommandResult[]>([])
|
| 131 |
+
const [isExecuting, setIsExecuting] = useState(false)
|
| 132 |
+
const [configs, setConfigs] = useState<ConfigData>({})
|
| 133 |
+
const [newConfigKey, setNewConfigKey] = useState("")
|
| 134 |
+
const [newConfigValue, setNewConfigValue] = useState("")
|
| 135 |
+
const [newConfigDescription, setNewConfigDescription] = useState("")
|
| 136 |
+
const [databaseStats, setDatabaseStats] = useState<DatabaseStats | null>(null)
|
| 137 |
+
const [databaseStorageStats, setDatabaseStorageStats] = useState<DatabaseStorageStats | null>(null)
|
| 138 |
+
const [selectedCrawler, setSelectedCrawler] = useState<string | null>(null)
|
| 139 |
+
const [isRefreshing, setIsRefreshing] = useState(false)
|
| 140 |
+
const isRefreshingRef = useRef(false)
|
| 141 |
+
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
| 142 |
+
const fetchCrawlersRef = useRef<(() => Promise<void>) | null>(null)
|
| 143 |
+
const intervalSetupRef = useRef(false)
|
| 144 |
+
|
| 145 |
+
useEffect(() => {
|
| 146 |
+
if (!loading && !isAuthenticated) {
|
| 147 |
+
router.push("/auth/signin?redirect=/admin")
|
| 148 |
+
} else if (!loading && isAuthenticated && !isAdmin) {
|
| 149 |
+
router.push("/chat")
|
| 150 |
+
}
|
| 151 |
+
}, [isAuthenticated, isAdmin, loading, router])
|
| 152 |
+
|
| 153 |
+
const fetchStats = async () => {
|
| 154 |
+
try {
|
| 155 |
+
const response = await fetch(`/api/cache/admin/stats`, {
|
| 156 |
+
headers: getAuthHeaders()
|
| 157 |
+
})
|
| 158 |
+
if (response.ok) {
|
| 159 |
+
const data = await response.json()
|
| 160 |
+
setStats(data)
|
| 161 |
+
}
|
| 162 |
+
} catch (error) {
|
| 163 |
+
console.error("Failed to fetch stats:", error)
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
const fetchHealth = async () => {
|
| 168 |
+
try {
|
| 169 |
+
const response = await fetch(`${API_BASE_URL}/health`)
|
| 170 |
+
if (response.ok) {
|
| 171 |
+
const data = await response.json()
|
| 172 |
+
setHealth(data)
|
| 173 |
+
}
|
| 174 |
+
} catch (error) {
|
| 175 |
+
console.error("Failed to fetch health:", error)
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// Create a stable fetch function that uses refs for auth state
|
| 180 |
+
const isAuthenticatedRef = useRef(isAuthenticated)
|
| 181 |
+
const isAdminRef = useRef(isAdmin)
|
| 182 |
+
|
| 183 |
+
// Update refs when values change
|
| 184 |
+
useEffect(() => {
|
| 185 |
+
isAuthenticatedRef.current = isAuthenticated
|
| 186 |
+
isAdminRef.current = isAdmin
|
| 187 |
+
}, [isAuthenticated, isAdmin])
|
| 188 |
+
|
| 189 |
+
const fetchCrawlers = useCallback(async () => {
|
| 190 |
+
// Only fetch if authenticated and admin (check refs for latest values)
|
| 191 |
+
if (!isAuthenticatedRef.current || !isAdminRef.current) {
|
| 192 |
+
return
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
// Prevent concurrent fetches
|
| 196 |
+
if (isRefreshingRef.current) {
|
| 197 |
+
return
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
try {
|
| 201 |
+
isRefreshingRef.current = true
|
| 202 |
+
setIsRefreshing(true)
|
| 203 |
+
const response = await fetch(`${API_BASE_URL}/api/admin/crawlers`, {
|
| 204 |
+
headers: {
|
| 205 |
+
"Content-Type": "application/json",
|
| 206 |
+
...getAuthHeaders()
|
| 207 |
+
},
|
| 208 |
+
})
|
| 209 |
+
if (response.ok) {
|
| 210 |
+
const data = await response.json()
|
| 211 |
+
setCrawlers(data.crawlers || {})
|
| 212 |
+
} else {
|
| 213 |
+
console.error("Failed to fetch crawlers:", response.status)
|
| 214 |
+
setCrawlers({})
|
| 215 |
+
}
|
| 216 |
+
} catch (error) {
|
| 217 |
+
console.error("Failed to fetch crawlers:", error)
|
| 218 |
+
setCrawlers({})
|
| 219 |
+
} finally {
|
| 220 |
+
isRefreshingRef.current = false
|
| 221 |
+
setIsRefreshing(false)
|
| 222 |
+
}
|
| 223 |
+
}, []) // No dependencies - uses refs for auth state
|
| 224 |
+
|
| 225 |
+
// Update the ref whenever fetchCrawlers changes (though it shouldn't change now)
|
| 226 |
+
useEffect(() => {
|
| 227 |
+
fetchCrawlersRef.current = fetchCrawlers
|
| 228 |
+
}, [fetchCrawlers])
|
| 229 |
+
|
| 230 |
+
const fetchConfigs = async () => {
|
| 231 |
+
try {
|
| 232 |
+
const response = await fetch(`/api/cache/admin/config`, {
|
| 233 |
+
headers: {
|
| 234 |
+
"Content-Type": "application/json",
|
| 235 |
+
...getAuthHeaders()
|
| 236 |
+
},
|
| 237 |
+
})
|
| 238 |
+
if (response.ok) {
|
| 239 |
+
const data = await response.json()
|
| 240 |
+
setConfigs(data)
|
| 241 |
+
}
|
| 242 |
+
} catch (error) {
|
| 243 |
+
console.error("Failed to fetch configs:", error)
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
const fetchDatabaseStats = async () => {
|
| 248 |
+
try {
|
| 249 |
+
const response = await fetch(`/api/cache/admin/databases`, {
|
| 250 |
+
headers: {
|
| 251 |
+
"Content-Type": "application/json",
|
| 252 |
+
...getAuthHeaders()
|
| 253 |
+
},
|
| 254 |
+
})
|
| 255 |
+
if (response.ok) {
|
| 256 |
+
const data = await response.json()
|
| 257 |
+
setDatabaseStats(data)
|
| 258 |
+
}
|
| 259 |
+
} catch (error) {
|
| 260 |
+
console.error("Failed to fetch database stats:", error)
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
const fetchDatabaseStorageStats = async () => {
|
| 265 |
+
try {
|
| 266 |
+
const response = await fetch(`/api/cache/admin/database-storage`, {
|
| 267 |
+
headers: {
|
| 268 |
+
"Content-Type": "application/json",
|
| 269 |
+
...getAuthHeaders()
|
| 270 |
+
},
|
| 271 |
+
})
|
| 272 |
+
if (response.ok) {
|
| 273 |
+
const data = await response.json()
|
| 274 |
+
setDatabaseStorageStats(data)
|
| 275 |
+
}
|
| 276 |
+
} catch (error) {
|
| 277 |
+
console.error("Failed to fetch database storage stats:", error)
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
const searchDocuments = async () => {
|
| 282 |
+
try {
|
| 283 |
+
const params = new URLSearchParams()
|
| 284 |
+
if (searchQuery) params.append("query", searchQuery)
|
| 285 |
+
params.append("limit", "100")
|
| 286 |
+
|
| 287 |
+
const response = await fetch(`/api/cache/admin/documents?${params}`, {
|
| 288 |
+
headers: {
|
| 289 |
+
"Content-Type": "application/json",
|
| 290 |
+
...getAuthHeaders()
|
| 291 |
+
},
|
| 292 |
+
})
|
| 293 |
+
if (response.ok) {
|
| 294 |
+
const data = await response.json()
|
| 295 |
+
setDocuments(data.documents)
|
| 296 |
+
}
|
| 297 |
+
} catch (error) {
|
| 298 |
+
console.error("Failed to search documents:", error)
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
const executeCommand = async () => {
|
| 303 |
+
if (!command.trim()) return
|
| 304 |
+
|
| 305 |
+
setIsExecuting(true)
|
| 306 |
+
try {
|
| 307 |
+
const response = await fetch(`${API_BASE_URL}/admin/execute`, {
|
| 308 |
+
method: "POST",
|
| 309 |
+
headers: {
|
| 310 |
+
"Content-Type": "application/json",
|
| 311 |
+
...getAuthHeaders()
|
| 312 |
+
},
|
| 313 |
+
body: JSON.stringify({ command }),
|
| 314 |
+
})
|
| 315 |
+
|
| 316 |
+
if (response.ok) {
|
| 317 |
+
const result: CommandResult = await response.json()
|
| 318 |
+
setCommandHistory(prev => [result, ...prev.slice(0, 9)]) // Keep last 10
|
| 319 |
+
setCommand("")
|
| 320 |
+
}
|
| 321 |
+
} catch (error) {
|
| 322 |
+
console.error("Failed to execute command:", error)
|
| 323 |
+
} finally {
|
| 324 |
+
setIsExecuting(false)
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
// Initial data fetch - only runs when auth state stabilizes
|
| 329 |
+
useEffect(() => {
|
| 330 |
+
// Only fetch data if authenticated and admin
|
| 331 |
+
if (!isAuthenticated || !isAdmin || loading) {
|
| 332 |
+
return
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
fetchStats()
|
| 336 |
+
fetchHealth()
|
| 337 |
+
fetchCrawlers() // Now stable, can be called directly
|
| 338 |
+
fetchConfigs()
|
| 339 |
+
fetchDatabaseStats()
|
| 340 |
+
fetchDatabaseStorageStats()
|
| 341 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 342 |
+
}, [isAuthenticated, isAdmin, loading])
|
| 343 |
+
|
| 344 |
+
// Separate effect for interval - only runs once when component mounts and auth is ready
|
| 345 |
+
useEffect(() => {
|
| 346 |
+
// Only set up interval if authenticated and admin
|
| 347 |
+
if (!isAuthenticated || !isAdmin || loading) {
|
| 348 |
+
// Clear interval if auth state changes
|
| 349 |
+
if (intervalRef.current) {
|
| 350 |
+
clearInterval(intervalRef.current)
|
| 351 |
+
intervalRef.current = null
|
| 352 |
+
intervalSetupRef.current = false
|
| 353 |
+
}
|
| 354 |
+
return
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
// Prevent multiple interval setups
|
| 358 |
+
if (intervalSetupRef.current && intervalRef.current) {
|
| 359 |
+
return
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
// Clear any existing interval before creating a new one (safety check)
|
| 363 |
+
if (intervalRef.current) {
|
| 364 |
+
clearInterval(intervalRef.current)
|
| 365 |
+
intervalRef.current = null
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
// Auto-refresh crawler status every 10 seconds
|
| 369 |
+
intervalRef.current = setInterval(() => {
|
| 370 |
+
// Use the ref to get the latest fetchCrawlers function
|
| 371 |
+
if (fetchCrawlersRef.current) {
|
| 372 |
+
fetchCrawlersRef.current()
|
| 373 |
+
}
|
| 374 |
+
}, 10000)
|
| 375 |
+
|
| 376 |
+
intervalSetupRef.current = true
|
| 377 |
+
|
| 378 |
+
return () => {
|
| 379 |
+
if (intervalRef.current) {
|
| 380 |
+
clearInterval(intervalRef.current)
|
| 381 |
+
intervalRef.current = null
|
| 382 |
+
intervalSetupRef.current = false
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
}, [isAuthenticated, isAdmin, loading])
|
| 386 |
+
|
| 387 |
+
const runCrawler = async (crawlerName: string) => {
|
| 388 |
+
try {
|
| 389 |
+
const response = await fetch(`${API_BASE_URL}/api/admin/crawlers/${crawlerName}/start`, {
|
| 390 |
+
method: "POST",
|
| 391 |
+
headers: {
|
| 392 |
+
"Content-Type": "application/json",
|
| 393 |
+
...getAuthHeaders()
|
| 394 |
+
},
|
| 395 |
+
})
|
| 396 |
+
|
| 397 |
+
if (response.ok) {
|
| 398 |
+
const result = await response.json().catch(() => ({}))
|
| 399 |
+
// Refresh crawler status after a short delay
|
| 400 |
+
setTimeout(() => {
|
| 401 |
+
fetchCrawlers()
|
| 402 |
+
}, 1000)
|
| 403 |
+
console.log("Crawler started successfully:", result)
|
| 404 |
+
} else {
|
| 405 |
+
let errorMessage = `Failed to start crawler: ${response.status} ${response.statusText}`
|
| 406 |
+
try {
|
| 407 |
+
const errorData = await response.json()
|
| 408 |
+
errorMessage = errorData.detail || errorData.message || errorMessage
|
| 409 |
+
} catch {
|
| 410 |
+
// If JSON parsing fails, use the default message
|
| 411 |
+
}
|
| 412 |
+
console.error("Failed to start crawler:", errorMessage)
|
| 413 |
+
alert(errorMessage)
|
| 414 |
+
}
|
| 415 |
+
} catch (error) {
|
| 416 |
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
| 417 |
+
console.error("Failed to start crawler:", errorMessage)
|
| 418 |
+
alert(`Failed to start crawler: ${errorMessage}`)
|
| 419 |
+
}
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
const stopCrawler = async (crawlerName: string) => {
|
| 423 |
+
try {
|
| 424 |
+
const response = await fetch(`${API_BASE_URL}/admin/crawlers/${crawlerName}/stop`, {
|
| 425 |
+
method: "POST",
|
| 426 |
+
headers: {
|
| 427 |
+
"Content-Type": "application/json",
|
| 428 |
+
...getAuthHeaders()
|
| 429 |
+
},
|
| 430 |
+
})
|
| 431 |
+
if (response.ok) {
|
| 432 |
+
setTimeout(() => {
|
| 433 |
+
fetchCrawlers()
|
| 434 |
+
}, 1000)
|
| 435 |
+
} else {
|
| 436 |
+
let errorMessage = `Failed to stop crawler: ${response.status} ${response.statusText}`
|
| 437 |
+
try {
|
| 438 |
+
const errorData = await response.json()
|
| 439 |
+
errorMessage = errorData.detail || errorData.message || errorMessage
|
| 440 |
+
} catch {
|
| 441 |
+
// If JSON parsing fails, use the default message
|
| 442 |
+
}
|
| 443 |
+
console.error("Failed to stop crawler:", errorMessage)
|
| 444 |
+
alert(errorMessage)
|
| 445 |
+
}
|
| 446 |
+
} catch (error) {
|
| 447 |
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
| 448 |
+
console.error("Failed to stop crawler:", errorMessage)
|
| 449 |
+
alert(`Failed to stop crawler: ${errorMessage}`)
|
| 450 |
+
}
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
const viewCrawlerLogs = (crawlerName: string) => {
|
| 454 |
+
setSelectedCrawler(selectedCrawler === crawlerName ? null : crawlerName)
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
const setConfig = async () => {
|
| 458 |
+
if (!newConfigKey.trim() || !newConfigValue.trim()) return
|
| 459 |
+
|
| 460 |
+
try {
|
| 461 |
+
const response = await fetch(`${API_BASE_URL}/admin/config`, {
|
| 462 |
+
method: "POST",
|
| 463 |
+
headers: {
|
| 464 |
+
"Content-Type": "application/json",
|
| 465 |
+
...getAuthHeaders()
|
| 466 |
+
},
|
| 467 |
+
body: JSON.stringify({
|
| 468 |
+
key: newConfigKey,
|
| 469 |
+
value: newConfigValue,
|
| 470 |
+
description: newConfigDescription
|
| 471 |
+
}),
|
| 472 |
+
})
|
| 473 |
+
if (response.ok) {
|
| 474 |
+
// Invalidate cache
|
| 475 |
+
await fetch(`/api/cache/admin/config`, { method: "DELETE" })
|
| 476 |
+
|
| 477 |
+
setNewConfigKey("")
|
| 478 |
+
setNewConfigValue("")
|
| 479 |
+
setNewConfigDescription("")
|
| 480 |
+
fetchConfigs()
|
| 481 |
+
}
|
| 482 |
+
} catch (error) {
|
| 483 |
+
console.error("Failed to set config:", error)
|
| 484 |
+
}
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
const deleteConfig = async (key: string) => {
|
| 488 |
+
try {
|
| 489 |
+
const response = await fetch(`${API_BASE_URL}/admin/config/${key}`, {
|
| 490 |
+
method: "DELETE",
|
| 491 |
+
headers: {
|
| 492 |
+
"Content-Type": "application/json",
|
| 493 |
+
...getAuthHeaders()
|
| 494 |
+
},
|
| 495 |
+
})
|
| 496 |
+
if (response.ok) {
|
| 497 |
+
// Invalidate cache
|
| 498 |
+
await fetch(`/api/cache/admin/config`, { method: "DELETE" })
|
| 499 |
+
fetchConfigs()
|
| 500 |
+
}
|
| 501 |
+
} catch (error) {
|
| 502 |
+
console.error("Failed to delete config:", error)
|
| 503 |
+
}
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
const getStatusColor = (status: string) => {
|
| 507 |
+
switch (status) {
|
| 508 |
+
case "healthy": return "text-green-600"
|
| 509 |
+
case "running": return "text-blue-600"
|
| 510 |
+
case "failed": return "text-red-600"
|
| 511 |
+
default: return "text-gray-600"
|
| 512 |
+
}
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
const getStatusIcon = (status: string) => {
|
| 516 |
+
switch (status) {
|
| 517 |
+
case "healthy": return <CheckCircle className="w-4 h-4" />
|
| 518 |
+
case "running": return <Activity className="w-4 h-4" />
|
| 519 |
+
case "failed": return <XCircle className="w-4 h-4" />
|
| 520 |
+
default: return <Clock className="w-4 h-4" />
|
| 521 |
+
}
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
if (loading) {
|
| 525 |
+
return (
|
| 526 |
+
<div className="min-h-screen flex items-center justify-center">
|
| 527 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
| 528 |
+
</div>
|
| 529 |
+
)
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
if (!isAuthenticated || !isAdmin) {
|
| 533 |
+
return null
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
return (
|
| 537 |
+
<div className="min-h-screen bg-background flex">
|
| 538 |
+
<AdminSidebar />
|
| 539 |
+
<div className="flex-1 ml-0 md:ml-[20px] p-4 md:p-6">
|
| 540 |
+
<div className="absolute top-4 right-4 z-10">
|
| 541 |
+
<ThemeToggle />
|
| 542 |
+
</div>
|
| 543 |
+
<div className="max-w-7xl mx-auto space-y-4 md:space-y-6">
|
| 544 |
+
{/* Header */}
|
| 545 |
+
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
| 546 |
+
<div>
|
| 547 |
+
<h1 className="text-2xl md:text-3xl font-bold">Admin Dashboard</h1>
|
| 548 |
+
<p className="text-sm md:text-base text-muted-foreground">Control Panel for AmaniQuery System</p>
|
| 549 |
+
</div>
|
| 550 |
+
<Link href="/">
|
| 551 |
+
<Button variant="outline" className="w-full sm:w-auto min-h-[44px]">
|
| 552 |
+
<Globe className="w-4 h-4 mr-2" />
|
| 553 |
+
Back to Home
|
| 554 |
+
</Button>
|
| 555 |
+
</Link>
|
| 556 |
+
</div>
|
| 557 |
+
|
| 558 |
+
{/* Stats Cards */}
|
| 559 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
|
| 560 |
+
<Card>
|
| 561 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
| 562 |
+
<CardTitle className="text-sm font-medium">Total Documents</CardTitle>
|
| 563 |
+
<FileText className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
| 564 |
+
</CardHeader>
|
| 565 |
+
<CardContent>
|
| 566 |
+
<div className="text-xl md:text-2xl font-bold">
|
| 567 |
+
{stats?.total_chunks || health?.database_chunks || 0}
|
| 568 |
+
</div>
|
| 569 |
+
<p className="text-xs text-muted-foreground">
|
| 570 |
+
Vector database chunks
|
| 571 |
+
</p>
|
| 572 |
+
</CardContent>
|
| 573 |
+
</Card>
|
| 574 |
+
|
| 575 |
+
<Card>
|
| 576 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
| 577 |
+
<CardTitle className="text-sm font-medium">Vector DB Status</CardTitle>
|
| 578 |
+
<Database className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
| 579 |
+
</CardHeader>
|
| 580 |
+
<CardContent>
|
| 581 |
+
<div className="flex items-center space-x-2">
|
| 582 |
+
{getStatusIcon(health?.status || "unknown")}
|
| 583 |
+
<span className={`text-sm font-medium ${getStatusColor(health?.status || "unknown")}`}>
|
| 584 |
+
{health?.status || "Unknown"}
|
| 585 |
+
</span>
|
| 586 |
+
</div>
|
| 587 |
+
<p className="text-xs text-muted-foreground">
|
| 588 |
+
{health?.embedding_model || "Loading..."}
|
| 589 |
+
</p>
|
| 590 |
+
</CardContent>
|
| 591 |
+
</Card>
|
| 592 |
+
|
| 593 |
+
<Card>
|
| 594 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
| 595 |
+
<CardTitle className="text-sm font-medium">System Status</CardTitle>
|
| 596 |
+
<Clock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
| 597 |
+
</CardHeader>
|
| 598 |
+
<CardContent>
|
| 599 |
+
<div className="text-lg md:text-2xl font-bold">
|
| 600 |
+
Active
|
| 601 |
+
</div>
|
| 602 |
+
<p className="text-xs text-muted-foreground">
|
| 603 |
+
System operational
|
| 604 |
+
</p>
|
| 605 |
+
</CardContent>
|
| 606 |
+
</Card>
|
| 607 |
+
|
| 608 |
+
<Card>
|
| 609 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
| 610 |
+
<CardTitle className="text-sm font-medium">API Health</CardTitle>
|
| 611 |
+
<Activity className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
| 612 |
+
</CardHeader>
|
| 613 |
+
<CardContent>
|
| 614 |
+
<div className="flex items-center space-x-2">
|
| 615 |
+
{health ? (
|
| 616 |
+
<>
|
| 617 |
+
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0" />
|
| 618 |
+
<span className={`text-sm font-medium ${getStatusColor("healthy")}`}>Online</span>
|
| 619 |
+
</>
|
| 620 |
+
) : (
|
| 621 |
+
<>
|
| 622 |
+
<XCircle className="w-4 h-4 text-red-600 flex-shrink-0" />
|
| 623 |
+
<span className={`text-sm font-medium ${getStatusColor("failed")}`}>Offline</span>
|
| 624 |
+
</>
|
| 625 |
+
)}
|
| 626 |
+
</div>
|
| 627 |
+
<p className="text-xs text-muted-foreground">
|
| 628 |
+
{health?.llm_provider || "Checking..."}
|
| 629 |
+
</p>
|
| 630 |
+
</CardContent>
|
| 631 |
+
</Card>
|
| 632 |
+
</div>
|
| 633 |
+
|
| 634 |
+
{/* Crawler Management */}
|
| 635 |
+
<Card>
|
| 636 |
+
<CardHeader>
|
| 637 |
+
<div className="flex items-center justify-between">
|
| 638 |
+
<CardTitle className="flex items-center text-lg md:text-xl">
|
| 639 |
+
<BarChart3 className="w-5 h-5 mr-2" />
|
| 640 |
+
Crawler Management
|
| 641 |
+
</CardTitle>
|
| 642 |
+
<Button
|
| 643 |
+
size="sm"
|
| 644 |
+
variant="outline"
|
| 645 |
+
onClick={fetchCrawlers}
|
| 646 |
+
disabled={isRefreshing}
|
| 647 |
+
className="h-8"
|
| 648 |
+
>
|
| 649 |
+
<RefreshCw className={`w-4 h-4 mr-1 ${isRefreshing ? "animate-spin" : ""}`} />
|
| 650 |
+
{isRefreshing ? "Refreshing..." : "Refresh"}
|
| 651 |
+
</Button>
|
| 652 |
+
</div>
|
| 653 |
+
</CardHeader>
|
| 654 |
+
<CardContent>
|
| 655 |
+
<div className="overflow-x-auto">
|
| 656 |
+
<Table>
|
| 657 |
+
<TableHeader>
|
| 658 |
+
<TableRow>
|
| 659 |
+
<TableHead className="min-w-[120px]">Crawler</TableHead>
|
| 660 |
+
<TableHead className="min-w-[80px]">Status</TableHead>
|
| 661 |
+
<TableHead className="min-w-[120px]">Last Run</TableHead>
|
| 662 |
+
<TableHead className="min-w-[80px]">PID</TableHead>
|
| 663 |
+
<TableHead className="min-w-[150px]">Actions</TableHead>
|
| 664 |
+
</TableRow>
|
| 665 |
+
</TableHeader>
|
| 666 |
+
<TableBody>
|
| 667 |
+
{crawlers && Object.keys(crawlers).length > 0 ? (
|
| 668 |
+
Object.entries(crawlers).map(([name, crawler]) => (
|
| 669 |
+
<React.Fragment key={name}>
|
| 670 |
+
<TableRow>
|
| 671 |
+
<TableCell className="font-medium capitalize text-sm md:text-base">
|
| 672 |
+
{name.replace(/_/g, ' ')}
|
| 673 |
+
</TableCell>
|
| 674 |
+
<TableCell>
|
| 675 |
+
<Badge variant={
|
| 676 |
+
crawler.status === "running" ? "default" :
|
| 677 |
+
crawler.status === "failed" ? "destructive" : "secondary"
|
| 678 |
+
} className="text-xs">
|
| 679 |
+
{crawler.status}
|
| 680 |
+
</Badge>
|
| 681 |
+
</TableCell>
|
| 682 |
+
<TableCell className="text-xs md:text-sm">
|
| 683 |
+
{crawler.last_run ? new Date(crawler.last_run).toLocaleString() : "Never"}
|
| 684 |
+
</TableCell>
|
| 685 |
+
<TableCell className="text-xs md:text-sm">
|
| 686 |
+
{crawler.pid || "-"}
|
| 687 |
+
</TableCell>
|
| 688 |
+
<TableCell>
|
| 689 |
+
<div className="flex space-x-1 md:space-x-2">
|
| 690 |
+
<Button
|
| 691 |
+
size="sm"
|
| 692 |
+
onClick={() => runCrawler(name)}
|
| 693 |
+
disabled={crawler.status === "running"}
|
| 694 |
+
className="h-8 px-2 md:px-3 text-xs md:text-sm min-w-[60px]"
|
| 695 |
+
>
|
| 696 |
+
<Play className="w-3 h-3 mr-1" />
|
| 697 |
+
Run
|
| 698 |
+
</Button>
|
| 699 |
+
{crawler.status === "running" && (
|
| 700 |
+
<Button
|
| 701 |
+
size="sm"
|
| 702 |
+
variant="destructive"
|
| 703 |
+
onClick={() => stopCrawler(name)}
|
| 704 |
+
className="h-8 px-2 md:px-3 text-xs md:text-sm min-w-[60px]"
|
| 705 |
+
>
|
| 706 |
+
<Square className="w-3 h-3 mr-1" />
|
| 707 |
+
Stop
|
| 708 |
+
</Button>
|
| 709 |
+
)}
|
| 710 |
+
<Button
|
| 711 |
+
size="sm"
|
| 712 |
+
variant="outline"
|
| 713 |
+
onClick={() => viewCrawlerLogs(name)}
|
| 714 |
+
className="h-8 px-2 md:px-3 text-xs md:text-sm"
|
| 715 |
+
>
|
| 716 |
+
{selectedCrawler === name ? "Hide" : "Logs"}
|
| 717 |
+
</Button>
|
| 718 |
+
</div>
|
| 719 |
+
</TableCell>
|
| 720 |
+
</TableRow>
|
| 721 |
+
{selectedCrawler === name && crawler.logs && crawler.logs.length > 0 && (
|
| 722 |
+
<TableRow>
|
| 723 |
+
<TableCell colSpan={5} className="bg-muted/50">
|
| 724 |
+
<div className="max-h-48 overflow-y-auto p-2">
|
| 725 |
+
<div className="text-xs font-mono space-y-1">
|
| 726 |
+
{crawler.logs.slice(-20).map((log, idx) => (
|
| 727 |
+
<div key={idx} className="text-muted-foreground">
|
| 728 |
+
{log}
|
| 729 |
+
</div>
|
| 730 |
+
))}
|
| 731 |
+
</div>
|
| 732 |
+
</div>
|
| 733 |
+
</TableCell>
|
| 734 |
+
</TableRow>
|
| 735 |
+
)}
|
| 736 |
+
</React.Fragment>
|
| 737 |
+
))
|
| 738 |
+
) : (
|
| 739 |
+
<TableRow>
|
| 740 |
+
<TableCell colSpan={5} className="text-center text-muted-foreground py-6 md:py-8 text-sm">
|
| 741 |
+
No crawlers available or loading...
|
| 742 |
+
</TableCell>
|
| 743 |
+
</TableRow>
|
| 744 |
+
)}
|
| 745 |
+
</TableBody>
|
| 746 |
+
</Table>
|
| 747 |
+
</div>
|
| 748 |
+
</CardContent>
|
| 749 |
+
</Card>
|
| 750 |
+
|
| 751 |
+
{/* Database Management */}
|
| 752 |
+
<Card>
|
| 753 |
+
<CardHeader>
|
| 754 |
+
<CardTitle className="flex items-center text-lg md:text-xl">
|
| 755 |
+
<Database className="w-5 h-5 mr-2" />
|
| 756 |
+
Database Management
|
| 757 |
+
</CardTitle>
|
| 758 |
+
</CardHeader>
|
| 759 |
+
<CardContent>
|
| 760 |
+
<div className="space-y-4">
|
| 761 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 762 |
+
<div className="text-center">
|
| 763 |
+
<div className="text-2xl font-bold text-blue-600">
|
| 764 |
+
{databaseStats?.total_databases || 0}
|
| 765 |
+
</div>
|
| 766 |
+
<p className="text-sm text-muted-foreground">Total Databases</p>
|
| 767 |
+
</div>
|
| 768 |
+
<div className="text-center">
|
| 769 |
+
<div className="text-2xl font-bold text-green-600">
|
| 770 |
+
{databaseStats?.active_databases || 0}
|
| 771 |
+
</div>
|
| 772 |
+
<p className="text-sm text-muted-foreground">Active Databases</p>
|
| 773 |
+
</div>
|
| 774 |
+
<div className="text-center">
|
| 775 |
+
<div className="text-2xl font-bold text-purple-600">
|
| 776 |
+
{databaseStats?.databases?.reduce((sum, db) => sum + db.total_chunks, 0) || 0}
|
| 777 |
+
</div>
|
| 778 |
+
<p className="text-sm text-muted-foreground">Total Chunks</p>
|
| 779 |
+
</div>
|
| 780 |
+
</div>
|
| 781 |
+
|
| 782 |
+
<div className="space-y-3">
|
| 783 |
+
{databaseStats?.databases?.map((db) => (
|
| 784 |
+
<Card key={`${db.name}-${db.type}`} className="bg-muted/50">
|
| 785 |
+
<CardContent className="p-4">
|
| 786 |
+
<div className="flex items-center justify-between mb-3">
|
| 787 |
+
<div className="flex items-center space-x-3">
|
| 788 |
+
<div className={`w-3 h-3 rounded-full ${
|
| 789 |
+
db.status === 'active' ? 'bg-green-500' : 'bg-gray-400'
|
| 790 |
+
}`} />
|
| 791 |
+
<div>
|
| 792 |
+
<h4 className="font-medium capitalize">{db.name}</h4>
|
| 793 |
+
<p className="text-sm text-muted-foreground capitalize">{db.type} Database</p>
|
| 794 |
+
</div>
|
| 795 |
+
</div>
|
| 796 |
+
<Badge variant={db.status === 'active' ? 'secondary' : 'outline'}>
|
| 797 |
+
{db.status}
|
| 798 |
+
</Badge>
|
| 799 |
+
</div>
|
| 800 |
+
|
| 801 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
| 802 |
+
<div>
|
| 803 |
+
<span className="text-muted-foreground">Chunks:</span>
|
| 804 |
+
<div className="font-medium">{db.total_chunks.toLocaleString()}</div>
|
| 805 |
+
</div>
|
| 806 |
+
{db.elasticsearch_enabled && (
|
| 807 |
+
<div>
|
| 808 |
+
<span className="text-muted-foreground">ES Docs:</span>
|
| 809 |
+
<div className="font-medium">{db.elasticsearch_docs.toLocaleString()}</div>
|
| 810 |
+
</div>
|
| 811 |
+
)}
|
| 812 |
+
<div>
|
| 813 |
+
<span className="text-muted-foreground">Categories:</span>
|
| 814 |
+
<div className="font-medium">{Object.keys(db.categories).length}</div>
|
| 815 |
+
</div>
|
| 816 |
+
{db.persist_directory && (
|
| 817 |
+
<div>
|
| 818 |
+
<span className="text-muted-foreground">Storage:</span>
|
| 819 |
+
<div className="font-medium text-xs truncate max-w-24" title={db.persist_directory}>
|
| 820 |
+
{db.persist_directory.split('/').pop() || 'Local'}
|
| 821 |
+
</div>
|
| 822 |
+
</div>
|
| 823 |
+
)}
|
| 824 |
+
</div>
|
| 825 |
+
|
| 826 |
+
{Object.keys(db.categories).length > 0 && (
|
| 827 |
+
<div className="mt-3 pt-3 border-t">
|
| 828 |
+
<div className="text-sm text-muted-foreground mb-2">Top Categories:</div>
|
| 829 |
+
<div className="flex flex-wrap gap-1">
|
| 830 |
+
{Object.entries(db.categories)
|
| 831 |
+
.sort(([,a], [,b]) => b - a)
|
| 832 |
+
.slice(0, 5)
|
| 833 |
+
.map(([category, count]) => (
|
| 834 |
+
<Badge key={category} variant="outline" className="text-xs">
|
| 835 |
+
{category}: {count}
|
| 836 |
+
</Badge>
|
| 837 |
+
))}
|
| 838 |
+
</div>
|
| 839 |
+
</div>
|
| 840 |
+
)}
|
| 841 |
+
</CardContent>
|
| 842 |
+
</Card>
|
| 843 |
+
))}
|
| 844 |
+
</div>
|
| 845 |
+
</div>
|
| 846 |
+
</CardContent>
|
| 847 |
+
</Card>
|
| 848 |
+
|
| 849 |
+
{/* Command Shell */}
|
| 850 |
+
<Card>
|
| 851 |
+
<CardHeader>
|
| 852 |
+
<CardTitle className="flex items-center text-lg md:text-xl">
|
| 853 |
+
<Activity className="w-5 h-5 mr-2" />
|
| 854 |
+
Command Shell
|
| 855 |
+
</CardTitle>
|
| 856 |
+
</CardHeader>
|
| 857 |
+
<CardContent>
|
| 858 |
+
<div className="space-y-3 md:space-y-4">
|
| 859 |
+
<div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2">
|
| 860 |
+
<Input
|
| 861 |
+
placeholder="Enter command to execute..."
|
| 862 |
+
value={command}
|
| 863 |
+
onChange={(e) => setCommand(e.target.value)}
|
| 864 |
+
onKeyPress={(e) => e.key === 'Enter' && executeCommand()}
|
| 865 |
+
className="flex-1 font-mono text-sm md:text-base h-10 md:h-11"
|
| 866 |
+
disabled={isExecuting}
|
| 867 |
+
/>
|
| 868 |
+
<Button
|
| 869 |
+
onClick={executeCommand}
|
| 870 |
+
disabled={isExecuting || !command.trim()}
|
| 871 |
+
className="w-full sm:w-auto min-h-[44px] px-4 md:px-6"
|
| 872 |
+
>
|
| 873 |
+
{isExecuting ? "Running..." : "Execute"}
|
| 874 |
+
</Button>
|
| 875 |
+
</div>
|
| 876 |
+
|
| 877 |
+
<div className="space-y-2 max-h-64 md:max-h-96 overflow-y-auto">
|
| 878 |
+
{commandHistory.map((result, index) => (
|
| 879 |
+
<Card key={index} className="bg-muted">
|
| 880 |
+
<CardContent className="p-3 md:p-4">
|
| 881 |
+
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 mb-2">
|
| 882 |
+
<span className="font-mono text-xs md:text-sm text-muted-foreground">$</span>
|
| 883 |
+
<code className="font-mono text-xs md:text-sm flex-1 break-all">{result.command}</code>
|
| 884 |
+
<Badge variant={result.exit_code === 0 ? "secondary" : "destructive"} className="text-xs w-fit">
|
| 885 |
+
Exit: {result.exit_code}
|
| 886 |
+
</Badge>
|
| 887 |
+
</div>
|
| 888 |
+
{result.stdout && (
|
| 889 |
+
<pre className="text-xs font-mono bg-background p-2 rounded border overflow-x-auto whitespace-pre-wrap">
|
| 890 |
+
{result.stdout}
|
| 891 |
+
</pre>
|
| 892 |
+
)}
|
| 893 |
+
{result.stderr && (
|
| 894 |
+
<pre className="text-xs font-mono bg-destructive/10 text-destructive p-2 rounded border overflow-x-auto whitespace-pre-wrap">
|
| 895 |
+
{result.stderr}
|
| 896 |
+
</pre>
|
| 897 |
+
)}
|
| 898 |
+
</CardContent>
|
| 899 |
+
</Card>
|
| 900 |
+
))}
|
| 901 |
+
</div>
|
| 902 |
+
</div>
|
| 903 |
+
</CardContent>
|
| 904 |
+
</Card>
|
| 905 |
+
|
| 906 |
+
{/* Data Explorer */}
|
| 907 |
+
<Card>
|
| 908 |
+
<CardHeader>
|
| 909 |
+
<CardTitle className="flex items-center text-lg md:text-xl">
|
| 910 |
+
<Search className="w-5 h-5 mr-2" />
|
| 911 |
+
Data Explorer
|
| 912 |
+
</CardTitle>
|
| 913 |
+
</CardHeader>
|
| 914 |
+
<CardContent>
|
| 915 |
+
<div className="space-y-3 md:space-y-4">
|
| 916 |
+
<div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2">
|
| 917 |
+
<Input
|
| 918 |
+
placeholder="Search documents by keyword, source, or date..."
|
| 919 |
+
value={searchQuery}
|
| 920 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 921 |
+
onKeyPress={(e) => e.key === 'Enter' && searchDocuments()}
|
| 922 |
+
className="flex-1 h-10 md:h-11 text-sm md:text-base"
|
| 923 |
+
/>
|
| 924 |
+
<Button
|
| 925 |
+
onClick={searchDocuments}
|
| 926 |
+
className="w-full sm:w-auto min-h-[44px] px-4 md:px-6"
|
| 927 |
+
>
|
| 928 |
+
<Search className="w-4 h-4 mr-2" />
|
| 929 |
+
Search
|
| 930 |
+
</Button>
|
| 931 |
+
</div>
|
| 932 |
+
|
| 933 |
+
{documents.length > 0 && (
|
| 934 |
+
<div className="space-y-2">
|
| 935 |
+
<p className="text-sm text-muted-foreground">
|
| 936 |
+
Found {documents.length} documents
|
| 937 |
+
</p>
|
| 938 |
+
<div className="max-h-64 md:max-h-96 overflow-y-auto space-y-2">
|
| 939 |
+
{documents.slice(0, 20).map((doc) => (
|
| 940 |
+
<Card key={doc.id} className="cursor-pointer hover:bg-muted/50">
|
| 941 |
+
<CardContent className="p-3 md:p-4">
|
| 942 |
+
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start mb-2 gap-2">
|
| 943 |
+
<h4 className="font-medium text-sm md:text-base line-clamp-1">
|
| 944 |
+
{doc.metadata.title || "Untitled"}
|
| 945 |
+
</h4>
|
| 946 |
+
<Badge variant="outline" className="text-xs w-fit flex-shrink-0">
|
| 947 |
+
{doc.metadata.category}
|
| 948 |
+
</Badge>
|
| 949 |
+
</div>
|
| 950 |
+
<p className="text-xs md:text-sm text-muted-foreground mb-2 line-clamp-2">
|
| 951 |
+
{doc.content.substring(0, 200)}...
|
| 952 |
+
</p>
|
| 953 |
+
<div className="flex flex-col sm:flex-row sm:justify-between text-xs text-muted-foreground gap-1">
|
| 954 |
+
<span className="truncate">{doc.metadata.source}</span>
|
| 955 |
+
<span className="flex-shrink-0">{doc.metadata.date}</span>
|
| 956 |
+
</div>
|
| 957 |
+
</CardContent>
|
| 958 |
+
</Card>
|
| 959 |
+
))}
|
| 960 |
+
</div>
|
| 961 |
+
</div>
|
| 962 |
+
)}
|
| 963 |
+
|
| 964 |
+
<div className="text-xs md:text-sm text-muted-foreground bg-muted/50 p-3 rounded-lg">
|
| 965 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
| 966 |
+
<div><strong>Categories:</strong> {stats ? Object.keys(stats.categories).join(", ") : "Loading..."}</div>
|
| 967 |
+
<div><strong>Sources:</strong> {stats ? stats.sources.join(", ") : "Loading..."}</div>
|
| 968 |
+
</div>
|
| 969 |
+
</div>
|
| 970 |
+
</div>
|
| 971 |
+
</CardContent>
|
| 972 |
+
</Card>
|
| 973 |
+
|
| 974 |
+
{/* Database Storage */}
|
| 975 |
+
<Card>
|
| 976 |
+
<CardHeader>
|
| 977 |
+
<CardTitle className="flex items-center text-lg md:text-xl">
|
| 978 |
+
<Database className="w-5 h-5 mr-2" />
|
| 979 |
+
Database Storage
|
| 980 |
+
</CardTitle>
|
| 981 |
+
</CardHeader>
|
| 982 |
+
<CardContent>
|
| 983 |
+
<div className="space-y-4">
|
| 984 |
+
{databaseStorageStats ? (
|
| 985 |
+
<>
|
| 986 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 987 |
+
<div className="text-center">
|
| 988 |
+
<div className="text-2xl font-bold text-blue-600">
|
| 989 |
+
{databaseStorageStats.raw_documents.total}
|
| 990 |
+
</div>
|
| 991 |
+
<p className="text-sm text-muted-foreground">Raw Documents</p>
|
| 992 |
+
<div className="text-xs text-green-600">
|
| 993 |
+
{databaseStorageStats.raw_documents.processed} processed
|
| 994 |
+
</div>
|
| 995 |
+
<div className="text-xs text-orange-600">
|
| 996 |
+
{databaseStorageStats.raw_documents.unprocessed} unprocessed
|
| 997 |
+
</div>
|
| 998 |
+
</div>
|
| 999 |
+
<div className="text-center">
|
| 1000 |
+
<div className="text-2xl font-bold text-purple-600">
|
| 1001 |
+
{databaseStorageStats.processed_chunks.total}
|
| 1002 |
+
</div>
|
| 1003 |
+
<p className="text-sm text-muted-foreground">Processed Chunks</p>
|
| 1004 |
+
</div>
|
| 1005 |
+
</div>
|
| 1006 |
+
|
| 1007 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 1008 |
+
<div>
|
| 1009 |
+
<h4 className="font-medium mb-2">Raw Documents by Category</h4>
|
| 1010 |
+
<div className="space-y-1">
|
| 1011 |
+
{Object.entries(databaseStorageStats.raw_documents.categories).map(([category, count]) => (
|
| 1012 |
+
<div key={category} className="flex justify-between text-sm">
|
| 1013 |
+
<span className="truncate">{category}</span>
|
| 1014 |
+
<Badge variant="outline" className="text-xs">{count}</Badge>
|
| 1015 |
+
</div>
|
| 1016 |
+
))}
|
| 1017 |
+
</div>
|
| 1018 |
+
</div>
|
| 1019 |
+
<div>
|
| 1020 |
+
<h4 className="font-medium mb-2">Processed Chunks by Category</h4>
|
| 1021 |
+
<div className="space-y-1">
|
| 1022 |
+
{Object.entries(databaseStorageStats.processed_chunks.categories).map(([category, count]) => (
|
| 1023 |
+
<div key={category} className="flex justify-between text-sm">
|
| 1024 |
+
<span className="truncate">{category}</span>
|
| 1025 |
+
<Badge variant="outline" className="text-xs">{count}</Badge>
|
| 1026 |
+
</div>
|
| 1027 |
+
))}
|
| 1028 |
+
</div>
|
| 1029 |
+
</div>
|
| 1030 |
+
</div>
|
| 1031 |
+
</>
|
| 1032 |
+
) : (
|
| 1033 |
+
<div className="text-center text-muted-foreground py-8">
|
| 1034 |
+
Database storage stats not available
|
| 1035 |
+
</div>
|
| 1036 |
+
)}
|
| 1037 |
+
</div>
|
| 1038 |
+
</CardContent>
|
| 1039 |
+
</Card>
|
| 1040 |
+
|
| 1041 |
+
{/* Configuration Management */}
|
| 1042 |
+
<Card>
|
| 1043 |
+
<CardHeader>
|
| 1044 |
+
<CardTitle className="flex items-center text-lg md:text-xl">
|
| 1045 |
+
<Database className="w-5 h-5 mr-2" />
|
| 1046 |
+
Configuration Management
|
| 1047 |
+
</CardTitle>
|
| 1048 |
+
</CardHeader>
|
| 1049 |
+
<CardContent>
|
| 1050 |
+
<div className="space-y-4">
|
| 1051 |
+
{/* Add New Config */}
|
| 1052 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 1053 |
+
<Input
|
| 1054 |
+
placeholder="Config Key (e.g., ELASTICSEARCH_URL)"
|
| 1055 |
+
value={newConfigKey}
|
| 1056 |
+
onChange={(e) => setNewConfigKey(e.target.value)}
|
| 1057 |
+
className="text-sm"
|
| 1058 |
+
/>
|
| 1059 |
+
<Input
|
| 1060 |
+
placeholder="Config Value"
|
| 1061 |
+
value={newConfigValue}
|
| 1062 |
+
onChange={(e) => setNewConfigValue(e.target.value)}
|
| 1063 |
+
type="password"
|
| 1064 |
+
className="text-sm"
|
| 1065 |
+
/>
|
| 1066 |
+
<div className="flex space-x-2">
|
| 1067 |
+
<Input
|
| 1068 |
+
placeholder="Description (optional)"
|
| 1069 |
+
value={newConfigDescription}
|
| 1070 |
+
onChange={(e) => setNewConfigDescription(e.target.value)}
|
| 1071 |
+
className="flex-1 text-sm"
|
| 1072 |
+
/>
|
| 1073 |
+
<Button
|
| 1074 |
+
onClick={setConfig}
|
| 1075 |
+
disabled={!newConfigKey.trim() || !newConfigValue.trim()}
|
| 1076 |
+
className="px-4"
|
| 1077 |
+
>
|
| 1078 |
+
Add
|
| 1079 |
+
</Button>
|
| 1080 |
+
</div>
|
| 1081 |
+
</div>
|
| 1082 |
+
|
| 1083 |
+
{/* Config List */}
|
| 1084 |
+
<div className="space-y-2">
|
| 1085 |
+
<h4 className="font-medium text-sm">Current Configurations</h4>
|
| 1086 |
+
<div className="max-h-64 overflow-y-auto space-y-2">
|
| 1087 |
+
{Object.entries(configs).map(([key, config]) => (
|
| 1088 |
+
<Card key={key} className="bg-muted/50">
|
| 1089 |
+
<CardContent className="p-3">
|
| 1090 |
+
<div className="flex items-center justify-between">
|
| 1091 |
+
<div className="flex-1 min-w-0">
|
| 1092 |
+
<div className="font-mono text-sm font-medium truncate">{key}</div>
|
| 1093 |
+
<div className="text-xs text-muted-foreground">{config.description || "No description"}</div>
|
| 1094 |
+
</div>
|
| 1095 |
+
<div className="flex items-center space-x-2 ml-4">
|
| 1096 |
+
<Badge variant={config.has_value ? "secondary" : "outline"} className="text-xs">
|
| 1097 |
+
{config.has_value ? "Set" : "Empty"}
|
| 1098 |
+
</Badge>
|
| 1099 |
+
<Button
|
| 1100 |
+
size="sm"
|
| 1101 |
+
variant="destructive"
|
| 1102 |
+
onClick={() => deleteConfig(key)}
|
| 1103 |
+
className="h-8 px-2 text-xs"
|
| 1104 |
+
>
|
| 1105 |
+
Delete
|
| 1106 |
+
</Button>
|
| 1107 |
+
</div>
|
| 1108 |
+
</div>
|
| 1109 |
+
</CardContent>
|
| 1110 |
+
</Card>
|
| 1111 |
+
))}
|
| 1112 |
+
{Object.keys(configs).length === 0 && (
|
| 1113 |
+
<div className="text-center text-muted-foreground py-4 text-sm">
|
| 1114 |
+
No configurations found
|
| 1115 |
+
</div>
|
| 1116 |
+
)}
|
| 1117 |
+
</div>
|
| 1118 |
+
</div>
|
| 1119 |
+
|
| 1120 |
+
{/* Common Config Templates */}
|
| 1121 |
+
<div className="text-xs text-muted-foreground bg-muted/50 p-3 rounded-lg">
|
| 1122 |
+
<div className="font-medium mb-2">Common Configuration Keys:</div>
|
| 1123 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-1 text-xs">
|
| 1124 |
+
<div>• ELASTICSEARCH_URL - Elasticsearch cloud URL</div>
|
| 1125 |
+
<div>• ELASTICSEARCH_API_KEY - Elasticsearch API key</div>
|
| 1126 |
+
<div>• UPSTASH_VECTOR_URL - Upstash Vector URL</div>
|
| 1127 |
+
<div>• UPSTASH_VECTOR_TOKEN - Upstash Vector token</div>
|
| 1128 |
+
<div>• QDRANT_URL - QDrant cloud URL</div>
|
| 1129 |
+
<div>• QDRANT_API_KEY - QDrant API key</div>
|
| 1130 |
+
<div>• UPSTASH_REDIS_URL - Upstash Redis URL</div>
|
| 1131 |
+
<div>• UPSTASH_REDIS_TOKEN - Upstash Redis token</div>
|
| 1132 |
+
<div>• LLM_PROVIDER - LLM provider (moonshot, openai, etc.)</div>
|
| 1133 |
+
<div>• GEMINI_API_KEY - Gemini API key</div>
|
| 1134 |
+
<div>• OPENAI_API_KEY - OpenAI API key</div>
|
| 1135 |
+
</div>
|
| 1136 |
+
</div>
|
| 1137 |
+
</div>
|
| 1138 |
+
</CardContent>
|
| 1139 |
+
</Card>
|
| 1140 |
+
</div>
|
| 1141 |
+
</div>
|
| 1142 |
+
</div>
|
| 1143 |
+
)
|
| 1144 |
+
}
|
src/app/admin/rag/page.tsx
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* RAG Settings - Admin Page
|
| 5 |
+
*
|
| 6 |
+
* Configure retrieval-augmented generation pipeline settings
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import { useState } from "react"
|
| 10 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
| 11 |
+
import { Button } from "@/components/ui/button"
|
| 12 |
+
import { Badge } from "@/components/ui/badge"
|
| 13 |
+
import { Input } from "@/components/ui/input"
|
| 14 |
+
import { Label } from "@/components/ui/label"
|
| 15 |
+
import { Switch } from "@/components/ui/switch"
|
| 16 |
+
import { Slider } from "@/components/ui/slider"
|
| 17 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
| 18 |
+
import {
|
| 19 |
+
Search,
|
| 20 |
+
Zap,
|
| 21 |
+
Filter,
|
| 22 |
+
Sparkles,
|
| 23 |
+
RefreshCw,
|
| 24 |
+
Database,
|
| 25 |
+
TrendingUp,
|
| 26 |
+
Settings,
|
| 27 |
+
CheckCircle
|
| 28 |
+
} from "lucide-react"
|
| 29 |
+
|
| 30 |
+
interface RAGConfig {
|
| 31 |
+
// Retrieval settings
|
| 32 |
+
topK: number
|
| 33 |
+
useReranking: boolean
|
| 34 |
+
rerankTopK: number
|
| 35 |
+
|
| 36 |
+
// Query expansion
|
| 37 |
+
useHyDE: boolean
|
| 38 |
+
useMultiQuery: boolean
|
| 39 |
+
queryVariants: number
|
| 40 |
+
|
| 41 |
+
// Hybrid search
|
| 42 |
+
useHybridSearch: boolean
|
| 43 |
+
hybridAlpha: number // 0 = all BM25, 1 = all vector
|
| 44 |
+
|
| 45 |
+
// Caching
|
| 46 |
+
enableSemanticCache: boolean
|
| 47 |
+
cacheTTLHours: number
|
| 48 |
+
|
| 49 |
+
// Performance
|
| 50 |
+
parallelRetrieval: boolean
|
| 51 |
+
maxNamespaces: number
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const DEFAULT_CONFIG: RAGConfig = {
|
| 55 |
+
topK: 5,
|
| 56 |
+
useReranking: true,
|
| 57 |
+
rerankTopK: 5,
|
| 58 |
+
useHyDE: false,
|
| 59 |
+
useMultiQuery: false,
|
| 60 |
+
queryVariants: 3,
|
| 61 |
+
useHybridSearch: false,
|
| 62 |
+
hybridAlpha: 0.7,
|
| 63 |
+
enableSemanticCache: true,
|
| 64 |
+
cacheTTLHours: 24,
|
| 65 |
+
parallelRetrieval: true,
|
| 66 |
+
maxNamespaces: 5
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export default function RAGSettingsPage() {
|
| 70 |
+
const [config, setConfig] = useState<RAGConfig>(DEFAULT_CONFIG)
|
| 71 |
+
const [isSaving, setIsSaving] = useState(false)
|
| 72 |
+
|
| 73 |
+
const updateConfig = <K extends keyof RAGConfig>(key: K, value: RAGConfig[K]) => {
|
| 74 |
+
setConfig(prev => ({ ...prev, [key]: value }))
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
const handleSave = async () => {
|
| 78 |
+
setIsSaving(true)
|
| 79 |
+
// TODO: Save to backend
|
| 80 |
+
await new Promise(r => setTimeout(r, 1000))
|
| 81 |
+
setIsSaving(false)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
return (
|
| 85 |
+
<div className="container mx-auto py-6 space-y-6">
|
| 86 |
+
<div className="flex items-center justify-between">
|
| 87 |
+
<div>
|
| 88 |
+
<h1 className="text-3xl font-bold">RAG Pipeline Settings</h1>
|
| 89 |
+
<p className="text-muted-foreground">Configure retrieval and generation parameters</p>
|
| 90 |
+
</div>
|
| 91 |
+
<Button onClick={handleSave} disabled={isSaving}>
|
| 92 |
+
{isSaving ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <CheckCircle className="h-4 w-4 mr-2" />}
|
| 93 |
+
Save Changes
|
| 94 |
+
</Button>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<div className="grid gap-6 md:grid-cols-4">
|
| 98 |
+
<Card>
|
| 99 |
+
<CardHeader className="pb-2">
|
| 100 |
+
<CardTitle className="text-sm font-medium">Top K</CardTitle>
|
| 101 |
+
</CardHeader>
|
| 102 |
+
<CardContent>
|
| 103 |
+
<div className="text-2xl font-bold">{config.topK}</div>
|
| 104 |
+
<p className="text-xs text-muted-foreground">Documents retrieved</p>
|
| 105 |
+
</CardContent>
|
| 106 |
+
</Card>
|
| 107 |
+
<Card>
|
| 108 |
+
<CardHeader className="pb-2">
|
| 109 |
+
<CardTitle className="text-sm font-medium">Re-ranking</CardTitle>
|
| 110 |
+
</CardHeader>
|
| 111 |
+
<CardContent>
|
| 112 |
+
<div className="text-2xl font-bold">{config.useReranking ? "Enabled" : "Disabled"}</div>
|
| 113 |
+
<p className="text-xs text-muted-foreground">Intelligent filtering</p>
|
| 114 |
+
</CardContent>
|
| 115 |
+
</Card>
|
| 116 |
+
<Card>
|
| 117 |
+
<CardHeader className="pb-2">
|
| 118 |
+
<CardTitle className="text-sm font-medium">Hybrid α</CardTitle>
|
| 119 |
+
</CardHeader>
|
| 120 |
+
<CardContent>
|
| 121 |
+
<div className="text-2xl font-bold">{config.hybridAlpha.toFixed(1)}</div>
|
| 122 |
+
<p className="text-xs text-muted-foreground">Vector vs BM25 balance</p>
|
| 123 |
+
</CardContent>
|
| 124 |
+
</Card>
|
| 125 |
+
<Card>
|
| 126 |
+
<CardHeader className="pb-2">
|
| 127 |
+
<CardTitle className="text-sm font-medium">Cache TTL</CardTitle>
|
| 128 |
+
</CardHeader>
|
| 129 |
+
<CardContent>
|
| 130 |
+
<div className="text-2xl font-bold">{config.cacheTTLHours}h</div>
|
| 131 |
+
<p className="text-xs text-muted-foreground">Result caching</p>
|
| 132 |
+
</CardContent>
|
| 133 |
+
</Card>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
<Tabs defaultValue="retrieval" className="space-y-4">
|
| 137 |
+
<TabsList>
|
| 138 |
+
<TabsTrigger value="retrieval">
|
| 139 |
+
<Search className="h-4 w-4 mr-2" />
|
| 140 |
+
Retrieval
|
| 141 |
+
</TabsTrigger>
|
| 142 |
+
<TabsTrigger value="expansion">
|
| 143 |
+
<Sparkles className="h-4 w-4 mr-2" />
|
| 144 |
+
Query Expansion
|
| 145 |
+
</TabsTrigger>
|
| 146 |
+
<TabsTrigger value="hybrid">
|
| 147 |
+
<Database className="h-4 w-4 mr-2" />
|
| 148 |
+
Hybrid Search
|
| 149 |
+
</TabsTrigger>
|
| 150 |
+
<TabsTrigger value="performance">
|
| 151 |
+
<Zap className="h-4 w-4 mr-2" />
|
| 152 |
+
Performance
|
| 153 |
+
</TabsTrigger>
|
| 154 |
+
</TabsList>
|
| 155 |
+
|
| 156 |
+
<TabsContent value="retrieval">
|
| 157 |
+
<Card>
|
| 158 |
+
<CardHeader>
|
| 159 |
+
<CardTitle>Retrieval Settings</CardTitle>
|
| 160 |
+
<CardDescription>Configure document retrieval parameters</CardDescription>
|
| 161 |
+
</CardHeader>
|
| 162 |
+
<CardContent className="space-y-6">
|
| 163 |
+
<div className="space-y-2">
|
| 164 |
+
<Label>Top K: {config.topK}</Label>
|
| 165 |
+
<Slider
|
| 166 |
+
value={[config.topK]}
|
| 167 |
+
min={1}
|
| 168 |
+
max={20}
|
| 169 |
+
step={1}
|
| 170 |
+
onValueChange={(v) => updateConfig('topK', v[0])}
|
| 171 |
+
/>
|
| 172 |
+
<p className="text-xs text-muted-foreground">Number of documents to retrieve</p>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
<div className="flex items-center justify-between">
|
| 176 |
+
<div className="space-y-0.5">
|
| 177 |
+
<Label>Intelligent Re-ranking</Label>
|
| 178 |
+
<p className="text-xs text-muted-foreground">Use cross-encoder to filter relevant docs</p>
|
| 179 |
+
</div>
|
| 180 |
+
<Switch
|
| 181 |
+
checked={config.useReranking}
|
| 182 |
+
onCheckedChange={(v: boolean) => updateConfig('useReranking', v)}
|
| 183 |
+
/>
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
{config.useReranking && (
|
| 187 |
+
<div className="space-y-2 ml-4 p-4 bg-muted/50 rounded-lg">
|
| 188 |
+
<Label>Re-rank Top K: {config.rerankTopK}</Label>
|
| 189 |
+
<Slider
|
| 190 |
+
value={[config.rerankTopK]}
|
| 191 |
+
min={1}
|
| 192 |
+
max={10}
|
| 193 |
+
step={1}
|
| 194 |
+
onValueChange={(v) => updateConfig('rerankTopK', v[0])}
|
| 195 |
+
/>
|
| 196 |
+
<p className="text-xs text-muted-foreground">Documents after re-ranking</p>
|
| 197 |
+
</div>
|
| 198 |
+
)}
|
| 199 |
+
</CardContent>
|
| 200 |
+
</Card>
|
| 201 |
+
</TabsContent>
|
| 202 |
+
|
| 203 |
+
<TabsContent value="expansion">
|
| 204 |
+
<Card>
|
| 205 |
+
<CardHeader>
|
| 206 |
+
<CardTitle>Query Expansion</CardTitle>
|
| 207 |
+
<CardDescription>Improve recall with query expansion techniques</CardDescription>
|
| 208 |
+
</CardHeader>
|
| 209 |
+
<CardContent className="space-y-6">
|
| 210 |
+
<div className="flex items-center justify-between">
|
| 211 |
+
<div className="space-y-0.5">
|
| 212 |
+
<Label>HyDE (Hypothetical Document Embeddings)</Label>
|
| 213 |
+
<p className="text-xs text-muted-foreground">Generate hypothetical answer, then search</p>
|
| 214 |
+
</div>
|
| 215 |
+
<Switch
|
| 216 |
+
checked={config.useHyDE}
|
| 217 |
+
onCheckedChange={(v: boolean) => updateConfig('useHyDE', v)}
|
| 218 |
+
/>
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
<div className="flex items-center justify-between">
|
| 222 |
+
<div className="space-y-0.5">
|
| 223 |
+
<Label>Multi-Query</Label>
|
| 224 |
+
<p className="text-xs text-muted-foreground">Generate multiple query variants</p>
|
| 225 |
+
</div>
|
| 226 |
+
<Switch
|
| 227 |
+
checked={config.useMultiQuery}
|
| 228 |
+
onCheckedChange={(v: boolean) => updateConfig('useMultiQuery', v)}
|
| 229 |
+
/>
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
{config.useMultiQuery && (
|
| 233 |
+
<div className="space-y-2 ml-4 p-4 bg-muted/50 rounded-lg">
|
| 234 |
+
<Label>Query Variants: {config.queryVariants}</Label>
|
| 235 |
+
<Slider
|
| 236 |
+
value={[config.queryVariants]}
|
| 237 |
+
min={2}
|
| 238 |
+
max={5}
|
| 239 |
+
step={1}
|
| 240 |
+
onValueChange={(v) => updateConfig('queryVariants', v[0])}
|
| 241 |
+
/>
|
| 242 |
+
</div>
|
| 243 |
+
)}
|
| 244 |
+
</CardContent>
|
| 245 |
+
</Card>
|
| 246 |
+
</TabsContent>
|
| 247 |
+
|
| 248 |
+
<TabsContent value="hybrid">
|
| 249 |
+
<Card>
|
| 250 |
+
<CardHeader>
|
| 251 |
+
<CardTitle>Hybrid Search</CardTitle>
|
| 252 |
+
<CardDescription>Combine vector and keyword search</CardDescription>
|
| 253 |
+
</CardHeader>
|
| 254 |
+
<CardContent className="space-y-6">
|
| 255 |
+
<div className="flex items-center justify-between">
|
| 256 |
+
<div className="space-y-0.5">
|
| 257 |
+
<Label>Enable Hybrid Search</Label>
|
| 258 |
+
<p className="text-xs text-muted-foreground">Combine BM25 + vector similarity</p>
|
| 259 |
+
</div>
|
| 260 |
+
<Switch
|
| 261 |
+
checked={config.useHybridSearch}
|
| 262 |
+
onCheckedChange={(v: boolean) => updateConfig('useHybridSearch', v)}
|
| 263 |
+
/>
|
| 264 |
+
</div>
|
| 265 |
+
|
| 266 |
+
{config.useHybridSearch && (
|
| 267 |
+
<div className="space-y-2">
|
| 268 |
+
<Label>Hybrid Alpha: {config.hybridAlpha.toFixed(2)}</Label>
|
| 269 |
+
<Slider
|
| 270 |
+
value={[config.hybridAlpha]}
|
| 271 |
+
min={0}
|
| 272 |
+
max={1}
|
| 273 |
+
step={0.05}
|
| 274 |
+
onValueChange={(v) => updateConfig('hybridAlpha', v[0])}
|
| 275 |
+
/>
|
| 276 |
+
<div className="flex justify-between text-xs text-muted-foreground">
|
| 277 |
+
<span>BM25 (keywords)</span>
|
| 278 |
+
<span>Vector (semantic)</span>
|
| 279 |
+
</div>
|
| 280 |
+
</div>
|
| 281 |
+
)}
|
| 282 |
+
</CardContent>
|
| 283 |
+
</Card>
|
| 284 |
+
</TabsContent>
|
| 285 |
+
|
| 286 |
+
<TabsContent value="performance">
|
| 287 |
+
<Card>
|
| 288 |
+
<CardHeader>
|
| 289 |
+
<CardTitle>Performance Settings</CardTitle>
|
| 290 |
+
<CardDescription>Optimize speed and caching</CardDescription>
|
| 291 |
+
</CardHeader>
|
| 292 |
+
<CardContent className="space-y-6">
|
| 293 |
+
<div className="flex items-center justify-between">
|
| 294 |
+
<div className="space-y-0.5">
|
| 295 |
+
<Label>Parallel Retrieval</Label>
|
| 296 |
+
<p className="text-xs text-muted-foreground">Query all namespaces simultaneously</p>
|
| 297 |
+
</div>
|
| 298 |
+
<Switch
|
| 299 |
+
checked={config.parallelRetrieval}
|
| 300 |
+
onCheckedChange={(v: boolean) => updateConfig('parallelRetrieval', v)}
|
| 301 |
+
/>
|
| 302 |
+
</div>
|
| 303 |
+
|
| 304 |
+
<div className="space-y-2">
|
| 305 |
+
<Label>Max Namespaces: {config.maxNamespaces}</Label>
|
| 306 |
+
<Slider
|
| 307 |
+
value={[config.maxNamespaces]}
|
| 308 |
+
min={1}
|
| 309 |
+
max={10}
|
| 310 |
+
step={1}
|
| 311 |
+
onValueChange={(v) => updateConfig('maxNamespaces', v[0])}
|
| 312 |
+
/>
|
| 313 |
+
</div>
|
| 314 |
+
|
| 315 |
+
<div className="flex items-center justify-between">
|
| 316 |
+
<div className="space-y-0.5">
|
| 317 |
+
<Label>Semantic Cache</Label>
|
| 318 |
+
<p className="text-xs text-muted-foreground">Cache similar query results</p>
|
| 319 |
+
</div>
|
| 320 |
+
<Switch
|
| 321 |
+
checked={config.enableSemanticCache}
|
| 322 |
+
onCheckedChange={(v: boolean) => updateConfig('enableSemanticCache', v)}
|
| 323 |
+
/>
|
| 324 |
+
</div>
|
| 325 |
+
|
| 326 |
+
{config.enableSemanticCache && (
|
| 327 |
+
<div className="space-y-2 ml-4 p-4 bg-muted/50 rounded-lg">
|
| 328 |
+
<Label>Cache TTL: {config.cacheTTLHours} hours</Label>
|
| 329 |
+
<Slider
|
| 330 |
+
value={[config.cacheTTLHours]}
|
| 331 |
+
min={1}
|
| 332 |
+
max={168}
|
| 333 |
+
step={1}
|
| 334 |
+
onValueChange={(v) => updateConfig('cacheTTLHours', v[0])}
|
| 335 |
+
/>
|
| 336 |
+
</div>
|
| 337 |
+
)}
|
| 338 |
+
</CardContent>
|
| 339 |
+
</Card>
|
| 340 |
+
</TabsContent>
|
| 341 |
+
</Tabs>
|
| 342 |
+
</div>
|
| 343 |
+
)
|
| 344 |
+
}
|
src/app/admin/settings/page.tsx
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react"
|
| 4 |
+
import { useRouter } from "next/navigation"
|
| 5 |
+
import { useAuth } from "@/lib/auth-context"
|
| 6 |
+
import { AdminSidebar } from "@/components/admin-sidebar"
|
| 7 |
+
import { ThemeToggle } from "@/components/theme-toggle"
|
| 8 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
| 9 |
+
import { Button } from "@/components/ui/button"
|
| 10 |
+
import { Input } from "@/components/ui/input"
|
| 11 |
+
import { Badge } from "@/components/ui/badge"
|
| 12 |
+
import { toast } from "sonner"
|
| 13 |
+
import {
|
| 14 |
+
Settings,
|
| 15 |
+
Plus,
|
| 16 |
+
Trash2,
|
| 17 |
+
Loader2,
|
| 18 |
+
RefreshCw,
|
| 19 |
+
} from "lucide-react"
|
| 20 |
+
|
| 21 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 22 |
+
|
| 23 |
+
interface ConfigEntry {
|
| 24 |
+
key: string
|
| 25 |
+
value: string
|
| 26 |
+
description?: string
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export default function AdminSettingsPage() {
|
| 30 |
+
const { isAuthenticated, isAdmin, loading } = useAuth()
|
| 31 |
+
const router = useRouter()
|
| 32 |
+
const [configs, setConfigs] = useState<Record<string, ConfigEntry>>({})
|
| 33 |
+
const [loadingConfigs, setLoadingConfigs] = useState(true)
|
| 34 |
+
const [newConfig, setNewConfig] = useState({
|
| 35 |
+
key: "",
|
| 36 |
+
value: "",
|
| 37 |
+
description: "",
|
| 38 |
+
})
|
| 39 |
+
const [saving, setSaving] = useState(false)
|
| 40 |
+
|
| 41 |
+
useEffect(() => {
|
| 42 |
+
if (!loading && !isAuthenticated) {
|
| 43 |
+
router.push("/auth/signin?redirect=/admin/settings")
|
| 44 |
+
} else if (!loading && isAuthenticated && !isAdmin) {
|
| 45 |
+
router.push("/chat")
|
| 46 |
+
}
|
| 47 |
+
}, [isAuthenticated, isAdmin, loading, router])
|
| 48 |
+
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
if (isAuthenticated && isAdmin) {
|
| 51 |
+
fetchConfigs()
|
| 52 |
+
}
|
| 53 |
+
}, [isAuthenticated, isAdmin])
|
| 54 |
+
|
| 55 |
+
const fetchConfigs = async () => {
|
| 56 |
+
setLoadingConfigs(true)
|
| 57 |
+
try {
|
| 58 |
+
const sessionToken = localStorage.getItem("session_token")
|
| 59 |
+
const response = await fetch(`${API_URL}/admin/config`, {
|
| 60 |
+
headers: {
|
| 61 |
+
"X-Session-Token": sessionToken || "",
|
| 62 |
+
},
|
| 63 |
+
})
|
| 64 |
+
if (response.ok) {
|
| 65 |
+
const data = await response.json()
|
| 66 |
+
setConfigs(data.configs || {})
|
| 67 |
+
} else {
|
| 68 |
+
toast.error("Failed to fetch settings")
|
| 69 |
+
}
|
| 70 |
+
} catch {
|
| 71 |
+
toast.error("Failed to fetch settings")
|
| 72 |
+
} finally {
|
| 73 |
+
setLoadingConfigs(false)
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
const handleSaveConfig = async (key: string, value: string) => {
|
| 78 |
+
try {
|
| 79 |
+
const sessionToken = localStorage.getItem("session_token")
|
| 80 |
+
const response = await fetch(`${API_URL}/admin/config/${key}`, {
|
| 81 |
+
method: "PUT",
|
| 82 |
+
headers: {
|
| 83 |
+
"Content-Type": "application/json",
|
| 84 |
+
"X-Session-Token": sessionToken || "",
|
| 85 |
+
},
|
| 86 |
+
body: JSON.stringify({ value }),
|
| 87 |
+
})
|
| 88 |
+
|
| 89 |
+
if (response.ok) {
|
| 90 |
+
toast.success("Setting updated successfully")
|
| 91 |
+
fetchConfigs()
|
| 92 |
+
} else {
|
| 93 |
+
toast.error("Failed to update setting")
|
| 94 |
+
}
|
| 95 |
+
} catch {
|
| 96 |
+
toast.error("Failed to update setting")
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
const handleAddConfig = async () => {
|
| 101 |
+
if (!newConfig.key || !newConfig.value) {
|
| 102 |
+
toast.error("Key and value are required")
|
| 103 |
+
return
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
setSaving(true)
|
| 107 |
+
try {
|
| 108 |
+
const sessionToken = localStorage.getItem("session_token")
|
| 109 |
+
const response = await fetch(`${API_URL}/admin/config/${newConfig.key}`, {
|
| 110 |
+
method: "PUT",
|
| 111 |
+
headers: {
|
| 112 |
+
"Content-Type": "application/json",
|
| 113 |
+
"X-Session-Token": sessionToken || "",
|
| 114 |
+
},
|
| 115 |
+
body: JSON.stringify({
|
| 116 |
+
value: newConfig.value,
|
| 117 |
+
description: newConfig.description,
|
| 118 |
+
}),
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
if (response.ok) {
|
| 122 |
+
toast.success("Setting added successfully")
|
| 123 |
+
setNewConfig({ key: "", value: "", description: "" })
|
| 124 |
+
fetchConfigs()
|
| 125 |
+
} else {
|
| 126 |
+
toast.error("Failed to add setting")
|
| 127 |
+
}
|
| 128 |
+
} catch {
|
| 129 |
+
toast.error("Failed to add setting")
|
| 130 |
+
} finally {
|
| 131 |
+
setSaving(false)
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
const handleDeleteConfig = async (key: string) => {
|
| 136 |
+
if (!confirm(`Are you sure you want to delete the setting "${key}"?`)) {
|
| 137 |
+
return
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
try {
|
| 141 |
+
const sessionToken = localStorage.getItem("session_token")
|
| 142 |
+
const response = await fetch(`${API_URL}/admin/config/${key}`, {
|
| 143 |
+
method: "DELETE",
|
| 144 |
+
headers: {
|
| 145 |
+
"X-Session-Token": sessionToken || "",
|
| 146 |
+
},
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
if (response.ok) {
|
| 150 |
+
toast.success("Setting deleted successfully")
|
| 151 |
+
fetchConfigs()
|
| 152 |
+
} else {
|
| 153 |
+
toast.error("Failed to delete setting")
|
| 154 |
+
}
|
| 155 |
+
} catch {
|
| 156 |
+
toast.error("Failed to delete setting")
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
if (loading || !isAuthenticated || !isAdmin) {
|
| 161 |
+
return (
|
| 162 |
+
<div className="min-h-screen flex items-center justify-center">
|
| 163 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
| 164 |
+
</div>
|
| 165 |
+
)
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
return (
|
| 169 |
+
<div className="min-h-screen bg-background flex">
|
| 170 |
+
<AdminSidebar />
|
| 171 |
+
<div className="flex-1 ml-0 md:ml-[20px] p-4 md:p-6">
|
| 172 |
+
<div className="absolute top-4 right-4 z-10">
|
| 173 |
+
<ThemeToggle />
|
| 174 |
+
</div>
|
| 175 |
+
<div className="max-w-4xl mx-auto space-y-6">
|
| 176 |
+
<div>
|
| 177 |
+
<h1 className="text-3xl font-bold flex items-center gap-2">
|
| 178 |
+
<Settings className="w-8 h-8" />
|
| 179 |
+
Site Settings
|
| 180 |
+
</h1>
|
| 181 |
+
<p className="text-muted-foreground">Manage system configuration and settings</p>
|
| 182 |
+
</div>
|
| 183 |
+
|
| 184 |
+
{/* Add New Config */}
|
| 185 |
+
<Card>
|
| 186 |
+
<CardHeader>
|
| 187 |
+
<CardTitle>Add New Setting</CardTitle>
|
| 188 |
+
<CardDescription>Create a new configuration entry</CardDescription>
|
| 189 |
+
</CardHeader>
|
| 190 |
+
<CardContent>
|
| 191 |
+
<div className="space-y-4">
|
| 192 |
+
<div>
|
| 193 |
+
<label className="text-sm font-medium">Key</label>
|
| 194 |
+
<Input
|
| 195 |
+
placeholder="setting.key"
|
| 196 |
+
value={newConfig.key}
|
| 197 |
+
onChange={(e) => setNewConfig({ ...newConfig, key: e.target.value })}
|
| 198 |
+
/>
|
| 199 |
+
</div>
|
| 200 |
+
<div>
|
| 201 |
+
<label className="text-sm font-medium">Value</label>
|
| 202 |
+
<Input
|
| 203 |
+
placeholder="Setting value"
|
| 204 |
+
value={newConfig.value}
|
| 205 |
+
onChange={(e) => setNewConfig({ ...newConfig, value: e.target.value })}
|
| 206 |
+
/>
|
| 207 |
+
</div>
|
| 208 |
+
<div>
|
| 209 |
+
<label className="text-sm font-medium">Description (optional)</label>
|
| 210 |
+
<Input
|
| 211 |
+
placeholder="What this setting does"
|
| 212 |
+
value={newConfig.description}
|
| 213 |
+
onChange={(e) => setNewConfig({ ...newConfig, description: e.target.value })}
|
| 214 |
+
/>
|
| 215 |
+
</div>
|
| 216 |
+
<Button onClick={handleAddConfig} disabled={saving}>
|
| 217 |
+
{saving ? (
|
| 218 |
+
<>
|
| 219 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 220 |
+
Adding...
|
| 221 |
+
</>
|
| 222 |
+
) : (
|
| 223 |
+
<>
|
| 224 |
+
<Plus className="mr-2 h-4 w-4" />
|
| 225 |
+
Add Setting
|
| 226 |
+
</>
|
| 227 |
+
)}
|
| 228 |
+
</Button>
|
| 229 |
+
</div>
|
| 230 |
+
</CardContent>
|
| 231 |
+
</Card>
|
| 232 |
+
|
| 233 |
+
{/* Existing Configs */}
|
| 234 |
+
<Card>
|
| 235 |
+
<CardHeader>
|
| 236 |
+
<div className="flex items-center justify-between">
|
| 237 |
+
<div>
|
| 238 |
+
<CardTitle>System Settings</CardTitle>
|
| 239 |
+
<CardDescription>Manage existing configuration entries</CardDescription>
|
| 240 |
+
</div>
|
| 241 |
+
<Button variant="outline" size="sm" onClick={fetchConfigs}>
|
| 242 |
+
<RefreshCw className="mr-2 h-4 w-4" />
|
| 243 |
+
Refresh
|
| 244 |
+
</Button>
|
| 245 |
+
</div>
|
| 246 |
+
</CardHeader>
|
| 247 |
+
<CardContent>
|
| 248 |
+
{loadingConfigs ? (
|
| 249 |
+
<div className="flex items-center justify-center py-12">
|
| 250 |
+
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
| 251 |
+
</div>
|
| 252 |
+
) : Object.keys(configs).length === 0 ? (
|
| 253 |
+
<div className="text-center py-12 text-muted-foreground">
|
| 254 |
+
No settings configured
|
| 255 |
+
</div>
|
| 256 |
+
) : (
|
| 257 |
+
<div className="space-y-4">
|
| 258 |
+
{Object.entries(configs).map(([key, config]) => (
|
| 259 |
+
<div
|
| 260 |
+
key={key}
|
| 261 |
+
className="flex items-start gap-4 p-4 border rounded-lg hover:bg-accent/50"
|
| 262 |
+
>
|
| 263 |
+
<div className="flex-1 space-y-2">
|
| 264 |
+
<div className="flex items-center gap-2">
|
| 265 |
+
<code className="text-sm font-mono font-semibold">{key}</code>
|
| 266 |
+
{config.description && (
|
| 267 |
+
<Badge variant="outline" className="text-xs">
|
| 268 |
+
{config.description}
|
| 269 |
+
</Badge>
|
| 270 |
+
)}
|
| 271 |
+
</div>
|
| 272 |
+
<Input
|
| 273 |
+
value={config.value}
|
| 274 |
+
onChange={(e) => {
|
| 275 |
+
setConfigs({
|
| 276 |
+
...configs,
|
| 277 |
+
[key]: { ...config, value: e.target.value },
|
| 278 |
+
})
|
| 279 |
+
}}
|
| 280 |
+
onBlur={() => handleSaveConfig(key, config.value)}
|
| 281 |
+
className="font-mono text-sm"
|
| 282 |
+
/>
|
| 283 |
+
</div>
|
| 284 |
+
<Button
|
| 285 |
+
variant="ghost"
|
| 286 |
+
size="sm"
|
| 287 |
+
onClick={() => handleDeleteConfig(key)}
|
| 288 |
+
className="text-destructive hover:text-destructive"
|
| 289 |
+
>
|
| 290 |
+
<Trash2 className="h-4 w-4" />
|
| 291 |
+
</Button>
|
| 292 |
+
</div>
|
| 293 |
+
))}
|
| 294 |
+
</div>
|
| 295 |
+
)}
|
| 296 |
+
</CardContent>
|
| 297 |
+
</Card>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
)
|
| 302 |
+
}
|
| 303 |
+
|
src/app/admin/training/page.tsx
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 5 |
+
import { Badge } from "@/components/ui/badge";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import { Database, Download, RefreshCw } from "lucide-react";
|
| 8 |
+
import { AdminSidebar } from "@/components/admin-sidebar";
|
| 9 |
+
|
| 10 |
+
interface FeedbackAnalytics {
|
| 11 |
+
feedback_distribution: {
|
| 12 |
+
total: number;
|
| 13 |
+
positive: number;
|
| 14 |
+
negative: number;
|
| 15 |
+
positive_rate: number;
|
| 16 |
+
};
|
| 17 |
+
training_impact: {
|
| 18 |
+
total_scored: number;
|
| 19 |
+
training_candidates: number;
|
| 20 |
+
awaiting_export: number;
|
| 21 |
+
conversion_rate: string;
|
| 22 |
+
average_quality_score: number;
|
| 23 |
+
};
|
| 24 |
+
score_distribution: {
|
| 25 |
+
[key: string]: number;
|
| 26 |
+
};
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
interface TrainingStats {
|
| 30 |
+
total_scored: number;
|
| 31 |
+
kept_for_training: number;
|
| 32 |
+
exported: number;
|
| 33 |
+
awaiting_export: number;
|
| 34 |
+
average_score: number;
|
| 35 |
+
score_distribution: {
|
| 36 |
+
[key: string]: number;
|
| 37 |
+
};
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
interface ClusterStats {
|
| 41 |
+
total_clusters: number;
|
| 42 |
+
active_clusters: number;
|
| 43 |
+
pending_suggestions: number;
|
| 44 |
+
total_queries_classified: number;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export default function TrainingMonitorPage() {
|
| 48 |
+
const [feedbackData, setFeedbackData] = useState<FeedbackAnalytics | null>(null);
|
| 49 |
+
const [trainingData, setTrainingData] = useState<TrainingStats | null>(null);
|
| 50 |
+
const [loading, setLoading] = useState(true);
|
| 51 |
+
const [refreshing, setRefreshing] = useState(false);
|
| 52 |
+
|
| 53 |
+
const fetchAnalytics = async () => {
|
| 54 |
+
try {
|
| 55 |
+
setRefreshing(true);
|
| 56 |
+
|
| 57 |
+
const feedbackRes = await fetch("/api/v1/feedback/analytics");
|
| 58 |
+
if (feedbackRes.ok) {
|
| 59 |
+
setFeedbackData(await feedbackRes.json());
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const trainingRes = await fetch("/api/v1/finetuning/stats");
|
| 63 |
+
if (trainingRes.ok) {
|
| 64 |
+
setTrainingData(await trainingRes.json());
|
| 65 |
+
}
|
| 66 |
+
} catch (error) {
|
| 67 |
+
console.error("Failed to fetch analytics:", error);
|
| 68 |
+
} finally {
|
| 69 |
+
setLoading(false);
|
| 70 |
+
setRefreshing(false);
|
| 71 |
+
}
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
useEffect(() => {
|
| 75 |
+
fetchAnalytics();
|
| 76 |
+
|
| 77 |
+
// Auto-refresh every 30 seconds for real-time monitoring
|
| 78 |
+
const interval = setInterval(() => {
|
| 79 |
+
fetchAnalytics();
|
| 80 |
+
}, 30000);
|
| 81 |
+
|
| 82 |
+
return () => clearInterval(interval);
|
| 83 |
+
}, []);
|
| 84 |
+
|
| 85 |
+
const handleExport = async (format: string) => {
|
| 86 |
+
try {
|
| 87 |
+
const res = await fetch(`/api/v1/finetuning/export?format=${format}&min_score=4.0`, {
|
| 88 |
+
method: "POST",
|
| 89 |
+
});
|
| 90 |
+
const data = await res.json();
|
| 91 |
+
alert(`Exported ${data.exported_count} interactions!`);
|
| 92 |
+
fetchAnalytics(); // Refresh stats
|
| 93 |
+
} catch (error) {
|
| 94 |
+
console.error("Export failed:", error);
|
| 95 |
+
alert("Export failed. Check console.");
|
| 96 |
+
}
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
const scoreLast7Days = async () => {
|
| 100 |
+
try {
|
| 101 |
+
const res = await fetch("/api/v1/finetuning/auto-score-all?days=7", { method: "POST" });
|
| 102 |
+
const data = await res.json();
|
| 103 |
+
alert(`Scored ${data.scored} interactions, ${data.saved_for_training} added to training dataset!`);
|
| 104 |
+
fetchAnalytics();
|
| 105 |
+
} catch (error) {
|
| 106 |
+
console.error("Scoring failed:", error);
|
| 107 |
+
}
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
if (loading) {
|
| 111 |
+
return (
|
| 112 |
+
<div className="flex items-center justify-center min-h-screen">
|
| 113 |
+
<div className="text-center space-y-4">
|
| 114 |
+
<RefreshCw className="w-8 h-8 animate-spin mx-auto text-blue-500" />
|
| 115 |
+
<p className="text-gray-600">Loading training metrics...</p>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
return (
|
| 122 |
+
<>
|
| 123 |
+
<AdminSidebar />
|
| 124 |
+
<div className="ml-0 md:ml-5 transition-all duration-300">
|
| 125 |
+
<div className="container mx-auto p-6 space-y-6">
|
| 126 |
+
<div className="flex justify-between items-center">
|
| 127 |
+
<div>
|
| 128 |
+
<h1 className="text-3xl font-bold">Training & Feedback Monitor</h1>
|
| 129 |
+
<p className="text-gray-500 mt-1">Track model improvement and data quality</p>
|
| 130 |
+
</div>
|
| 131 |
+
<div className="flex gap-2">
|
| 132 |
+
<Button onClick={scoreLast7Days} variant="outline">
|
| 133 |
+
Score Last 7 Days
|
| 134 |
+
</Button>
|
| 135 |
+
<Button onClick={fetchAnalytics} disabled={refreshing} variant="outline">
|
| 136 |
+
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? "animate-spin" : ""}`} />
|
| 137 |
+
Refresh
|
| 138 |
+
</Button>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
{/* KPI Cards */}
|
| 143 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 144 |
+
<Card className="border-l-4 border-l-blue-500">
|
| 145 |
+
<CardHeader className="pb-2">
|
| 146 |
+
<CardTitle className="text-sm font-medium">Total Feedback</CardTitle>
|
| 147 |
+
</CardHeader>
|
| 148 |
+
<CardContent>
|
| 149 |
+
<div className="text-3xl font-bold">{feedbackData?.feedback_distribution.total || 0}</div>
|
| 150 |
+
<p className="text-xs text-green-600 mt-1">
|
| 151 |
+
↑ {feedbackData?.feedback_distribution.positive_rate.toFixed(1)}% positive
|
| 152 |
+
</p>
|
| 153 |
+
</CardContent>
|
| 154 |
+
</Card>
|
| 155 |
+
|
| 156 |
+
<Card className="border-l-4 border-l-green-500">
|
| 157 |
+
<CardHeader className="pb-2">
|
| 158 |
+
<CardTitle className="text-sm font-medium">Training Ready</CardTitle>
|
| 159 |
+
</CardHeader>
|
| 160 |
+
<CardContent>
|
| 161 |
+
<div className="text-3xl font-bold">{feedbackData?.training_impact.training_candidates || 0}</div>
|
| 162 |
+
<p className="text-xs text-gray-500 mt-1">
|
| 163 |
+
Score: {feedbackData?.training_impact.average_quality_score.toFixed(2) || "N/A"}
|
| 164 |
+
</p>
|
| 165 |
+
</CardContent>
|
| 166 |
+
</Card>
|
| 167 |
+
|
| 168 |
+
<Card className="border-l-4 border-l-orange-500">
|
| 169 |
+
<CardHeader className="pb-2">
|
| 170 |
+
<CardTitle className="text-sm font-medium">Awaiting Export</CardTitle>
|
| 171 |
+
</CardHeader>
|
| 172 |
+
<CardContent>
|
| 173 |
+
<div className="text-3xl font-bold">{feedbackData?.training_impact.awaiting_export || 0}</div>
|
| 174 |
+
<p className="text-xs text-gray-500 mt-1">Ready to fine-tune</p>
|
| 175 |
+
</CardContent>
|
| 176 |
+
</Card>
|
| 177 |
+
|
| 178 |
+
<Card className="border-l-4 border-l-purple-500">
|
| 179 |
+
<CardHeader className="pb-2">
|
| 180 |
+
<CardTitle className="text-sm font-medium">Conversion Rate</CardTitle>
|
| 181 |
+
</CardHeader>
|
| 182 |
+
<CardContent>
|
| 183 |
+
<div className="text-3xl font-bold">{feedbackData?.training_impact.conversion_rate || "0%"}</div>
|
| 184 |
+
<p className="text-xs text-gray-500 mt-1">Feedback → Training</p>
|
| 185 |
+
</CardContent>
|
| 186 |
+
</Card>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
{/* Export Section */}
|
| 190 |
+
<Card>
|
| 191 |
+
<CardHeader>
|
| 192 |
+
<CardTitle className="flex items-center gap-2">
|
| 193 |
+
<Download className="w-5 h-5" />
|
| 194 |
+
Export Training Dataset
|
| 195 |
+
</CardTitle>
|
| 196 |
+
<CardDescription>
|
| 197 |
+
Export {feedbackData?.training_impact.awaiting_export || 0} high-quality interactions for fine-tuning
|
| 198 |
+
</CardDescription>
|
| 199 |
+
</CardHeader>
|
| 200 |
+
<CardContent>
|
| 201 |
+
<div className="flex gap-3">
|
| 202 |
+
<Button onClick={() => handleExport("alpaca")} className="flex-1" size="lg">
|
| 203 |
+
<Database className="w-4 h-4 mr-2" />
|
| 204 |
+
Export Alpaca Format
|
| 205 |
+
</Button>
|
| 206 |
+
<Button onClick={() => handleExport("sharegpt")} variant="secondary" className="flex-1" size="lg">
|
| 207 |
+
<Database className="w-4 h-4 mr-2" />
|
| 208 |
+
Export ShareGPT Format
|
| 209 |
+
</Button>
|
| 210 |
+
</div>
|
| 211 |
+
</CardContent>
|
| 212 |
+
</Card>
|
| 213 |
+
|
| 214 |
+
{/* Quality Distribution */}
|
| 215 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 216 |
+
<Card>
|
| 217 |
+
<CardHeader>
|
| 218 |
+
<CardTitle>Quality Score Distribution</CardTitle>
|
| 219 |
+
</CardHeader>
|
| 220 |
+
<CardContent>
|
| 221 |
+
<div className="space-y-3">
|
| 222 |
+
{feedbackData?.score_distribution && Object.entries(feedbackData.score_distribution).map(([tier, count]) => {
|
| 223 |
+
const percentage = (count / (feedbackData.training_impact.total_scored || 1)) * 100;
|
| 224 |
+
return (
|
| 225 |
+
<div key={tier}>
|
| 226 |
+
<div className="flex justify-between mb-1">
|
| 227 |
+
<span className="text-sm capitalize">{tier.replace(/_/g, " ")}</span>
|
| 228 |
+
<span className="text-sm font-medium">{count}</span>
|
| 229 |
+
</div>
|
| 230 |
+
<div className="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
|
| 231 |
+
<div
|
| 232 |
+
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
| 233 |
+
style={{ width: `${percentage}%` }}
|
| 234 |
+
/>
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
);
|
| 238 |
+
})}
|
| 239 |
+
</div>
|
| 240 |
+
</CardContent>
|
| 241 |
+
</Card>
|
| 242 |
+
|
| 243 |
+
<Card>
|
| 244 |
+
<CardHeader>
|
| 245 |
+
<CardTitle>Training Pipeline Status</CardTitle>
|
| 246 |
+
</CardHeader>
|
| 247 |
+
<CardContent>
|
| 248 |
+
<div className="space-y-4">
|
| 249 |
+
<div className="flex justify-between items-center p-3 bg-blue-50 rounded">
|
| 250 |
+
<span className="text-sm">Total Interactions Scored</span>
|
| 251 |
+
<Badge variant="outline">{trainingData?.total_scored || 0}</Badge>
|
| 252 |
+
</div>
|
| 253 |
+
<div className="flex justify-between items-center p-3 bg-green-50 rounded">
|
| 254 |
+
<span className="text-sm">Approved for Training</span>
|
| 255 |
+
<Badge className="bg-green-600">{trainingData?.kept_for_training || 0}</Badge>
|
| 256 |
+
</div>
|
| 257 |
+
<div className="flex justify-between items-center p-3 bg-gray-50 rounded">
|
| 258 |
+
<span className="text-sm">Already Exported</span>
|
| 259 |
+
<Badge variant="secondary">{trainingData?.exported || 0}</Badge>
|
| 260 |
+
</div>
|
| 261 |
+
<div className="flex justify-between items-center p-3 bg-orange-50 rounded">
|
| 262 |
+
<span className="text-sm">Pending Export</span>
|
| 263 |
+
<Badge className="bg-orange-600">{trainingData?.awaiting_export || 0}</Badge>
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
</CardContent>
|
| 267 |
+
</Card>
|
| 268 |
+
</div>
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
</>
|
| 272 |
+
);
|
| 273 |
+
}
|
src/app/admin/users/page.tsx
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react"
|
| 4 |
+
import { useRouter } from "next/navigation"
|
| 5 |
+
import { useAuth } from "@/lib/auth-context"
|
| 6 |
+
import { AdminSidebar } from "@/components/admin-sidebar"
|
| 7 |
+
import { ThemeToggle } from "@/components/theme-toggle"
|
| 8 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
| 9 |
+
import { Button } from "@/components/ui/button"
|
| 10 |
+
import { Input } from "@/components/ui/input"
|
| 11 |
+
import { Badge } from "@/components/ui/badge"
|
| 12 |
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
| 13 |
+
import {
|
| 14 |
+
Dialog,
|
| 15 |
+
DialogContent,
|
| 16 |
+
DialogDescription,
|
| 17 |
+
DialogFooter,
|
| 18 |
+
DialogHeader,
|
| 19 |
+
DialogTitle,
|
| 20 |
+
} from "@/components/ui/dialog"
|
| 21 |
+
import {
|
| 22 |
+
Select,
|
| 23 |
+
SelectContent,
|
| 24 |
+
SelectItem,
|
| 25 |
+
SelectTrigger,
|
| 26 |
+
SelectValue,
|
| 27 |
+
} from "@/components/ui/select"
|
| 28 |
+
import {
|
| 29 |
+
DropdownMenu,
|
| 30 |
+
DropdownMenuContent,
|
| 31 |
+
DropdownMenuItem,
|
| 32 |
+
DropdownMenuLabel,
|
| 33 |
+
DropdownMenuSeparator,
|
| 34 |
+
DropdownMenuTrigger,
|
| 35 |
+
} from "@/components/ui/dropdown-menu"
|
| 36 |
+
import { Label } from "@/components/ui/label"
|
| 37 |
+
import { toast } from "sonner"
|
| 38 |
+
import {
|
| 39 |
+
Users,
|
| 40 |
+
Search,
|
| 41 |
+
CheckCircle,
|
| 42 |
+
XCircle,
|
| 43 |
+
ChevronLeft,
|
| 44 |
+
ChevronRight,
|
| 45 |
+
Loader2,
|
| 46 |
+
MoreVertical,
|
| 47 |
+
Edit,
|
| 48 |
+
Trash2,
|
| 49 |
+
Ban,
|
| 50 |
+
CheckCheck,
|
| 51 |
+
ShieldOff,
|
| 52 |
+
ShieldCheck,
|
| 53 |
+
Activity,
|
| 54 |
+
Filter,
|
| 55 |
+
} from "lucide-react"
|
| 56 |
+
|
| 57 |
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 58 |
+
|
| 59 |
+
interface User {
|
| 60 |
+
id: string
|
| 61 |
+
email: string
|
| 62 |
+
name: string | null
|
| 63 |
+
status: string
|
| 64 |
+
email_verified: boolean
|
| 65 |
+
last_login: string | null
|
| 66 |
+
created_at: string
|
| 67 |
+
updated_at: string
|
| 68 |
+
roles?: string[]
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
interface AdminStats {
|
| 72 |
+
total_users: number
|
| 73 |
+
active_users: number
|
| 74 |
+
suspended_users: number
|
| 75 |
+
verified_users: number
|
| 76 |
+
unverified_users: number
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
export default function AdminUsersPage() {
|
| 80 |
+
const { isAuthenticated, isAdmin, loading } = useAuth()
|
| 81 |
+
const router = useRouter()
|
| 82 |
+
const [users, setUsers] = useState<User[]>([])
|
| 83 |
+
const [stats, setStats] = useState<AdminStats | null>(null)
|
| 84 |
+
const [loadingUsers, setLoadingUsers] = useState(true)
|
| 85 |
+
const [searchQuery, setSearchQuery] = useState("")
|
| 86 |
+
const [statusFilter, setStatusFilter] = useState<string>("all")
|
| 87 |
+
const [verificationFilter, setVerificationFilter] = useState<string>("all")
|
| 88 |
+
const [page, setPage] = useState(1)
|
| 89 |
+
const [total, setTotal] = useState(0)
|
| 90 |
+
const [pageSize] = useState(20)
|
| 91 |
+
|
| 92 |
+
// Dialog states
|
| 93 |
+
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
| 94 |
+
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
| 95 |
+
const [selectedUser, setSelectedUser] = useState<User | null>(null)
|
| 96 |
+
const [editFormData, setEditFormData] = useState({ name: "", email: "", status: "" })
|
| 97 |
+
const [actionLoading, setActionLoading] = useState(false)
|
| 98 |
+
|
| 99 |
+
useEffect(() => {
|
| 100 |
+
if (!loading && !isAuthenticated) {
|
| 101 |
+
router.push("/auth/signin?redirect=/admin/users")
|
| 102 |
+
} else if (!loading && isAuthenticated && !isAdmin) {
|
| 103 |
+
router.push("/chat")
|
| 104 |
+
}
|
| 105 |
+
}, [isAuthenticated, isAdmin, loading, router])
|
| 106 |
+
|
| 107 |
+
useEffect(() => {
|
| 108 |
+
if (isAuthenticated && isAdmin) {
|
| 109 |
+
fetchUsers()
|
| 110 |
+
fetchStats()
|
| 111 |
+
}
|
| 112 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 113 |
+
}, [page, statusFilter, verificationFilter, isAuthenticated, isAdmin])
|
| 114 |
+
|
| 115 |
+
const fetchStats = async () => {
|
| 116 |
+
try {
|
| 117 |
+
const sessionToken = localStorage.getItem("session_token")
|
| 118 |
+
const response = await fetch(`${API_URL}/api/v1/auth/admin/stats`, {
|
| 119 |
+
headers: {
|
| 120 |
+
"X-Session-Token": sessionToken || "",
|
| 121 |
+
},
|
| 122 |
+
})
|
| 123 |
+
if (response.ok) {
|
| 124 |
+
const data = await response.json()
|
| 125 |
+
setStats(data)
|
| 126 |
+
}
|
| 127 |
+
} catch {
|
| 128 |
+
// Stats are optional, don't show error
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
const fetchUsers = async () => {
|
| 133 |
+
setLoadingUsers(true)
|
| 134 |
+
try {
|
| 135 |
+
const sessionToken = localStorage.getItem("session_token")
|
| 136 |
+
const params = new URLSearchParams({
|
| 137 |
+
page: page.toString(),
|
| 138 |
+
page_size: pageSize.toString(),
|
| 139 |
+
})
|
| 140 |
+
|
| 141 |
+
if (searchQuery) params.append("search", searchQuery)
|
| 142 |
+
if (statusFilter !== "all") params.append("status", statusFilter)
|
| 143 |
+
if (verificationFilter !== "all") params.append("email_verified", verificationFilter)
|
| 144 |
+
|
| 145 |
+
const response = await fetch(
|
| 146 |
+
`${API_URL}/api/v1/auth/admin/users?${params}`,
|
| 147 |
+
{
|
| 148 |
+
headers: {
|
| 149 |
+
"X-Session-Token": sessionToken || "",
|
| 150 |
+
},
|
| 151 |
+
}
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
if (response.ok) {
|
| 155 |
+
const data = await response.json()
|
| 156 |
+
setUsers(data.users || [])
|
| 157 |
+
setTotal(data.total || 0)
|
| 158 |
+
} else {
|
| 159 |
+
toast.error("Failed to fetch users")
|
| 160 |
+
}
|
| 161 |
+
} catch {
|
| 162 |
+
toast.error("Failed to fetch users")
|
| 163 |
+
} finally {
|
| 164 |
+
setLoadingUsers(false)
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
const handleEditUser = (user: User) => {
|
| 169 |
+
setSelectedUser(user)
|
| 170 |
+
setEditFormData({
|
| 171 |
+
name: user.name || "",
|
| 172 |
+
email: user.email,
|
| 173 |
+
status: user.status,
|
| 174 |
+
})
|
| 175 |
+
setEditDialogOpen(true)
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
const handleSaveEdit = async () => {
|
| 179 |
+
if (!selectedUser) return
|
| 180 |
+
|
| 181 |
+
setActionLoading(true)
|
| 182 |
+
try {
|
| 183 |
+
const sessionToken = localStorage.getItem("session_token")
|
| 184 |
+
const response = await fetch(
|
| 185 |
+
`${API_URL}/api/v1/auth/admin/users/${selectedUser.id}`,
|
| 186 |
+
{
|
| 187 |
+
method: "PUT",
|
| 188 |
+
headers: {
|
| 189 |
+
"Content-Type": "application/json",
|
| 190 |
+
"X-Session-Token": sessionToken || "",
|
| 191 |
+
},
|
| 192 |
+
body: JSON.stringify({
|
| 193 |
+
name: editFormData.name || null,
|
| 194 |
+
email: editFormData.email,
|
| 195 |
+
status: editFormData.status,
|
| 196 |
+
}),
|
| 197 |
+
}
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
if (response.ok) {
|
| 201 |
+
toast.success("User updated successfully")
|
| 202 |
+
setEditDialogOpen(false)
|
| 203 |
+
fetchUsers()
|
| 204 |
+
fetchStats()
|
| 205 |
+
} else {
|
| 206 |
+
const error = await response.json()
|
| 207 |
+
toast.error(error.detail || "Failed to update user")
|
| 208 |
+
}
|
| 209 |
+
} catch {
|
| 210 |
+
toast.error("Failed to update user")
|
| 211 |
+
} finally {
|
| 212 |
+
setActionLoading(false)
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
const handleVerifyEmail = async (userId: string, verify: boolean) => {
|
| 217 |
+
try {
|
| 218 |
+
const sessionToken = localStorage.getItem("session_token")
|
| 219 |
+
const endpoint = verify ? "verify-email" : "unverify-email"
|
| 220 |
+
const response = await fetch(
|
| 221 |
+
`${API_URL}/api/v1/auth/admin/users/${userId}/${endpoint}`,
|
| 222 |
+
{
|
| 223 |
+
method: "POST",
|
| 224 |
+
headers: {
|
| 225 |
+
"X-Session-Token": sessionToken || "",
|
| 226 |
+
},
|
| 227 |
+
}
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
if (response.ok) {
|
| 231 |
+
toast.success(verify ? "Email verified" : "Email unverified")
|
| 232 |
+
fetchUsers()
|
| 233 |
+
fetchStats()
|
| 234 |
+
} else {
|
| 235 |
+
toast.error("Failed to update verification status")
|
| 236 |
+
}
|
| 237 |
+
} catch {
|
| 238 |
+
toast.error("Failed to update verification status")
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
const handleSuspendUser = async (userId: string, suspend: boolean) => {
|
| 243 |
+
try {
|
| 244 |
+
const sessionToken = localStorage.getItem("session_token")
|
| 245 |
+
const endpoint = suspend ? "suspend" : "activate"
|
| 246 |
+
const response = await fetch(
|
| 247 |
+
`${API_URL}/api/v1/auth/admin/users/${userId}/${endpoint}`,
|
| 248 |
+
{
|
| 249 |
+
method: "POST",
|
| 250 |
+
headers: {
|
| 251 |
+
"X-Session-Token": sessionToken || "",
|
| 252 |
+
},
|
| 253 |
+
}
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
if (response.ok) {
|
| 257 |
+
toast.success(suspend ? "User suspended" : "User activated")
|
| 258 |
+
fetchUsers()
|
| 259 |
+
fetchStats()
|
| 260 |
+
} else {
|
| 261 |
+
toast.error("Failed to update user status")
|
| 262 |
+
}
|
| 263 |
+
} catch {
|
| 264 |
+
toast.error("Failed to update user status")
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
const handleDeleteUser = async () => {
|
| 269 |
+
if (!selectedUser) return
|
| 270 |
+
|
| 271 |
+
setActionLoading(true)
|
| 272 |
+
try {
|
| 273 |
+
const sessionToken = localStorage.getItem("session_token")
|
| 274 |
+
const response = await fetch(
|
| 275 |
+
`${API_URL}/api/v1/auth/admin/users/${selectedUser.id}`,
|
| 276 |
+
{
|
| 277 |
+
method: "DELETE",
|
| 278 |
+
headers: {
|
| 279 |
+
"X-Session-Token": sessionToken || "",
|
| 280 |
+
},
|
| 281 |
+
}
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
if (response.ok) {
|
| 285 |
+
toast.success("User deleted successfully")
|
| 286 |
+
setDeleteDialogOpen(false)
|
| 287 |
+
fetchUsers()
|
| 288 |
+
fetchStats()
|
| 289 |
+
} else {
|
| 290 |
+
const error = await response.json()
|
| 291 |
+
toast.error(error.detail || "Failed to delete user")
|
| 292 |
+
}
|
| 293 |
+
} catch {
|
| 294 |
+
toast.error("Failed to delete user")
|
| 295 |
+
} finally {
|
| 296 |
+
setActionLoading(false)
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
const handleRevokeSession = async (userId: string) => {
|
| 301 |
+
try {
|
| 302 |
+
const sessionToken = localStorage.getItem("session_token")
|
| 303 |
+
const response = await fetch(
|
| 304 |
+
`${API_URL}/api/v1/auth/admin/users/${userId}/revoke-sessions`,
|
| 305 |
+
{
|
| 306 |
+
method: "POST",
|
| 307 |
+
headers: {
|
| 308 |
+
"X-Session-Token": sessionToken || "",
|
| 309 |
+
},
|
| 310 |
+
}
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
if (response.ok) {
|
| 314 |
+
const data = await response.json()
|
| 315 |
+
toast.success(`Revoked ${data.sessions_revoked} session(s)`)
|
| 316 |
+
} else {
|
| 317 |
+
toast.error("Failed to revoke sessions")
|
| 318 |
+
}
|
| 319 |
+
} catch {
|
| 320 |
+
toast.error("Failed to revoke sessions")
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
const filteredUsers = users.filter(
|
| 326 |
+
(user) =>
|
| 327 |
+
user.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
| 328 |
+
(user.name && user.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
const totalPages = Math.ceil(total / pageSize)
|
| 332 |
+
|
| 333 |
+
if (loading || !isAuthenticated || !isAdmin) {
|
| 334 |
+
return (
|
| 335 |
+
<div className="min-h-screen flex items-center justify-center">
|
| 336 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
| 337 |
+
</div>
|
| 338 |
+
)
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
return (
|
| 342 |
+
<div className="min-h-screen bg-background flex">
|
| 343 |
+
<AdminSidebar />
|
| 344 |
+
<div className="flex-1 ml-0 md:ml-[20px] p-4 md:p-6">
|
| 345 |
+
<div className="absolute top-4 right-4 z-10">
|
| 346 |
+
<ThemeToggle />
|
| 347 |
+
</div>
|
| 348 |
+
<div className="max-w-7xl mx-auto space-y-6">
|
| 349 |
+
<div>
|
| 350 |
+
<h1 className="text-3xl font-bold flex items-center gap-2">
|
| 351 |
+
<Users className="w-8 h-8" />
|
| 352 |
+
User Management
|
| 353 |
+
</h1>
|
| 354 |
+
<p className="text-muted-foreground">Manage all users in the system</p>
|
| 355 |
+
</div>
|
| 356 |
+
|
| 357 |
+
{/* Search and Filters */}
|
| 358 |
+
<Card>
|
| 359 |
+
<CardHeader>
|
| 360 |
+
<CardTitle className="flex items-center gap-2">
|
| 361 |
+
<Filter className="w-5 h-5" />
|
| 362 |
+
Filters & Search
|
| 363 |
+
</CardTitle>
|
| 364 |
+
<CardDescription>
|
| 365 |
+
{stats && (
|
| 366 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
| 367 |
+
<div>
|
| 368 |
+
<p className="text-sm text-muted-foreground">Total Users</p>
|
| 369 |
+
<p className="text-2xl font-bold">{stats.total_users}</p>
|
| 370 |
+
</div>
|
| 371 |
+
<div>
|
| 372 |
+
<p className="text-sm text-muted-foreground">Active</p>
|
| 373 |
+
<p className="text-2xl font-bold text-green-600">{stats.active_users}</p>
|
| 374 |
+
</div>
|
| 375 |
+
<div>
|
| 376 |
+
<p className="text-sm text-muted-foreground">Suspended</p>
|
| 377 |
+
<p className="text-2xl font-bold text-red-600">{stats.suspended_users}</p>
|
| 378 |
+
</div>
|
| 379 |
+
<div>
|
| 380 |
+
<p className="text-sm text-muted-foreground">Verified</p>
|
| 381 |
+
<p className="text-2xl font-bold text-blue-600">{stats.verified_users}</p>
|
| 382 |
+
</div>
|
| 383 |
+
</div>
|
| 384 |
+
)}
|
| 385 |
+
</CardDescription>
|
| 386 |
+
</CardHeader>
|
| 387 |
+
<CardContent>
|
| 388 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 389 |
+
<div className="relative md:col-span-1">
|
| 390 |
+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
| 391 |
+
<Input
|
| 392 |
+
placeholder="Search users..."
|
| 393 |
+
value={searchQuery}
|
| 394 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 395 |
+
onKeyDown={(e) => e.key === "Enter" && fetchUsers()}
|
| 396 |
+
className="pl-10"
|
| 397 |
+
/>
|
| 398 |
+
</div>
|
| 399 |
+
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
| 400 |
+
<SelectTrigger>
|
| 401 |
+
<SelectValue placeholder="Filter by status" />
|
| 402 |
+
</SelectTrigger>
|
| 403 |
+
<SelectContent>
|
| 404 |
+
<SelectItem value="all">All Statuses</SelectItem>
|
| 405 |
+
<SelectItem value="active">Active</SelectItem>
|
| 406 |
+
<SelectItem value="inactive">Inactive</SelectItem>
|
| 407 |
+
<SelectItem value="suspended">Suspended</SelectItem>
|
| 408 |
+
</SelectContent>
|
| 409 |
+
</Select>
|
| 410 |
+
<Select value={verificationFilter} onValueChange={setVerificationFilter}>
|
| 411 |
+
<SelectTrigger>
|
| 412 |
+
<SelectValue placeholder="Filter by verification" />
|
| 413 |
+
</SelectTrigger>
|
| 414 |
+
<SelectContent>
|
| 415 |
+
<SelectItem value="all">All Users</SelectItem>
|
| 416 |
+
<SelectItem value="true">Verified Only</SelectItem>
|
| 417 |
+
<SelectItem value="false">Unverified Only</SelectItem>
|
| 418 |
+
</SelectContent>
|
| 419 |
+
</Select>
|
| 420 |
+
</div>
|
| 421 |
+
</CardContent>
|
| 422 |
+
</Card>
|
| 423 |
+
|
| 424 |
+
{/* Users Table */}
|
| 425 |
+
<Card>
|
| 426 |
+
<CardHeader>
|
| 427 |
+
<CardTitle>All Users</CardTitle>
|
| 428 |
+
</CardHeader>
|
| 429 |
+
<CardContent>
|
| 430 |
+
{loadingUsers ? (
|
| 431 |
+
<div className="flex items-center justify-center py-12">
|
| 432 |
+
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
| 433 |
+
</div>
|
| 434 |
+
) : (
|
| 435 |
+
<>
|
| 436 |
+
<div className="overflow-x-auto">
|
| 437 |
+
<Table>
|
| 438 |
+
<TableHeader>
|
| 439 |
+
<TableRow>
|
| 440 |
+
<TableHead>Email</TableHead>
|
| 441 |
+
<TableHead>Name</TableHead>
|
| 442 |
+
<TableHead>Status</TableHead>
|
| 443 |
+
<TableHead>Verified</TableHead>
|
| 444 |
+
<TableHead>Roles</TableHead>
|
| 445 |
+
<TableHead>Last Login</TableHead>
|
| 446 |
+
<TableHead className="text-right">Actions</TableHead>
|
| 447 |
+
</TableRow>
|
| 448 |
+
</TableHeader>
|
| 449 |
+
<TableBody>
|
| 450 |
+
{filteredUsers.length === 0 ? (
|
| 451 |
+
<TableRow>
|
| 452 |
+
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
| 453 |
+
No users found
|
| 454 |
+
</TableCell>
|
| 455 |
+
</TableRow>
|
| 456 |
+
) : (
|
| 457 |
+
filteredUsers.map((user) => (
|
| 458 |
+
<TableRow key={user.id}>
|
| 459 |
+
<TableCell className="font-medium">{user.email}</TableCell>
|
| 460 |
+
<TableCell>{user.name || "—"}</TableCell>
|
| 461 |
+
<TableCell>
|
| 462 |
+
<Badge
|
| 463 |
+
variant={
|
| 464 |
+
user.status === "active" ? "default" :
|
| 465 |
+
user.status === "suspended" ? "destructive" :
|
| 466 |
+
"secondary"
|
| 467 |
+
}
|
| 468 |
+
>
|
| 469 |
+
{user.status}
|
| 470 |
+
</Badge>
|
| 471 |
+
</TableCell>
|
| 472 |
+
<TableCell>
|
| 473 |
+
{user.email_verified ? (
|
| 474 |
+
<CheckCircle className="w-5 h-5 text-green-600" />
|
| 475 |
+
) : (
|
| 476 |
+
<XCircle className="w-5 h-5 text-yellow-600" />
|
| 477 |
+
)}
|
| 478 |
+
</TableCell>
|
| 479 |
+
<TableCell>
|
| 480 |
+
{user.roles && user.roles.length > 0 ? (
|
| 481 |
+
<div className="flex gap-1 flex-wrap">
|
| 482 |
+
{user.roles.map((role) => (
|
| 483 |
+
<Badge key={role} variant="outline" className="text-xs">
|
| 484 |
+
{role}
|
| 485 |
+
</Badge>
|
| 486 |
+
))}
|
| 487 |
+
</div>
|
| 488 |
+
) : (
|
| 489 |
+
<span className="text-muted-foreground text-sm">—</span>
|
| 490 |
+
)}
|
| 491 |
+
</TableCell>
|
| 492 |
+
<TableCell className="text-sm text-muted-foreground">
|
| 493 |
+
{user.last_login
|
| 494 |
+
? new Date(user.last_login).toLocaleDateString()
|
| 495 |
+
: "Never"}
|
| 496 |
+
</TableCell>
|
| 497 |
+
<TableCell className="text-right">
|
| 498 |
+
<DropdownMenu>
|
| 499 |
+
<DropdownMenuTrigger asChild>
|
| 500 |
+
<Button variant="ghost" size="sm">
|
| 501 |
+
<MoreVertical className="w-4 h-4" />
|
| 502 |
+
</Button>
|
| 503 |
+
</DropdownMenuTrigger>
|
| 504 |
+
<DropdownMenuContent align="end" className="w-56">
|
| 505 |
+
<DropdownMenuLabel>User Actions</DropdownMenuLabel>
|
| 506 |
+
<DropdownMenuSeparator />
|
| 507 |
+
<DropdownMenuItem onClick={() => handleEditUser(user)}>
|
| 508 |
+
<Edit className="w-4 h-4 mr-2" />
|
| 509 |
+
Edit User
|
| 510 |
+
</DropdownMenuItem>
|
| 511 |
+
<DropdownMenuItem onClick={() => handleVerifyEmail(user.id, !user.email_verified)}>
|
| 512 |
+
{user.email_verified ? (
|
| 513 |
+
<>
|
| 514 |
+
<ShieldOff className="w-4 h-4 mr-2" />
|
| 515 |
+
Unverify Email
|
| 516 |
+
</>
|
| 517 |
+
) : (
|
| 518 |
+
<>
|
| 519 |
+
<ShieldCheck className="w-4 h-4 mr-2" />
|
| 520 |
+
Verify Email
|
| 521 |
+
</>
|
| 522 |
+
)}
|
| 523 |
+
</DropdownMenuItem>
|
| 524 |
+
<DropdownMenuItem onClick={() => handleSuspendUser(user.id, user.status !== "suspended")}>
|
| 525 |
+
{user.status === "suspended" ? (
|
| 526 |
+
<>
|
| 527 |
+
<CheckCheck className="w-4 h-4 mr-2" />
|
| 528 |
+
Activate Account
|
| 529 |
+
</>
|
| 530 |
+
) : (
|
| 531 |
+
<>
|
| 532 |
+
<Ban className="w-4 h-4 mr-2" />
|
| 533 |
+
Suspend Account
|
| 534 |
+
</>
|
| 535 |
+
)}
|
| 536 |
+
</DropdownMenuItem>
|
| 537 |
+
<DropdownMenuItem onClick={() => handleRevokeSession(user.id)}>
|
| 538 |
+
<Activity className="w-4 h-4 mr-2" />
|
| 539 |
+
Revoke Sessions
|
| 540 |
+
</DropdownMenuItem>
|
| 541 |
+
<DropdownMenuSeparator />
|
| 542 |
+
<DropdownMenuItem
|
| 543 |
+
onClick={() => {
|
| 544 |
+
setSelectedUser(user)
|
| 545 |
+
setDeleteDialogOpen(true)
|
| 546 |
+
}}
|
| 547 |
+
className="text-red-600"
|
| 548 |
+
>
|
| 549 |
+
<Trash2 className="w-4 h-4 mr-2" />
|
| 550 |
+
Delete User
|
| 551 |
+
</DropdownMenuItem>
|
| 552 |
+
</DropdownMenuContent>
|
| 553 |
+
</DropdownMenu>
|
| 554 |
+
</TableCell>
|
| 555 |
+
</TableRow>
|
| 556 |
+
))
|
| 557 |
+
)}
|
| 558 |
+
</TableBody>
|
| 559 |
+
</Table>
|
| 560 |
+
</div>
|
| 561 |
+
|
| 562 |
+
{/* Pagination */}
|
| 563 |
+
{totalPages > 1 && (
|
| 564 |
+
<div className="flex items-center justify-between mt-4">
|
| 565 |
+
<div className="text-sm text-muted-foreground">
|
| 566 |
+
Page {page} of {totalPages}
|
| 567 |
+
</div>
|
| 568 |
+
<div className="flex gap-2">
|
| 569 |
+
<Button
|
| 570 |
+
variant="outline"
|
| 571 |
+
size="sm"
|
| 572 |
+
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
| 573 |
+
disabled={page === 1}
|
| 574 |
+
>
|
| 575 |
+
<ChevronLeft className="w-4 h-4" />
|
| 576 |
+
Previous
|
| 577 |
+
</Button>
|
| 578 |
+
<Button
|
| 579 |
+
variant="outline"
|
| 580 |
+
size="sm"
|
| 581 |
+
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
| 582 |
+
disabled={page === totalPages}
|
| 583 |
+
>
|
| 584 |
+
Next
|
| 585 |
+
<ChevronRight className="w-4 h-4" />
|
| 586 |
+
</Button>
|
| 587 |
+
</div>
|
| 588 |
+
</div>
|
| 589 |
+
)}
|
| 590 |
+
</>
|
| 591 |
+
)}
|
| 592 |
+
</CardContent>
|
| 593 |
+
</Card>
|
| 594 |
+
</div>
|
| 595 |
+
</div>
|
| 596 |
+
|
| 597 |
+
{/* Edit User Dialog */}
|
| 598 |
+
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
| 599 |
+
<DialogContent>
|
| 600 |
+
<DialogHeader>
|
| 601 |
+
<DialogTitle>Edit User</DialogTitle>
|
| 602 |
+
<DialogDescription>
|
| 603 |
+
Update user information and account settings.
|
| 604 |
+
</DialogDescription>
|
| 605 |
+
</DialogHeader>
|
| 606 |
+
<div className="space-y-4 py-4">
|
| 607 |
+
<div className="space-y-2">
|
| 608 |
+
<Label htmlFor="edit-name">Name</Label>
|
| 609 |
+
<Input
|
| 610 |
+
id="edit-name"
|
| 611 |
+
value={editFormData.name}
|
| 612 |
+
onChange={(e) => setEditFormData({ ...editFormData, name: e.target.value })}
|
| 613 |
+
placeholder="User name"
|
| 614 |
+
/>
|
| 615 |
+
</div>
|
| 616 |
+
<div className="space-y-2">
|
| 617 |
+
<Label htmlFor="edit-email">Email</Label>
|
| 618 |
+
<Input
|
| 619 |
+
id="edit-email"
|
| 620 |
+
type="email"
|
| 621 |
+
value={editFormData.email}
|
| 622 |
+
onChange={(e) => setEditFormData({ ...editFormData, email: e.target.value })}
|
| 623 |
+
placeholder="user@example.com"
|
| 624 |
+
/>
|
| 625 |
+
</div>
|
| 626 |
+
<div className="space-y-2">
|
| 627 |
+
<Label htmlFor="edit-status">Status</Label>
|
| 628 |
+
<Select
|
| 629 |
+
value={editFormData.status}
|
| 630 |
+
onValueChange={(value: string) => setEditFormData({ ...editFormData, status: value })}
|
| 631 |
+
>
|
| 632 |
+
<SelectTrigger id="edit-status">
|
| 633 |
+
<SelectValue />
|
| 634 |
+
</SelectTrigger>
|
| 635 |
+
<SelectContent>
|
| 636 |
+
<SelectItem value="active">Active</SelectItem>
|
| 637 |
+
<SelectItem value="inactive">Inactive</SelectItem>
|
| 638 |
+
<SelectItem value="suspended">Suspended</SelectItem>
|
| 639 |
+
</SelectContent>
|
| 640 |
+
</Select>
|
| 641 |
+
</div>
|
| 642 |
+
</div>
|
| 643 |
+
<DialogFooter>
|
| 644 |
+
<Button variant="outline" onClick={() => setEditDialogOpen(false)} disabled={actionLoading}>
|
| 645 |
+
Cancel
|
| 646 |
+
</Button>
|
| 647 |
+
<Button onClick={handleSaveEdit} disabled={actionLoading}>
|
| 648 |
+
{actionLoading ? (
|
| 649 |
+
<>
|
| 650 |
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
| 651 |
+
Saving...
|
| 652 |
+
</>
|
| 653 |
+
) : (
|
| 654 |
+
"Save Changes"
|
| 655 |
+
)}
|
| 656 |
+
</Button>
|
| 657 |
+
</DialogFooter>
|
| 658 |
+
</DialogContent>
|
| 659 |
+
</Dialog>
|
| 660 |
+
|
| 661 |
+
{/* Delete User Dialog */}
|
| 662 |
+
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
| 663 |
+
<DialogContent>
|
| 664 |
+
<DialogHeader>
|
| 665 |
+
<DialogTitle>Delete User</DialogTitle>
|
| 666 |
+
<DialogDescription>
|
| 667 |
+
Are you sure you want to permanently delete this user? This action cannot be undone.
|
| 668 |
+
</DialogDescription>
|
| 669 |
+
</DialogHeader>
|
| 670 |
+
{selectedUser && (
|
| 671 |
+
<div className="py-4">
|
| 672 |
+
<div className="p-4 bg-muted rounded-lg space-y-2">
|
| 673 |
+
<p className="text-sm">
|
| 674 |
+
<span className="font-medium">Email:</span> {selectedUser.email}
|
| 675 |
+
</p>
|
| 676 |
+
<p className="text-sm">
|
| 677 |
+
<span className="font-medium">Name:</span> {selectedUser.name || "—"}
|
| 678 |
+
</p>
|
| 679 |
+
<p className="text-sm">
|
| 680 |
+
<span className="font-medium">Status:</span> {selectedUser.status}
|
| 681 |
+
</p>
|
| 682 |
+
</div>
|
| 683 |
+
<p className="text-sm text-red-600 mt-4">
|
| 684 |
+
⚠️ All user data, sessions, and associated records will be permanently deleted.
|
| 685 |
+
</p>
|
| 686 |
+
</div>
|
| 687 |
+
)}
|
| 688 |
+
<DialogFooter>
|
| 689 |
+
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={actionLoading}>
|
| 690 |
+
Cancel
|
| 691 |
+
</Button>
|
| 692 |
+
<Button variant="destructive" onClick={handleDeleteUser} disabled={actionLoading}>
|
| 693 |
+
{actionLoading ? (
|
| 694 |
+
<>
|
| 695 |
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
| 696 |
+
Deleting...
|
| 697 |
+
</>
|
| 698 |
+
) : (
|
| 699 |
+
<>
|
| 700 |
+
<Trash2 className="w-4 h-4 mr-2" />
|
| 701 |
+
Delete User
|
| 702 |
+
</>
|
| 703 |
+
)}
|
| 704 |
+
</Button>
|
| 705 |
+
</DialogFooter>
|
| 706 |
+
</DialogContent>
|
| 707 |
+
</Dialog>
|
| 708 |
+
</div>
|
| 709 |
+
)
|
| 710 |
+
}
|
| 711 |
+
|
src/app/api/admin/agent-metrics/route.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 4 |
+
|
| 5 |
+
export async function GET(request: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const sessionToken = request.headers.get("x-session-token")
|
| 8 |
+
|
| 9 |
+
const headers: Record<string, string> = {
|
| 10 |
+
"Content-Type": "application/json",
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
if (sessionToken) {
|
| 14 |
+
headers["X-Session-Token"] = sessionToken
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const response = await fetch(`${API_BASE_URL}/api/admin/agent-metrics`, {
|
| 18 |
+
headers,
|
| 19 |
+
})
|
| 20 |
+
|
| 21 |
+
if (!response.ok) {
|
| 22 |
+
return NextResponse.json(
|
| 23 |
+
{ error: "Failed to fetch agent metrics" },
|
| 24 |
+
{ status: response.status }
|
| 25 |
+
)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const data = await response.json()
|
| 29 |
+
return NextResponse.json(data)
|
| 30 |
+
} catch (error) {
|
| 31 |
+
console.error("Error fetching agent metrics:", error)
|
| 32 |
+
return NextResponse.json(
|
| 33 |
+
{ error: "Internal server error" },
|
| 34 |
+
{ status: 500 }
|
| 35 |
+
)
|
| 36 |
+
}
|
| 37 |
+
}
|
src/app/api/admin/query-logs/route.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 4 |
+
|
| 5 |
+
export async function GET(request: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const sessionToken = request.headers.get("x-session-token")
|
| 8 |
+
const searchParams = request.nextUrl.searchParams
|
| 9 |
+
const limit = searchParams.get("limit") || "100"
|
| 10 |
+
const offset = searchParams.get("offset") || "0"
|
| 11 |
+
|
| 12 |
+
const headers: Record<string, string> = {
|
| 13 |
+
"Content-Type": "application/json",
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
if (sessionToken) {
|
| 17 |
+
headers["X-Session-Token"] = sessionToken
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const response = await fetch(
|
| 21 |
+
`${API_BASE_URL}/api/admin/query-logs?limit=${limit}&offset=${offset}`,
|
| 22 |
+
{ headers }
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
if (!response.ok) {
|
| 26 |
+
return NextResponse.json(
|
| 27 |
+
{ error: "Failed to fetch query logs" },
|
| 28 |
+
{ status: response.status }
|
| 29 |
+
)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const data = await response.json()
|
| 33 |
+
return NextResponse.json(data)
|
| 34 |
+
} catch (error) {
|
| 35 |
+
console.error("Error fetching query logs:", error)
|
| 36 |
+
return NextResponse.json(
|
| 37 |
+
{ error: "Internal server error" },
|
| 38 |
+
{ status: 500 }
|
| 39 |
+
)
|
| 40 |
+
}
|
| 41 |
+
}
|
src/app/api/admin/retrain/route.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 4 |
+
|
| 5 |
+
export async function POST(request: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const sessionToken = request.headers.get("x-session-token")
|
| 8 |
+
|
| 9 |
+
const headers: Record<string, string> = {
|
| 10 |
+
"Content-Type": "application/json",
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
if (sessionToken) {
|
| 14 |
+
headers["X-Session-Token"] = sessionToken
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const response = await fetch(`${API_BASE_URL}/api/admin/retrain`, {
|
| 18 |
+
method: "POST",
|
| 19 |
+
headers,
|
| 20 |
+
})
|
| 21 |
+
|
| 22 |
+
if (!response.ok) {
|
| 23 |
+
return NextResponse.json(
|
| 24 |
+
{ error: "Failed to initiate retraining" },
|
| 25 |
+
{ status: response.status }
|
| 26 |
+
)
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const data = await response.json()
|
| 30 |
+
return NextResponse.json(data)
|
| 31 |
+
} catch (error) {
|
| 32 |
+
console.error("Error initiating retrain:", error)
|
| 33 |
+
return NextResponse.json(
|
| 34 |
+
{ error: "Internal server error" },
|
| 35 |
+
{ status: 500 }
|
| 36 |
+
)
|
| 37 |
+
}
|
| 38 |
+
}
|
src/app/api/admin/review-queue/[id]/approve/route.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 4 |
+
|
| 5 |
+
export async function POST(
|
| 6 |
+
request: NextRequest,
|
| 7 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 8 |
+
) {
|
| 9 |
+
const { id } = await params
|
| 10 |
+
try {
|
| 11 |
+
const sessionToken = request.headers.get("x-session-token")
|
| 12 |
+
|
| 13 |
+
const headers: Record<string, string> = {
|
| 14 |
+
"Content-Type": "application/json",
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
if (sessionToken) {
|
| 18 |
+
headers["X-Session-Token"] = sessionToken
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const response = await fetch(
|
| 22 |
+
`${API_BASE_URL}/api/admin/review-queue/${id}/approve`,
|
| 23 |
+
{
|
| 24 |
+
method: "POST",
|
| 25 |
+
headers,
|
| 26 |
+
}
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
if (!response.ok) {
|
| 30 |
+
return NextResponse.json(
|
| 31 |
+
{ error: "Failed to approve review" },
|
| 32 |
+
{ status: response.status }
|
| 33 |
+
)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const data = await response.json()
|
| 37 |
+
return NextResponse.json(data)
|
| 38 |
+
} catch (error) {
|
| 39 |
+
console.error("Error approving review:", error)
|
| 40 |
+
return NextResponse.json(
|
| 41 |
+
{ error: "Internal server error" },
|
| 42 |
+
{ status: 500 }
|
| 43 |
+
)
|
| 44 |
+
}
|
| 45 |
+
}
|
src/app/api/admin/review-queue/[id]/reject/route.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 4 |
+
|
| 5 |
+
export async function POST(
|
| 6 |
+
request: NextRequest,
|
| 7 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 8 |
+
) {
|
| 9 |
+
const { id } = await params
|
| 10 |
+
try {
|
| 11 |
+
const sessionToken = request.headers.get("x-session-token")
|
| 12 |
+
const body = await request.json()
|
| 13 |
+
|
| 14 |
+
const headers: Record<string, string> = {
|
| 15 |
+
"Content-Type": "application/json",
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
if (sessionToken) {
|
| 19 |
+
headers["X-Session-Token"] = sessionToken
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const response = await fetch(
|
| 23 |
+
`${API_BASE_URL}/api/admin/review-queue/${id}/reject`,
|
| 24 |
+
{
|
| 25 |
+
method: "POST",
|
| 26 |
+
headers,
|
| 27 |
+
body: JSON.stringify(body),
|
| 28 |
+
}
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
if (!response.ok) {
|
| 32 |
+
return NextResponse.json(
|
| 33 |
+
{ error: "Failed to reject review" },
|
| 34 |
+
{ status: response.status }
|
| 35 |
+
)
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const data = await response.json()
|
| 39 |
+
return NextResponse.json(data)
|
| 40 |
+
} catch (error) {
|
| 41 |
+
console.error("Error rejecting review:", error)
|
| 42 |
+
return NextResponse.json(
|
| 43 |
+
{ error: "Internal server error" },
|
| 44 |
+
{ status: 500 }
|
| 45 |
+
)
|
| 46 |
+
}
|
| 47 |
+
}
|
src/app/api/admin/review-queue/route.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 4 |
+
|
| 5 |
+
export async function GET(request: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const sessionToken = request.headers.get("x-session-token")
|
| 8 |
+
|
| 9 |
+
const headers: Record<string, string> = {
|
| 10 |
+
"Content-Type": "application/json",
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
if (sessionToken) {
|
| 14 |
+
headers["X-Session-Token"] = sessionToken
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const response = await fetch(`${API_BASE_URL}/api/admin/review-queue`, {
|
| 18 |
+
headers,
|
| 19 |
+
})
|
| 20 |
+
|
| 21 |
+
if (!response.ok) {
|
| 22 |
+
return NextResponse.json(
|
| 23 |
+
{ error: "Failed to fetch review queue" },
|
| 24 |
+
{ status: response.status }
|
| 25 |
+
)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const data = await response.json()
|
| 29 |
+
return NextResponse.json(data)
|
| 30 |
+
} catch (error) {
|
| 31 |
+
console.error("Error fetching review queue:", error)
|
| 32 |
+
return NextResponse.json(
|
| 33 |
+
{ error: "Internal server error" },
|
| 34 |
+
{ status: 500 }
|
| 35 |
+
)
|
| 36 |
+
}
|
| 37 |
+
}
|
src/app/api/cache/admin/analytics/route.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
import { getCached, setCached, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
|
| 3 |
+
|
| 4 |
+
export async function GET(request: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
const cacheKey = CACHE_KEYS.ADMIN_ANALYTICS
|
| 7 |
+
|
| 8 |
+
// Try to get from cache first
|
| 9 |
+
const cached = await getCached(cacheKey)
|
| 10 |
+
if (cached) {
|
| 11 |
+
return NextResponse.json(cached, {
|
| 12 |
+
headers: { "X-Cache": "HIT" },
|
| 13 |
+
})
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// Fetch from backend
|
| 17 |
+
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 18 |
+
const userToken = request.headers.get("X-Session-Token") || null
|
| 19 |
+
const headers: Record<string, string> = {}
|
| 20 |
+
if (userToken) {
|
| 21 |
+
headers["X-Session-Token"] = userToken
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const response = await fetch(`${apiBaseUrl}/api/v1/auth/analytics/dashboard`, { headers })
|
| 25 |
+
|
| 26 |
+
if (!response.ok) {
|
| 27 |
+
return NextResponse.json(
|
| 28 |
+
{ error: "Failed to fetch analytics" },
|
| 29 |
+
{ status: response.status }
|
| 30 |
+
)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const analytics = await response.json()
|
| 34 |
+
|
| 35 |
+
// Cache the result
|
| 36 |
+
await setCached(cacheKey, analytics, CACHE_TTL.ADMIN_ANALYTICS)
|
| 37 |
+
|
| 38 |
+
return NextResponse.json(analytics, {
|
| 39 |
+
headers: { "X-Cache": "MISS" },
|
| 40 |
+
})
|
| 41 |
+
} catch (error: any) {
|
| 42 |
+
console.error("Analytics cache error:", error)
|
| 43 |
+
return NextResponse.json(
|
| 44 |
+
{ error: error.message || "Internal server error" },
|
| 45 |
+
{ status: 500 }
|
| 46 |
+
)
|
| 47 |
+
}
|
| 48 |
+
}
|
src/app/api/cache/admin/config/route.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
import { getCached, setCached, deleteCacheKey, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
|
| 3 |
+
|
| 4 |
+
export async function GET(request: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
const cacheKey = CACHE_KEYS.ADMIN_CONFIG
|
| 7 |
+
|
| 8 |
+
// Try to get from cache first
|
| 9 |
+
const cached = await getCached(cacheKey)
|
| 10 |
+
if (cached) {
|
| 11 |
+
return NextResponse.json(cached, {
|
| 12 |
+
headers: { "X-Cache": "HIT" },
|
| 13 |
+
})
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// Fetch from backend
|
| 17 |
+
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 18 |
+
const userToken = request.headers.get("X-Session-Token") || null
|
| 19 |
+
const headers: Record<string, string> = {}
|
| 20 |
+
if (userToken) {
|
| 21 |
+
headers["X-Session-Token"] = userToken
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const response = await fetch(`${apiBaseUrl}/api/admin/config`, { headers })
|
| 25 |
+
|
| 26 |
+
if (!response.ok) {
|
| 27 |
+
return NextResponse.json(
|
| 28 |
+
{ error: "Failed to fetch config" },
|
| 29 |
+
{ status: response.status }
|
| 30 |
+
)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const config = await response.json()
|
| 34 |
+
|
| 35 |
+
// Cache the result
|
| 36 |
+
await setCached(cacheKey, config, CACHE_TTL.ADMIN_CONFIG)
|
| 37 |
+
|
| 38 |
+
return NextResponse.json(config, {
|
| 39 |
+
headers: { "X-Cache": "MISS" },
|
| 40 |
+
})
|
| 41 |
+
} catch (error: any) {
|
| 42 |
+
console.error("Config cache error:", error)
|
| 43 |
+
return NextResponse.json(
|
| 44 |
+
{ error: error.message || "Internal server error" },
|
| 45 |
+
{ status: 500 }
|
| 46 |
+
)
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// DELETE: Invalidate config cache
|
| 51 |
+
export async function DELETE() {
|
| 52 |
+
try {
|
| 53 |
+
const cacheKey = CACHE_KEYS.ADMIN_CONFIG
|
| 54 |
+
await deleteCacheKey(cacheKey)
|
| 55 |
+
return NextResponse.json({ success: true })
|
| 56 |
+
} catch (error: any) {
|
| 57 |
+
console.error("Delete config cache error:", error)
|
| 58 |
+
return NextResponse.json(
|
| 59 |
+
{ error: error.message || "Internal server error" },
|
| 60 |
+
{ status: 500 }
|
| 61 |
+
)
|
| 62 |
+
}
|
| 63 |
+
}
|
src/app/api/cache/admin/database-storage/route.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
import { getCached, setCached, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
|
| 3 |
+
|
| 4 |
+
export async function GET(request: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
const cacheKey = CACHE_KEYS.ADMIN_DATABASE_STORAGE
|
| 7 |
+
|
| 8 |
+
// Try to get from cache first
|
| 9 |
+
const cached = await getCached(cacheKey)
|
| 10 |
+
if (cached) {
|
| 11 |
+
return NextResponse.json(cached, {
|
| 12 |
+
headers: { "X-Cache": "HIT" },
|
| 13 |
+
})
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// Fetch from backend
|
| 17 |
+
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 18 |
+
const userToken = request.headers.get("X-Session-Token") || null
|
| 19 |
+
const headers: Record<string, string> = {}
|
| 20 |
+
if (userToken) {
|
| 21 |
+
headers["X-Session-Token"] = userToken
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const response = await fetch(`${apiBaseUrl}/api/admin/database-storage`, { headers })
|
| 25 |
+
|
| 26 |
+
if (!response.ok) {
|
| 27 |
+
return NextResponse.json(
|
| 28 |
+
{ error: "Failed to fetch database storage" },
|
| 29 |
+
{ status: response.status }
|
| 30 |
+
)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const storage = await response.json()
|
| 34 |
+
|
| 35 |
+
// Cache the result
|
| 36 |
+
await setCached(cacheKey, storage, CACHE_TTL.ADMIN_DATABASE_STORAGE)
|
| 37 |
+
|
| 38 |
+
return NextResponse.json(storage, {
|
| 39 |
+
headers: { "X-Cache": "MISS" },
|
| 40 |
+
})
|
| 41 |
+
} catch (error: any) {
|
| 42 |
+
console.error("Database storage cache error:", error)
|
| 43 |
+
return NextResponse.json(
|
| 44 |
+
{ error: error.message || "Internal server error" },
|
| 45 |
+
{ status: 500 }
|
| 46 |
+
)
|
| 47 |
+
}
|
| 48 |
+
}
|
src/app/api/cache/admin/databases/route.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
import { getCached, setCached, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
|
| 3 |
+
|
| 4 |
+
export async function GET(request: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
const cacheKey = CACHE_KEYS.ADMIN_DATABASES
|
| 7 |
+
|
| 8 |
+
// Try to get from cache first
|
| 9 |
+
const cached = await getCached(cacheKey)
|
| 10 |
+
if (cached) {
|
| 11 |
+
return NextResponse.json(cached, {
|
| 12 |
+
headers: { "X-Cache": "HIT" },
|
| 13 |
+
})
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// Fetch from backend
|
| 17 |
+
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 18 |
+
const userToken = request.headers.get("X-Session-Token") || null
|
| 19 |
+
const headers: Record<string, string> = {}
|
| 20 |
+
if (userToken) {
|
| 21 |
+
headers["X-Session-Token"] = userToken
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const response = await fetch(`${apiBaseUrl}/api/admin/databases`, { headers })
|
| 25 |
+
|
| 26 |
+
if (!response.ok) {
|
| 27 |
+
return NextResponse.json(
|
| 28 |
+
{ error: "Failed to fetch databases" },
|
| 29 |
+
{ status: response.status }
|
| 30 |
+
)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const databases = await response.json()
|
| 34 |
+
|
| 35 |
+
// Cache the result
|
| 36 |
+
await setCached(cacheKey, databases, CACHE_TTL.ADMIN_DATABASES)
|
| 37 |
+
|
| 38 |
+
return NextResponse.json(databases, {
|
| 39 |
+
headers: { "X-Cache": "MISS" },
|
| 40 |
+
})
|
| 41 |
+
} catch (error: any) {
|
| 42 |
+
console.error("Databases cache error:", error)
|
| 43 |
+
return NextResponse.json(
|
| 44 |
+
{ error: error.message || "Internal server error" },
|
| 45 |
+
{ status: 500 }
|
| 46 |
+
)
|
| 47 |
+
}
|
| 48 |
+
}
|
src/app/api/cache/admin/documents/route.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
import { getCached, setCached, CACHE_KEYS, CACHE_TTL, hashQueryString } from "@/lib/redis"
|
| 3 |
+
|
| 4 |
+
export async function GET(request: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
// Get query string and hash it for cache key
|
| 7 |
+
const queryString = request.nextUrl.search || ""
|
| 8 |
+
const queryHash = hashQueryString(queryString)
|
| 9 |
+
const cacheKey = CACHE_KEYS.ADMIN_DOCUMENTS(queryHash)
|
| 10 |
+
|
| 11 |
+
// Try to get from cache first
|
| 12 |
+
const cached = await getCached(cacheKey)
|
| 13 |
+
if (cached) {
|
| 14 |
+
return NextResponse.json(cached, {
|
| 15 |
+
headers: { "X-Cache": "HIT" },
|
| 16 |
+
})
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
// Fetch from backend
|
| 20 |
+
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 21 |
+
const userToken = request.headers.get("X-Session-Token") || null
|
| 22 |
+
const headers: Record<string, string> = {}
|
| 23 |
+
if (userToken) {
|
| 24 |
+
headers["X-Session-Token"] = userToken
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const response = await fetch(`${apiBaseUrl}/api/admin/documents${queryString}`, { headers })
|
| 28 |
+
|
| 29 |
+
if (!response.ok) {
|
| 30 |
+
return NextResponse.json(
|
| 31 |
+
{ error: "Failed to fetch documents" },
|
| 32 |
+
{ status: response.status }
|
| 33 |
+
)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const documents = await response.json()
|
| 37 |
+
|
| 38 |
+
// Cache the result
|
| 39 |
+
await setCached(cacheKey, documents, CACHE_TTL.ADMIN_DOCUMENTS)
|
| 40 |
+
|
| 41 |
+
return NextResponse.json(documents, {
|
| 42 |
+
headers: { "X-Cache": "MISS" },
|
| 43 |
+
})
|
| 44 |
+
} catch (error: any) {
|
| 45 |
+
console.error("Documents cache error:", error)
|
| 46 |
+
return NextResponse.json(
|
| 47 |
+
{ error: error.message || "Internal server error" },
|
| 48 |
+
{ status: 500 }
|
| 49 |
+
)
|
| 50 |
+
}
|
| 51 |
+
}
|
src/app/api/cache/admin/stats/route.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
import { getCached, setCached, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
|
| 3 |
+
|
| 4 |
+
export async function GET(request: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
const cacheKey = CACHE_KEYS.ADMIN_STATS
|
| 7 |
+
|
| 8 |
+
// Try to get from cache first
|
| 9 |
+
const cached = await getCached(cacheKey)
|
| 10 |
+
if (cached) {
|
| 11 |
+
return NextResponse.json(cached, {
|
| 12 |
+
headers: { "X-Cache": "HIT" },
|
| 13 |
+
})
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// Fetch from backend
|
| 17 |
+
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 18 |
+
const userToken = request.headers.get("X-Session-Token") || null
|
| 19 |
+
const headers: Record<string, string> = {}
|
| 20 |
+
if (userToken) {
|
| 21 |
+
headers["X-Session-Token"] = userToken
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const response = await fetch(`${apiBaseUrl}/stats`, { headers })
|
| 25 |
+
|
| 26 |
+
if (!response.ok) {
|
| 27 |
+
return NextResponse.json(
|
| 28 |
+
{ error: "Failed to fetch stats" },
|
| 29 |
+
{ status: response.status }
|
| 30 |
+
)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const stats = await response.json()
|
| 34 |
+
|
| 35 |
+
// Cache the result
|
| 36 |
+
await setCached(cacheKey, stats, CACHE_TTL.ADMIN_STATS)
|
| 37 |
+
|
| 38 |
+
return NextResponse.json(stats, {
|
| 39 |
+
headers: { "X-Cache": "MISS" },
|
| 40 |
+
})
|
| 41 |
+
} catch (error: any) {
|
| 42 |
+
console.error("Stats cache error:", error)
|
| 43 |
+
return NextResponse.json(
|
| 44 |
+
{ error: error.message || "Internal server error" },
|
| 45 |
+
{ status: 500 }
|
| 46 |
+
)
|
| 47 |
+
}
|
| 48 |
+
}
|
src/app/api/cache/admin/users/route.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
import { getCached, setCached, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
|
| 3 |
+
|
| 4 |
+
export async function GET(request: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
// Get page from query params
|
| 7 |
+
const searchParams = request.nextUrl.searchParams
|
| 8 |
+
const page = parseInt(searchParams.get("page") || "1")
|
| 9 |
+
const pageSize = searchParams.get("page_size") || "20"
|
| 10 |
+
|
| 11 |
+
const cacheKey = CACHE_KEYS.ADMIN_USERS(page)
|
| 12 |
+
|
| 13 |
+
// Try to get from cache first
|
| 14 |
+
const cached = await getCached(cacheKey)
|
| 15 |
+
if (cached) {
|
| 16 |
+
return NextResponse.json(cached, {
|
| 17 |
+
headers: { "X-Cache": "HIT" },
|
| 18 |
+
})
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// Fetch from backend
|
| 22 |
+
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 23 |
+
const userToken = request.headers.get("X-Session-Token") || null
|
| 24 |
+
const headers: Record<string, string> = {}
|
| 25 |
+
if (userToken) {
|
| 26 |
+
headers["X-Session-Token"] = userToken
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const response = await fetch(
|
| 30 |
+
`${apiBaseUrl}/api/v1/auth/admin/users?page=${page}&page_size=${pageSize}`,
|
| 31 |
+
{ headers }
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
if (!response.ok) {
|
| 35 |
+
return NextResponse.json(
|
| 36 |
+
{ error: "Failed to fetch users" },
|
| 37 |
+
{ status: response.status }
|
| 38 |
+
)
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const users = await response.json()
|
| 42 |
+
|
| 43 |
+
// Cache the result
|
| 44 |
+
await setCached(cacheKey, users, CACHE_TTL.ADMIN_USERS)
|
| 45 |
+
|
| 46 |
+
return NextResponse.json(users, {
|
| 47 |
+
headers: { "X-Cache": "MISS" },
|
| 48 |
+
})
|
| 49 |
+
} catch (error: any) {
|
| 50 |
+
console.error("Users cache error:", error)
|
| 51 |
+
return NextResponse.json(
|
| 52 |
+
{ error: error.message || "Internal server error" },
|
| 53 |
+
{ status: 500 }
|
| 54 |
+
)
|
| 55 |
+
}
|
| 56 |
+
}
|
src/app/api/cache/sessions/[sessionId]/messages/route.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
import { getCached, setCached, deleteCacheKey, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
|
| 3 |
+
|
| 4 |
+
export async function GET(
|
| 5 |
+
request: NextRequest,
|
| 6 |
+
{ params }: { params: Promise<{ sessionId: string }> }
|
| 7 |
+
) {
|
| 8 |
+
try {
|
| 9 |
+
const { sessionId } = await params
|
| 10 |
+
const cacheKey = CACHE_KEYS.SESSION_MESSAGES(sessionId)
|
| 11 |
+
|
| 12 |
+
// Try to get from cache first
|
| 13 |
+
const cached = await getCached(cacheKey)
|
| 14 |
+
if (cached) {
|
| 15 |
+
return NextResponse.json(cached, {
|
| 16 |
+
headers: { "X-Cache": "HIT" },
|
| 17 |
+
})
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
// Fetch from backend
|
| 21 |
+
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 22 |
+
const userToken = request.headers.get("X-Session-Token") || null
|
| 23 |
+
const headers: Record<string, string> = {
|
| 24 |
+
"Content-Type": "application/json",
|
| 25 |
+
}
|
| 26 |
+
if (userToken) {
|
| 27 |
+
headers["X-Session-Token"] = userToken
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const response = await fetch(
|
| 31 |
+
`${apiBaseUrl}/api/v1/chat/sessions/${sessionId}/messages`,
|
| 32 |
+
{ headers }
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
if (!response.ok) {
|
| 36 |
+
return NextResponse.json(
|
| 37 |
+
{ error: "Failed to fetch messages" },
|
| 38 |
+
{ status: response.status }
|
| 39 |
+
)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const messages = await response.json()
|
| 43 |
+
|
| 44 |
+
// Cache the result
|
| 45 |
+
await setCached(cacheKey, messages, CACHE_TTL.SESSION_MESSAGES)
|
| 46 |
+
|
| 47 |
+
return NextResponse.json(messages, {
|
| 48 |
+
headers: { "X-Cache": "MISS" },
|
| 49 |
+
})
|
| 50 |
+
} catch (error: unknown) {
|
| 51 |
+
console.error("Messages cache error:", error)
|
| 52 |
+
const errorMessage = typeof error === "object" && error !== null && "message" in error ? (error as { message?: string }).message : "Internal server error";
|
| 53 |
+
return NextResponse.json(
|
| 54 |
+
{ error: errorMessage || "Internal server error" },
|
| 55 |
+
{ status: 500 }
|
| 56 |
+
)
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// DELETE: Invalidate messages cache for a specific session
|
| 61 |
+
export async function DELETE(
|
| 62 |
+
request: NextRequest,
|
| 63 |
+
{ params }: { params: Promise<{ sessionId: string }> }
|
| 64 |
+
) {
|
| 65 |
+
try {
|
| 66 |
+
const { sessionId } = await params
|
| 67 |
+
const cacheKey = CACHE_KEYS.SESSION_MESSAGES(sessionId)
|
| 68 |
+
await deleteCacheKey(cacheKey)
|
| 69 |
+
|
| 70 |
+
return NextResponse.json({ success: true })
|
| 71 |
+
} catch (error: unknown) {
|
| 72 |
+
console.error("Delete messages cache error:", error)
|
| 73 |
+
const errorMessage = typeof error === "object" && error !== null && "message" in error ? (error as { message?: string }).message : "Internal server error";
|
| 74 |
+
return NextResponse.json(
|
| 75 |
+
{ error: errorMessage || "Internal server error" },
|
| 76 |
+
{ status: 500 }
|
| 77 |
+
)
|
| 78 |
+
}
|
| 79 |
+
}
|
src/app/api/cache/sessions/route.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
import { getCached, setCached, CACHE_KEYS, CACHE_TTL, getUserTokenHash } from "@/lib/redis"
|
| 3 |
+
|
| 4 |
+
export async function GET(request: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
const userToken = request.headers.get("X-Session-Token") || null
|
| 7 |
+
const tokenHash = getUserTokenHash(userToken)
|
| 8 |
+
const cacheKey = CACHE_KEYS.SESSIONS(tokenHash)
|
| 9 |
+
|
| 10 |
+
// Try to get from cache first
|
| 11 |
+
const cached = await getCached(cacheKey)
|
| 12 |
+
if (cached) {
|
| 13 |
+
return NextResponse.json(cached, {
|
| 14 |
+
headers: { "X-Cache": "HIT" },
|
| 15 |
+
})
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// Fetch from backend
|
| 19 |
+
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 20 |
+
const headers: Record<string, string> = {
|
| 21 |
+
"Content-Type": "application/json",
|
| 22 |
+
}
|
| 23 |
+
if (userToken) {
|
| 24 |
+
headers["X-Session-Token"] = userToken
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const response = await fetch(`${apiBaseUrl}/api/v1/chat/sessions`, {
|
| 28 |
+
headers,
|
| 29 |
+
})
|
| 30 |
+
|
| 31 |
+
if (!response.ok) {
|
| 32 |
+
return NextResponse.json(
|
| 33 |
+
{ error: "Failed to fetch sessions" },
|
| 34 |
+
{ status: response.status }
|
| 35 |
+
)
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const sessions = await response.json()
|
| 39 |
+
|
| 40 |
+
// Cache the result
|
| 41 |
+
await setCached(cacheKey, sessions, CACHE_TTL.SESSIONS)
|
| 42 |
+
|
| 43 |
+
return NextResponse.json(sessions, {
|
| 44 |
+
headers: { "X-Cache": "MISS" },
|
| 45 |
+
})
|
| 46 |
+
} catch (error: any) {
|
| 47 |
+
console.error("Sessions cache error:", error)
|
| 48 |
+
return NextResponse.json(
|
| 49 |
+
{ error: error.message || "Internal server error" },
|
| 50 |
+
{ status: 500 }
|
| 51 |
+
)
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// DELETE: Invalidate sessions cache
|
| 56 |
+
export async function DELETE(request: NextRequest) {
|
| 57 |
+
try {
|
| 58 |
+
const userToken = request.headers.get("X-Session-Token") || null
|
| 59 |
+
const tokenHash = getUserTokenHash(userToken)
|
| 60 |
+
const cacheKey = CACHE_KEYS.SESSIONS(tokenHash)
|
| 61 |
+
|
| 62 |
+
const { deleteCacheKey } = await import("@/lib/redis")
|
| 63 |
+
await deleteCacheKey(cacheKey)
|
| 64 |
+
|
| 65 |
+
return NextResponse.json({ success: true })
|
| 66 |
+
} catch (error: any) {
|
| 67 |
+
console.error("Delete sessions cache error:", error)
|
| 68 |
+
return NextResponse.json(
|
| 69 |
+
{ error: error.message || "Internal server error" },
|
| 70 |
+
{ status: 500 }
|
| 71 |
+
)
|
| 72 |
+
}
|
| 73 |
+
}
|
src/app/api/livekit-token/route.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
import { AccessToken } from "livekit-server-sdk"
|
| 3 |
+
|
| 4 |
+
export async function POST(request: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
const { roomName, participantName } = await request.json()
|
| 7 |
+
|
| 8 |
+
if (!roomName) {
|
| 9 |
+
return NextResponse.json(
|
| 10 |
+
{ error: "roomName is required" },
|
| 11 |
+
{ status: 400 }
|
| 12 |
+
)
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// Use server-side environment variables only (not NEXT_PUBLIC_* which are exposed to client)
|
| 16 |
+
// These must match the credentials used by your LiveKit server
|
| 17 |
+
const apiKey = process.env.LIVEKIT_API_KEY
|
| 18 |
+
const apiSecret = process.env.LIVEKIT_API_SECRET
|
| 19 |
+
|
| 20 |
+
if (!apiKey) {
|
| 21 |
+
return NextResponse.json(
|
| 22 |
+
{
|
| 23 |
+
error: "LIVEKIT_API_KEY not configured",
|
| 24 |
+
hint: "Set LIVEKIT_API_KEY environment variable (server-side, not NEXT_PUBLIC_*). This must match the API key configured in your LiveKit server. Get it from LiveKit Cloud dashboard (Settings > Keys) or your self-hosted LiveKit config."
|
| 25 |
+
},
|
| 26 |
+
{ status: 500 }
|
| 27 |
+
)
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
if (!apiSecret) {
|
| 31 |
+
return NextResponse.json(
|
| 32 |
+
{
|
| 33 |
+
error: "LIVEKIT_API_SECRET not configured",
|
| 34 |
+
hint: "Set LIVEKIT_API_SECRET environment variable (server-side). This must match the API secret configured in your LiveKit server.",
|
| 35 |
+
instructions: [
|
| 36 |
+
"For LiveKit Cloud:",
|
| 37 |
+
" 1. Go to Settings > Keys in your LiveKit Cloud dashboard",
|
| 38 |
+
" 2. Click on your API key row or the menu (three dots) to reveal the secret",
|
| 39 |
+
" 3. If the secret is not visible, you may need to create a NEW API key",
|
| 40 |
+
" 4. When creating a new key, copy BOTH the API key AND the secret immediately",
|
| 41 |
+
" 5. Note: Secrets are only shown once when created - save it securely!",
|
| 42 |
+
"",
|
| 43 |
+
"For self-hosted LiveKit:",
|
| 44 |
+
" - Check your LiveKit server configuration file (usually livekit.yaml)",
|
| 45 |
+
" - Or use the default 'secret' for development mode"
|
| 46 |
+
]
|
| 47 |
+
},
|
| 48 |
+
{ status: 500 }
|
| 49 |
+
)
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Validate API secret format - it should NOT be a JWT token
|
| 53 |
+
const trimmedSecret = apiSecret.trim()
|
| 54 |
+
if (trimmedSecret.startsWith('eyJ')) {
|
| 55 |
+
console.error(`[LiveKit Token] ❌ ERROR: API secret appears to be a JWT token, not a secret key!`)
|
| 56 |
+
console.error(`[LiveKit Token] The secret starts with 'eyJ' which indicates it's a JWT token.`)
|
| 57 |
+
console.error(`[LiveKit Token] LiveKit API secret should be a random string, not a token.`)
|
| 58 |
+
return NextResponse.json(
|
| 59 |
+
{
|
| 60 |
+
error: "Invalid API secret format",
|
| 61 |
+
hint: "Your LIVEKIT_API_SECRET appears to be a JWT token (starts with 'eyJ'), but it should be the actual secret key. In LiveKit Cloud: Go to Settings > Keys and copy the 'API Secret' (not a token). It should be a long random string, not a JWT token.",
|
| 62 |
+
details: "The API secret is used to SIGN tokens, not a token itself. Make sure you're using the secret key from your LiveKit dashboard, not a generated token."
|
| 63 |
+
},
|
| 64 |
+
{ status: 500 }
|
| 65 |
+
)
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// Log API key prefix for debugging (without exposing full key or secret)
|
| 69 |
+
console.log(`[LiveKit Token] Using API key: ${apiKey.substring(0, 8)}...`)
|
| 70 |
+
console.log(`[LiveKit Token] API secret length: ${trimmedSecret.length} characters`)
|
| 71 |
+
console.log(`[LiveKit Token] API secret starts with: ${trimmedSecret.substring(0, 4)}...`)
|
| 72 |
+
|
| 73 |
+
// Warn if secret looks suspicious
|
| 74 |
+
if (trimmedSecret.length < 20) {
|
| 75 |
+
console.warn(`[LiveKit Token] ⚠️ WARNING: API secret is very short (${trimmedSecret.length} chars). LiveKit secrets are usually much longer.`)
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const at = new AccessToken(apiKey, trimmedSecret, {
|
| 79 |
+
identity: participantName || "user",
|
| 80 |
+
})
|
| 81 |
+
|
| 82 |
+
at.addGrant({
|
| 83 |
+
room: roomName,
|
| 84 |
+
roomJoin: true,
|
| 85 |
+
canPublish: true,
|
| 86 |
+
canSubscribe: true,
|
| 87 |
+
})
|
| 88 |
+
|
| 89 |
+
const token = await at.toJwt()
|
| 90 |
+
|
| 91 |
+
// Decode token to verify it was created correctly (for debugging)
|
| 92 |
+
const tokenParts = token.split('.')
|
| 93 |
+
if (tokenParts.length === 3) {
|
| 94 |
+
try {
|
| 95 |
+
const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString())
|
| 96 |
+
console.log(`[LiveKit Token] Generated token details:`)
|
| 97 |
+
console.log(` - Room: ${payload.video?.room}`)
|
| 98 |
+
console.log(` - Identity: ${payload.sub}`)
|
| 99 |
+
console.log(` - Issuer (API Key): ${payload.iss}`)
|
| 100 |
+
console.log(` - Expires: ${new Date(payload.exp * 1000).toISOString()}`)
|
| 101 |
+
console.log(`[LiveKit Token] ⚠️ If token validation fails, verify:`)
|
| 102 |
+
console.log(` 1. API Key in token (${payload.iss}) matches your LiveKit server's API key`)
|
| 103 |
+
console.log(` 2. API Secret used to sign token matches your LiveKit server's API secret`)
|
| 104 |
+
console.log(` 3. Both are set in your Next.js environment variables (not NEXT_PUBLIC_*)`)
|
| 105 |
+
} catch (e) {
|
| 106 |
+
// Ignore decode errors
|
| 107 |
+
console.error(`[LiveKit Token] Failed to decode token payload:`, e)
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
return NextResponse.json({ token })
|
| 112 |
+
} catch (error) {
|
| 113 |
+
console.error("[LiveKit Token] Error generating token:", error)
|
| 114 |
+
const errorMessage = error instanceof Error ? error.message : "Failed to generate token"
|
| 115 |
+
|
| 116 |
+
// Provide detailed troubleshooting information
|
| 117 |
+
const troubleshooting = {
|
| 118 |
+
error: errorMessage,
|
| 119 |
+
hint: "Token signature verification failed. This means the API secret doesn't match your LiveKit server.",
|
| 120 |
+
steps: [
|
| 121 |
+
"1. Verify LIVEKIT_API_KEY matches your LiveKit server's API key",
|
| 122 |
+
"2. Verify LIVEKIT_API_SECRET matches your LiveKit server's API secret (exactly, including any spaces)",
|
| 123 |
+
"3. For LiveKit Cloud: Get credentials from Settings > Keys in your dashboard",
|
| 124 |
+
"4. For self-hosted: Check your LiveKit server configuration file",
|
| 125 |
+
"5. Ensure environment variables are set in .env.local (not .env) for Next.js",
|
| 126 |
+
"6. Restart your Next.js dev server after changing environment variables"
|
| 127 |
+
]
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
return NextResponse.json(troubleshooting, { status: 500 })
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
src/app/auth/forgot-password/page.tsx
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState } from "react"
|
| 4 |
+
import { useRouter } from "next/navigation"
|
| 5 |
+
import Link from "next/link"
|
| 6 |
+
import { apiClient } from "@/lib/api-client"
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
import { Input } from "@/components/ui/input"
|
| 9 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
| 10 |
+
import { OTPInput } from "@/components/ui/otp-input"
|
| 11 |
+
import { toast } from "sonner"
|
| 12 |
+
import { Loader2 } from "lucide-react"
|
| 13 |
+
|
| 14 |
+
export default function ForgotPasswordPage() {
|
| 15 |
+
const [step, setStep] = useState<"email" | "otp" | "reset">("email")
|
| 16 |
+
const [loading, setLoading] = useState(false)
|
| 17 |
+
const [email, setEmail] = useState("")
|
| 18 |
+
const [phoneNumber, setPhoneNumber] = useState("")
|
| 19 |
+
const [maskedPhoneNumber, setMaskedPhoneNumber] = useState<string | null>(null)
|
| 20 |
+
const [otp, setOtp] = useState("")
|
| 21 |
+
const [resetToken, setResetToken] = useState("")
|
| 22 |
+
const [newPassword, setNewPassword] = useState("")
|
| 23 |
+
const router = useRouter()
|
| 24 |
+
|
| 25 |
+
const handleEmailSubmit = async (e: React.FormEvent) => {
|
| 26 |
+
e.preventDefault()
|
| 27 |
+
setLoading(true)
|
| 28 |
+
|
| 29 |
+
try {
|
| 30 |
+
// Request password reset (this will return masked phone number)
|
| 31 |
+
const response = await apiClient.post<{ message: string; phone_number?: string }>(
|
| 32 |
+
"/api/v1/auth/password/reset-request",
|
| 33 |
+
{
|
| 34 |
+
email: email,
|
| 35 |
+
}
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
if (response.phone_number) {
|
| 39 |
+
// Store masked phone number for display
|
| 40 |
+
setMaskedPhoneNumber(response.phone_number)
|
| 41 |
+
// Show masked phone number to user - they still need to enter full number for OTP verification
|
| 42 |
+
toast.success(`${response.message} (ending in ${response.phone_number.slice(-4)})`)
|
| 43 |
+
setStep("otp")
|
| 44 |
+
} else {
|
| 45 |
+
// No phone number found, ask user to enter it
|
| 46 |
+
setMaskedPhoneNumber(null)
|
| 47 |
+
toast.success("Please enter your phone number to receive OTP")
|
| 48 |
+
setStep("otp")
|
| 49 |
+
}
|
| 50 |
+
} catch (error: unknown) {
|
| 51 |
+
const errorMessage = error instanceof Error ? error.message : "Failed to request password reset"
|
| 52 |
+
toast.error(errorMessage)
|
| 53 |
+
} finally {
|
| 54 |
+
setLoading(false)
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const handleOTPVerify = async () => {
|
| 59 |
+
if (otp.length !== 6) {
|
| 60 |
+
toast.error("Please enter a valid 6-digit OTP")
|
| 61 |
+
return
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
setLoading(true)
|
| 65 |
+
|
| 66 |
+
try {
|
| 67 |
+
// Verify OTP and get password reset token
|
| 68 |
+
const response = await apiClient.post<{ status: string; message: string; reset_token?: string }>(
|
| 69 |
+
"/api/v1/auth/phone/verify-otp",
|
| 70 |
+
{
|
| 71 |
+
phone_number: phoneNumber,
|
| 72 |
+
otp: otp,
|
| 73 |
+
purpose: "password_reset",
|
| 74 |
+
}
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
if (response.reset_token) {
|
| 78 |
+
setResetToken(response.reset_token)
|
| 79 |
+
setStep("reset")
|
| 80 |
+
toast.success("OTP verified. Please set your new password.")
|
| 81 |
+
} else {
|
| 82 |
+
// Fallback: request reset token again if not returned
|
| 83 |
+
await apiClient.post("/api/v1/auth/password/reset-request", {
|
| 84 |
+
email: email,
|
| 85 |
+
})
|
| 86 |
+
// Note: In production, the token should always come from OTP verification
|
| 87 |
+
setResetToken("verified")
|
| 88 |
+
setStep("reset")
|
| 89 |
+
toast.success("OTP verified. Please set your new password.")
|
| 90 |
+
}
|
| 91 |
+
} catch (error: unknown) {
|
| 92 |
+
const errorMessage = error instanceof Error ? error.message : "OTP verification failed"
|
| 93 |
+
toast.error(errorMessage)
|
| 94 |
+
} finally {
|
| 95 |
+
setLoading(false)
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const handlePasswordReset = async (e: React.FormEvent) => {
|
| 100 |
+
e.preventDefault()
|
| 101 |
+
setLoading(true)
|
| 102 |
+
|
| 103 |
+
try {
|
| 104 |
+
// Reset password with token and OTP verification
|
| 105 |
+
await apiClient.post("/api/v1/auth/password/reset", {
|
| 106 |
+
token: resetToken,
|
| 107 |
+
new_password: newPassword,
|
| 108 |
+
})
|
| 109 |
+
|
| 110 |
+
toast.success("Password reset successfully! Please sign in.")
|
| 111 |
+
router.push("/auth/signin")
|
| 112 |
+
} catch (error: unknown) {
|
| 113 |
+
const errorMessage = error instanceof Error ? error.message : "Password reset failed"
|
| 114 |
+
toast.error(errorMessage)
|
| 115 |
+
} finally {
|
| 116 |
+
setLoading(false)
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
const handleResendOTP = async () => {
|
| 121 |
+
setLoading(true)
|
| 122 |
+
try {
|
| 123 |
+
await apiClient.post("/api/v1/auth/phone/resend-otp", {
|
| 124 |
+
phone_number: phoneNumber,
|
| 125 |
+
purpose: "password_reset",
|
| 126 |
+
})
|
| 127 |
+
toast.success("OTP resent successfully")
|
| 128 |
+
} catch (error: unknown) {
|
| 129 |
+
const errorMessage = error instanceof Error ? error.message : "Failed to resend OTP"
|
| 130 |
+
toast.error(errorMessage)
|
| 131 |
+
} finally {
|
| 132 |
+
setLoading(false)
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
if (step === "otp") {
|
| 137 |
+
return (
|
| 138 |
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
|
| 139 |
+
<Card className="w-full max-w-md">
|
| 140 |
+
<CardHeader>
|
| 141 |
+
<CardTitle>Verify OTP</CardTitle>
|
| 142 |
+
<CardDescription>
|
| 143 |
+
{maskedPhoneNumber
|
| 144 |
+
? `Enter the 6-digit code sent to ${maskedPhoneNumber}`
|
| 145 |
+
: phoneNumber
|
| 146 |
+
? `Enter the 6-digit code sent to ${phoneNumber}`
|
| 147 |
+
: "Enter the 6-digit code sent to your phone number"}
|
| 148 |
+
</CardDescription>
|
| 149 |
+
</CardHeader>
|
| 150 |
+
<CardContent className="space-y-4">
|
| 151 |
+
{!maskedPhoneNumber && (
|
| 152 |
+
<div>
|
| 153 |
+
<label htmlFor="phone" className="text-sm font-medium">
|
| 154 |
+
Phone Number
|
| 155 |
+
</label>
|
| 156 |
+
<Input
|
| 157 |
+
id="phone"
|
| 158 |
+
type="tel"
|
| 159 |
+
placeholder="+254712345678 or 0712345678"
|
| 160 |
+
value={phoneNumber}
|
| 161 |
+
onChange={(e) => setPhoneNumber(e.target.value)}
|
| 162 |
+
required
|
| 163 |
+
className="mt-1"
|
| 164 |
+
/>
|
| 165 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 166 |
+
Format: +254712345678, +2541XXXXXXX, 0712345678, or 01XXXXXXX
|
| 167 |
+
</p>
|
| 168 |
+
</div>
|
| 169 |
+
)}
|
| 170 |
+
{maskedPhoneNumber && !phoneNumber && (
|
| 171 |
+
<div>
|
| 172 |
+
<label htmlFor="phone" className="text-sm font-medium">
|
| 173 |
+
Confirm Phone Number
|
| 174 |
+
</label>
|
| 175 |
+
<Input
|
| 176 |
+
id="phone"
|
| 177 |
+
type="tel"
|
| 178 |
+
placeholder={`Enter full number ending in ${maskedPhoneNumber.slice(-4)}`}
|
| 179 |
+
value={phoneNumber}
|
| 180 |
+
onChange={(e) => setPhoneNumber(e.target.value)}
|
| 181 |
+
required
|
| 182 |
+
className="mt-1"
|
| 183 |
+
/>
|
| 184 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 185 |
+
Enter your full phone number to verify
|
| 186 |
+
</p>
|
| 187 |
+
</div>
|
| 188 |
+
)}
|
| 189 |
+
<OTPInput
|
| 190 |
+
value={otp}
|
| 191 |
+
onChange={setOtp}
|
| 192 |
+
onComplete={handleOTPVerify}
|
| 193 |
+
disabled={loading}
|
| 194 |
+
/>
|
| 195 |
+
<Button
|
| 196 |
+
onClick={handleOTPVerify}
|
| 197 |
+
disabled={loading || otp.length !== 6 || !phoneNumber}
|
| 198 |
+
className="w-full"
|
| 199 |
+
>
|
| 200 |
+
{loading ? (
|
| 201 |
+
<>
|
| 202 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 203 |
+
Verifying...
|
| 204 |
+
</>
|
| 205 |
+
) : (
|
| 206 |
+
"Verify OTP"
|
| 207 |
+
)}
|
| 208 |
+
</Button>
|
| 209 |
+
<div className="text-center">
|
| 210 |
+
<button
|
| 211 |
+
onClick={handleResendOTP}
|
| 212 |
+
disabled={loading}
|
| 213 |
+
className="text-sm text-primary hover:underline"
|
| 214 |
+
>
|
| 215 |
+
Resend OTP
|
| 216 |
+
</button>
|
| 217 |
+
</div>
|
| 218 |
+
<div className="text-center text-sm">
|
| 219 |
+
<button
|
| 220 |
+
onClick={() => setStep("email")}
|
| 221 |
+
className="text-muted-foreground hover:underline"
|
| 222 |
+
>
|
| 223 |
+
Back
|
| 224 |
+
</button>
|
| 225 |
+
</div>
|
| 226 |
+
</CardContent>
|
| 227 |
+
</Card>
|
| 228 |
+
</div>
|
| 229 |
+
)
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
if (step === "reset") {
|
| 233 |
+
return (
|
| 234 |
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
|
| 235 |
+
<Card className="w-full max-w-md">
|
| 236 |
+
<CardHeader>
|
| 237 |
+
<CardTitle>Reset Password</CardTitle>
|
| 238 |
+
<CardDescription>Enter your new password</CardDescription>
|
| 239 |
+
</CardHeader>
|
| 240 |
+
<CardContent>
|
| 241 |
+
<form onSubmit={handlePasswordReset} className="space-y-4">
|
| 242 |
+
<div>
|
| 243 |
+
<label htmlFor="newPassword" className="text-sm font-medium">
|
| 244 |
+
New Password
|
| 245 |
+
</label>
|
| 246 |
+
<Input
|
| 247 |
+
id="newPassword"
|
| 248 |
+
type="password"
|
| 249 |
+
placeholder="••••••••"
|
| 250 |
+
value={newPassword}
|
| 251 |
+
onChange={(e) => setNewPassword(e.target.value)}
|
| 252 |
+
required
|
| 253 |
+
minLength={8}
|
| 254 |
+
/>
|
| 255 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 256 |
+
Must be at least 8 characters with uppercase, lowercase, and digit
|
| 257 |
+
</p>
|
| 258 |
+
</div>
|
| 259 |
+
<Button type="submit" disabled={loading} className="w-full">
|
| 260 |
+
{loading ? (
|
| 261 |
+
<>
|
| 262 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 263 |
+
Resetting...
|
| 264 |
+
</>
|
| 265 |
+
) : (
|
| 266 |
+
"Reset Password"
|
| 267 |
+
)}
|
| 268 |
+
</Button>
|
| 269 |
+
</form>
|
| 270 |
+
<div className="mt-4 text-center text-sm">
|
| 271 |
+
<Link href="/auth/signin" className="text-primary hover:underline">
|
| 272 |
+
Back to sign in
|
| 273 |
+
</Link>
|
| 274 |
+
</div>
|
| 275 |
+
</CardContent>
|
| 276 |
+
</Card>
|
| 277 |
+
</div>
|
| 278 |
+
)
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
return (
|
| 282 |
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
|
| 283 |
+
<Card className="w-full max-w-md">
|
| 284 |
+
<CardHeader>
|
| 285 |
+
<CardTitle>Forgot Password</CardTitle>
|
| 286 |
+
<CardDescription>
|
| 287 |
+
Enter your email and phone number to reset your password
|
| 288 |
+
</CardDescription>
|
| 289 |
+
</CardHeader>
|
| 290 |
+
<CardContent>
|
| 291 |
+
<form onSubmit={handleEmailSubmit} className="space-y-4">
|
| 292 |
+
<div>
|
| 293 |
+
<label htmlFor="email" className="text-sm font-medium">
|
| 294 |
+
Email
|
| 295 |
+
</label>
|
| 296 |
+
<Input
|
| 297 |
+
id="email"
|
| 298 |
+
type="email"
|
| 299 |
+
placeholder="you@example.com"
|
| 300 |
+
value={email}
|
| 301 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 302 |
+
required
|
| 303 |
+
/>
|
| 304 |
+
</div>
|
| 305 |
+
<div>
|
| 306 |
+
<label htmlFor="phone" className="text-sm font-medium">
|
| 307 |
+
Phone Number
|
| 308 |
+
</label>
|
| 309 |
+
<Input
|
| 310 |
+
id="phone"
|
| 311 |
+
type="tel"
|
| 312 |
+
placeholder="+254712345678 or 0712345678"
|
| 313 |
+
value={phoneNumber}
|
| 314 |
+
onChange={(e) => setPhoneNumber(e.target.value)}
|
| 315 |
+
required
|
| 316 |
+
/>
|
| 317 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 318 |
+
Format: +254712345678, +2541XXXXXXX, 0712345678, or 01XXXXXXX
|
| 319 |
+
</p>
|
| 320 |
+
</div>
|
| 321 |
+
<Button type="submit" disabled={loading} className="w-full">
|
| 322 |
+
{loading ? (
|
| 323 |
+
<>
|
| 324 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 325 |
+
Sending OTP...
|
| 326 |
+
</>
|
| 327 |
+
) : (
|
| 328 |
+
"Send OTP"
|
| 329 |
+
)}
|
| 330 |
+
</Button>
|
| 331 |
+
</form>
|
| 332 |
+
<div className="mt-4 text-center text-sm">
|
| 333 |
+
<Link href="/auth/signin" className="text-primary hover:underline">
|
| 334 |
+
Back to sign in
|
| 335 |
+
</Link>
|
| 336 |
+
</div>
|
| 337 |
+
</CardContent>
|
| 338 |
+
</Card>
|
| 339 |
+
</div>
|
| 340 |
+
)
|
| 341 |
+
}
|
| 342 |
+
|
src/app/auth/reset-password/page.tsx
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, Suspense } from "react"
|
| 4 |
+
import { useRouter, useSearchParams } from "next/navigation"
|
| 5 |
+
import Link from "next/link"
|
| 6 |
+
import { apiClient } from "@/lib/api-client"
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
import { Input } from "@/components/ui/input"
|
| 9 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
| 10 |
+
import { OTPInput } from "@/components/ui/otp-input"
|
| 11 |
+
import { toast } from "sonner"
|
| 12 |
+
import { Loader2 } from "lucide-react"
|
| 13 |
+
|
| 14 |
+
function ResetPasswordForm() {
|
| 15 |
+
const [step, setStep] = useState<"otp" | "reset">("otp")
|
| 16 |
+
const [loading, setLoading] = useState(false)
|
| 17 |
+
const [token, setToken] = useState("")
|
| 18 |
+
const [phoneNumber, setPhoneNumber] = useState("")
|
| 19 |
+
const [otp, setOtp] = useState("")
|
| 20 |
+
const [newPassword, setNewPassword] = useState("")
|
| 21 |
+
const router = useRouter()
|
| 22 |
+
const searchParams = useSearchParams()
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
const tokenParam = searchParams.get("token")
|
| 26 |
+
if (tokenParam) {
|
| 27 |
+
setToken(tokenParam)
|
| 28 |
+
} else {
|
| 29 |
+
toast.error("Invalid reset link")
|
| 30 |
+
router.push("/auth/forgot-password")
|
| 31 |
+
}
|
| 32 |
+
}, [searchParams, router])
|
| 33 |
+
|
| 34 |
+
const handleOTPVerify = async () => {
|
| 35 |
+
if (otp.length !== 6) {
|
| 36 |
+
toast.error("Please enter a valid 6-digit OTP")
|
| 37 |
+
return
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
setLoading(true)
|
| 41 |
+
|
| 42 |
+
try {
|
| 43 |
+
// Verify OTP
|
| 44 |
+
await apiClient.post("/api/v1/auth/phone/verify-otp", {
|
| 45 |
+
phone_number: phoneNumber,
|
| 46 |
+
otp: otp,
|
| 47 |
+
purpose: "password_reset",
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
setStep("reset")
|
| 51 |
+
toast.success("OTP verified. Please set your new password.")
|
| 52 |
+
} catch (error: unknown) {
|
| 53 |
+
const errorMessage = error instanceof Error ? error.message : "OTP verification failed"
|
| 54 |
+
toast.error(errorMessage)
|
| 55 |
+
} finally {
|
| 56 |
+
setLoading(false)
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
const handlePasswordReset = async (e: React.FormEvent) => {
|
| 61 |
+
e.preventDefault()
|
| 62 |
+
setLoading(true)
|
| 63 |
+
|
| 64 |
+
try {
|
| 65 |
+
// Reset password with token
|
| 66 |
+
await apiClient.post("/api/v1/auth/password/reset", {
|
| 67 |
+
token: token,
|
| 68 |
+
new_password: newPassword,
|
| 69 |
+
})
|
| 70 |
+
|
| 71 |
+
toast.success("Password reset successfully! Please sign in.")
|
| 72 |
+
router.push("/auth/signin")
|
| 73 |
+
} catch (error: unknown) {
|
| 74 |
+
const errorMessage = error instanceof Error ? error.message : "Password reset failed"
|
| 75 |
+
toast.error(errorMessage)
|
| 76 |
+
} finally {
|
| 77 |
+
setLoading(false)
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
const handleResendOTP = async () => {
|
| 82 |
+
setLoading(true)
|
| 83 |
+
try {
|
| 84 |
+
await apiClient.post("/api/v1/auth/phone/resend-otp", {
|
| 85 |
+
phone_number: phoneNumber,
|
| 86 |
+
purpose: "password_reset",
|
| 87 |
+
})
|
| 88 |
+
toast.success("OTP resent successfully")
|
| 89 |
+
} catch (error: unknown) {
|
| 90 |
+
const errorMessage = error instanceof Error ? error.message : "Failed to resend OTP"
|
| 91 |
+
toast.error(errorMessage)
|
| 92 |
+
} finally {
|
| 93 |
+
setLoading(false)
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
if (step === "otp") {
|
| 98 |
+
return (
|
| 99 |
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
|
| 100 |
+
<Card className="w-full max-w-md">
|
| 101 |
+
<CardHeader>
|
| 102 |
+
<CardTitle>Verify OTP</CardTitle>
|
| 103 |
+
<CardDescription>
|
| 104 |
+
Enter your phone number and the OTP code sent to you
|
| 105 |
+
</CardDescription>
|
| 106 |
+
</CardHeader>
|
| 107 |
+
<CardContent className="space-y-4">
|
| 108 |
+
<div>
|
| 109 |
+
<label htmlFor="phone" className="text-sm font-medium">
|
| 110 |
+
Phone Number
|
| 111 |
+
</label>
|
| 112 |
+
<Input
|
| 113 |
+
id="phone"
|
| 114 |
+
type="tel"
|
| 115 |
+
placeholder="+254712345678 or 0712345678"
|
| 116 |
+
value={phoneNumber}
|
| 117 |
+
onChange={(e) => setPhoneNumber(e.target.value)}
|
| 118 |
+
required
|
| 119 |
+
/>
|
| 120 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 121 |
+
Format: +254712345678, +2541XXXXXXX, 0712345678, or 01XXXXXXX
|
| 122 |
+
</p>
|
| 123 |
+
</div>
|
| 124 |
+
<OTPInput
|
| 125 |
+
value={otp}
|
| 126 |
+
onChange={setOtp}
|
| 127 |
+
onComplete={handleOTPVerify}
|
| 128 |
+
disabled={loading}
|
| 129 |
+
/>
|
| 130 |
+
<Button
|
| 131 |
+
onClick={handleOTPVerify}
|
| 132 |
+
disabled={loading || otp.length !== 6 || !phoneNumber}
|
| 133 |
+
className="w-full"
|
| 134 |
+
>
|
| 135 |
+
{loading ? (
|
| 136 |
+
<>
|
| 137 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 138 |
+
Verifying...
|
| 139 |
+
</>
|
| 140 |
+
) : (
|
| 141 |
+
"Verify OTP"
|
| 142 |
+
)}
|
| 143 |
+
</Button>
|
| 144 |
+
<div className="text-center">
|
| 145 |
+
<button
|
| 146 |
+
onClick={handleResendOTP}
|
| 147 |
+
disabled={loading || !phoneNumber}
|
| 148 |
+
className="text-sm text-primary hover:underline"
|
| 149 |
+
>
|
| 150 |
+
Resend OTP
|
| 151 |
+
</button>
|
| 152 |
+
</div>
|
| 153 |
+
</CardContent>
|
| 154 |
+
</Card>
|
| 155 |
+
</div>
|
| 156 |
+
)
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
return (
|
| 160 |
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
|
| 161 |
+
<Card className="w-full max-w-md">
|
| 162 |
+
<CardHeader>
|
| 163 |
+
<CardTitle>Reset Password</CardTitle>
|
| 164 |
+
<CardDescription>Enter your new password</CardDescription>
|
| 165 |
+
</CardHeader>
|
| 166 |
+
<CardContent>
|
| 167 |
+
<form onSubmit={handlePasswordReset} className="space-y-4">
|
| 168 |
+
<div>
|
| 169 |
+
<label htmlFor="newPassword" className="text-sm font-medium">
|
| 170 |
+
New Password
|
| 171 |
+
</label>
|
| 172 |
+
<Input
|
| 173 |
+
id="newPassword"
|
| 174 |
+
type="password"
|
| 175 |
+
placeholder="••••••••"
|
| 176 |
+
value={newPassword}
|
| 177 |
+
onChange={(e) => setNewPassword(e.target.value)}
|
| 178 |
+
required
|
| 179 |
+
minLength={8}
|
| 180 |
+
/>
|
| 181 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 182 |
+
Must be at least 8 characters with uppercase, lowercase, and digit
|
| 183 |
+
</p>
|
| 184 |
+
</div>
|
| 185 |
+
<Button type="submit" disabled={loading} className="w-full">
|
| 186 |
+
{loading ? (
|
| 187 |
+
<>
|
| 188 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 189 |
+
Resetting...
|
| 190 |
+
</>
|
| 191 |
+
) : (
|
| 192 |
+
"Reset Password"
|
| 193 |
+
)}
|
| 194 |
+
</Button>
|
| 195 |
+
</form>
|
| 196 |
+
<div className="mt-4 text-center text-sm">
|
| 197 |
+
<Link href="/auth/signin" className="text-primary hover:underline">
|
| 198 |
+
Back to sign in
|
| 199 |
+
</Link>
|
| 200 |
+
</div>
|
| 201 |
+
</CardContent>
|
| 202 |
+
</Card>
|
| 203 |
+
</div>
|
| 204 |
+
)
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
export default function ResetPasswordPage() {
|
| 208 |
+
return (
|
| 209 |
+
<Suspense fallback={
|
| 210 |
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
|
| 211 |
+
<Card className="w-full max-w-md">
|
| 212 |
+
<CardContent className="flex items-center justify-center p-6">
|
| 213 |
+
<Loader2 className="h-8 w-8 animate-spin" />
|
| 214 |
+
</CardContent>
|
| 215 |
+
</Card>
|
| 216 |
+
</div>
|
| 217 |
+
}>
|
| 218 |
+
<ResetPasswordForm />
|
| 219 |
+
</Suspense>
|
| 220 |
+
)
|
| 221 |
+
}
|
src/app/auth/signin/page.tsx
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState } from "react"
|
| 4 |
+
import { useRouter } from "next/navigation"
|
| 5 |
+
import Link from "next/link"
|
| 6 |
+
import { useAuth } from "@/lib/auth-context"
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
import { Input } from "@/components/ui/input"
|
| 9 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
| 10 |
+
import { toast } from "sonner"
|
| 11 |
+
import { Loader2 } from "lucide-react"
|
| 12 |
+
|
| 13 |
+
export default function SignInPage() {
|
| 14 |
+
const [email, setEmail] = useState("")
|
| 15 |
+
const [password, setPassword] = useState("")
|
| 16 |
+
const [loading, setLoading] = useState(false)
|
| 17 |
+
const router = useRouter()
|
| 18 |
+
const { login } = useAuth()
|
| 19 |
+
|
| 20 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 21 |
+
e.preventDefault()
|
| 22 |
+
setLoading(true)
|
| 23 |
+
|
| 24 |
+
try {
|
| 25 |
+
await login(email, password)
|
| 26 |
+
toast.success("Signed in successfully")
|
| 27 |
+
// Navigation handled by auth context
|
| 28 |
+
} catch (error: unknown) {
|
| 29 |
+
const errorMessage = error instanceof Error ? error.message : "Sign in failed"
|
| 30 |
+
toast.error(errorMessage)
|
| 31 |
+
} finally {
|
| 32 |
+
setLoading(false)
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
|
| 38 |
+
<Card className="w-full max-w-md">
|
| 39 |
+
<CardHeader>
|
| 40 |
+
<CardTitle>Sign In</CardTitle>
|
| 41 |
+
<CardDescription>
|
| 42 |
+
Enter your credentials to access your account
|
| 43 |
+
</CardDescription>
|
| 44 |
+
</CardHeader>
|
| 45 |
+
<CardContent>
|
| 46 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 47 |
+
<div>
|
| 48 |
+
<label htmlFor="email" className="text-sm font-medium">
|
| 49 |
+
Email
|
| 50 |
+
</label>
|
| 51 |
+
<Input
|
| 52 |
+
id="email"
|
| 53 |
+
type="email"
|
| 54 |
+
placeholder="you@example.com"
|
| 55 |
+
value={email}
|
| 56 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 57 |
+
required
|
| 58 |
+
autoFocus
|
| 59 |
+
/>
|
| 60 |
+
</div>
|
| 61 |
+
<div>
|
| 62 |
+
<label htmlFor="password" className="text-sm font-medium">
|
| 63 |
+
Password
|
| 64 |
+
</label>
|
| 65 |
+
<Input
|
| 66 |
+
id="password"
|
| 67 |
+
type="password"
|
| 68 |
+
placeholder="••••••••"
|
| 69 |
+
value={password}
|
| 70 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 71 |
+
required
|
| 72 |
+
/>
|
| 73 |
+
</div>
|
| 74 |
+
<div className="text-right">
|
| 75 |
+
<Link
|
| 76 |
+
href="/auth/forgot-password"
|
| 77 |
+
className="text-sm text-primary hover:underline"
|
| 78 |
+
>
|
| 79 |
+
Forgot password?
|
| 80 |
+
</Link>
|
| 81 |
+
</div>
|
| 82 |
+
<Button type="submit" disabled={loading} className="w-full">
|
| 83 |
+
{loading ? (
|
| 84 |
+
<>
|
| 85 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 86 |
+
Signing in...
|
| 87 |
+
</>
|
| 88 |
+
) : (
|
| 89 |
+
"Sign In"
|
| 90 |
+
)}
|
| 91 |
+
</Button>
|
| 92 |
+
</form>
|
| 93 |
+
<div className="mt-4 text-center text-sm">
|
| 94 |
+
<span className="text-muted-foreground">Don't have an account? </span>
|
| 95 |
+
<Link href="/auth/signup" className="text-primary hover:underline">
|
| 96 |
+
Sign up
|
| 97 |
+
</Link>
|
| 98 |
+
</div>
|
| 99 |
+
</CardContent>
|
| 100 |
+
</Card>
|
| 101 |
+
</div>
|
| 102 |
+
)
|
| 103 |
+
}
|
| 104 |
+
|
src/app/auth/signup/page.tsx
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState } from "react"
|
| 4 |
+
import { useRouter } from "next/navigation"
|
| 5 |
+
import Link from "next/link"
|
| 6 |
+
import { useAuth } from "@/lib/auth-context"
|
| 7 |
+
import { apiClient } from "@/lib/api-client"
|
| 8 |
+
import { Button } from "@/components/ui/button"
|
| 9 |
+
import { Input } from "@/components/ui/input"
|
| 10 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
| 11 |
+
import { OTPInput } from "@/components/ui/otp-input"
|
| 12 |
+
import { toast } from "sonner"
|
| 13 |
+
import { Loader2 } from "lucide-react"
|
| 14 |
+
|
| 15 |
+
export default function SignUpPage() {
|
| 16 |
+
const [step, setStep] = useState<"form" | "otp">("form")
|
| 17 |
+
const [loading, setLoading] = useState(false)
|
| 18 |
+
const [formData, setFormData] = useState({
|
| 19 |
+
email: "",
|
| 20 |
+
password: "",
|
| 21 |
+
name: "",
|
| 22 |
+
phone_number: "",
|
| 23 |
+
})
|
| 24 |
+
const [otp, setOtp] = useState("")
|
| 25 |
+
const [phoneNumber, setPhoneNumber] = useState("")
|
| 26 |
+
const router = useRouter()
|
| 27 |
+
const { register } = useAuth()
|
| 28 |
+
|
| 29 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 30 |
+
e.preventDefault()
|
| 31 |
+
setLoading(true)
|
| 32 |
+
|
| 33 |
+
try {
|
| 34 |
+
// Register user
|
| 35 |
+
await register(
|
| 36 |
+
formData.email,
|
| 37 |
+
formData.password,
|
| 38 |
+
formData.name,
|
| 39 |
+
formData.phone_number
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
// Send OTP
|
| 43 |
+
await apiClient.post("/api/v1/auth/phone/send-otp", {
|
| 44 |
+
phone_number: formData.phone_number,
|
| 45 |
+
purpose: "verification",
|
| 46 |
+
})
|
| 47 |
+
|
| 48 |
+
setPhoneNumber(formData.phone_number)
|
| 49 |
+
setStep("otp")
|
| 50 |
+
toast.success("OTP sent to your phone number")
|
| 51 |
+
} catch (error: unknown) {
|
| 52 |
+
const errorMessage = error instanceof Error ? error.message : "Registration failed"
|
| 53 |
+
toast.error(errorMessage)
|
| 54 |
+
} finally {
|
| 55 |
+
setLoading(false)
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const handleOTPVerify = async () => {
|
| 60 |
+
if (otp.length !== 6) {
|
| 61 |
+
toast.error("Please enter a valid 6-digit OTP")
|
| 62 |
+
return
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
setLoading(true)
|
| 66 |
+
|
| 67 |
+
try {
|
| 68 |
+
// Verify OTP
|
| 69 |
+
await apiClient.post("/api/v1/auth/phone/verify-otp", {
|
| 70 |
+
phone_number: phoneNumber,
|
| 71 |
+
otp: otp,
|
| 72 |
+
purpose: "verification",
|
| 73 |
+
})
|
| 74 |
+
|
| 75 |
+
toast.success("Phone verified successfully! Please sign in.")
|
| 76 |
+
router.push("/auth/signin")
|
| 77 |
+
} catch (error: unknown) {
|
| 78 |
+
const errorMessage = error instanceof Error ? error.message : "OTP verification failed"
|
| 79 |
+
toast.error(errorMessage)
|
| 80 |
+
} finally {
|
| 81 |
+
setLoading(false)
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const handleResendOTP = async () => {
|
| 86 |
+
setLoading(true)
|
| 87 |
+
try {
|
| 88 |
+
await apiClient.post("/api/v1/auth/phone/resend-otp", {
|
| 89 |
+
phone_number: phoneNumber,
|
| 90 |
+
purpose: "verification",
|
| 91 |
+
})
|
| 92 |
+
toast.success("OTP resent successfully")
|
| 93 |
+
} catch (error: unknown) {
|
| 94 |
+
const errorMessage = error instanceof Error ? error.message : "Failed to resend OTP"
|
| 95 |
+
toast.error(errorMessage)
|
| 96 |
+
} finally {
|
| 97 |
+
setLoading(false)
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
if (step === "otp") {
|
| 102 |
+
return (
|
| 103 |
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
|
| 104 |
+
<Card className="w-full max-w-md">
|
| 105 |
+
<CardHeader>
|
| 106 |
+
<CardTitle>Verify Phone Number</CardTitle>
|
| 107 |
+
<CardDescription>
|
| 108 |
+
Enter the 6-digit code sent to {phoneNumber}
|
| 109 |
+
</CardDescription>
|
| 110 |
+
</CardHeader>
|
| 111 |
+
<CardContent className="space-y-4">
|
| 112 |
+
<OTPInput
|
| 113 |
+
value={otp}
|
| 114 |
+
onChange={setOtp}
|
| 115 |
+
onComplete={handleOTPVerify}
|
| 116 |
+
disabled={loading}
|
| 117 |
+
/>
|
| 118 |
+
<Button
|
| 119 |
+
onClick={handleOTPVerify}
|
| 120 |
+
disabled={loading || otp.length !== 6}
|
| 121 |
+
className="w-full"
|
| 122 |
+
>
|
| 123 |
+
{loading ? (
|
| 124 |
+
<>
|
| 125 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 126 |
+
Verifying...
|
| 127 |
+
</>
|
| 128 |
+
) : (
|
| 129 |
+
"Verify OTP"
|
| 130 |
+
)}
|
| 131 |
+
</Button>
|
| 132 |
+
<div className="text-center">
|
| 133 |
+
<button
|
| 134 |
+
onClick={handleResendOTP}
|
| 135 |
+
disabled={loading}
|
| 136 |
+
className="text-sm text-primary hover:underline"
|
| 137 |
+
>
|
| 138 |
+
Resend OTP
|
| 139 |
+
</button>
|
| 140 |
+
</div>
|
| 141 |
+
<div className="text-center text-sm">
|
| 142 |
+
<button
|
| 143 |
+
onClick={() => setStep("form")}
|
| 144 |
+
className="text-muted-foreground hover:underline"
|
| 145 |
+
>
|
| 146 |
+
Back to registration
|
| 147 |
+
</button>
|
| 148 |
+
</div>
|
| 149 |
+
</CardContent>
|
| 150 |
+
</Card>
|
| 151 |
+
</div>
|
| 152 |
+
)
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
return (
|
| 156 |
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-primary/5 to-accent/10 p-4">
|
| 157 |
+
<Card className="w-full max-w-md">
|
| 158 |
+
<CardHeader>
|
| 159 |
+
<CardTitle>Create Account</CardTitle>
|
| 160 |
+
<CardDescription>
|
| 161 |
+
Sign up to get started with AmaniQuery
|
| 162 |
+
</CardDescription>
|
| 163 |
+
</CardHeader>
|
| 164 |
+
<CardContent>
|
| 165 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 166 |
+
<div>
|
| 167 |
+
<label htmlFor="name" className="text-sm font-medium">
|
| 168 |
+
Full Name
|
| 169 |
+
</label>
|
| 170 |
+
<Input
|
| 171 |
+
id="name"
|
| 172 |
+
type="text"
|
| 173 |
+
placeholder="John Doe"
|
| 174 |
+
value={formData.name}
|
| 175 |
+
onChange={(e) =>
|
| 176 |
+
setFormData({ ...formData, name: e.target.value })
|
| 177 |
+
}
|
| 178 |
+
required
|
| 179 |
+
/>
|
| 180 |
+
</div>
|
| 181 |
+
<div>
|
| 182 |
+
<label htmlFor="email" className="text-sm font-medium">
|
| 183 |
+
Email
|
| 184 |
+
</label>
|
| 185 |
+
<Input
|
| 186 |
+
id="email"
|
| 187 |
+
type="email"
|
| 188 |
+
placeholder="you@example.com"
|
| 189 |
+
value={formData.email}
|
| 190 |
+
onChange={(e) =>
|
| 191 |
+
setFormData({ ...formData, email: e.target.value })
|
| 192 |
+
}
|
| 193 |
+
required
|
| 194 |
+
/>
|
| 195 |
+
</div>
|
| 196 |
+
<div>
|
| 197 |
+
<label htmlFor="phone" className="text-sm font-medium">
|
| 198 |
+
Phone Number
|
| 199 |
+
</label>
|
| 200 |
+
<Input
|
| 201 |
+
id="phone"
|
| 202 |
+
type="tel"
|
| 203 |
+
placeholder="+254712345678 or 0712345678"
|
| 204 |
+
value={formData.phone_number}
|
| 205 |
+
onChange={(e) =>
|
| 206 |
+
setFormData({ ...formData, phone_number: e.target.value })
|
| 207 |
+
}
|
| 208 |
+
required
|
| 209 |
+
/>
|
| 210 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 211 |
+
Format: +254712345678, +2541XXXXXXX, 0712345678, or 01XXXXXXX
|
| 212 |
+
</p>
|
| 213 |
+
</div>
|
| 214 |
+
<div>
|
| 215 |
+
<label htmlFor="password" className="text-sm font-medium">
|
| 216 |
+
Password
|
| 217 |
+
</label>
|
| 218 |
+
<Input
|
| 219 |
+
id="password"
|
| 220 |
+
type="password"
|
| 221 |
+
placeholder="••••••••"
|
| 222 |
+
value={formData.password}
|
| 223 |
+
onChange={(e) =>
|
| 224 |
+
setFormData({ ...formData, password: e.target.value })
|
| 225 |
+
}
|
| 226 |
+
required
|
| 227 |
+
minLength={8}
|
| 228 |
+
/>
|
| 229 |
+
<p className="text-xs text-muted-foreground mt-1">
|
| 230 |
+
Must be at least 8 characters with uppercase, lowercase, and digit
|
| 231 |
+
</p>
|
| 232 |
+
</div>
|
| 233 |
+
<Button type="submit" disabled={loading} className="w-full">
|
| 234 |
+
{loading ? (
|
| 235 |
+
<>
|
| 236 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 237 |
+
Creating Account...
|
| 238 |
+
</>
|
| 239 |
+
) : (
|
| 240 |
+
"Create Account"
|
| 241 |
+
)}
|
| 242 |
+
</Button>
|
| 243 |
+
</form>
|
| 244 |
+
<div className="mt-4 text-center text-sm">
|
| 245 |
+
<span className="text-muted-foreground">Already have an account? </span>
|
| 246 |
+
<Link href="/auth/signin" className="text-primary hover:underline">
|
| 247 |
+
Sign in
|
| 248 |
+
</Link>
|
| 249 |
+
</div>
|
| 250 |
+
</CardContent>
|
| 251 |
+
</Card>
|
| 252 |
+
</div>
|
| 253 |
+
)
|
| 254 |
+
}
|
| 255 |
+
|
src/app/blog/[slug]/page.tsx
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react"
|
| 4 |
+
import { useParams, useRouter } from "next/navigation"
|
| 5 |
+
import Link from "next/link"
|
| 6 |
+
import { Button } from "@/components/ui/button"
|
| 7 |
+
import { Badge } from "@/components/ui/badge"
|
| 8 |
+
import { Card, CardContent } from "@/components/ui/card"
|
| 9 |
+
import { Calendar, User, ArrowLeft, Share2 } from "lucide-react"
|
| 10 |
+
import Image from "next/image"
|
| 11 |
+
|
| 12 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 13 |
+
|
| 14 |
+
interface BlogPost {
|
| 15 |
+
id: string
|
| 16 |
+
title: string
|
| 17 |
+
slug: string
|
| 18 |
+
markdown_content?: string
|
| 19 |
+
html_content?: string
|
| 20 |
+
excerpt?: string
|
| 21 |
+
post_type: string
|
| 22 |
+
featured_image_url?: string
|
| 23 |
+
author: {
|
| 24 |
+
id: string
|
| 25 |
+
name?: string
|
| 26 |
+
email: string
|
| 27 |
+
profile_image_url?: string
|
| 28 |
+
}
|
| 29 |
+
categories: Array<{ id: string; name: string; slug: string }>
|
| 30 |
+
tags: Array<{ id: string; name: string; slug: string }>
|
| 31 |
+
published_at?: string
|
| 32 |
+
created_at: string
|
| 33 |
+
updated_at: string
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export default function BlogPostPage() {
|
| 37 |
+
const params = useParams()
|
| 38 |
+
const router = useRouter()
|
| 39 |
+
const slug = params.slug as string
|
| 40 |
+
const [post, setPost] = useState<BlogPost | null>(null)
|
| 41 |
+
const [loading, setLoading] = useState(true)
|
| 42 |
+
|
| 43 |
+
useEffect(() => {
|
| 44 |
+
const fetchPost = async () => {
|
| 45 |
+
setLoading(true)
|
| 46 |
+
try {
|
| 47 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts/${slug}`)
|
| 48 |
+
if (response.ok) {
|
| 49 |
+
const data: BlogPost = await response.json()
|
| 50 |
+
setPost(data)
|
| 51 |
+
} else if (response.status === 404) {
|
| 52 |
+
router.push("/blog")
|
| 53 |
+
}
|
| 54 |
+
} catch (error) {
|
| 55 |
+
console.error("Failed to fetch post:", error)
|
| 56 |
+
} finally {
|
| 57 |
+
setLoading(false)
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
if (slug) {
|
| 62 |
+
fetchPost()
|
| 63 |
+
}
|
| 64 |
+
}, [slug, router])
|
| 65 |
+
|
| 66 |
+
const formatDate = (dateString: string) => {
|
| 67 |
+
const date = new Date(dateString)
|
| 68 |
+
return date.toLocaleDateString("en-US", {
|
| 69 |
+
year: "numeric",
|
| 70 |
+
month: "long",
|
| 71 |
+
day: "numeric",
|
| 72 |
+
})
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const getPostTypeColor = (type: string) => {
|
| 76 |
+
switch (type) {
|
| 77 |
+
case "news":
|
| 78 |
+
return "bg-blue-500/10 text-blue-500"
|
| 79 |
+
case "announcement":
|
| 80 |
+
return "bg-yellow-500/10 text-yellow-500"
|
| 81 |
+
case "update":
|
| 82 |
+
return "bg-green-500/10 text-green-500"
|
| 83 |
+
default:
|
| 84 |
+
return "bg-gray-500/10 text-gray-500"
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const handleShare = async () => {
|
| 89 |
+
if (navigator.share && post) {
|
| 90 |
+
try {
|
| 91 |
+
await navigator.share({
|
| 92 |
+
title: post.title,
|
| 93 |
+
text: post.excerpt || "",
|
| 94 |
+
url: window.location.href,
|
| 95 |
+
})
|
| 96 |
+
} catch (error) {
|
| 97 |
+
// User cancelled or error occurred
|
| 98 |
+
console.log("Share cancelled or failed")
|
| 99 |
+
}
|
| 100 |
+
} else {
|
| 101 |
+
// Fallback: copy to clipboard
|
| 102 |
+
navigator.clipboard.writeText(window.location.href)
|
| 103 |
+
alert("Link copied to clipboard!")
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
if (loading) {
|
| 108 |
+
return (
|
| 109 |
+
<div className="min-h-screen flex items-center justify-center">
|
| 110 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
| 111 |
+
</div>
|
| 112 |
+
)
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
if (!post) {
|
| 116 |
+
return (
|
| 117 |
+
<div className="min-h-screen flex items-center justify-center">
|
| 118 |
+
<Card>
|
| 119 |
+
<CardContent className="p-12 text-center">
|
| 120 |
+
<p className="text-muted-foreground mb-4">Post not found</p>
|
| 121 |
+
<Link href="/blog">
|
| 122 |
+
<Button>Back to Blog</Button>
|
| 123 |
+
</Link>
|
| 124 |
+
</CardContent>
|
| 125 |
+
</Card>
|
| 126 |
+
</div>
|
| 127 |
+
)
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
return (
|
| 131 |
+
<div className="min-h-screen bg-background">
|
| 132 |
+
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 133 |
+
{/* Back Button */}
|
| 134 |
+
<Link href="/blog">
|
| 135 |
+
<Button variant="ghost" className="mb-6">
|
| 136 |
+
<ArrowLeft className="w-4 h-4 mr-2" />
|
| 137 |
+
Back to Blog
|
| 138 |
+
</Button>
|
| 139 |
+
</Link>
|
| 140 |
+
|
| 141 |
+
{/* Header */}
|
| 142 |
+
<div className="mb-8">
|
| 143 |
+
<div className="flex items-center gap-2 mb-4">
|
| 144 |
+
<Badge className={getPostTypeColor(post.post_type)}>
|
| 145 |
+
{post.post_type}
|
| 146 |
+
</Badge>
|
| 147 |
+
{post.published_at && (
|
| 148 |
+
<div className="flex items-center text-sm text-muted-foreground">
|
| 149 |
+
<Calendar className="w-4 h-4 mr-1" />
|
| 150 |
+
{formatDate(post.published_at)}
|
| 151 |
+
</div>
|
| 152 |
+
)}
|
| 153 |
+
</div>
|
| 154 |
+
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
|
| 155 |
+
{post.excerpt && (
|
| 156 |
+
<p className="text-xl text-muted-foreground mb-6">{post.excerpt}</p>
|
| 157 |
+
)}
|
| 158 |
+
<div className="flex items-center justify-between">
|
| 159 |
+
<div className="flex items-center gap-4">
|
| 160 |
+
<div className="flex items-center text-sm text-muted-foreground">
|
| 161 |
+
<User className="w-4 h-4 mr-1" />
|
| 162 |
+
<span>{post.author.name || post.author.email}</span>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
<Button variant="outline" size="sm" onClick={handleShare}>
|
| 166 |
+
<Share2 className="w-4 h-4 mr-2" />
|
| 167 |
+
Share
|
| 168 |
+
</Button>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
{/* Featured Image */}
|
| 173 |
+
{post.featured_image_url && (
|
| 174 |
+
<div className="relative w-full h-96 mb-8 rounded-lg overflow-hidden">
|
| 175 |
+
<Image
|
| 176 |
+
src={post.featured_image_url}
|
| 177 |
+
alt={post.title}
|
| 178 |
+
fill
|
| 179 |
+
className="object-cover"
|
| 180 |
+
/>
|
| 181 |
+
</div>
|
| 182 |
+
)}
|
| 183 |
+
|
| 184 |
+
{/* Content */}
|
| 185 |
+
<article className="prose prose-lg dark:prose-invert max-w-none mb-8">
|
| 186 |
+
{post.html_content ? (
|
| 187 |
+
<div dangerouslySetInnerHTML={{ __html: post.html_content }} />
|
| 188 |
+
) : post.markdown_content ? (
|
| 189 |
+
<div className="whitespace-pre-wrap">{post.markdown_content}</div>
|
| 190 |
+
) : (
|
| 191 |
+
<p className="text-muted-foreground">No content available.</p>
|
| 192 |
+
)}
|
| 193 |
+
</article>
|
| 194 |
+
|
| 195 |
+
{/* Categories and Tags */}
|
| 196 |
+
{(post.categories.length > 0 || post.tags.length > 0) && (
|
| 197 |
+
<Card className="mb-8">
|
| 198 |
+
<CardContent className="p-6">
|
| 199 |
+
{post.categories.length > 0 && (
|
| 200 |
+
<div className="mb-4">
|
| 201 |
+
<h3 className="text-sm font-semibold mb-2">Categories</h3>
|
| 202 |
+
<div className="flex flex-wrap gap-2">
|
| 203 |
+
{post.categories.map((category) => (
|
| 204 |
+
<Badge key={category.id} variant="outline">
|
| 205 |
+
{category.name}
|
| 206 |
+
</Badge>
|
| 207 |
+
))}
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
)}
|
| 211 |
+
{post.tags.length > 0 && (
|
| 212 |
+
<div>
|
| 213 |
+
<h3 className="text-sm font-semibold mb-2">Tags</h3>
|
| 214 |
+
<div className="flex flex-wrap gap-2">
|
| 215 |
+
{post.tags.map((tag) => (
|
| 216 |
+
<Badge key={tag.id} variant="secondary">
|
| 217 |
+
{tag.name}
|
| 218 |
+
</Badge>
|
| 219 |
+
))}
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
)}
|
| 223 |
+
</CardContent>
|
| 224 |
+
</Card>
|
| 225 |
+
)}
|
| 226 |
+
|
| 227 |
+
{/* Navigation */}
|
| 228 |
+
<div className="flex justify-between items-center pt-8 border-t">
|
| 229 |
+
<Link href="/blog">
|
| 230 |
+
<Button variant="outline">
|
| 231 |
+
<ArrowLeft className="w-4 h-4 mr-2" />
|
| 232 |
+
All Posts
|
| 233 |
+
</Button>
|
| 234 |
+
</Link>
|
| 235 |
+
<Link href="/">
|
| 236 |
+
<Button variant="ghost">Back to Home</Button>
|
| 237 |
+
</Link>
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
)
|
| 242 |
+
}
|
| 243 |
+
|
src/app/blog/page.tsx
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, Suspense } from "react"
|
| 4 |
+
import { useSearchParams, useRouter } from "next/navigation"
|
| 5 |
+
import { BlogPostCard } from "@/components/blog-post-card"
|
| 6 |
+
import { Button } from "@/components/ui/button"
|
| 7 |
+
import { Input } from "@/components/ui/input"
|
| 8 |
+
import { Badge } from "@/components/ui/badge"
|
| 9 |
+
import { Card, CardContent } from "@/components/ui/card"
|
| 10 |
+
import {
|
| 11 |
+
Search,
|
| 12 |
+
Filter,
|
| 13 |
+
ChevronLeft,
|
| 14 |
+
ChevronRight,
|
| 15 |
+
Newspaper,
|
| 16 |
+
Megaphone,
|
| 17 |
+
RefreshCw,
|
| 18 |
+
} from "lucide-react"
|
| 19 |
+
import Link from "next/link"
|
| 20 |
+
|
| 21 |
+
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
| 22 |
+
|
| 23 |
+
interface BlogPost {
|
| 24 |
+
id: string
|
| 25 |
+
title: string
|
| 26 |
+
slug: string
|
| 27 |
+
excerpt?: string
|
| 28 |
+
post_type: string
|
| 29 |
+
featured_image_url?: string
|
| 30 |
+
author: {
|
| 31 |
+
id: string
|
| 32 |
+
name?: string
|
| 33 |
+
email: string
|
| 34 |
+
profile_image_url?: string
|
| 35 |
+
}
|
| 36 |
+
categories: Array<{ id: string; name: string; slug: string }>
|
| 37 |
+
tags: Array<{ id: string; name: string; slug: string }>
|
| 38 |
+
published_at?: string
|
| 39 |
+
created_at: string
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
interface BlogResponse {
|
| 43 |
+
posts: BlogPost[]
|
| 44 |
+
total: number
|
| 45 |
+
page: number
|
| 46 |
+
page_size: number
|
| 47 |
+
total_pages: number
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
interface Category {
|
| 51 |
+
id: string
|
| 52 |
+
name: string
|
| 53 |
+
slug: string
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
interface Tag {
|
| 57 |
+
id: string
|
| 58 |
+
name: string
|
| 59 |
+
slug: string
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function BlogList() {
|
| 63 |
+
const searchParams = useSearchParams()
|
| 64 |
+
const router = useRouter()
|
| 65 |
+
const [posts, setPosts] = useState<BlogPost[]>([])
|
| 66 |
+
const [categories, setCategories] = useState<Category[]>([])
|
| 67 |
+
const [tags, setTags] = useState<Tag[]>([])
|
| 68 |
+
const [loading, setLoading] = useState(true)
|
| 69 |
+
const [searchQuery, setSearchQuery] = useState(searchParams.get("search") || "")
|
| 70 |
+
const [selectedPostType, setSelectedPostType] = useState<string | null>(
|
| 71 |
+
searchParams.get("post_type") || null
|
| 72 |
+
)
|
| 73 |
+
const [selectedCategory, setSelectedCategory] = useState<string | null>(
|
| 74 |
+
searchParams.get("category") || null
|
| 75 |
+
)
|
| 76 |
+
const [selectedTag, setSelectedTag] = useState<string | null>(
|
| 77 |
+
searchParams.get("tag") || null
|
| 78 |
+
)
|
| 79 |
+
const [currentPage, setCurrentPage] = useState(
|
| 80 |
+
parseInt(searchParams.get("page") || "1")
|
| 81 |
+
)
|
| 82 |
+
const [pagination, setPagination] = useState({
|
| 83 |
+
total: 0,
|
| 84 |
+
page: 1,
|
| 85 |
+
page_size: 10,
|
| 86 |
+
total_pages: 1,
|
| 87 |
+
})
|
| 88 |
+
|
| 89 |
+
const fetchPosts = async () => {
|
| 90 |
+
setLoading(true)
|
| 91 |
+
try {
|
| 92 |
+
const params = new URLSearchParams()
|
| 93 |
+
params.append("page", currentPage.toString())
|
| 94 |
+
params.append("page_size", "10")
|
| 95 |
+
if (selectedPostType) params.append("post_type", selectedPostType)
|
| 96 |
+
if (selectedCategory) params.append("category_slug", selectedCategory)
|
| 97 |
+
if (selectedTag) params.append("tag_slug", selectedTag)
|
| 98 |
+
if (searchQuery) params.append("search", searchQuery)
|
| 99 |
+
|
| 100 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts?${params}`)
|
| 101 |
+
if (response.ok) {
|
| 102 |
+
const data: BlogResponse = await response.json()
|
| 103 |
+
setPosts(data.posts)
|
| 104 |
+
setPagination({
|
| 105 |
+
total: data.total,
|
| 106 |
+
page: data.page,
|
| 107 |
+
page_size: data.page_size,
|
| 108 |
+
total_pages: data.total_pages,
|
| 109 |
+
})
|
| 110 |
+
}
|
| 111 |
+
} catch (error) {
|
| 112 |
+
console.error("Failed to fetch posts:", error)
|
| 113 |
+
} finally {
|
| 114 |
+
setLoading(false)
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
const fetchCategories = async () => {
|
| 119 |
+
try {
|
| 120 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/categories`)
|
| 121 |
+
if (response.ok) {
|
| 122 |
+
const data: Category[] = await response.json()
|
| 123 |
+
setCategories(data)
|
| 124 |
+
}
|
| 125 |
+
} catch (error) {
|
| 126 |
+
console.error("Failed to fetch categories:", error)
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
const fetchTags = async () => {
|
| 131 |
+
try {
|
| 132 |
+
const response = await fetch(`${API_BASE_URL}/api/v1/blog/tags`)
|
| 133 |
+
if (response.ok) {
|
| 134 |
+
const data: Tag[] = await response.json()
|
| 135 |
+
setTags(data)
|
| 136 |
+
}
|
| 137 |
+
} catch (error) {
|
| 138 |
+
console.error("Failed to fetch tags:", error)
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
useEffect(() => {
|
| 143 |
+
fetchCategories()
|
| 144 |
+
fetchTags()
|
| 145 |
+
}, [])
|
| 146 |
+
|
| 147 |
+
useEffect(() => {
|
| 148 |
+
fetchPosts()
|
| 149 |
+
}, [currentPage, selectedPostType, selectedCategory, selectedTag, searchQuery])
|
| 150 |
+
|
| 151 |
+
useEffect(() => {
|
| 152 |
+
// Update URL with current filters
|
| 153 |
+
const params = new URLSearchParams()
|
| 154 |
+
if (selectedPostType) params.append("post_type", selectedPostType)
|
| 155 |
+
if (selectedCategory) params.append("category", selectedCategory)
|
| 156 |
+
if (selectedTag) params.append("tag", selectedTag)
|
| 157 |
+
if (searchQuery) params.append("search", searchQuery)
|
| 158 |
+
if (currentPage > 1) params.append("page", currentPage.toString())
|
| 159 |
+
|
| 160 |
+
router.push(`/blog?${params.toString()}`, { scroll: false })
|
| 161 |
+
}, [selectedPostType, selectedCategory, selectedTag, searchQuery, currentPage, router])
|
| 162 |
+
|
| 163 |
+
const handleSearch = (e: React.FormEvent) => {
|
| 164 |
+
e.preventDefault()
|
| 165 |
+
setCurrentPage(1)
|
| 166 |
+
fetchPosts()
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
const clearFilters = () => {
|
| 170 |
+
setSelectedPostType(null)
|
| 171 |
+
setSelectedCategory(null)
|
| 172 |
+
setSelectedTag(null)
|
| 173 |
+
setSearchQuery("")
|
| 174 |
+
setCurrentPage(1)
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
return (
|
| 178 |
+
<div className="min-h-screen bg-background">
|
| 179 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 180 |
+
{/* Header */}
|
| 181 |
+
<div className="mb-8">
|
| 182 |
+
<div className="flex items-center justify-between mb-4">
|
| 183 |
+
<div>
|
| 184 |
+
<h1 className="text-3xl font-bold mb-2">Blog</h1>
|
| 185 |
+
<p className="text-muted-foreground">
|
| 186 |
+
News, announcements, and updates from AmaniQuery
|
| 187 |
+
</p>
|
| 188 |
+
</div>
|
| 189 |
+
<Link href="/">
|
| 190 |
+
<Button variant="outline">Back to Home</Button>
|
| 191 |
+
</Link>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
{/* Search */}
|
| 195 |
+
<form onSubmit={handleSearch} className="mb-4">
|
| 196 |
+
<div className="flex gap-2">
|
| 197 |
+
<div className="relative flex-1">
|
| 198 |
+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
| 199 |
+
<Input
|
| 200 |
+
type="text"
|
| 201 |
+
placeholder="Search posts..."
|
| 202 |
+
value={searchQuery}
|
| 203 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 204 |
+
className="pl-10"
|
| 205 |
+
/>
|
| 206 |
+
</div>
|
| 207 |
+
<Button type="submit">Search</Button>
|
| 208 |
+
</div>
|
| 209 |
+
</form>
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
| 213 |
+
{/* Sidebar Filters */}
|
| 214 |
+
<div className="lg:col-span-1">
|
| 215 |
+
<Card>
|
| 216 |
+
<CardContent className="p-4">
|
| 217 |
+
<div className="flex items-center justify-between mb-4">
|
| 218 |
+
<h2 className="font-semibold flex items-center">
|
| 219 |
+
<Filter className="w-4 h-4 mr-2" />
|
| 220 |
+
Filters
|
| 221 |
+
</h2>
|
| 222 |
+
{(selectedPostType || selectedCategory || selectedTag) && (
|
| 223 |
+
<Button
|
| 224 |
+
variant="ghost"
|
| 225 |
+
size="sm"
|
| 226 |
+
onClick={clearFilters}
|
| 227 |
+
className="text-xs"
|
| 228 |
+
>
|
| 229 |
+
Clear
|
| 230 |
+
</Button>
|
| 231 |
+
)}
|
| 232 |
+
</div>
|
| 233 |
+
|
| 234 |
+
{/* Post Type Filter */}
|
| 235 |
+
<div className="mb-4">
|
| 236 |
+
<h3 className="text-sm font-medium mb-2">Post Type</h3>
|
| 237 |
+
<div className="space-y-2">
|
| 238 |
+
{["news", "announcement", "update"].map((type) => (
|
| 239 |
+
<button
|
| 240 |
+
key={type}
|
| 241 |
+
onClick={() => {
|
| 242 |
+
setSelectedPostType(
|
| 243 |
+
selectedPostType === type ? null : type
|
| 244 |
+
)
|
| 245 |
+
setCurrentPage(1)
|
| 246 |
+
}}
|
| 247 |
+
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
|
| 248 |
+
selectedPostType === type
|
| 249 |
+
? "bg-primary text-primary-foreground"
|
| 250 |
+
: "hover:bg-accent"
|
| 251 |
+
}`}
|
| 252 |
+
>
|
| 253 |
+
<div className="flex items-center">
|
| 254 |
+
{type === "news" && <Newspaper className="w-4 h-4 mr-2" />}
|
| 255 |
+
{type === "announcement" && (
|
| 256 |
+
<Megaphone className="w-4 h-4 mr-2" />
|
| 257 |
+
)}
|
| 258 |
+
{type === "update" && (
|
| 259 |
+
<RefreshCw className="w-4 h-4 mr-2" />
|
| 260 |
+
)}
|
| 261 |
+
{type.charAt(0).toUpperCase() + type.slice(1)}
|
| 262 |
+
</div>
|
| 263 |
+
</button>
|
| 264 |
+
))}
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
|
| 268 |
+
{/* Categories Filter */}
|
| 269 |
+
{categories.length > 0 && (
|
| 270 |
+
<div className="mb-4">
|
| 271 |
+
<h3 className="text-sm font-medium mb-2">Categories</h3>
|
| 272 |
+
<div className="space-y-2 max-h-48 overflow-y-auto">
|
| 273 |
+
{categories.map((category) => (
|
| 274 |
+
<button
|
| 275 |
+
key={category.id}
|
| 276 |
+
onClick={() => {
|
| 277 |
+
setSelectedCategory(
|
| 278 |
+
selectedCategory === category.slug ? null : category.slug
|
| 279 |
+
)
|
| 280 |
+
setCurrentPage(1)
|
| 281 |
+
}}
|
| 282 |
+
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
|
| 283 |
+
selectedCategory === category.slug
|
| 284 |
+
? "bg-primary text-primary-foreground"
|
| 285 |
+
: "hover:bg-accent"
|
| 286 |
+
}`}
|
| 287 |
+
>
|
| 288 |
+
{category.name}
|
| 289 |
+
</button>
|
| 290 |
+
))}
|
| 291 |
+
</div>
|
| 292 |
+
</div>
|
| 293 |
+
)}
|
| 294 |
+
|
| 295 |
+
{/* Tags Filter */}
|
| 296 |
+
{tags.length > 0 && (
|
| 297 |
+
<div>
|
| 298 |
+
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
| 299 |
+
<div className="flex flex-wrap gap-2">
|
| 300 |
+
{tags.map((tag) => (
|
| 301 |
+
<Badge
|
| 302 |
+
key={tag.id}
|
| 303 |
+
variant={
|
| 304 |
+
selectedTag === tag.slug ? "default" : "outline"
|
| 305 |
+
}
|
| 306 |
+
className="cursor-pointer"
|
| 307 |
+
onClick={() => {
|
| 308 |
+
setSelectedTag(
|
| 309 |
+
selectedTag === tag.slug ? null : tag.slug
|
| 310 |
+
)
|
| 311 |
+
setCurrentPage(1)
|
| 312 |
+
}}
|
| 313 |
+
>
|
| 314 |
+
{tag.name}
|
| 315 |
+
</Badge>
|
| 316 |
+
))}
|
| 317 |
+
</div>
|
| 318 |
+
</div>
|
| 319 |
+
)}
|
| 320 |
+
</CardContent>
|
| 321 |
+
</Card>
|
| 322 |
+
</div>
|
| 323 |
+
|
| 324 |
+
{/* Main Content */}
|
| 325 |
+
<div className="lg:col-span-3">
|
| 326 |
+
{loading ? (
|
| 327 |
+
<div className="flex items-center justify-center py-12">
|
| 328 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
| 329 |
+
</div>
|
| 330 |
+
) : posts.length === 0 ? (
|
| 331 |
+
<Card>
|
| 332 |
+
<CardContent className="p-12 text-center">
|
| 333 |
+
<p className="text-muted-foreground">
|
| 334 |
+
No posts found. Try adjusting your filters.
|
| 335 |
+
</p>
|
| 336 |
+
</CardContent>
|
| 337 |
+
</Card>
|
| 338 |
+
) : (
|
| 339 |
+
<>
|
| 340 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
| 341 |
+
{posts.map((post) => (
|
| 342 |
+
<BlogPostCard key={post.id} post={post} />
|
| 343 |
+
))}
|
| 344 |
+
</div>
|
| 345 |
+
|
| 346 |
+
{/* Pagination */}
|
| 347 |
+
{pagination.total_pages > 1 && (
|
| 348 |
+
<div className="flex items-center justify-center gap-2">
|
| 349 |
+
<Button
|
| 350 |
+
variant="outline"
|
| 351 |
+
size="sm"
|
| 352 |
+
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
| 353 |
+
disabled={currentPage === 1}
|
| 354 |
+
>
|
| 355 |
+
<ChevronLeft className="w-4 h-4" />
|
| 356 |
+
Previous
|
| 357 |
+
</Button>
|
| 358 |
+
<span className="text-sm text-muted-foreground">
|
| 359 |
+
Page {pagination.page} of {pagination.total_pages}
|
| 360 |
+
</span>
|
| 361 |
+
<Button
|
| 362 |
+
variant="outline"
|
| 363 |
+
size="sm"
|
| 364 |
+
onClick={() =>
|
| 365 |
+
setCurrentPage((p) =>
|
| 366 |
+
Math.min(pagination.total_pages, p + 1)
|
| 367 |
+
)
|
| 368 |
+
}
|
| 369 |
+
disabled={currentPage === pagination.total_pages}
|
| 370 |
+
>
|
| 371 |
+
Next
|
| 372 |
+
<ChevronRight className="w-4 h-4" />
|
| 373 |
+
</Button>
|
| 374 |
+
</div>
|
| 375 |
+
)}
|
| 376 |
+
</>
|
| 377 |
+
)}
|
| 378 |
+
</div>
|
| 379 |
+
</div>
|
| 380 |
+
</div>
|
| 381 |
+
</div>
|
| 382 |
+
)
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
export default function BlogPage() {
|
| 386 |
+
return (
|
| 387 |
+
<Suspense fallback={
|
| 388 |
+
<div className="min-h-screen bg-background flex items-center justify-center">
|
| 389 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
| 390 |
+
</div>
|
| 391 |
+
}>
|
| 392 |
+
<BlogList />
|
| 393 |
+
</Suspense>
|
| 394 |
+
)
|
| 395 |
+
}
|
src/app/chat/page.tsx
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from "react"
|
| 4 |
+
import { useRouter } from "next/navigation"
|
| 5 |
+
import { AmaniChat } from "@/components/chat/AmaniChat"
|
| 6 |
+
import { AmaniSidebar } from "@/components/AmaniSidebar"
|
| 7 |
+
import { ThemeToggle } from "@/components/theme-toggle"
|
| 8 |
+
import { useAuth } from "@/lib/auth-context"
|
| 9 |
+
import { cn } from "@/lib/utils"
|
| 10 |
+
import { Menu } from "lucide-react"
|
| 11 |
+
import type { ChatSession } from "@/components/chat/types"
|
| 12 |
+
|
| 13 |
+
export default function ChatPage() {
|
| 14 |
+
const { isAuthenticated, loading } = useAuth()
|
| 15 |
+
const router = useRouter()
|
| 16 |
+
const [chatHistory, setChatHistory] = useState<ChatSession[]>([])
|
| 17 |
+
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
|
| 18 |
+
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
// Define loadChatHistory before useEffect to avoid TDZ error
|
| 22 |
+
const loadChatHistory = async () => {
|
| 23 |
+
try {
|
| 24 |
+
const token = localStorage.getItem("session_token")
|
| 25 |
+
const headers = { "X-Session-Token": token || "" }
|
| 26 |
+
const response = await fetch("/api/cache/sessions", { headers })
|
| 27 |
+
if (response.ok) {
|
| 28 |
+
const sessions = await response.json()
|
| 29 |
+
setChatHistory(sessions)
|
| 30 |
+
}
|
| 31 |
+
} catch (error) {
|
| 32 |
+
console.error("Failed to load chat history:", error)
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Load chat history on mount
|
| 37 |
+
useEffect(() => {
|
| 38 |
+
if (isAuthenticated) {
|
| 39 |
+
(async () => {
|
| 40 |
+
await loadChatHistory();
|
| 41 |
+
})();
|
| 42 |
+
}
|
| 43 |
+
}, [isAuthenticated])
|
| 44 |
+
|
| 45 |
+
useEffect(() => {
|
| 46 |
+
if (!loading && !isAuthenticated) {
|
| 47 |
+
router.push("/auth/signin?redirect=/chat")
|
| 48 |
+
}
|
| 49 |
+
}, [isAuthenticated, loading, router])
|
| 50 |
+
|
| 51 |
+
if (loading) {
|
| 52 |
+
return (
|
| 53 |
+
<div className="min-h-screen flex items-center justify-center">
|
| 54 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
| 55 |
+
</div>
|
| 56 |
+
)
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
if (!isAuthenticated) {
|
| 60 |
+
return null
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
return (
|
| 64 |
+
<div className="min-h-screen max-h-screen bg-background flex overflow-hidden">
|
| 65 |
+
{/* New AmaniSidebar with integrated chat history */}
|
| 66 |
+
<AmaniSidebar
|
| 67 |
+
chatHistory={chatHistory}
|
| 68 |
+
currentSessionId={currentSessionId}
|
| 69 |
+
onSessionSelect={setCurrentSessionId}
|
| 70 |
+
onNewSession={() => setCurrentSessionId(null)}
|
| 71 |
+
onDeleteSession={async (sessionId) => {
|
| 72 |
+
// Handle session deletion
|
| 73 |
+
setChatHistory(prev => prev.filter(s => s.id !== sessionId))
|
| 74 |
+
if (currentSessionId === sessionId) {
|
| 75 |
+
setCurrentSessionId(null)
|
| 76 |
+
}
|
| 77 |
+
// Call API to delete session
|
| 78 |
+
try {
|
| 79 |
+
const token = localStorage.getItem("session_token")
|
| 80 |
+
const headers = { "X-Session-Token": token || "" }
|
| 81 |
+
await fetch(`/api/v1/chat/sessions/${sessionId}`, {
|
| 82 |
+
method: "DELETE",
|
| 83 |
+
headers
|
| 84 |
+
})
|
| 85 |
+
} catch (error) {
|
| 86 |
+
console.error("Failed to delete session:", error)
|
| 87 |
+
}
|
| 88 |
+
}}
|
| 89 |
+
onRenameSession={async (sessionId, newTitle) => {
|
| 90 |
+
// Handle session renaming
|
| 91 |
+
setChatHistory(prev => prev.map(s =>
|
| 92 |
+
s.id === sessionId ? { ...s, title: newTitle } : s
|
| 93 |
+
))
|
| 94 |
+
// Call API to rename session
|
| 95 |
+
try {
|
| 96 |
+
const token = localStorage.getItem("session_token")
|
| 97 |
+
const headers = {
|
| 98 |
+
"X-Session-Token": token || "",
|
| 99 |
+
"Content-Type": "application/json"
|
| 100 |
+
}
|
| 101 |
+
await fetch(`/api/v1/chat/sessions/${sessionId}`, {
|
| 102 |
+
method: "PATCH",
|
| 103 |
+
headers,
|
| 104 |
+
body: JSON.stringify({ title: newTitle })
|
| 105 |
+
})
|
| 106 |
+
} catch (error) {
|
| 107 |
+
console.error("Failed to rename session:", error)
|
| 108 |
+
}
|
| 109 |
+
}}
|
| 110 |
+
isOpen={isSidebarOpen}
|
| 111 |
+
onToggle={() => setIsSidebarOpen(!isSidebarOpen)}
|
| 112 |
+
/>
|
| 113 |
+
|
| 114 |
+
{/* Main Chat Area */}
|
| 115 |
+
<div className={cn(
|
| 116 |
+
"flex-1 min-w-0 overflow-hidden relative transition-all duration-300",
|
| 117 |
+
isSidebarOpen ? "md:ml-0" : "md:ml-0"
|
| 118 |
+
)}>
|
| 119 |
+
{/* Mobile Menu Button */}
|
| 120 |
+
<button
|
| 121 |
+
onClick={() => setIsSidebarOpen(true)}
|
| 122 |
+
className="md:hidden absolute top-4 left-4 z-10 p-2 bg-card border rounded-lg shadow-sm hover:bg-accent transition-colors"
|
| 123 |
+
aria-label="Open menu"
|
| 124 |
+
>
|
| 125 |
+
<Menu className="w-5 h-5" />
|
| 126 |
+
</button>
|
| 127 |
+
|
| 128 |
+
<div className="absolute top-2 right-2 md:top-4 md:right-4 z-10">
|
| 129 |
+
<ThemeToggle />
|
| 130 |
+
</div>
|
| 131 |
+
<AmaniChat
|
| 132 |
+
showWelcomeScreen={true}
|
| 133 |
+
enableThinkingIndicator={true}
|
| 134 |
+
showInlineSources={true}
|
| 135 |
+
enableVoice={false}
|
| 136 |
+
currentSessionId={currentSessionId}
|
| 137 |
+
onSessionChange={setCurrentSessionId}
|
| 138 |
+
chatHistory={chatHistory}
|
| 139 |
+
onChatHistoryUpdate={setChatHistory}
|
| 140 |
+
onToggleSidebar={() => setIsSidebarOpen(true)}
|
| 141 |
+
/>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
)
|
| 145 |
+
}
|
src/app/developers/constants.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const DEVELOPER_KIT_VERSION = "1.0"
|
| 2 |
+
export const LAST_UPDATED = "2025-11-29"
|
| 3 |
+
|
| 4 |
+
export const MASTER_SYSTEM_PROMPT = `You are AmaniQuery, an expert legal research assistant and systems architect for Kenyan law. Your purpose is to provide accurate, comprehensive, and legally grounded answers in both English and Swahili.
|
| 5 |
+
|
| 6 |
+
### CORE INSTRUCTIONS
|
| 7 |
+
1. **Output Format**: You must ALWAYS respond using a strict JSON-like structure with two distinct fields:
|
| 8 |
+
- \`reasoning_content\`: Internal chain-of-thought, planning, and analysis. This is NEVER shown to the user.
|
| 9 |
+
- \`content\`: The final, polished user-facing response.
|
| 10 |
+
|
| 11 |
+
2. **Language**: Detect the user's language (English or Swahili) and respond in the SAME language. Maintain identical formatting integrity and professional tone in both languages.
|
| 12 |
+
|
| 13 |
+
3. **Modes of Operation**: Automatically detect the intent and apply the correct mode:
|
| 14 |
+
- **Standard Chat**: For casual greetings, general questions, or simple clarifications.
|
| 15 |
+
- **Hybrid RAG**: For specific legal questions requiring retrieval of cases, statutes, or constitution.
|
| 16 |
+
- **Research Agent**: For complex, multi-step research tasks or report generation.
|
| 17 |
+
|
| 18 |
+
### RESPONSE STRUCTURE (JSON-LIKE)
|
| 19 |
+
\`\`\`json
|
| 20 |
+
{
|
| 21 |
+
"reasoning_content": "Step-by-step analysis, search strategy, and synthesis of information...",
|
| 22 |
+
"content": "The final formatted response..."
|
| 23 |
+
}
|
| 24 |
+
\`\`\`
|
| 25 |
+
|
| 26 |
+
### FORMATTING RULES (Apply to 'content' field)
|
| 27 |
+
- **Markdown**: Use generous whitespace. Paragraphs must be ≤80 words.
|
| 28 |
+
- **Headings**: Use max 4 levels (H1-H4).
|
| 29 |
+
- **Styling**: Use **bold** for emphasis, *italics* for definitions/nuance.
|
| 30 |
+
- **Lists**: Use bullet points or numbered lists for readability. Avoid excessive nesting.
|
| 31 |
+
- **Tables**: Use Markdown tables for comparisons and structured data.
|
| 32 |
+
- **Separators**: Use horizontal rules (\`---\`) to visually separate distinct sections.
|
| 33 |
+
- **Blockquotes**: Use \`>\` for quoting legal text or external sources.
|
| 34 |
+
- **Code**: Use code blocks for statutes or specific clauses if needed.
|
| 35 |
+
- **Math**: Use LaTeX \`\\( \\)\` for inline and \`\\[ \\]\` for block equations.`
|
| 36 |
+
|
| 37 |
+
export const HYBRID_RAG_PROMPT = `You are in Hybrid RAG Mode. Your "content" field MUST follow this precise structure:
|
| 38 |
+
|
| 39 |
+
## Key Sources
|
| 40 |
+
- [Summary of source 1] (Source Name)
|
| 41 |
+
- [Summary of source 2] (Source Name)
|
| 42 |
+
|
| 43 |
+
## Analysis & Synthesis
|
| 44 |
+
[Step-by-step explanation of how sources were evaluated, compared, and combined to form the answer.]
|
| 45 |
+
|
| 46 |
+
## Final Answer
|
| 47 |
+
[A polished, comprehensive answer to the user's query. Every factual claim must be supported by numeric in-line citations like [1][2].]
|
| 48 |
+
|
| 49 |
+
## References
|
| 50 |
+
1. [Title](URL) - Author/Publication, Date
|
| 51 |
+
2. [Title](URL) - Author/Publication, Date`
|
| 52 |
+
|
| 53 |
+
export const LEGAL_SPECIALIST_PROMPT = `You are the Legal Content Specialist for AmaniQuery. Your role is to transform legal analysis into professionally formatted, court-ready documents.
|
| 54 |
+
|
| 55 |
+
### FORMATTING STANDARDS
|
| 56 |
+
1. **Citations**: Use Bluebook (21st Ed.) or standard Kenyan legal citation style (e.g., *Republic v. John Doe* [2025] eKLR).
|
| 57 |
+
- **Statutes**: *The Constitution of Kenya, 2010, Art. 43(1)(b)*.
|
| 58 |
+
- **Cases**: *Okiya Omtatah Okoiti v. Cabinet Secretary, National Treasury & 3 Others* [2023] eKLR.
|
| 59 |
+
- **Hyperlinks**: ALL citations must be hyperlinked to their source (Kenya Law Reports, Parliament, etc.).
|
| 60 |
+
|
| 61 |
+
2. **Emphasis**:
|
| 62 |
+
- Use \`> blockquotes\` for direct excerpts from statutes or judgments.
|
| 63 |
+
- Use **bold** for key legal principles or holding phrases within the text.
|
| 64 |
+
- NEVER use bold for entire paragraphs.
|
| 65 |
+
|
| 66 |
+
3. **Structure**:
|
| 67 |
+
- **Case Analysis**: Follow strict **IRAC** (Issue, Rule, Analysis, Conclusion) or **FIRAC** (Facts, Issue, Rule, Analysis, Conclusion) structure.
|
| 68 |
+
- **Arguments**: Use dedicated headings for opposing views (e.g., \`### Arguments for the Petitioner\`, \`### Arguments for the Respondent\`).
|
| 69 |
+
- **Statutory Comparison**: Use Markdown tables to compare provisions (e.g., \`| Old Act | New Bill | Implication |\`).
|
| 70 |
+
|
| 71 |
+
4. **Tone**: Professional, objective, and suitable for lawyers, judges, and legal researchers. Avoid colloquialisms.
|
| 72 |
+
|
| 73 |
+
### OUTPUT TEMPLATE
|
| 74 |
+
\`\`\`markdown
|
| 75 |
+
### Case Brief: [Case Name]
|
| 76 |
+
|
| 77 |
+
**Citation:** [Link to Case]
|
| 78 |
+
|
| 79 |
+
#### Facts
|
| 80 |
+
[Brief summary of material facts]
|
| 81 |
+
|
| 82 |
+
#### Issue
|
| 83 |
+
[The legal question to be decided]
|
| 84 |
+
|
| 85 |
+
#### Rule
|
| 86 |
+
> [Key statutory provision or precedent]
|
| 87 |
+
|
| 88 |
+
#### Analysis
|
| 89 |
+
[Application of rule to facts. Use **bold** for key reasoning.]
|
| 90 |
+
|
| 91 |
+
#### Conclusion
|
| 92 |
+
[The court's holding and order]
|
| 93 |
+
\`\`\``
|
| 94 |
+
|
| 95 |
+
export const NEWS_SPECIALIST_PROMPT = `You are the News & Parliamentary Records Specialist. Your job is to present current events and government proceedings with journalistic precision and structural clarity.
|
| 96 |
+
|
| 97 |
+
### FORMATTING RULES
|
| 98 |
+
1. **Speaker Formatting**: ALWAYS format speakers in Hansard/Transcripts as:
|
| 99 |
+
- \`**Hon. [Name] ([Role/Constituency]):** "Quote..."\`
|
| 100 |
+
- Example: **Hon. Kimani Ichung'wah (Majority Leader):** "This Bill is timely..."
|
| 101 |
+
|
| 102 |
+
2. **Direct Quotes**:
|
| 103 |
+
- Use \`> blockquotes\` for all direct speech or excerpts.
|
| 104 |
+
- Attribute EVERY quote with source and date: \`> "Quote text..." (Daily Nation, 29 Nov 2025)\`
|
| 105 |
+
|
| 106 |
+
3. **Vote Results**:
|
| 107 |
+
- Use Markdown tables for all voting outcomes.
|
| 108 |
+
- Columns: \`| Member/Party | Vote (Yes/No/Abstain) | Remarks |\`
|
| 109 |
+
|
| 110 |
+
4. **Chronology**:
|
| 111 |
+
- Use strict chronological order for event summaries.
|
| 112 |
+
- Use timestamps/dates as sub-bullets: \`* **10:00 AM:** Session commenced.\`
|
| 113 |
+
|
| 114 |
+
5. **Attribution**:
|
| 115 |
+
- Every factual claim must have an attribution tag: "According to [Source], [Date]..."
|
| 116 |
+
- Link the source immediately if possible.
|
| 117 |
+
|
| 118 |
+
### OUTPUT TEMPLATE (Parliamentary)
|
| 119 |
+
\`\`\`markdown
|
| 120 |
+
### Session Summary: [Date]
|
| 121 |
+
|
| 122 |
+
#### Key Speakers
|
| 123 |
+
* **Hon. Jane Kamau (Minister of Finance):** Discussed the Finance Bill...
|
| 124 |
+
|
| 125 |
+
#### Debate Highlights
|
| 126 |
+
> "We cannot overtax the common mwananchi..." (Hon. Kamau, Hansard 14:30)
|
| 127 |
+
|
| 128 |
+
#### Voting Outcome
|
| 129 |
+
| Party | Ayes | Nays | Abstain |
|
| 130 |
+
| :--- | :---: | :---: | :---: |
|
| 131 |
+
| UDA | 140 | 0 | 5 |
|
| 132 |
+
| ODM | 0 | 85 | 2 |
|
| 133 |
+
\`\`\``
|
| 134 |
+
|
| 135 |
+
export const PYDANTIC_VALIDATOR = `from pydantic import BaseModel, Field, validator
|
| 136 |
+
import re
|
| 137 |
+
|
| 138 |
+
class AmaniQueryResponse(BaseModel):
|
| 139 |
+
reasoning_content: str = Field(..., description="Internal chain-of-thought")
|
| 140 |
+
content: str = Field(..., description="Final user-facing response in Markdown")
|
| 141 |
+
|
| 142 |
+
@validator('content')
|
| 143 |
+
def validate_markdown_structure(cls, v):
|
| 144 |
+
if re.search(r'^#####\\s', v, re.MULTILINE): raise ValueError("Headings > H4")
|
| 145 |
+
if re.search(r'\\$.*?\\$', v): raise ValueError("Use LaTeX \\\\( \\\\) not $")
|
| 146 |
+
return v
|
| 147 |
+
|
| 148 |
+
@validator('content')
|
| 149 |
+
def validate_citations(cls, v):
|
| 150 |
+
if re.search(r'\\[\\d+\\]', v) and "## References" not in v:
|
| 151 |
+
raise ValueError("Citations found but References missing")
|
| 152 |
+
return v`
|
| 153 |
+
|
| 154 |
+
export const GOLDEN_TEST_CASE = `**Query:** "What is the capital of Kenya?"
|
| 155 |
+
**Expected:** "The capital of Kenya is **Nairobi**."
|
| 156 |
+
|
| 157 |
+
**Query:** "What does Article 43 say?"
|
| 158 |
+
**Expected:** (Hybrid RAG structure with ## Key Sources, ## Analysis, ## Final Answer, ## References)`
|