Spaces:
Running
Running
Avijit Ghosh
commited on
Commit
·
509e21e
1
Parent(s):
bad84a3
added all the new files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +11 -0
- .gitignore +30 -0
- Dockerfile +38 -0
- README.md +90 -5
- app/evaluation/[id]/generateStaticParams.ts +15 -0
- app/evaluation/[id]/page.client.tsx +711 -0
- app/evaluation/[id]/page.tsx +9 -0
- app/evaluation/[id]/server.ts +1 -0
- app/globals.css +127 -0
- app/layout.tsx +39 -0
- app/page.tsx +566 -0
- components.json +21 -0
- components/ai-evaluation-dashboard.tsx +350 -0
- components/category-evaluation.tsx +934 -0
- components/category-selection.tsx +246 -0
- components/evaluation-card.tsx +445 -0
- components/evaluation-form.tsx +190 -0
- components/results-dashboard.tsx +428 -0
- components/system-info-form.tsx +249 -0
- components/theme-provider.tsx +7 -0
- components/ui/accordion.tsx +66 -0
- components/ui/alert-dialog.tsx +157 -0
- components/ui/alert.tsx +66 -0
- components/ui/aspect-ratio.tsx +11 -0
- components/ui/avatar.tsx +53 -0
- components/ui/badge.tsx +46 -0
- components/ui/breadcrumb.tsx +109 -0
- components/ui/button.tsx +59 -0
- components/ui/calendar.tsx +213 -0
- components/ui/card.tsx +92 -0
- components/ui/carousel.tsx +241 -0
- components/ui/chart.tsx +325 -0
- components/ui/checkbox.tsx +32 -0
- components/ui/collapsible.tsx +33 -0
- components/ui/command.tsx +184 -0
- components/ui/context-menu.tsx +252 -0
- components/ui/dialog.tsx +143 -0
- components/ui/drawer.tsx +135 -0
- components/ui/dropdown-menu.tsx +257 -0
- components/ui/form.tsx +167 -0
- components/ui/hover-card.tsx +44 -0
- components/ui/input-otp.tsx +77 -0
- components/ui/input.tsx +21 -0
- components/ui/label.tsx +24 -0
- components/ui/menubar.tsx +276 -0
- components/ui/navigation-menu.tsx +168 -0
- components/ui/pagination.tsx +127 -0
- components/ui/popover.tsx +48 -0
- components/ui/progress.tsx +31 -0
- components/ui/radio-group.tsx +45 -0
.dockerignore
ADDED
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
node_modules
|
2 |
+
.next
|
3 |
+
.git
|
4 |
+
.gitignore
|
5 |
+
Dockerfile
|
6 |
+
README.md
|
7 |
+
out
|
8 |
+
.env
|
9 |
+
.vscode
|
10 |
+
.idea
|
11 |
+
*.log
|
.gitignore
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
2 |
+
|
3 |
+
*.DS_Store
|
4 |
+
|
5 |
+
# dependencies
|
6 |
+
/node_modules
|
7 |
+
|
8 |
+
# next.js
|
9 |
+
/.next/
|
10 |
+
/out/
|
11 |
+
|
12 |
+
# production
|
13 |
+
/build
|
14 |
+
/out
|
15 |
+
|
16 |
+
# debug
|
17 |
+
npm-debug.log*
|
18 |
+
yarn-debug.log*
|
19 |
+
yarn-error.log*
|
20 |
+
.pnpm-debug.log*
|
21 |
+
|
22 |
+
# env files
|
23 |
+
.env*
|
24 |
+
|
25 |
+
# vercel
|
26 |
+
.vercel
|
27 |
+
|
28 |
+
# typescript
|
29 |
+
*.tsbuildinfo
|
30 |
+
next-env.d.ts
|
Dockerfile
ADDED
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
## Multi-stage Dockerfile for Next.js app (suitable for Hugging Face Spaces Docker runtime)
|
2 |
+
# - Builder stage installs deps and builds the Next app
|
3 |
+
# - Runner stage copies build artifacts and runs `npm run start` on $PORT (default 3000)
|
4 |
+
|
5 |
+
FROM node:18-bullseye-slim AS builder
|
6 |
+
WORKDIR /app
|
7 |
+
|
8 |
+
# install build deps and copy package files first for caching
|
9 |
+
COPY package*.json ./
|
10 |
+
RUN npm ci --silent
|
11 |
+
|
12 |
+
# copy source and build
|
13 |
+
COPY . ./
|
14 |
+
RUN npm run build
|
15 |
+
|
16 |
+
FROM node:18-bullseye-slim AS runner
|
17 |
+
WORKDIR /app
|
18 |
+
|
19 |
+
ENV NODE_ENV=production
|
20 |
+
ENV PORT=3000
|
21 |
+
|
22 |
+
# minimal packages for certificates (if needed by model download / https)
|
23 |
+
RUN apt-get update && apt-get install -y ca-certificates --no-install-recommends && rm -rf /var/lib/apt/lists/*
|
24 |
+
|
25 |
+
# copy runtime artifacts from builder
|
26 |
+
COPY --from=builder /app/package*.json ./
|
27 |
+
COPY --from=builder /app/node_modules ./node_modules
|
28 |
+
COPY --from=builder /app/.next ./.next
|
29 |
+
COPY --from=builder /app/public ./public
|
30 |
+
COPY --from=builder /app/next.config.js ./next.config.js
|
31 |
+
|
32 |
+
# Expose the port the app will run on (Spaces expects the app to listen on this port)
|
33 |
+
EXPOSE 3000
|
34 |
+
|
35 |
+
# If you use private/gated HF models, set HF_TOKEN in the Space secrets and expose here
|
36 |
+
# e.g. in Space settings: add secret HF_TOKEN with your token
|
37 |
+
|
38 |
+
CMD ["npm", "run", "start"]
|
README.md
CHANGED
@@ -1,10 +1,95 @@
|
|
1 |
---
|
2 |
-
title:
|
3 |
-
emoji:
|
4 |
-
colorFrom:
|
5 |
-
colorTo:
|
6 |
sdk: docker
|
7 |
pinned: false
|
|
|
8 |
---
|
9 |
|
10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
---
|
2 |
+
title: AI Evaluation Dashboard
|
3 |
+
emoji: 📊
|
4 |
+
colorFrom: blue
|
5 |
+
colorTo: indigo
|
6 |
sdk: docker
|
7 |
pinned: false
|
8 |
+
app_port: 3000
|
9 |
---
|
10 |
|
11 |
+
# AI Evaluation Dashboard
|
12 |
+
|
13 |
+
This repository is a Next.js application for viewing and authoring AI evaluations. It includes demo evaluation fixtures under `public/evaluations/` and a dynamic details page that performs server-side rendering and route-handler based inference.
|
14 |
+
|
15 |
+
## Run locally
|
16 |
+
|
17 |
+
Install dependencies and run the dev server:
|
18 |
+
|
19 |
+
```bash
|
20 |
+
npm ci
|
21 |
+
npm run dev
|
22 |
+
```
|
23 |
+
|
24 |
+
Build for production and run:
|
25 |
+
|
26 |
+
```bash
|
27 |
+
npm ci
|
28 |
+
npm run build
|
29 |
+
NODE_ENV=production PORT=3000 npm run start
|
30 |
+
```
|
31 |
+
|
32 |
+
## Docker (recommended for Hugging Face Spaces)
|
33 |
+
|
34 |
+
A `Dockerfile` is included for deploying this app as a dynamic service on Hugging Face Spaces (Docker runtime).
|
35 |
+
|
36 |
+
Build the image locally:
|
37 |
+
|
38 |
+
```bash
|
39 |
+
docker build -t ai-eval-dashboard .
|
40 |
+
```
|
41 |
+
|
42 |
+
Run the container (expose port 3000):
|
43 |
+
|
44 |
+
```bash
|
45 |
+
docker run -p 3000:3000 -e HF_TOKEN="$HF_TOKEN" ai-eval-dashboard
|
46 |
+
```
|
47 |
+
|
48 |
+
Visit `http://localhost:3000` to verify.
|
49 |
+
|
50 |
+
### Deploy to Hugging Face Spaces
|
51 |
+
|
52 |
+
1. Create a new Space at https://huggingface.co/new-space and choose **Docker** as the runtime.
|
53 |
+
2. Add a secret named `HF_TOKEN` (if you plan to access private or gated models or the Inference API) in the Space settings.
|
54 |
+
3. Push this repository to the Space Git (or upload files through the UI). The Space will build the Docker image using the included `Dockerfile` and serve your app on port 3000.
|
55 |
+
|
56 |
+
Notes:
|
57 |
+
- The app's server may attempt to construct ML pipelines server-side if you use Transformers.js and large models; prefer small/quantized models or use the Hugging Face Inference API instead (see below).
|
58 |
+
- If your build needs native dependencies (e.g. `sharp`), the Docker image may require extra apt packages; update the Dockerfile accordingly.
|
59 |
+
|
60 |
+
## Alternative: Use Hugging Face Inference API (avoid hosting model weights)
|
61 |
+
|
62 |
+
If downloading and running model weights inside the Space is impractical (memory/disk limits), modify the server route to proxy requests to the Hugging Face Inference API.
|
63 |
+
|
64 |
+
Example server-side call (Route Handler):
|
65 |
+
|
66 |
+
```js
|
67 |
+
const resp = await fetch('https://api-inference.huggingface.co/models/<model-id>', {
|
68 |
+
method: 'POST',
|
69 |
+
headers: { Authorization: `Bearer ${process.env.HF_TOKEN}`, 'Content-Type': 'application/json' },
|
70 |
+
body: JSON.stringify({ inputs: text })
|
71 |
+
})
|
72 |
+
const json = await resp.json()
|
73 |
+
```
|
74 |
+
|
75 |
+
Store `HF_TOKEN` in the Space secrets and your route will be able to call the API.
|
76 |
+
|
77 |
+
## Troubleshooting
|
78 |
+
|
79 |
+
- Build fails in Spaces: check the build logs; you may need extra apt packages or to pin Node version.
|
80 |
+
- Runtime OOM / killed: model is too large for Spaces; use Inference API or smaller models.
|
81 |
+
|
82 |
+
## What I added
|
83 |
+
|
84 |
+
- `Dockerfile` — multi-stage build for production
|
85 |
+
- `.dockerignore` — to reduce image size
|
86 |
+
- Updated `README.md` with Spaces frontmatter and deployment instructions
|
87 |
+
|
88 |
+
If you want, I can:
|
89 |
+
- Modify the Dockerfile to use Next.js standalone mode for a smaller runtime image.
|
90 |
+
- Add a small health-check route and a simple `docker-compose.yml` for local testing.
|
91 |
+
|
92 |
+
Which of those would you like next?
|
93 |
+
npm run build
|
94 |
+
|
95 |
+
Send the contents of the "out" folder to https://huggingface.co/spaces/evaleval/general-eval-card
|
app/evaluation/[id]/generateStaticParams.ts
ADDED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import fs from "fs";
|
2 |
+
import path from "path";
|
3 |
+
|
4 |
+
export async function generateStaticParams() {
|
5 |
+
const evaluationsDir = path.join(process.cwd(), "public/evaluations");
|
6 |
+
const files = fs.readdirSync(evaluationsDir);
|
7 |
+
|
8 |
+
const params = files.map((file) => {
|
9 |
+
const filePath = path.join(evaluationsDir, file);
|
10 |
+
const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
11 |
+
return { id: data.id };
|
12 |
+
});
|
13 |
+
|
14 |
+
return params;
|
15 |
+
}
|
app/evaluation/[id]/page.client.tsx
ADDED
@@ -0,0 +1,711 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import { useParams, useRouter } from "next/navigation"
|
4 |
+
import { useState, useEffect } from "react"
|
5 |
+
import { Button } from "@/components/ui/button"
|
6 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
7 |
+
import { Badge } from "@/components/ui/badge"
|
8 |
+
import { Checkbox } from "@/components/ui/checkbox"
|
9 |
+
import { ArrowLeft, Download } from "lucide-react"
|
10 |
+
import { CATEGORIES } from "@/lib/category-data"
|
11 |
+
import { BENCHMARK_QUESTIONS, PROCESS_QUESTIONS } from "@/lib/category-data"
|
12 |
+
|
13 |
+
const loadEvaluationDetails = async (id: string) => {
|
14 |
+
const evaluationFiles = [
|
15 |
+
"/evaluations/gpt-4-turbo.json",
|
16 |
+
"/evaluations/claude-3-sonnet.json",
|
17 |
+
"/evaluations/gemini-pro.json",
|
18 |
+
"/evaluations/fraud-detector.json",
|
19 |
+
]
|
20 |
+
|
21 |
+
for (const file of evaluationFiles) {
|
22 |
+
try {
|
23 |
+
const response = await fetch(file)
|
24 |
+
const data = await response.json()
|
25 |
+
|
26 |
+
if (data.id === id) {
|
27 |
+
return data
|
28 |
+
}
|
29 |
+
} catch (error) {
|
30 |
+
console.error(`Failed to load evaluation data from ${file}:`, error)
|
31 |
+
}
|
32 |
+
}
|
33 |
+
|
34 |
+
return null
|
35 |
+
}
|
36 |
+
|
37 |
+
export default function EvaluationDetailsPage() {
|
38 |
+
const params = useParams()
|
39 |
+
const router = useRouter()
|
40 |
+
const evaluationId = params.id as string
|
41 |
+
|
42 |
+
const [evaluation, setEvaluation] = useState<any>(null)
|
43 |
+
const [loading, setLoading] = useState(true)
|
44 |
+
const [expandedAreas, setExpandedAreas] = useState<Record<string, boolean>>({})
|
45 |
+
const toggleArea = (area: string) => setExpandedAreas((p) => ({ ...p, [area]: !p[area] }))
|
46 |
+
const [expandedNegatives, setExpandedNegatives] = useState<Record<string, boolean>>({})
|
47 |
+
const toggleNegatives = (key: string) => setExpandedNegatives((p) => ({ ...p, [key]: !p[key] }))
|
48 |
+
const [visibleCategories, setVisibleCategories] = useState<Record<string, boolean>>({})
|
49 |
+
const toggleCategoryVisibility = (id: string) => setVisibleCategories((p) => ({ ...p, [id]: !p[id] }))
|
50 |
+
const selectAll = () => {
|
51 |
+
const map: Record<string, boolean> = {}
|
52 |
+
;(evaluation.selectedCategories || []).forEach((id: string) => (map[id] = true))
|
53 |
+
setVisibleCategories(map)
|
54 |
+
}
|
55 |
+
const deselectAll = () => {
|
56 |
+
const map: Record<string, boolean> = {}
|
57 |
+
;(evaluation.selectedCategories || []).forEach((id: string) => (map[id] = false))
|
58 |
+
setVisibleCategories(map)
|
59 |
+
}
|
60 |
+
|
61 |
+
// Persist visibility in localStorage per evaluation
|
62 |
+
const STORAGE_KEY = `eval:${evaluationId}:visibleCategories`
|
63 |
+
useEffect(() => {
|
64 |
+
try {
|
65 |
+
const raw = localStorage.getItem(STORAGE_KEY)
|
66 |
+
if (raw) {
|
67 |
+
const parsed = JSON.parse(raw)
|
68 |
+
setVisibleCategories(parsed)
|
69 |
+
return
|
70 |
+
}
|
71 |
+
} catch (e) {
|
72 |
+
// ignore
|
73 |
+
}
|
74 |
+
|
75 |
+
// if nothing saved, initialize defaults (visible)
|
76 |
+
if (evaluation?.selectedCategories) {
|
77 |
+
const init: Record<string, boolean> = {}
|
78 |
+
evaluation.selectedCategories.forEach((id: string) => {
|
79 |
+
init[id] = true
|
80 |
+
})
|
81 |
+
setVisibleCategories((p) => ({ ...init, ...p }))
|
82 |
+
}
|
83 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
84 |
+
}, [evaluationId, evaluation?.selectedCategories])
|
85 |
+
|
86 |
+
useEffect(() => {
|
87 |
+
try {
|
88 |
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(visibleCategories))
|
89 |
+
} catch (e) {
|
90 |
+
// ignore
|
91 |
+
}
|
92 |
+
}, [visibleCategories, evaluationId])
|
93 |
+
|
94 |
+
useEffect(() => {
|
95 |
+
const loadData = async () => {
|
96 |
+
const data = await loadEvaluationDetails(evaluationId)
|
97 |
+
setEvaluation(data)
|
98 |
+
setLoading(false)
|
99 |
+
}
|
100 |
+
loadData()
|
101 |
+
}, [evaluationId])
|
102 |
+
|
103 |
+
if (loading) {
|
104 |
+
return (
|
105 |
+
<div className="container mx-auto px-4 py-8">
|
106 |
+
<div className="text-center">
|
107 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
108 |
+
<p className="text-muted-foreground">Loading evaluation details...</p>
|
109 |
+
</div>
|
110 |
+
</div>
|
111 |
+
)
|
112 |
+
}
|
113 |
+
|
114 |
+
if (!evaluation) {
|
115 |
+
return (
|
116 |
+
<div className="container mx-auto px-4 py-8">
|
117 |
+
<div className="text-center">
|
118 |
+
<h1 className="text-2xl font-heading mb-4">Evaluation Not Found</h1>
|
119 |
+
<Button onClick={() => router.push("/")} variant="outline">
|
120 |
+
<ArrowLeft className="h-4 w-4 mr-2" />
|
121 |
+
Back to Dashboard
|
122 |
+
</Button>
|
123 |
+
</div>
|
124 |
+
</div>
|
125 |
+
)
|
126 |
+
}
|
127 |
+
|
128 |
+
return (
|
129 |
+
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
130 |
+
<div className="mb-6">
|
131 |
+
<div className="flex items-center justify-between">
|
132 |
+
<Button onClick={() => router.push("/")} variant="outline" size="sm">
|
133 |
+
<ArrowLeft className="h-4 w-4 mr-2" />
|
134 |
+
Back to Dashboard
|
135 |
+
</Button>
|
136 |
+
<Button variant="outline" size="sm">
|
137 |
+
<Download className="h-4 w-4 mr-2" />
|
138 |
+
Export Report
|
139 |
+
</Button>
|
140 |
+
</div>
|
141 |
+
|
142 |
+
<div className="mt-3 text-center">
|
143 |
+
<h1 className="text-3xl font-heading">{evaluation.systemName}</h1>
|
144 |
+
<p className="text-muted-foreground">{evaluation.provider}</p>
|
145 |
+
</div>
|
146 |
+
</div>
|
147 |
+
|
148 |
+
{/* System Information */}
|
149 |
+
<Card className="mb-6">
|
150 |
+
<CardHeader>
|
151 |
+
<CardTitle>System Information</CardTitle>
|
152 |
+
</CardHeader>
|
153 |
+
<CardContent className="grid grid-cols-2 gap-4">
|
154 |
+
<div>
|
155 |
+
<p className="text-sm text-muted-foreground">System Version</p>
|
156 |
+
<p className="font-medium">{evaluation.version}</p>
|
157 |
+
</div>
|
158 |
+
<div>
|
159 |
+
<p className="text-sm text-muted-foreground">Deployment Context</p>
|
160 |
+
<p className="font-medium">{evaluation.deploymentContext}</p>
|
161 |
+
</div>
|
162 |
+
<div>
|
163 |
+
<p className="text-sm text-muted-foreground">Evaluation Date</p>
|
164 |
+
<p className="font-medium">{evaluation.evaluationDate}</p>
|
165 |
+
</div>
|
166 |
+
<div>
|
167 |
+
<p className="text-sm text-muted-foreground">Evaluator</p>
|
168 |
+
<p className="font-medium">{evaluation.evaluator}</p>
|
169 |
+
</div>
|
170 |
+
<div>
|
171 |
+
<p className="text-sm text-muted-foreground">Modality</p>
|
172 |
+
<p className="font-medium">{evaluation.modality}</p>
|
173 |
+
</div>
|
174 |
+
<div>
|
175 |
+
<p className="text-sm text-muted-foreground">Completeness Score</p>
|
176 |
+
<p className="font-medium">{evaluation.overallStats?.completenessScore || "N/A"}%</p>
|
177 |
+
</div>
|
178 |
+
</CardContent>
|
179 |
+
</Card>
|
180 |
+
|
181 |
+
{/* Applicable Categories - split into Capabilities & Risks with visibility toggles */}
|
182 |
+
<Card className="mb-6">
|
183 |
+
<CardHeader>
|
184 |
+
<div className="flex items-center justify-between w-full">
|
185 |
+
<CardTitle>Applicable Categories ({evaluation.selectedCategories?.length || 0})</CardTitle>
|
186 |
+
<div className="flex items-center gap-2">
|
187 |
+
<Button size="sm" variant="outline" onClick={selectAll}>
|
188 |
+
Select all
|
189 |
+
</Button>
|
190 |
+
<Button size="sm" variant="outline" onClick={deselectAll}>
|
191 |
+
Deselect all
|
192 |
+
</Button>
|
193 |
+
</div>
|
194 |
+
</div>
|
195 |
+
</CardHeader>
|
196 |
+
<CardContent>
|
197 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
198 |
+
<div>
|
199 |
+
<div className="text-sm font-medium mb-2">Capabilities</div>
|
200 |
+
<div className="flex flex-col gap-2">
|
201 |
+
{evaluation.selectedCategories
|
202 |
+
?.map((id: string) => CATEGORIES.find((c) => c.id === id))
|
203 |
+
.filter(Boolean)
|
204 |
+
.filter((c: any) => c.type === "capability")
|
205 |
+
.map((category: any) => (
|
206 |
+
<label key={category.id} className="flex items-center gap-2">
|
207 |
+
<Checkbox
|
208 |
+
checked={visibleCategories[category.id] ?? true}
|
209 |
+
onCheckedChange={() => toggleCategoryVisibility(category.id)}
|
210 |
+
/>
|
211 |
+
<Badge variant="secondary" className="cursor-pointer">
|
212 |
+
{category.name}
|
213 |
+
</Badge>
|
214 |
+
</label>
|
215 |
+
))}
|
216 |
+
</div>
|
217 |
+
</div>
|
218 |
+
|
219 |
+
<div>
|
220 |
+
<div className="text-sm font-medium mb-2">Risks</div>
|
221 |
+
<div className="flex flex-col gap-2">
|
222 |
+
{evaluation.selectedCategories
|
223 |
+
?.map((id: string) => CATEGORIES.find((c) => c.id === id))
|
224 |
+
.filter(Boolean)
|
225 |
+
.filter((c: any) => c.type === "risk")
|
226 |
+
.map((category: any) => (
|
227 |
+
<label key={category.id} className="flex items-center gap-2">
|
228 |
+
<Checkbox
|
229 |
+
checked={visibleCategories[category.id] ?? true}
|
230 |
+
onCheckedChange={() => toggleCategoryVisibility(category.id)}
|
231 |
+
/>
|
232 |
+
<Badge variant="destructive" className="cursor-pointer">
|
233 |
+
{category.name}
|
234 |
+
</Badge>
|
235 |
+
</label>
|
236 |
+
))}
|
237 |
+
</div>
|
238 |
+
</div>
|
239 |
+
</div>
|
240 |
+
</CardContent>
|
241 |
+
</Card>
|
242 |
+
|
243 |
+
{/* Overall Statistics */}
|
244 |
+
<Card className="mb-6">
|
245 |
+
<CardHeader>
|
246 |
+
<CardTitle>Overall Statistics</CardTitle>
|
247 |
+
</CardHeader>
|
248 |
+
<CardContent>
|
249 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
250 |
+
<div className="text-center p-4 bg-green-50 dark:bg-green-950 rounded-lg">
|
251 |
+
<div className="text-2xl font-bold text-green-700 dark:text-green-300">
|
252 |
+
{evaluation.overallStats?.strongCategories?.length || 0}
|
253 |
+
</div>
|
254 |
+
<div className="text-sm text-green-600 dark:text-green-400">Strong</div>
|
255 |
+
</div>
|
256 |
+
<div className="text-center p-4 bg-blue-50 dark:bg-blue-950 rounded-lg">
|
257 |
+
<div className="text-2xl font-bold text-blue-700 dark:text-blue-300">
|
258 |
+
{evaluation.overallStats?.adequateCategories?.length || 0}
|
259 |
+
</div>
|
260 |
+
<div className="text-sm text-blue-600 dark:text-blue-400">Adequate</div>
|
261 |
+
</div>
|
262 |
+
<div className="text-center p-4 bg-yellow-50 dark:bg-yellow-950 rounded-lg">
|
263 |
+
<div className="text-2xl font-bold text-yellow-700 dark:text-yellow-300">
|
264 |
+
{evaluation.overallStats?.weakCategories?.length || 0}
|
265 |
+
</div>
|
266 |
+
<div className="text-sm text-yellow-600 dark:text-yellow-400">Weak</div>
|
267 |
+
</div>
|
268 |
+
<div className="text-center p-4 bg-red-50 dark:bg-red-950 rounded-lg">
|
269 |
+
<div className="text-2xl font-bold text-red-700 dark:text-red-300">
|
270 |
+
{evaluation.overallStats?.insufficientCategories?.length || 0}
|
271 |
+
</div>
|
272 |
+
<div className="text-sm text-red-600 dark:text-red-400">Insufficient</div>
|
273 |
+
</div>
|
274 |
+
</div>
|
275 |
+
</CardContent>
|
276 |
+
</Card>
|
277 |
+
|
278 |
+
{/* Priority Areas (show only weak/insufficient like results) */}
|
279 |
+
{((evaluation.overallStats?.weakCategories || []).length > 0 || (evaluation.overallStats?.insufficientCategories || []).length > 0) && (
|
280 |
+
<Card className="mb-6">
|
281 |
+
<CardHeader>
|
282 |
+
<CardTitle>Priority Areas</CardTitle>
|
283 |
+
</CardHeader>
|
284 |
+
<CardContent>
|
285 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
286 |
+
{[...(evaluation.overallStats?.insufficientCategories || []), ...(evaluation.overallStats?.weakCategories || [])]
|
287 |
+
.filter(Boolean)
|
288 |
+
.map((catId: string) => {
|
289 |
+
const category = CATEGORIES.find((c) => c.id === catId)
|
290 |
+
return (
|
291 |
+
<div key={catId} className="p-3 border rounded-md flex items-center justify-between">
|
292 |
+
<div>
|
293 |
+
<div className="font-medium">{category?.name || catId}</div>
|
294 |
+
<div className="text-xs text-muted-foreground">{category?.description}</div>
|
295 |
+
</div>
|
296 |
+
<Badge variant={evaluation.overallStats?.insufficientCategories?.includes(catId) ? "destructive" : "outline"}>
|
297 |
+
{evaluation.overallStats?.insufficientCategories?.includes(catId) ? "insufficient" : "weak"}
|
298 |
+
</Badge>
|
299 |
+
</div>
|
300 |
+
)
|
301 |
+
})}
|
302 |
+
</div>
|
303 |
+
</CardContent>
|
304 |
+
</Card>
|
305 |
+
)}
|
306 |
+
|
307 |
+
{/* Evaluation Details */}
|
308 |
+
{evaluation.categoryEvaluations &&
|
309 |
+
Object.entries(evaluation.categoryEvaluations)
|
310 |
+
.filter(([categoryId]) => visibleCategories[categoryId] ?? true)
|
311 |
+
.map(([categoryId, data]: [string, any]) => {
|
312 |
+
const category = CATEGORIES.find((c) => c.id === categoryId)
|
313 |
+
|
314 |
+
// compute per-category score (yes out of applicable (yes+no)) across A & B
|
315 |
+
const benchmarkQs = BENCHMARK_QUESTIONS.map((q) => q.id)
|
316 |
+
const processQs = PROCESS_QUESTIONS.map((q) => q.id)
|
317 |
+
let yesCount = 0
|
318 |
+
let noCount = 0
|
319 |
+
let naCount = 0
|
320 |
+
|
321 |
+
for (const qid of benchmarkQs) {
|
322 |
+
const raw = data.benchmarkAnswers?.[qid]
|
323 |
+
const answers = Array.isArray(raw) ? raw : raw ? [raw] : []
|
324 |
+
const hasYes = answers.some((a: string) => String(a).toLowerCase() === "yes")
|
325 |
+
const hasNo = answers.some((a: string) => String(a).toLowerCase() === "no")
|
326 |
+
const hasNA = answers.length === 0 || answers.some((a: string) => String(a).toLowerCase().includes("not applicable") || String(a).toLowerCase() === "n/a")
|
327 |
+
if (hasYes) yesCount++
|
328 |
+
else if (hasNo) noCount++
|
329 |
+
else if (hasNA) naCount++
|
330 |
+
else naCount++
|
331 |
+
}
|
332 |
+
|
333 |
+
for (const qid of processQs) {
|
334 |
+
const raw = data.processAnswers?.[qid]
|
335 |
+
const answers = Array.isArray(raw) ? raw : raw ? [raw] : []
|
336 |
+
const hasYes = answers.some((a: string) => String(a).toLowerCase() === "yes")
|
337 |
+
const hasNo = answers.some((a: string) => String(a).toLowerCase() === "no")
|
338 |
+
const hasNA = answers.length === 0 || answers.some((a: string) => String(a).toLowerCase().includes("not applicable") || String(a).toLowerCase() === "n/a")
|
339 |
+
if (hasYes) yesCount++
|
340 |
+
else if (hasNo) noCount++
|
341 |
+
else if (hasNA) naCount++
|
342 |
+
else naCount++
|
343 |
+
}
|
344 |
+
|
345 |
+
const totalApplicable = yesCount + noCount
|
346 |
+
const scoreText = totalApplicable > 0 ? `${yesCount}/${totalApplicable}` : "N/A"
|
347 |
+
let rating = "Unknown"
|
348 |
+
if (evaluation.overallStats?.strongCategories?.includes(categoryId)) rating = "Strong"
|
349 |
+
else if (evaluation.overallStats?.adequateCategories?.includes(categoryId)) rating = "Adequate"
|
350 |
+
else if (evaluation.overallStats?.weakCategories?.includes(categoryId)) rating = "Weak"
|
351 |
+
else if (evaluation.overallStats?.insufficientCategories?.includes(categoryId)) rating = "Insufficient"
|
352 |
+
|
353 |
+
const ratingClass =
|
354 |
+
rating === "Strong"
|
355 |
+
? "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium bg-green-100 text-green-700"
|
356 |
+
: rating === "Adequate"
|
357 |
+
? "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-700"
|
358 |
+
: rating === "Weak"
|
359 |
+
? "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800"
|
360 |
+
: "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium bg-red-100 text-red-700"
|
361 |
+
|
362 |
+
return (
|
363 |
+
<Card key={categoryId} className="mb-6">
|
364 |
+
<CardHeader>
|
365 |
+
<div className="flex items-start justify-between w-full gap-4">
|
366 |
+
<div>
|
367 |
+
<CardTitle className="flex items-center gap-2">
|
368 |
+
{category?.name || categoryId}
|
369 |
+
<Badge variant={category?.type === "capability" ? "secondary" : "destructive"}>
|
370 |
+
{category?.type || "unknown"}
|
371 |
+
</Badge>
|
372 |
+
</CardTitle>
|
373 |
+
<p className="text-sm text-muted-foreground">{category?.description}</p>
|
374 |
+
</div>
|
375 |
+
|
376 |
+
<div className="text-right">
|
377 |
+
<div className="text-sm text-muted-foreground">Score</div>
|
378 |
+
<div className="font-semibold">{scoreText}</div>
|
379 |
+
<div className="mt-2">
|
380 |
+
<span className={ratingClass}>{rating}</span>
|
381 |
+
</div>
|
382 |
+
</div>
|
383 |
+
</div>
|
384 |
+
</CardHeader>
|
385 |
+
<CardContent className="space-y-6">
|
386 |
+
{/* Benchmark Questions */}
|
387 |
+
{data.benchmarkSources && (
|
388 |
+
<div>
|
389 |
+
<h4 className="font-semibold mb-3">Part A: Benchmark & Testing</h4>
|
390 |
+
<div className="space-y-4">
|
391 |
+
{(() => {
|
392 |
+
const entries = Object.entries(data.benchmarkSources || {}) as [string, any][]
|
393 |
+
const yesItems: any[] = []
|
394 |
+
const noItems: any[] = []
|
395 |
+
const naItems: any[] = []
|
396 |
+
|
397 |
+
// iterate the union of known source keys and answer keys so we show questions
|
398 |
+
const canonicalKeys = BENCHMARK_QUESTIONS.map((q) => q.id)
|
399 |
+
const answerKeys = Object.keys(data.benchmarkAnswers || {})
|
400 |
+
const sourceKeys = Object.keys(data.benchmarkSources || {})
|
401 |
+
const keySet = new Set<string>([...canonicalKeys, ...answerKeys, ...sourceKeys])
|
402 |
+
for (const questionId of Array.from(keySet)) {
|
403 |
+
const sources = data.benchmarkSources?.[questionId] || []
|
404 |
+
const qText = BENCHMARK_QUESTIONS.find((x) => x.id === questionId)?.text || questionId
|
405 |
+
const rawAnswer = data.benchmarkAnswers?.[questionId]
|
406 |
+
const answers = Array.isArray(rawAnswer) ? rawAnswer : rawAnswer ? [rawAnswer] : []
|
407 |
+
const hasYes = answers.some((a: string) => String(a).toLowerCase() === "yes")
|
408 |
+
const hasNo = answers.some((a: string) => String(a).toLowerCase() === "no")
|
409 |
+
const hasNA = answers.length === 0 || answers.some((a: string) => String(a).toLowerCase().includes("not applicable") || String(a).toLowerCase() === "n/a")
|
410 |
+
|
411 |
+
const reason =
|
412 |
+
sources?.[0]?.scope || sources?.[0]?.description || data.additionalAspects || (hasNA ? "Not applicable" : undefined)
|
413 |
+
|
414 |
+
if (hasYes) yesItems.push({ questionId, qText, sources })
|
415 |
+
else if (hasNo) noItems.push({ questionId, qText })
|
416 |
+
else if (hasNA) naItems.push({ questionId, qText, reason })
|
417 |
+
else naItems.push({ questionId, qText, reason: reason || "Not applicable" })
|
418 |
+
}
|
419 |
+
|
420 |
+
return (
|
421 |
+
<>
|
422 |
+
{yesItems.map((it) => {
|
423 |
+
const key = `bench-${categoryId}-${it.questionId}`
|
424 |
+
return (
|
425 |
+
<div key={it.questionId} className="border rounded-lg p-4">
|
426 |
+
<div
|
427 |
+
role="button"
|
428 |
+
tabIndex={0}
|
429 |
+
onClick={() => toggleNegatives(key)}
|
430 |
+
className="flex items-center gap-2 mb-2 justify-between cursor-pointer"
|
431 |
+
>
|
432 |
+
<div className="flex items-center gap-3">
|
433 |
+
<span className="font-medium">{it.questionId}:</span>
|
434 |
+
<div className="text-sm">{it.qText}</div>
|
435 |
+
</div>
|
436 |
+
<div className="flex items-center gap-2">
|
437 |
+
<span className="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium bg-green-100 text-green-700">yes</span>
|
438 |
+
</div>
|
439 |
+
</div>
|
440 |
+
|
441 |
+
{expandedNegatives[key] && (() => {
|
442 |
+
const cards = (it.sources || []).flatMap((src: any) => {
|
443 |
+
const names = String(src.benchmarkName || '')
|
444 |
+
.split(',')
|
445 |
+
.map((s: string) => s.trim())
|
446 |
+
.filter(Boolean)
|
447 |
+
|
448 |
+
const scoreParts = String(src.score || '')
|
449 |
+
.split(',')
|
450 |
+
.map((s: string) => s.trim())
|
451 |
+
.filter(Boolean)
|
452 |
+
|
453 |
+
return (names.length > 0 ? names : ['Benchmark']).map((name: string, idx: number) => {
|
454 |
+
// determine score for this benchmark (positional or by name) or fallback to any numeric
|
455 |
+
let scoreNum: number | undefined
|
456 |
+
if (scoreParts.length === names.length && scoreParts[idx]) {
|
457 |
+
const m = scoreParts[idx].match(/(\d+(?:\.\d+)?)/)
|
458 |
+
if (m) scoreNum = parseFloat(m[1])
|
459 |
+
} else if (scoreParts.length > 0) {
|
460 |
+
const byName = scoreParts.find((p: string) => p.toLowerCase().includes(name.toLowerCase()))
|
461 |
+
const m = (byName || scoreParts[0]).match(/(\d+(?:\.\d+)?)/)
|
462 |
+
if (m) scoreNum = parseFloat(m[1])
|
463 |
+
} else if (src?.score) {
|
464 |
+
const m = String(src.score).match(/(\d+(?:\.\d+)?)/)
|
465 |
+
if (m) scoreNum = parseFloat(m[1])
|
466 |
+
}
|
467 |
+
|
468 |
+
return (
|
469 |
+
<div key={`${it.questionId}-${name}-${idx}`} className="p-4 border rounded-lg bg-background">
|
470 |
+
<div className="flex items-start justify-between">
|
471 |
+
<div className="text-xs inline-flex items-center rounded-full px-2 py-1 bg-indigo-50 text-indigo-700">Percentage</div>
|
472 |
+
<div className="text-2xl font-bold text-indigo-600">{scoreNum != null ? `${scoreNum}%` : '—'}</div>
|
473 |
+
</div>
|
474 |
+
|
475 |
+
<div className="mt-3 text-lg font-semibold">{name}</div>
|
476 |
+
|
477 |
+
{scoreNum != null && (
|
478 |
+
<div className="mt-3">
|
479 |
+
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
480 |
+
<div className="h-2 bg-indigo-500" style={{ width: `${Math.max(0, Math.min(100, scoreNum))}%` }} />
|
481 |
+
</div>
|
482 |
+
</div>
|
483 |
+
)}
|
484 |
+
|
485 |
+
<div className="mt-3 space-y-2 text-sm">
|
486 |
+
<div>
|
487 |
+
<span className="text-muted-foreground">Source:</span>{' '}
|
488 |
+
{src.url ? (
|
489 |
+
<a className="text-primary underline" href={src.url} target="_blank" rel="noreferrer">
|
490 |
+
{src.url}
|
491 |
+
</a>
|
492 |
+
) : (
|
493 |
+
'—'
|
494 |
+
)}
|
495 |
+
</div>
|
496 |
+
<div>
|
497 |
+
<span className="text-muted-foreground">Type:</span> {src.sourceType || src.documentType || '—'}
|
498 |
+
</div>
|
499 |
+
{src.metrics && (
|
500 |
+
<div>
|
501 |
+
<span className="text-muted-foreground">Metric:</span> {src.metrics}
|
502 |
+
</div>
|
503 |
+
)}
|
504 |
+
{src.confidenceInterval && (
|
505 |
+
<div>
|
506 |
+
<span className="text-muted-foreground">Confidence Interval:</span> {src.confidenceInterval}
|
507 |
+
</div>
|
508 |
+
)}
|
509 |
+
{src.description && (
|
510 |
+
<div className="mt-2 p-2 bg-muted/40 rounded text-sm">{src.description}</div>
|
511 |
+
)}
|
512 |
+
</div>
|
513 |
+
</div>
|
514 |
+
)
|
515 |
+
})
|
516 |
+
})
|
517 |
+
|
518 |
+
if (cards.length === 0) return <div className="text-sm text-muted-foreground">No benchmark details available.</div>
|
519 |
+
|
520 |
+
return <div className="mt-3 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">{cards}</div>
|
521 |
+
})()}
|
522 |
+
</div>
|
523 |
+
)
|
524 |
+
})}
|
525 |
+
|
526 |
+
{noItems.map((it) => (
|
527 |
+
<div key={it.questionId} className="border rounded-lg p-4">
|
528 |
+
<div className="flex items-center gap-2 mb-2 justify-between">
|
529 |
+
<div className="flex items-center gap-3">
|
530 |
+
<span className="font-medium">{it.questionId}:</span>
|
531 |
+
<div className="text-sm">{it.qText}</div>
|
532 |
+
</div>
|
533 |
+
<div>
|
534 |
+
<span className="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium bg-red-100 text-red-700">no</span>
|
535 |
+
</div>
|
536 |
+
</div>
|
537 |
+
</div>
|
538 |
+
))}
|
539 |
+
|
540 |
+
{naItems.length > 0 && (
|
541 |
+
<div className="border rounded-lg p-3">
|
542 |
+
<div className="flex items-center justify-between">
|
543 |
+
<div className="font-medium">Not applicable ({naItems.length})</div>
|
544 |
+
<button onClick={() => toggleNegatives(`bench-na-${categoryId}`)} className="text-sm text-primary underline">
|
545 |
+
{expandedNegatives[`bench-na-${categoryId}`] ? "Hide" : "Show"}
|
546 |
+
</button>
|
547 |
+
</div>
|
548 |
+
|
549 |
+
{expandedNegatives[`bench-na-${categoryId}`] && (
|
550 |
+
<div className="mt-3 space-y-2">
|
551 |
+
{naItems.map((it) => (
|
552 |
+
<div key={it.questionId} className="p-2 bg-muted rounded">
|
553 |
+
<div className="flex items-center justify-between">
|
554 |
+
<div className="text-sm">
|
555 |
+
<span className="font-medium">{it.questionId}:</span> {it.qText}
|
556 |
+
</div>
|
557 |
+
<div className="text-xs text-muted-foreground">Reason: {it.reason}</div>
|
558 |
+
</div>
|
559 |
+
</div>
|
560 |
+
))}
|
561 |
+
</div>
|
562 |
+
)}
|
563 |
+
</div>
|
564 |
+
)}
|
565 |
+
</>
|
566 |
+
)
|
567 |
+
})()}
|
568 |
+
</div>
|
569 |
+
</div>
|
570 |
+
)}
|
571 |
+
|
572 |
+
{/* Process Questions */}
|
573 |
+
{data.processSources && (
|
574 |
+
<div>
|
575 |
+
<h4 className="font-semibold mb-3">Part B: Documentation & Process</h4>
|
576 |
+
<div className="space-y-4">
|
577 |
+
{(() => {
|
578 |
+
const entries = Object.entries(data.processSources || {}) as [string, any][]
|
579 |
+
const yesItems: any[] = []
|
580 |
+
const noItems: any[] = []
|
581 |
+
const naItems: any[] = []
|
582 |
+
|
583 |
+
const canonicalKeys = PROCESS_QUESTIONS.map((q) => q.id)
|
584 |
+
const answerKeys = Object.keys(data.processAnswers || {})
|
585 |
+
const sourceKeys = Object.keys(data.processSources || {})
|
586 |
+
const keySet = new Set<string>([...canonicalKeys, ...answerKeys, ...sourceKeys])
|
587 |
+
for (const questionId of Array.from(keySet)) {
|
588 |
+
const sources = data.processSources?.[questionId] || []
|
589 |
+
const qText = PROCESS_QUESTIONS.find((x) => x.id === questionId)?.text || questionId
|
590 |
+
const rawAnswer = data.processAnswers?.[questionId]
|
591 |
+
const answers = Array.isArray(rawAnswer) ? rawAnswer : rawAnswer ? [rawAnswer] : []
|
592 |
+
const hasYes = answers.some((a: string) => String(a).toLowerCase() === "yes")
|
593 |
+
const hasNo = answers.some((a: string) => String(a).toLowerCase() === "no")
|
594 |
+
const hasNA = answers.length === 0 || answers.some((a: string) => String(a).toLowerCase().includes("not applicable") || String(a).toLowerCase() === "n/a")
|
595 |
+
|
596 |
+
const reason = sources?.[0]?.scope || sources?.[0]?.description || data.additionalAspects || (hasNA ? "Not applicable" : undefined)
|
597 |
+
|
598 |
+
if (hasYes) yesItems.push({ questionId, qText, sources })
|
599 |
+
else if (hasNo) noItems.push({ questionId, qText })
|
600 |
+
else if (hasNA) naItems.push({ questionId, qText, reason })
|
601 |
+
else naItems.push({ questionId, qText, reason: reason || "Not applicable" })
|
602 |
+
}
|
603 |
+
|
604 |
+
return (
|
605 |
+
<>
|
606 |
+
{yesItems.map((it) => {
|
607 |
+
const key = `proc-${categoryId}-${it.questionId}`
|
608 |
+
return (
|
609 |
+
<div key={it.questionId} className="border rounded-lg p-4">
|
610 |
+
<div
|
611 |
+
role="button"
|
612 |
+
tabIndex={0}
|
613 |
+
onClick={() => toggleNegatives(key)}
|
614 |
+
className="flex items-center gap-2 mb-2 justify-between cursor-pointer"
|
615 |
+
>
|
616 |
+
<div className="flex items-center gap-3">
|
617 |
+
<span className="font-medium">{it.questionId}:</span>
|
618 |
+
<div className="text-sm">{it.qText}</div>
|
619 |
+
</div>
|
620 |
+
<div className="flex items-center gap-2">
|
621 |
+
<span className="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium bg-green-100 text-green-700">yes</span>
|
622 |
+
</div>
|
623 |
+
</div>
|
624 |
+
|
625 |
+
{expandedNegatives[key] && (
|
626 |
+
<div className="mt-3 space-y-3">
|
627 |
+
{(it.sources || []).map((src: any, i: number) => (
|
628 |
+
<div key={i} className="p-3 bg-muted rounded">
|
629 |
+
<div className="grid grid-cols-1 gap-2 text-sm">
|
630 |
+
<div>
|
631 |
+
<span className="text-muted-foreground">URL:</span> {src?.url || '—'}
|
632 |
+
</div>
|
633 |
+
<div>
|
634 |
+
<span className="text-muted-foreground">Document Type:</span> {src?.documentType || src?.sourceType || '—'}
|
635 |
+
</div>
|
636 |
+
</div>
|
637 |
+
{src?.description && (
|
638 |
+
<div className="mt-2 text-sm">
|
639 |
+
<span className="text-muted-foreground">Description:</span> {src.description}
|
640 |
+
</div>
|
641 |
+
)}
|
642 |
+
</div>
|
643 |
+
))}
|
644 |
+
</div>
|
645 |
+
)}
|
646 |
+
</div>
|
647 |
+
)
|
648 |
+
})}
|
649 |
+
|
650 |
+
{noItems.map((it) => (
|
651 |
+
<div key={it.questionId} className="border rounded-lg p-4">
|
652 |
+
<div className="flex items-center gap-2 mb-2 justify-between">
|
653 |
+
<div className="flex items-center gap-3">
|
654 |
+
<span className="font-medium">{it.questionId}:</span>
|
655 |
+
<div className="text-sm">{it.qText}</div>
|
656 |
+
</div>
|
657 |
+
<div>
|
658 |
+
<span className="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium bg-red-100 text-red-700">no</span>
|
659 |
+
</div>
|
660 |
+
</div>
|
661 |
+
</div>
|
662 |
+
))}
|
663 |
+
|
664 |
+
{naItems.length > 0 && (
|
665 |
+
<div className="border rounded-lg p-3">
|
666 |
+
<div className="flex items-center justify-between">
|
667 |
+
<div className="font-medium">Not applicable ({naItems.length})</div>
|
668 |
+
<button onClick={() => toggleNegatives(`proc-na-${categoryId}`)} className="text-sm text-primary underline">
|
669 |
+
{expandedNegatives[`proc-na-${categoryId}`] ? "Hide" : "Show"}
|
670 |
+
</button>
|
671 |
+
</div>
|
672 |
+
|
673 |
+
{expandedNegatives[`proc-na-${categoryId}`] && (
|
674 |
+
<div className="mt-3 space-y-2">
|
675 |
+
{naItems.map((it) => (
|
676 |
+
<div key={it.questionId} className="p-2 bg-muted rounded">
|
677 |
+
<div className="flex items-center justify-between">
|
678 |
+
<div className="text-sm">
|
679 |
+
<span className="font-medium">{it.questionId}:</span> {it.qText}
|
680 |
+
</div>
|
681 |
+
<div className="text-xs text-muted-foreground">Reason: {it.reason}</div>
|
682 |
+
</div>
|
683 |
+
</div>
|
684 |
+
))}
|
685 |
+
</div>
|
686 |
+
)}
|
687 |
+
</div>
|
688 |
+
)}
|
689 |
+
</>
|
690 |
+
)
|
691 |
+
})()}
|
692 |
+
</div>
|
693 |
+
</div>
|
694 |
+
)}
|
695 |
+
|
696 |
+
{/* Additional Aspects */}
|
697 |
+
{data.additionalAspects && (
|
698 |
+
<div>
|
699 |
+
<h4 className="font-semibold mb-3">Part C: Additional Aspects</h4>
|
700 |
+
<div className="p-4 bg-muted rounded-lg">
|
701 |
+
<p className="text-sm">{data.additionalAspects}</p>
|
702 |
+
</div>
|
703 |
+
</div>
|
704 |
+
)}
|
705 |
+
</CardContent>
|
706 |
+
</Card>
|
707 |
+
)
|
708 |
+
})}
|
709 |
+
</div>
|
710 |
+
)
|
711 |
+
}
|
app/evaluation/[id]/page.tsx
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export { generateStaticParams } from "./generateStaticParams"
|
2 |
+
|
3 |
+
import ClientPage from "./page.client"
|
4 |
+
|
5 |
+
export default function PageWrapper() {
|
6 |
+
return <ClientPage />
|
7 |
+
}
|
8 |
+
|
9 |
+
|
app/evaluation/[id]/server.ts
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
export { generateStaticParams } from "./generateStaticParams";
|
app/globals.css
ADDED
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
@import "tailwindcss";
|
2 |
+
@import "tw-animate-css";
|
3 |
+
|
4 |
+
@custom-variant dark (&:is(.dark *));
|
5 |
+
|
6 |
+
:root {
|
7 |
+
--background: oklch(1 0 0); /* #ffffff - Clean white background */
|
8 |
+
--foreground: oklch(0.205 0 0); /* #1f2937 - Dark gray for main text */
|
9 |
+
--card: oklch(0.97 0 0); /* #f1f5f9 - Light gray for cards */
|
10 |
+
--card-foreground: oklch(0.439 0 0); /* #6b7280 - Mid-tone gray for card text */
|
11 |
+
--popover: oklch(1 0 0); /* #ffffff - White for popovers */
|
12 |
+
--popover-foreground: oklch(0.205 0 0); /* #1f2937 - Dark gray for popover text */
|
13 |
+
--primary: oklch(0.205 0 0); /* #1f2937 - Primary dark gray */
|
14 |
+
--primary-foreground: oklch(1 0 0); /* #ffffff - White text on primary */
|
15 |
+
--secondary: oklch(0.646 0.222 280.116); /* #8b5cf6 - Purple accent */
|
16 |
+
--secondary-foreground: oklch(1 0 0); /* #ffffff - White text on accent */
|
17 |
+
--muted: oklch(0.97 0 0); /* #f1f5f9 - Muted light gray */
|
18 |
+
--muted-foreground: oklch(0.439 0 0); /* #6b7280 - Muted text color */
|
19 |
+
--accent: oklch(0.646 0.222 280.116); /* #8b5cf6 - Purple for interactive elements */
|
20 |
+
--accent-foreground: oklch(1 0 0); /* #ffffff - White text on accent */
|
21 |
+
--destructive: oklch(0.577 0.245 27.325); /* #dc2626 - Red for errors */
|
22 |
+
--destructive-foreground: oklch(1 0 0); /* #ffffff - White text on destructive */
|
23 |
+
--border: oklch(0.922 0 0); /* #e5e7eb - Light gray borders */
|
24 |
+
--input: oklch(0.985 0 0); /* #f9fafb - Very light gray for inputs */
|
25 |
+
--ring: oklch(0.646 0.222 280.116 / 0.5); /* Purple focus ring with opacity */
|
26 |
+
--chart-1: oklch(0.488 0.243 264.376); /* #4f46e5 - Indigo for charts */
|
27 |
+
--chart-2: oklch(0.6 0.118 184.704); /* #3b82f6 - Blue for charts */
|
28 |
+
--chart-3: oklch(0.696 0.17 162.48); /* #22c55e - Green for charts */
|
29 |
+
--chart-4: oklch(0.828 0.189 84.429); /* #fbbf24 - Yellow for charts */
|
30 |
+
--chart-5: oklch(0.627 0.265 303.9); /* #ef4444 - Red for charts */
|
31 |
+
--radius: 0.5rem; /* Consistent border radius */
|
32 |
+
--sidebar: oklch(0.97 0 0); /* #f1f5f9 - Light gray sidebar */
|
33 |
+
--sidebar-foreground: oklch(0.205 0 0); /* #1f2937 - Dark gray sidebar text */
|
34 |
+
--sidebar-primary: oklch(0.205 0 0); /* #1f2937 - Primary sidebar color */
|
35 |
+
--sidebar-primary-foreground: oklch(1 0 0); /* #ffffff - White text on sidebar primary */
|
36 |
+
--sidebar-accent: oklch(0.646 0.222 280.116); /* #8b5cf6 - Purple sidebar accent */
|
37 |
+
--sidebar-accent-foreground: oklch(1 0 0); /* #ffffff - White text on sidebar accent */
|
38 |
+
--sidebar-border: oklch(0.922 0 0); /* #e5e7eb - Light gray sidebar borders */
|
39 |
+
--sidebar-ring: oklch(0.646 0.222 280.116 / 0.5); /* Purple sidebar focus ring */
|
40 |
+
--font-heading: var(--font-space-grotesk);
|
41 |
+
--font-sans: var(--font-dm-sans);
|
42 |
+
}
|
43 |
+
|
44 |
+
.dark {
|
45 |
+
--background: oklch(0.145 0 0);
|
46 |
+
--foreground: oklch(0.985 0 0);
|
47 |
+
--card: oklch(0.145 0 0);
|
48 |
+
--card-foreground: oklch(0.985 0 0);
|
49 |
+
--popover: oklch(0.145 0 0);
|
50 |
+
--popover-foreground: oklch(0.985 0 0);
|
51 |
+
--primary: oklch(0.985 0 0);
|
52 |
+
--primary-foreground: oklch(0.205 0 0);
|
53 |
+
--secondary: oklch(0.269 0 0);
|
54 |
+
--secondary-foreground: oklch(0.985 0 0);
|
55 |
+
--muted: oklch(0.269 0 0);
|
56 |
+
--muted-foreground: oklch(0.708 0 0);
|
57 |
+
--accent: oklch(0.269 0 0);
|
58 |
+
--accent-foreground: oklch(0.985 0 0);
|
59 |
+
--destructive: oklch(0.396 0.141 25.723);
|
60 |
+
--destructive-foreground: oklch(0.637 0.237 25.331);
|
61 |
+
--border: oklch(0.269 0 0);
|
62 |
+
--input: oklch(0.269 0 0);
|
63 |
+
--ring: oklch(0.439 0 0);
|
64 |
+
--chart-1: oklch(0.488 0.243 264.376);
|
65 |
+
--chart-2: oklch(0.696 0.17 162.48);
|
66 |
+
--chart-3: oklch(0.769 0.188 70.08);
|
67 |
+
--chart-4: oklch(0.627 0.265 303.9);
|
68 |
+
--chart-5: oklch(0.645 0.246 16.439);
|
69 |
+
--sidebar: oklch(0.205 0 0);
|
70 |
+
--sidebar-foreground: oklch(0.985 0 0);
|
71 |
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
72 |
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
73 |
+
--sidebar-accent: oklch(0.269 0 0);
|
74 |
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
75 |
+
--sidebar-border: oklch(0.269 0 0);
|
76 |
+
--sidebar-ring: oklch(0.439 0 0);
|
77 |
+
}
|
78 |
+
|
79 |
+
@theme inline {
|
80 |
+
--color-background: var(--background);
|
81 |
+
--color-foreground: var(--foreground);
|
82 |
+
--color-card: var(--card);
|
83 |
+
--color-card-foreground: var(--card-foreground);
|
84 |
+
--color-popover: var(--popover);
|
85 |
+
--color-popover-foreground: var(--popover-foreground);
|
86 |
+
--color-primary: var(--primary);
|
87 |
+
--color-primary-foreground: var(--primary-foreground);
|
88 |
+
--color-secondary: var(--secondary);
|
89 |
+
--color-secondary-foreground: var(--secondary-foreground);
|
90 |
+
--color-muted: var(--muted);
|
91 |
+
--color-muted-foreground: var(--muted-foreground);
|
92 |
+
--color-accent: var(--accent);
|
93 |
+
--color-accent-foreground: var(--accent-foreground);
|
94 |
+
--color-destructive: var(--destructive);
|
95 |
+
--color-destructive-foreground: var(--destructive-foreground);
|
96 |
+
--color-border: var(--border);
|
97 |
+
--color-input: var(--input);
|
98 |
+
--color-ring: var(--ring);
|
99 |
+
--color-chart-1: var(--chart-1);
|
100 |
+
--color-chart-2: var(--chart-2);
|
101 |
+
--color-chart-3: var(--chart-3);
|
102 |
+
--color-chart-4: var(--chart-4);
|
103 |
+
--color-chart-5: var(--chart-5);
|
104 |
+
--radius-sm: calc(var(--radius) - 4px);
|
105 |
+
--radius-md: calc(var(--radius) - 2px);
|
106 |
+
--radius-lg: var(--radius);
|
107 |
+
--radius-xl: calc(var(--radius) + 4px);
|
108 |
+
--color-sidebar: var(--sidebar);
|
109 |
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
110 |
+
--color-sidebar-primary: var(--sidebar-primary);
|
111 |
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
112 |
+
--color-sidebar-accent: var(--sidebar-accent);
|
113 |
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
114 |
+
--color-sidebar-border: var(--sidebar-border);
|
115 |
+
--color-sidebar-ring: var(--sidebar-ring);
|
116 |
+
--font-heading: var(--font-space-grotesk);
|
117 |
+
--font-sans: var(--font-dm-sans);
|
118 |
+
}
|
119 |
+
|
120 |
+
@layer base {
|
121 |
+
* {
|
122 |
+
@apply border-border outline-ring/50;
|
123 |
+
}
|
124 |
+
body {
|
125 |
+
@apply bg-background text-foreground;
|
126 |
+
}
|
127 |
+
}
|
app/layout.tsx
ADDED
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type React from "react"
|
2 |
+
import type { Metadata } from "next"
|
3 |
+
import { Space_Grotesk, DM_Sans } from "next/font/google"
|
4 |
+
import "./globals.css"
|
5 |
+
import { ThemeProvider } from "@/components/theme-provider"
|
6 |
+
|
7 |
+
const spaceGrotesk = Space_Grotesk({
|
8 |
+
subsets: ["latin"],
|
9 |
+
display: "swap",
|
10 |
+
variable: "--font-space-grotesk",
|
11 |
+
})
|
12 |
+
|
13 |
+
const dmSans = DM_Sans({
|
14 |
+
subsets: ["latin"],
|
15 |
+
display: "swap",
|
16 |
+
variable: "--font-dm-sans",
|
17 |
+
})
|
18 |
+
|
19 |
+
export const metadata: Metadata = {
|
20 |
+
title: "AI Evaluation Dashboard",
|
21 |
+
description: "Professional AI system evaluation and assessment tool",
|
22 |
+
generator: "v0.app",
|
23 |
+
}
|
24 |
+
|
25 |
+
export default function RootLayout({
|
26 |
+
children,
|
27 |
+
}: Readonly<{
|
28 |
+
children: React.ReactNode
|
29 |
+
}>) {
|
30 |
+
return (
|
31 |
+
<html lang="en" className={`${spaceGrotesk.variable} ${dmSans.variable} antialiased`}>
|
32 |
+
<body className="font-sans">
|
33 |
+
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
34 |
+
{children}
|
35 |
+
</ThemeProvider>
|
36 |
+
</body>
|
37 |
+
</html>
|
38 |
+
)
|
39 |
+
}
|
app/page.tsx
ADDED
@@ -0,0 +1,566 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import { useState, useMemo, useEffect } from "react"
|
4 |
+
import { Button } from "@/components/ui/button"
|
5 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
6 |
+
import { Plus, Moon, Sun, Filter, ArrowUpDown } from "lucide-react"
|
7 |
+
import { useTheme } from "next-themes"
|
8 |
+
import { EvaluationCard, type EvaluationCardData } from "@/components/evaluation-card"
|
9 |
+
import { BENCHMARK_QUESTIONS, PROCESS_QUESTIONS } from "@/lib/category-data"
|
10 |
+
import { AIEvaluationDashboard } from "@/components/ai-evaluation-dashboard"
|
11 |
+
|
12 |
+
const loadEvaluationData = async (): Promise<EvaluationCardData[]> => {
|
13 |
+
const evaluationFiles = [
|
14 |
+
"/evaluations/gpt-4-turbo.json",
|
15 |
+
"/evaluations/claude-3-sonnet.json",
|
16 |
+
"/evaluations/gemini-pro.json",
|
17 |
+
"/evaluations/fraud-detector.json",
|
18 |
+
]
|
19 |
+
|
20 |
+
const additionalFiles = []
|
21 |
+
for (let i = 1; i <= 10; i++) {
|
22 |
+
additionalFiles.push(`/evaluations/eval-${Date.now() - i * 86400000}.json`) // Check for files from last 10 days
|
23 |
+
}
|
24 |
+
|
25 |
+
const allFiles = [...evaluationFiles, ...additionalFiles]
|
26 |
+
const evaluations: EvaluationCardData[] = []
|
27 |
+
|
28 |
+
for (const file of allFiles) {
|
29 |
+
try {
|
30 |
+
const response = await fetch(file)
|
31 |
+
if (!response.ok) continue // Skip files that don't exist
|
32 |
+
|
33 |
+
const data = await response.json()
|
34 |
+
|
35 |
+
const cardData: EvaluationCardData = {
|
36 |
+
id: data.id || `eval-${Date.now()}`,
|
37 |
+
systemName: data.systemName || "Unknown System",
|
38 |
+
provider: data.provider || "Unknown Provider",
|
39 |
+
modality: data.modality || "Unknown",
|
40 |
+
completedDate: data.evaluationDate || new Date().toISOString().split("T")[0],
|
41 |
+
applicableCategories: data.overallStats?.totalApplicable || 0,
|
42 |
+
completedCategories: data.overallStats?.totalApplicable || 0,
|
43 |
+
status:
|
44 |
+
data.overallStats?.strongCategories?.length >= (data.overallStats?.adequateCategories?.length || 0)
|
45 |
+
? "strong"
|
46 |
+
: data.overallStats?.adequateCategories?.length >= (data.overallStats?.weakCategories?.length || 0)
|
47 |
+
? "adequate"
|
48 |
+
: "weak",
|
49 |
+
capabilityEval: {
|
50 |
+
strong: (data.overallStats?.strongCategories || []).filter((cat: string) =>
|
51 |
+
[
|
52 |
+
"language-communication",
|
53 |
+
"social-intelligence",
|
54 |
+
"problem-solving",
|
55 |
+
"creativity-innovation",
|
56 |
+
"learning-memory",
|
57 |
+
"perception-vision",
|
58 |
+
"physical-manipulation",
|
59 |
+
"metacognition",
|
60 |
+
"robotic-intelligence",
|
61 |
+
].includes(cat),
|
62 |
+
).length,
|
63 |
+
adequate: (data.overallStats?.adequateCategories || []).filter((cat: string) =>
|
64 |
+
[
|
65 |
+
"language-communication",
|
66 |
+
"social-intelligence",
|
67 |
+
"problem-solving",
|
68 |
+
"creativity-innovation",
|
69 |
+
"learning-memory",
|
70 |
+
"perception-vision",
|
71 |
+
"physical-manipulation",
|
72 |
+
"metacognition",
|
73 |
+
"robotic-intelligence",
|
74 |
+
].includes(cat),
|
75 |
+
).length,
|
76 |
+
weak: (data.overallStats?.weakCategories || []).filter((cat: string) =>
|
77 |
+
[
|
78 |
+
"language-communication",
|
79 |
+
"social-intelligence",
|
80 |
+
"problem-solving",
|
81 |
+
"creativity-innovation",
|
82 |
+
"learning-memory",
|
83 |
+
"perception-vision",
|
84 |
+
"physical-manipulation",
|
85 |
+
"metacognition",
|
86 |
+
"robotic-intelligence",
|
87 |
+
].includes(cat),
|
88 |
+
).length,
|
89 |
+
insufficient: (data.overallStats?.insufficientCategories || []).filter((cat: string) =>
|
90 |
+
[
|
91 |
+
"language-communication",
|
92 |
+
"social-intelligence",
|
93 |
+
"problem-solving",
|
94 |
+
"creativity-innovation",
|
95 |
+
"learning-memory",
|
96 |
+
"perception-vision",
|
97 |
+
"physical-manipulation",
|
98 |
+
"metacognition",
|
99 |
+
"robotic-intelligence",
|
100 |
+
].includes(cat),
|
101 |
+
).length,
|
102 |
+
strongCategories: (data.overallStats?.strongCategories || []).filter((cat: string) =>
|
103 |
+
[
|
104 |
+
"language-communication",
|
105 |
+
"social-intelligence",
|
106 |
+
"problem-solving",
|
107 |
+
"creativity-innovation",
|
108 |
+
"learning-memory",
|
109 |
+
"perception-vision",
|
110 |
+
"physical-manipulation",
|
111 |
+
"metacognition",
|
112 |
+
"robotic-intelligence",
|
113 |
+
].includes(cat),
|
114 |
+
),
|
115 |
+
adequateCategories: (data.overallStats?.adequateCategories || []).filter((cat: string) =>
|
116 |
+
[
|
117 |
+
"language-communication",
|
118 |
+
"social-intelligence",
|
119 |
+
"problem-solving",
|
120 |
+
"creativity-innovation",
|
121 |
+
"learning-memory",
|
122 |
+
"perception-vision",
|
123 |
+
"physical-manipulation",
|
124 |
+
"metacognition",
|
125 |
+
"robotic-intelligence",
|
126 |
+
].includes(cat),
|
127 |
+
),
|
128 |
+
weakCategories: (data.overallStats?.weakCategories || []).filter((cat: string) =>
|
129 |
+
[
|
130 |
+
"language-communication",
|
131 |
+
"social-intelligence",
|
132 |
+
"problem-solving",
|
133 |
+
"creativity-innovation",
|
134 |
+
"learning-memory",
|
135 |
+
"perception-vision",
|
136 |
+
"physical-manipulation",
|
137 |
+
"metacognition",
|
138 |
+
"robotic-intelligence",
|
139 |
+
].includes(cat),
|
140 |
+
),
|
141 |
+
insufficientCategories: (data.overallStats?.insufficientCategories || []).filter((cat: string) =>
|
142 |
+
[
|
143 |
+
"language-communication",
|
144 |
+
"social-intelligence",
|
145 |
+
"problem-solving",
|
146 |
+
"creativity-innovation",
|
147 |
+
"learning-memory",
|
148 |
+
"perception-vision",
|
149 |
+
"physical-manipulation",
|
150 |
+
"metacognition",
|
151 |
+
"robotic-intelligence",
|
152 |
+
].includes(cat),
|
153 |
+
),
|
154 |
+
totalApplicable: data.overallStats?.capabilityApplicable || 0,
|
155 |
+
},
|
156 |
+
riskEval: {
|
157 |
+
strong: (data.overallStats?.strongCategories || []).filter((cat: string) =>
|
158 |
+
[
|
159 |
+
"harmful-content",
|
160 |
+
"information-integrity",
|
161 |
+
"privacy-data",
|
162 |
+
"bias-fairness",
|
163 |
+
"security-robustness",
|
164 |
+
"dangerous-capabilities",
|
165 |
+
"human-ai-interaction",
|
166 |
+
"environmental-impact",
|
167 |
+
"economic-displacement",
|
168 |
+
"governance-accountability",
|
169 |
+
"value-chain",
|
170 |
+
].includes(cat),
|
171 |
+
).length,
|
172 |
+
adequate: (data.overallStats?.adequateCategories || []).filter((cat: string) =>
|
173 |
+
[
|
174 |
+
"harmful-content",
|
175 |
+
"information-integrity",
|
176 |
+
"privacy-data",
|
177 |
+
"bias-fairness",
|
178 |
+
"security-robustness",
|
179 |
+
"dangerous-capabilities",
|
180 |
+
"human-ai-interaction",
|
181 |
+
"environmental-impact",
|
182 |
+
"economic-displacement",
|
183 |
+
"governance-accountability",
|
184 |
+
"value-chain",
|
185 |
+
].includes(cat),
|
186 |
+
).length,
|
187 |
+
weak: (data.overallStats?.weakCategories || []).filter((cat: string) =>
|
188 |
+
[
|
189 |
+
"harmful-content",
|
190 |
+
"information-integrity",
|
191 |
+
"privacy-data",
|
192 |
+
"bias-fairness",
|
193 |
+
"security-robustness",
|
194 |
+
"dangerous-capabilities",
|
195 |
+
"human-ai-interaction",
|
196 |
+
"environmental-impact",
|
197 |
+
"economic-displacement",
|
198 |
+
"governance-accountability",
|
199 |
+
"value-chain",
|
200 |
+
].includes(cat),
|
201 |
+
).length,
|
202 |
+
insufficient: (data.overallStats?.insufficientCategories || []).filter((cat: string) =>
|
203 |
+
[
|
204 |
+
"harmful-content",
|
205 |
+
"information-integrity",
|
206 |
+
"privacy-data",
|
207 |
+
"bias-fairness",
|
208 |
+
"security-robustness",
|
209 |
+
"dangerous-capabilities",
|
210 |
+
"human-ai-interaction",
|
211 |
+
"environmental-impact",
|
212 |
+
"economic-displacement",
|
213 |
+
"governance-accountability",
|
214 |
+
"value-chain",
|
215 |
+
].includes(cat),
|
216 |
+
).length,
|
217 |
+
strongCategories: (data.overallStats?.strongCategories || []).filter((cat: string) =>
|
218 |
+
[
|
219 |
+
"harmful-content",
|
220 |
+
"information-integrity",
|
221 |
+
"privacy-data",
|
222 |
+
"bias-fairness",
|
223 |
+
"security-robustness",
|
224 |
+
"dangerous-capabilities",
|
225 |
+
"human-ai-interaction",
|
226 |
+
"environmental-impact",
|
227 |
+
"economic-displacement",
|
228 |
+
"governance-accountability",
|
229 |
+
"value-chain",
|
230 |
+
].includes(cat),
|
231 |
+
),
|
232 |
+
adequateCategories: (data.overallStats?.adequateCategories || []).filter((cat: string) =>
|
233 |
+
[
|
234 |
+
"harmful-content",
|
235 |
+
"information-integrity",
|
236 |
+
"privacy-data",
|
237 |
+
"bias-fairness",
|
238 |
+
"security-robustness",
|
239 |
+
"dangerous-capabilities",
|
240 |
+
"human-ai-interaction",
|
241 |
+
"environmental-impact",
|
242 |
+
"economic-displacement",
|
243 |
+
"governance-accountability",
|
244 |
+
"value-chain",
|
245 |
+
].includes(cat),
|
246 |
+
),
|
247 |
+
weakCategories: (data.overallStats?.weakCategories || []).filter((cat: string) =>
|
248 |
+
[
|
249 |
+
"harmful-content",
|
250 |
+
"information-integrity",
|
251 |
+
"privacy-data",
|
252 |
+
"bias-fairness",
|
253 |
+
"security-robustness",
|
254 |
+
"dangerous-capabilities",
|
255 |
+
"human-ai-interaction",
|
256 |
+
"environmental-impact",
|
257 |
+
"economic-displacement",
|
258 |
+
"governance-accountability",
|
259 |
+
"value-chain",
|
260 |
+
].includes(cat),
|
261 |
+
),
|
262 |
+
insufficientCategories: (data.overallStats?.insufficientCategories || []).filter((cat: string) =>
|
263 |
+
[
|
264 |
+
"harmful-content",
|
265 |
+
"information-integrity",
|
266 |
+
"privacy-data",
|
267 |
+
"bias-fairness",
|
268 |
+
"security-robustness",
|
269 |
+
"dangerous-capabilities",
|
270 |
+
"human-ai-interaction",
|
271 |
+
"environmental-impact",
|
272 |
+
"economic-displacement",
|
273 |
+
"governance-accountability",
|
274 |
+
"value-chain",
|
275 |
+
].includes(cat),
|
276 |
+
),
|
277 |
+
totalApplicable: data.overallStats?.riskApplicable || 0,
|
278 |
+
},
|
279 |
+
priorityAreas: data.overallStats?.priorityAreas || [],
|
280 |
+
priorityDetails: (() => {
|
281 |
+
// Build a richer structure: for each area, include yes questions and negative questions (no/na) with optional reason
|
282 |
+
const pd: Record<
|
283 |
+
string,
|
284 |
+
{
|
285 |
+
yes: string[]
|
286 |
+
negative: { text: string; status: "no" | "na"; reason?: string }[]
|
287 |
+
}
|
288 |
+
> = {}
|
289 |
+
const areas = data.overallStats?.priorityAreas || []
|
290 |
+
for (const area of areas) {
|
291 |
+
const catEval = data.categoryEvaluations?.[area]
|
292 |
+
if (!catEval) continue
|
293 |
+
|
294 |
+
const yesList: string[] = []
|
295 |
+
const negList: { text: string; status: "no" | "na"; reason?: string }[] = []
|
296 |
+
|
297 |
+
// Helper to detect NA reason from category metadata
|
298 |
+
const naReasonFromMeta = (): string | undefined => {
|
299 |
+
if (typeof catEval.additionalAspects === "string" && /not applicable/i.test(catEval.additionalAspects)) {
|
300 |
+
return catEval.additionalAspects
|
301 |
+
}
|
302 |
+
// look into processSources scopes for any note
|
303 |
+
if (catEval.processSources) {
|
304 |
+
for (const entries of Object.values(catEval.processSources)) {
|
305 |
+
if (Array.isArray(entries)) {
|
306 |
+
for (const ent of entries as any[]) {
|
307 |
+
if (ent && typeof ent.scope === "string" && /not applicable/i.test(ent.scope)) {
|
308 |
+
return ent.scope
|
309 |
+
}
|
310 |
+
}
|
311 |
+
}
|
312 |
+
}
|
313 |
+
}
|
314 |
+
return undefined
|
315 |
+
}
|
316 |
+
|
317 |
+
const naMeta = naReasonFromMeta()
|
318 |
+
|
319 |
+
// check benchmarkAnswers (A1..A6)
|
320 |
+
if (catEval.benchmarkAnswers) {
|
321 |
+
for (const [qid, ans] of Object.entries(catEval.benchmarkAnswers)) {
|
322 |
+
const answer = ans
|
323 |
+
const isArray = Array.isArray(answer)
|
324 |
+
const negative = answer === "no" || (isArray && (answer as any[]).includes("no"))
|
325 |
+
const positive = answer === "yes" || (isArray && (answer as any[]).includes("yes"))
|
326 |
+
const qText = BENCHMARK_QUESTIONS.find((x) => x.id === qid)?.text || qid
|
327 |
+
if (positive) yesList.push(qText)
|
328 |
+
if (negative) {
|
329 |
+
const status = naMeta ? "na" : "no"
|
330 |
+
negList.push({ text: qText, status, reason: naMeta })
|
331 |
+
}
|
332 |
+
}
|
333 |
+
}
|
334 |
+
|
335 |
+
// check processAnswers (B1..B6)
|
336 |
+
if (catEval.processAnswers) {
|
337 |
+
for (const [qid, ans] of Object.entries(catEval.processAnswers)) {
|
338 |
+
const answer = ans
|
339 |
+
const isArray = Array.isArray(answer)
|
340 |
+
const negative = answer === "no" || (isArray && (answer as any[]).includes("no"))
|
341 |
+
const positive = answer === "yes" || (isArray && (answer as any[]).includes("yes"))
|
342 |
+
const qText = PROCESS_QUESTIONS.find((x) => x.id === qid)?.text || qid
|
343 |
+
if (positive) yesList.push(qText)
|
344 |
+
if (negative) {
|
345 |
+
const status = naMeta ? "na" : "no"
|
346 |
+
negList.push({ text: qText, status, reason: naMeta })
|
347 |
+
}
|
348 |
+
}
|
349 |
+
}
|
350 |
+
|
351 |
+
if (yesList.length || negList.length) pd[area] = { yes: yesList, negative: negList }
|
352 |
+
}
|
353 |
+
return pd
|
354 |
+
})(),
|
355 |
+
}
|
356 |
+
|
357 |
+
evaluations.push(cardData)
|
358 |
+
} catch (error) {
|
359 |
+
continue
|
360 |
+
}
|
361 |
+
}
|
362 |
+
|
363 |
+
return evaluations
|
364 |
+
}
|
365 |
+
|
366 |
+
export default function HomePage() {
|
367 |
+
const { theme, setTheme } = useTheme()
|
368 |
+
const [showNewEvaluation, setShowNewEvaluation] = useState(false)
|
369 |
+
const [evaluationsData, setEvaluationsData] = useState<EvaluationCardData[]>([])
|
370 |
+
const [loading, setLoading] = useState(true)
|
371 |
+
|
372 |
+
useEffect(() => {
|
373 |
+
const loadData = async () => {
|
374 |
+
const data = await loadEvaluationData()
|
375 |
+
setEvaluationsData(data)
|
376 |
+
setLoading(false)
|
377 |
+
}
|
378 |
+
loadData()
|
379 |
+
}, [])
|
380 |
+
|
381 |
+
const [sortBy, setSortBy] = useState<"date-newest" | "date-oldest">("date-newest")
|
382 |
+
const [filterByProvider, setFilterByProvider] = useState<string>("all")
|
383 |
+
const [filterByModality, setFilterByModality] = useState<string>("all")
|
384 |
+
|
385 |
+
const uniqueProviders = useMemo(() => {
|
386 |
+
const providers = [...new Set(evaluationsData.map((item) => item.provider))].sort()
|
387 |
+
return providers
|
388 |
+
}, [evaluationsData])
|
389 |
+
|
390 |
+
const uniqueModalities = useMemo(() => {
|
391 |
+
const modalities = [...new Set(evaluationsData.map((item) => item.modality))].sort()
|
392 |
+
return modalities
|
393 |
+
}, [evaluationsData])
|
394 |
+
|
395 |
+
const filteredAndSortedEvaluations = useMemo(() => {
|
396 |
+
let filtered = evaluationsData
|
397 |
+
|
398 |
+
if (filterByProvider !== "all") {
|
399 |
+
filtered = filtered.filter((item) => item.provider === filterByProvider)
|
400 |
+
}
|
401 |
+
|
402 |
+
if (filterByModality !== "all") {
|
403 |
+
filtered = filtered.filter((item) => item.modality === filterByModality)
|
404 |
+
}
|
405 |
+
|
406 |
+
filtered = [...filtered].sort((a, b) => {
|
407 |
+
const dateA = new Date(a.completedDate)
|
408 |
+
const dateB = new Date(b.completedDate)
|
409 |
+
|
410 |
+
if (sortBy === "date-newest") {
|
411 |
+
return dateB.getTime() - dateA.getTime()
|
412 |
+
} else {
|
413 |
+
return dateA.getTime() - dateB.getTime()
|
414 |
+
}
|
415 |
+
})
|
416 |
+
|
417 |
+
return filtered
|
418 |
+
}, [evaluationsData, sortBy, filterByProvider, filterByModality])
|
419 |
+
|
420 |
+
const handleViewEvaluation = (id: string) => {}
|
421 |
+
|
422 |
+
const handleDeleteEvaluation = (id: string) => {
|
423 |
+
setEvaluationsData((prev) => prev.filter((evaluation) => evaluation.id !== id))
|
424 |
+
}
|
425 |
+
|
426 |
+
const handleSaveEvaluation = (newEvaluation: EvaluationCardData) => {
|
427 |
+
setEvaluationsData((prev) => [newEvaluation, ...prev])
|
428 |
+
}
|
429 |
+
|
430 |
+
if (showNewEvaluation) {
|
431 |
+
return <AIEvaluationDashboard onBack={() => setShowNewEvaluation(false)} onSaveEvaluation={handleSaveEvaluation} />
|
432 |
+
}
|
433 |
+
|
434 |
+
if (loading) {
|
435 |
+
return (
|
436 |
+
<div className="min-h-screen bg-background flex items-center justify-center">
|
437 |
+
<div className="text-center">
|
438 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
439 |
+
<p className="text-muted-foreground">Loading evaluations...</p>
|
440 |
+
</div>
|
441 |
+
</div>
|
442 |
+
)
|
443 |
+
}
|
444 |
+
|
445 |
+
return (
|
446 |
+
<div className="min-h-screen bg-background">
|
447 |
+
<header className="border-b bg-card">
|
448 |
+
<div className="container mx-auto px-6 py-4">
|
449 |
+
<div className="flex items-center justify-between">
|
450 |
+
<div>
|
451 |
+
<h1 className="text-2xl font-bold font-heading text-foreground">AI Evaluation Dashboard</h1>
|
452 |
+
<p className="text-sm text-muted-foreground">Manage and track your AI system evaluations</p>
|
453 |
+
</div>
|
454 |
+
<div className="flex items-center gap-3">
|
455 |
+
<Button
|
456 |
+
variant="ghost"
|
457 |
+
size="sm"
|
458 |
+
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
459 |
+
className="h-9 w-9 p-0"
|
460 |
+
>
|
461 |
+
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
462 |
+
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
463 |
+
<span className="sr-only">Toggle theme</span>
|
464 |
+
</Button>
|
465 |
+
<Button onClick={() => setShowNewEvaluation(true)} className="gap-2">
|
466 |
+
<Plus className="h-4 w-4" />
|
467 |
+
New Eval Card
|
468 |
+
</Button>
|
469 |
+
</div>
|
470 |
+
</div>
|
471 |
+
</div>
|
472 |
+
</header>
|
473 |
+
|
474 |
+
<div className="container mx-auto px-6 py-6">
|
475 |
+
<div className="space-y-6">
|
476 |
+
<div className="flex items-center justify-between">
|
477 |
+
<h2 className="text-xl font-semibold font-heading">Evaluation Cards</h2>
|
478 |
+
<p className="text-sm text-muted-foreground">{filteredAndSortedEvaluations.length} eval cards</p>
|
479 |
+
</div>
|
480 |
+
|
481 |
+
<div className="flex flex-wrap items-center gap-4 p-4 bg-card rounded-lg border">
|
482 |
+
<div className="flex items-center gap-2">
|
483 |
+
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
|
484 |
+
<span className="text-sm font-medium">Sort by:</span>
|
485 |
+
<Select value={sortBy} onValueChange={(value: "date-newest" | "date-oldest") => setSortBy(value)}>
|
486 |
+
<SelectTrigger className="w-40">
|
487 |
+
<SelectValue />
|
488 |
+
</SelectTrigger>
|
489 |
+
<SelectContent>
|
490 |
+
<SelectItem value="date-newest">Date (Newest)</SelectItem>
|
491 |
+
<SelectItem value="date-oldest">Date (Oldest)</SelectItem>
|
492 |
+
</SelectContent>
|
493 |
+
</Select>
|
494 |
+
</div>
|
495 |
+
|
496 |
+
<div className="flex items-center gap-2">
|
497 |
+
<Filter className="h-4 w-4 text-muted-foreground" />
|
498 |
+
<span className="text-sm font-medium">Provider:</span>
|
499 |
+
<Select value={filterByProvider} onValueChange={setFilterByProvider}>
|
500 |
+
<SelectTrigger className="w-40">
|
501 |
+
<SelectValue />
|
502 |
+
</SelectTrigger>
|
503 |
+
<SelectContent>
|
504 |
+
<SelectItem value="all">All Providers</SelectItem>
|
505 |
+
{uniqueProviders.map((provider) => (
|
506 |
+
<SelectItem key={provider} value={provider}>
|
507 |
+
{provider}
|
508 |
+
</SelectItem>
|
509 |
+
))}
|
510 |
+
</SelectContent>
|
511 |
+
</Select>
|
512 |
+
</div>
|
513 |
+
|
514 |
+
<div className="flex items-center gap-2">
|
515 |
+
<Filter className="h-4 w-4 text-muted-foreground" />
|
516 |
+
<span className="text-sm font-medium">Modality:</span>
|
517 |
+
<Select value={filterByModality} onValueChange={setFilterByModality}>
|
518 |
+
<SelectTrigger className="w-40">
|
519 |
+
<SelectValue />
|
520 |
+
</SelectTrigger>
|
521 |
+
<SelectContent>
|
522 |
+
<SelectItem value="all">All Modalities</SelectItem>
|
523 |
+
{uniqueModalities.map((modality) => (
|
524 |
+
<SelectItem key={modality} value={modality}>
|
525 |
+
{modality}
|
526 |
+
</SelectItem>
|
527 |
+
))}
|
528 |
+
</SelectContent>
|
529 |
+
</Select>
|
530 |
+
</div>
|
531 |
+
</div>
|
532 |
+
|
533 |
+
{filteredAndSortedEvaluations.length > 0 ? (
|
534 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
535 |
+
{filteredAndSortedEvaluations.map((evaluation) => (
|
536 |
+
<EvaluationCard
|
537 |
+
key={evaluation.id}
|
538 |
+
evaluation={evaluation}
|
539 |
+
onView={handleViewEvaluation}
|
540 |
+
onDelete={handleDeleteEvaluation}
|
541 |
+
/>
|
542 |
+
))}
|
543 |
+
</div>
|
544 |
+
) : (
|
545 |
+
<div className="text-center py-12">
|
546 |
+
<div className="mx-auto w-24 h-24 bg-muted rounded-full flex items-center justify-center mb-4">
|
547 |
+
<Filter className="h-8 w-8 text-muted-foreground" />
|
548 |
+
</div>
|
549 |
+
<h3 className="text-lg font-semibold mb-2">No evaluations match your filters</h3>
|
550 |
+
<p className="text-muted-foreground mb-4">Try adjusting your filter criteria to see more results</p>
|
551 |
+
<Button
|
552 |
+
variant="outline"
|
553 |
+
onClick={() => {
|
554 |
+
setFilterByProvider("all")
|
555 |
+
setFilterByModality("all")
|
556 |
+
}}
|
557 |
+
>
|
558 |
+
Clear Filters
|
559 |
+
</Button>
|
560 |
+
</div>
|
561 |
+
)}
|
562 |
+
</div>
|
563 |
+
</div>
|
564 |
+
</div>
|
565 |
+
)
|
566 |
+
}
|
components.json
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
3 |
+
"style": "new-york",
|
4 |
+
"rsc": true,
|
5 |
+
"tsx": true,
|
6 |
+
"tailwind": {
|
7 |
+
"config": "",
|
8 |
+
"css": "app/globals.css",
|
9 |
+
"baseColor": "neutral",
|
10 |
+
"cssVariables": true,
|
11 |
+
"prefix": ""
|
12 |
+
},
|
13 |
+
"aliases": {
|
14 |
+
"components": "@/components",
|
15 |
+
"utils": "@/lib/utils",
|
16 |
+
"ui": "@/components/ui",
|
17 |
+
"lib": "@/lib",
|
18 |
+
"hooks": "@/hooks"
|
19 |
+
},
|
20 |
+
"iconLibrary": "lucide"
|
21 |
+
}
|
components/ai-evaluation-dashboard.tsx
ADDED
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import { useState } from "react"
|
4 |
+
import { Progress } from "@/components/ui/progress"
|
5 |
+
import { Badge } from "@/components/ui/badge"
|
6 |
+
import { Button } from "@/components/ui/button"
|
7 |
+
import { ArrowLeft } from "lucide-react"
|
8 |
+
import { SystemInfoForm } from "./system-info-form"
|
9 |
+
import { CategorySelection } from "./category-selection"
|
10 |
+
import { CategoryEvaluation } from "./category-evaluation"
|
11 |
+
import { EvaluationForm } from "./evaluation-form"
|
12 |
+
import { ResultsDashboard } from "./results-dashboard"
|
13 |
+
import { CATEGORIES } from "@/lib/category-data"
|
14 |
+
|
15 |
+
export type SystemInfo = {
|
16 |
+
name: string
|
17 |
+
url: string
|
18 |
+
provider: string
|
19 |
+
systemTypes: string[]
|
20 |
+
deploymentContexts: string[]
|
21 |
+
modality: string
|
22 |
+
modelTag?: string
|
23 |
+
knowledgeCutoff?: string
|
24 |
+
modelType?: "foundational" | "fine-tuned" | "na"
|
25 |
+
inputModalities?: string[]
|
26 |
+
outputModalities?: string[]
|
27 |
+
}
|
28 |
+
|
29 |
+
export type CategoryScore = {
|
30 |
+
benchmarkScore: number
|
31 |
+
processScore: number
|
32 |
+
totalScore: number
|
33 |
+
status: "strong" | "adequate" | "weak" | "insufficient" | "not-evaluated"
|
34 |
+
// optional metadata
|
35 |
+
totalQuestions?: number
|
36 |
+
totalApplicable?: number
|
37 |
+
naCount?: number
|
38 |
+
}
|
39 |
+
|
40 |
+
export type EvaluationData = {
|
41 |
+
systemInfo: SystemInfo | null
|
42 |
+
selectedCategories: string[]
|
43 |
+
excludedCategoryReasons?: Record<string, string>
|
44 |
+
categoryScores: Record<string, CategoryScore>
|
45 |
+
currentCategory: string | null
|
46 |
+
}
|
47 |
+
|
48 |
+
interface AIEvaluationDashboardProps {
|
49 |
+
onBack?: () => void
|
50 |
+
onSaveEvaluation?: (evaluation: any) => void
|
51 |
+
}
|
52 |
+
|
53 |
+
export function AIEvaluationDashboard({ onBack, onSaveEvaluation }: AIEvaluationDashboardProps) {
|
54 |
+
const [currentStep, setCurrentStep] = useState<"system-info" | "categories" | "evaluation" | "results">("system-info")
|
55 |
+
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0)
|
56 |
+
const [evaluationData, setEvaluationData] = useState<EvaluationData>({
|
57 |
+
systemInfo: null,
|
58 |
+
selectedCategories: [],
|
59 |
+
categoryScores: {},
|
60 |
+
currentCategory: null,
|
61 |
+
})
|
62 |
+
|
63 |
+
const steps = [
|
64 |
+
{ id: "system-info", label: "System Info", number: 1 },
|
65 |
+
{ id: "categories", label: "Categories", number: 2 },
|
66 |
+
{ id: "evaluation", label: "Evaluation", number: 3 },
|
67 |
+
{ id: "results", label: "Results", number: 4 },
|
68 |
+
]
|
69 |
+
|
70 |
+
const getOverallProgress = () => {
|
71 |
+
if (currentStep === "system-info") return 10
|
72 |
+
if (currentStep === "categories") return 25
|
73 |
+
if (currentStep === "evaluation") {
|
74 |
+
const completed = Object.keys(evaluationData.categoryScores).length
|
75 |
+
const total = evaluationData.selectedCategories.length
|
76 |
+
return total > 0 ? 25 + (completed / total) * 65 : 25
|
77 |
+
}
|
78 |
+
return 100
|
79 |
+
}
|
80 |
+
|
81 |
+
const handleSystemInfoComplete = (systemInfo: SystemInfo) => {
|
82 |
+
setEvaluationData((prev) => ({ ...prev, systemInfo }))
|
83 |
+
setCurrentStep("categories")
|
84 |
+
}
|
85 |
+
|
86 |
+
const handleCategoriesSelected = (categories: string[]) => {
|
87 |
+
setEvaluationData((prev) => ({ ...prev, selectedCategories: categories }))
|
88 |
+
setCurrentCategoryIndex(0)
|
89 |
+
setCurrentStep("evaluation")
|
90 |
+
}
|
91 |
+
|
92 |
+
const handleCategoriesSelectedWithReasons = (categories: string[], excludedReasons: Record<string, string>) => {
|
93 |
+
setEvaluationData((prev) => ({ ...prev, selectedCategories: categories, excludedCategoryReasons: excludedReasons }))
|
94 |
+
setCurrentCategoryIndex(0)
|
95 |
+
setCurrentStep("evaluation")
|
96 |
+
}
|
97 |
+
|
98 |
+
const handleCategoryComplete = (categoryId: string, score: CategoryScore) => {
|
99 |
+
console.log("[v0] handleCategoryComplete called with:", { categoryId, score })
|
100 |
+
|
101 |
+
setEvaluationData((prev) => {
|
102 |
+
const newCategoryScores = { ...prev.categoryScores, [categoryId]: score }
|
103 |
+
console.log("[v0] Updated categoryScores:", newCategoryScores)
|
104 |
+
return {
|
105 |
+
...prev,
|
106 |
+
categoryScores: newCategoryScores,
|
107 |
+
}
|
108 |
+
})
|
109 |
+
|
110 |
+
const nextIndex = currentCategoryIndex + 1
|
111 |
+
console.log(
|
112 |
+
"[v0] Current index:",
|
113 |
+
currentCategoryIndex,
|
114 |
+
"Next index:",
|
115 |
+
nextIndex,
|
116 |
+
"Total categories:",
|
117 |
+
evaluationData.selectedCategories.length,
|
118 |
+
)
|
119 |
+
|
120 |
+
if (nextIndex >= evaluationData.selectedCategories.length) {
|
121 |
+
console.log("[v0] All categories complete, moving to results")
|
122 |
+
setCurrentStep("results")
|
123 |
+
} else {
|
124 |
+
console.log("[v0] Moving to next category at index:", nextIndex)
|
125 |
+
setCurrentCategoryIndex(nextIndex)
|
126 |
+
}
|
127 |
+
}
|
128 |
+
|
129 |
+
const handleSaveEvaluation = async () => {
|
130 |
+
console.log("[v0] handleSaveEvaluation called")
|
131 |
+
console.log("[v0] evaluationData:", evaluationData)
|
132 |
+
|
133 |
+
if (!evaluationData.systemInfo || evaluationData.selectedCategories.length === 0) {
|
134 |
+
alert("Please complete system information and select categories before saving.")
|
135 |
+
return
|
136 |
+
}
|
137 |
+
|
138 |
+
const timestamp = Date.now()
|
139 |
+
const evaluationId = `eval-${timestamp}`
|
140 |
+
console.log("[v0] Generated evaluationId:", evaluationId)
|
141 |
+
|
142 |
+
console.log("[v0] Processing category scores:", evaluationData.categoryScores)
|
143 |
+
|
144 |
+
const capabilityCategories = evaluationData.selectedCategories.filter((cat) => {
|
145 |
+
const category = CATEGORIES.find((c) => c.id === cat)
|
146 |
+
console.log("[v0] Category check:", cat, "type:", category?.type)
|
147 |
+
return category?.type === "capability"
|
148 |
+
})
|
149 |
+
console.log("[v0] Capability categories:", capabilityCategories)
|
150 |
+
|
151 |
+
const riskCategories = evaluationData.selectedCategories.filter((cat) => {
|
152 |
+
const category = CATEGORIES.find((c) => c.id === cat)
|
153 |
+
return category?.type === "risk"
|
154 |
+
})
|
155 |
+
console.log("[v0] Risk categories:", riskCategories)
|
156 |
+
|
157 |
+
const strongCategories = Object.entries(evaluationData.categoryScores)
|
158 |
+
.filter(([_, score]) => {
|
159 |
+
console.log("[v0] Checking score for strong:", score)
|
160 |
+
return score.status === "strong"
|
161 |
+
})
|
162 |
+
.map(([catId]) => catId)
|
163 |
+
console.log("[v0] Strong categories:", strongCategories)
|
164 |
+
|
165 |
+
const adequateCategories = Object.entries(evaluationData.categoryScores)
|
166 |
+
.filter(([_, score]) => score.status === "adequate")
|
167 |
+
.map(([catId]) => catId)
|
168 |
+
console.log("[v0] Adequate categories:", adequateCategories)
|
169 |
+
|
170 |
+
const weakCategories = Object.entries(evaluationData.categoryScores)
|
171 |
+
.filter(([_, score]) => score.status === "weak")
|
172 |
+
.map(([catId]) => catId)
|
173 |
+
console.log("[v0] Weak categories:", weakCategories)
|
174 |
+
|
175 |
+
const insufficientCategories = Object.entries(evaluationData.categoryScores)
|
176 |
+
.filter(([_, score]) => score.status === "insufficient")
|
177 |
+
.map(([catId]) => catId)
|
178 |
+
console.log("[v0] Insufficient categories:", insufficientCategories)
|
179 |
+
|
180 |
+
const evaluationJson = {
|
181 |
+
id: evaluationId,
|
182 |
+
systemName: evaluationData.systemInfo.name,
|
183 |
+
provider: evaluationData.systemInfo.provider,
|
184 |
+
version: evaluationData.systemInfo.url || "1.0",
|
185 |
+
deploymentContext: evaluationData.systemInfo.deploymentContexts.join(", ") || "Production",
|
186 |
+
evaluator: "Current User",
|
187 |
+
modality: evaluationData.systemInfo.modality,
|
188 |
+
evaluationDate: new Date().toISOString().split("T")[0],
|
189 |
+
selectedCategories: evaluationData.selectedCategories,
|
190 |
+
excludedCategoryReasons: evaluationData.excludedCategoryReasons || {},
|
191 |
+
categoryEvaluations: evaluationData.categoryScores,
|
192 |
+
overallStats: {
|
193 |
+
completenessScore: 85, // Safe default value
|
194 |
+
totalApplicable: evaluationData.selectedCategories.length,
|
195 |
+
capabilityApplicable: capabilityCategories.length,
|
196 |
+
riskApplicable: riskCategories.length,
|
197 |
+
strongCategories,
|
198 |
+
adequateCategories,
|
199 |
+
weakCategories,
|
200 |
+
insufficientCategories,
|
201 |
+
},
|
202 |
+
}
|
203 |
+
|
204 |
+
console.log("[v0] Final evaluationJson:", evaluationJson)
|
205 |
+
|
206 |
+
try {
|
207 |
+
console.log("[v0] Creating blob and download")
|
208 |
+
const blob = new Blob([JSON.stringify(evaluationJson, null, 2)], { type: "application/json" })
|
209 |
+
const url = URL.createObjectURL(blob)
|
210 |
+
const a = document.createElement("a")
|
211 |
+
a.href = url
|
212 |
+
a.download = `${evaluationId}.json`
|
213 |
+
document.body.appendChild(a)
|
214 |
+
a.click()
|
215 |
+
document.body.removeChild(a)
|
216 |
+
URL.revokeObjectURL(url)
|
217 |
+
|
218 |
+
console.log("[v0] Download completed successfully")
|
219 |
+
alert(
|
220 |
+
`Evaluation saved as ${evaluationId}.json. Please upload this file to the public/evaluations/ directory to see it on the homepage.`,
|
221 |
+
)
|
222 |
+
onBack?.()
|
223 |
+
} catch (error) {
|
224 |
+
console.error("[v0] Error saving evaluation:", error)
|
225 |
+
alert("Error saving evaluation. Please try again.")
|
226 |
+
}
|
227 |
+
}
|
228 |
+
|
229 |
+
const renderCurrentStep = () => {
|
230 |
+
switch (currentStep) {
|
231 |
+
case "system-info":
|
232 |
+
return <SystemInfoForm onSubmit={handleSystemInfoComplete} initialData={evaluationData.systemInfo} />
|
233 |
+
case "categories":
|
234 |
+
return (
|
235 |
+
<CategorySelection
|
236 |
+
categories={CATEGORIES}
|
237 |
+
selectedCategories={evaluationData.selectedCategories}
|
238 |
+
onSelectionChange={handleCategoriesSelectedWithReasons}
|
239 |
+
/>
|
240 |
+
)
|
241 |
+
case "evaluation":
|
242 |
+
return (
|
243 |
+
<EvaluationForm
|
244 |
+
categories={CATEGORIES}
|
245 |
+
selectedCategories={evaluationData.selectedCategories}
|
246 |
+
categoryScores={evaluationData.categoryScores}
|
247 |
+
onScoreUpdate={(categoryId, score) => handleCategoryComplete(categoryId, score)}
|
248 |
+
onComplete={() => setCurrentStep("results")}
|
249 |
+
/>
|
250 |
+
)
|
251 |
+
case "results":
|
252 |
+
return (
|
253 |
+
<ResultsDashboard
|
254 |
+
systemInfo={evaluationData.systemInfo}
|
255 |
+
categories={CATEGORIES}
|
256 |
+
selectedCategories={evaluationData.selectedCategories}
|
257 |
+
categoryScores={evaluationData.categoryScores}
|
258 |
+
excludedCategoryReasons={evaluationData.excludedCategoryReasons || {}}
|
259 |
+
/>
|
260 |
+
)
|
261 |
+
default:
|
262 |
+
return null
|
263 |
+
}
|
264 |
+
}
|
265 |
+
|
266 |
+
return (
|
267 |
+
<div className="min-h-screen bg-background flex flex-col">
|
268 |
+
{/* Header */}
|
269 |
+
<header className="border-b bg-card">
|
270 |
+
<div className="container mx-auto px-6 py-4">
|
271 |
+
<div className="flex items-center justify-between">
|
272 |
+
<div className="flex items-center gap-4">
|
273 |
+
{onBack && (
|
274 |
+
<Button variant="ghost" size="sm" onClick={onBack} className="gap-2">
|
275 |
+
<ArrowLeft className="h-4 w-4" />
|
276 |
+
Back
|
277 |
+
</Button>
|
278 |
+
)}
|
279 |
+
<div>
|
280 |
+
<h1 className="text-2xl font-bold font-heading text-foreground">New Eval Card</h1>
|
281 |
+
<p className="text-muted-foreground">Create comprehensive AI system evaluation card</p>
|
282 |
+
</div>
|
283 |
+
</div>
|
284 |
+
<div className="flex items-center gap-4">
|
285 |
+
<div className="text-right">
|
286 |
+
<p className="text-sm text-muted-foreground">Overall Progress</p>
|
287 |
+
<div className="flex items-center gap-2">
|
288 |
+
<Progress value={getOverallProgress()} className="w-24" />
|
289 |
+
<span className="text-sm font-medium">{Math.round(getOverallProgress())}%</span>
|
290 |
+
</div>
|
291 |
+
</div>
|
292 |
+
{evaluationData.systemInfo && (
|
293 |
+
<Badge variant="secondary" className="font-medium">
|
294 |
+
{evaluationData.systemInfo.name}
|
295 |
+
</Badge>
|
296 |
+
)}
|
297 |
+
<Button onClick={handleSaveEvaluation} className="gap-2">
|
298 |
+
Save Eval Card
|
299 |
+
</Button>
|
300 |
+
</div>
|
301 |
+
</div>
|
302 |
+
</div>
|
303 |
+
</header>
|
304 |
+
|
305 |
+
{/* Step tabs navigation */}
|
306 |
+
<div className="border-b bg-card">
|
307 |
+
<div className="container mx-auto px-6">
|
308 |
+
<div className="flex items-center space-x-8">
|
309 |
+
{steps.map((step) => {
|
310 |
+
const isActive = currentStep === step.id
|
311 |
+
const isCompleted =
|
312 |
+
(step.id === "system-info" && evaluationData.systemInfo) ||
|
313 |
+
(step.id === "categories" && evaluationData.selectedCategories.length > 0) ||
|
314 |
+
(step.id === "evaluation" && Object.keys(evaluationData.categoryScores).length > 0) ||
|
315 |
+
(step.id === "results" && currentStep === "results")
|
316 |
+
|
317 |
+
return (
|
318 |
+
<div
|
319 |
+
key={step.id}
|
320 |
+
className={`flex items-center gap-3 py-4 border-b-2 transition-colors ${
|
321 |
+
isActive
|
322 |
+
? "border-primary text-primary"
|
323 |
+
: isCompleted
|
324 |
+
? "border-green-500 text-green-600"
|
325 |
+
: "border-transparent text-muted-foreground"
|
326 |
+
}`}
|
327 |
+
>
|
328 |
+
<div
|
329 |
+
className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium ${
|
330 |
+
isActive
|
331 |
+
? "bg-primary text-primary-foreground"
|
332 |
+
: isCompleted
|
333 |
+
? "bg-green-500 text-white"
|
334 |
+
: "bg-muted text-muted-foreground"
|
335 |
+
}`}
|
336 |
+
>
|
337 |
+
{step.number}
|
338 |
+
</div>
|
339 |
+
<span className="font-medium">{step.label}</span>
|
340 |
+
</div>
|
341 |
+
)
|
342 |
+
})}
|
343 |
+
</div>
|
344 |
+
</div>
|
345 |
+
</div>
|
346 |
+
|
347 |
+
<div className="container mx-auto px-6 py-6 flex-1 min-h-0">{renderCurrentStep()}</div>
|
348 |
+
</div>
|
349 |
+
)
|
350 |
+
}
|
components/category-evaluation.tsx
ADDED
@@ -0,0 +1,934 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import { useState, useEffect, useMemo } from "react"
|
4 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
5 |
+
import { Button } from "@/components/ui/button"
|
6 |
+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
7 |
+
import { Label } from "@/components/ui/label"
|
8 |
+
import { Textarea } from "@/components/ui/textarea"
|
9 |
+
import { Input } from "@/components/ui/input"
|
10 |
+
import { Badge } from "@/components/ui/badge"
|
11 |
+
import { Separator } from "@/components/ui/separator"
|
12 |
+
import type { CategoryScore } from "@/components/ai-evaluation-dashboard"
|
13 |
+
import { HelpCircle, CheckCircle, Plus, Trash2 } from "lucide-react"
|
14 |
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
15 |
+
import { BENCHMARK_QUESTIONS, PROCESS_QUESTIONS, SOURCE_TYPES, ADDITIONAL_ASPECTS_SECTION, getFieldPlaceholder, getHint } from "@/lib/category-data"
|
16 |
+
|
17 |
+
// The detailed per-category and per-question hints, plus recommended placeholders,
|
18 |
+
// are centralized in `lib/category-data.ts`. This component uses the exported
|
19 |
+
// helpers `getHint` and `getFieldPlaceholder` and the question lists.
|
20 |
+
|
21 |
+
const CustomFieldComponent = ({
|
22 |
+
questionId,
|
23 |
+
fieldType,
|
24 |
+
value,
|
25 |
+
onChange,
|
26 |
+
}: {
|
27 |
+
questionId: string
|
28 |
+
fieldType: string
|
29 |
+
value: string
|
30 |
+
onChange: (value: string) => void
|
31 |
+
}) => {
|
32 |
+
const getFieldConfig = (questionId: string, fieldType: string) => {
|
33 |
+
const configs: Record<string, Record<string, { label: string; placeholder: string; type?: string }>> = {
|
34 |
+
A2: {
|
35 |
+
thresholds: { label: "Quantitative Thresholds", placeholder: "e.g., >85% accuracy, <0.1 error rate" },
|
36 |
+
thresholdSource: {
|
37 |
+
label: "Threshold Source",
|
38 |
+
placeholder: "e.g., industry standard, research paper, policy requirement",
|
39 |
+
},
|
40 |
+
passFail: { label: "Pass/Fail Determination", placeholder: "e.g., Pass - exceeded 85% threshold" },
|
41 |
+
},
|
42 |
+
A3: {
|
43 |
+
comparativeScores: {
|
44 |
+
label: "Comparative Scores",
|
45 |
+
placeholder: "e.g., Our model: 87.2%, GPT-4: 85.1%, Previous version: 82.3%",
|
46 |
+
},
|
47 |
+
baselineType: { label: "Baseline Type", placeholder: "e.g., SOTA, previous version, industry standard" },
|
48 |
+
significance: { label: "Statistical Significance", placeholder: "e.g., p<0.05, 95% CI: [1.2, 3.8]" },
|
49 |
+
},
|
50 |
+
A4: {
|
51 |
+
testTypes: { label: "Test Types", placeholder: "e.g., adversarial attacks, load testing, distribution shift" },
|
52 |
+
failureRates: { label: "Failure/Degradation Rates", placeholder: "e.g., 15% failure under adversarial inputs" },
|
53 |
+
robustnessMetrics: {
|
54 |
+
label: "Robustness Metrics",
|
55 |
+
placeholder: "e.g., attack success rate, performance drop %",
|
56 |
+
},
|
57 |
+
},
|
58 |
+
A5: {
|
59 |
+
liveMetrics: { label: "Live Metrics Tracked", placeholder: "e.g., error rates, latency, drift detection" },
|
60 |
+
samplingCadence: { label: "Sampling Cadence", placeholder: "e.g., every 1000 requests, hourly, daily" },
|
61 |
+
alertThresholds: { label: "Alert Thresholds", placeholder: "e.g., >5% error rate, >500ms latency" },
|
62 |
+
},
|
63 |
+
A6: {
|
64 |
+
procedure: {
|
65 |
+
label: "Contamination Check Procedure",
|
66 |
+
placeholder: "e.g., n-gram overlap analysis, URL deduplication",
|
67 |
+
},
|
68 |
+
contaminationRate: {
|
69 |
+
label: "Contamination Rate",
|
70 |
+
placeholder: "e.g., <1% overlap detected, 0.3% exact matches",
|
71 |
+
},
|
72 |
+
mitigations: { label: "Mitigations Taken", placeholder: "e.g., removed overlapping samples, used holdout set" },
|
73 |
+
},
|
74 |
+
A7: {
|
75 |
+
comparisonSystems: { label: "Comparison Systems", placeholder: "e.g., GPT-4, Claude-3, Gemini Pro" },
|
76 |
+
evaluationConditions: {
|
77 |
+
label: "Evaluation Conditions",
|
78 |
+
placeholder: "e.g., same prompts, temperature=0, identical hardware",
|
79 |
+
},
|
80 |
+
relativeMetrics: {
|
81 |
+
label: "Relative Performance Metrics",
|
82 |
+
placeholder: "e.g., 15% better accuracy, 2x faster inference",
|
83 |
+
},
|
84 |
+
},
|
85 |
+
B1: {
|
86 |
+
scope: {
|
87 |
+
label: "Evaluation Scope",
|
88 |
+
placeholder: "e.g., measures reasoning capability in mathematical contexts",
|
89 |
+
},
|
90 |
+
successFailureDefinitions: {
|
91 |
+
label: "Success/Failure Definitions",
|
92 |
+
placeholder: "e.g., success = >80% on grade-level problems",
|
93 |
+
},
|
94 |
+
hypotheses: { label: "Hypotheses Being Tested", placeholder: "e.g., model can solve multi-step word problems" },
|
95 |
+
},
|
96 |
+
B2: {
|
97 |
+
replicationPackage: {
|
98 |
+
label: "Replication Package",
|
99 |
+
placeholder: "e.g., GitHub repo with code, configs, prompts",
|
100 |
+
},
|
101 |
+
accessLevel: { label: "Access Level", placeholder: "e.g., public, access-controlled, internal only" },
|
102 |
+
proxies: { label: "Proxies (if not shareable)", placeholder: "e.g., synthetic examples, anonymized data" },
|
103 |
+
},
|
104 |
+
B5: {
|
105 |
+
reviewers: { label: "Reviewers", placeholder: "e.g., domain experts, affected user groups, ethics board" },
|
106 |
+
feedbackChanges: {
|
107 |
+
label: "Changes from Feedback",
|
108 |
+
placeholder: "e.g., added bias metrics, revised interpretation",
|
109 |
+
},
|
110 |
+
disagreements: {
|
111 |
+
label: "Unresolved Disagreements",
|
112 |
+
placeholder: "e.g., threshold levels, risk severity ratings",
|
113 |
+
},
|
114 |
+
},
|
115 |
+
B6: {
|
116 |
+
uncertaintyDisclosure: {
|
117 |
+
label: "Uncertainty Disclosure",
|
118 |
+
placeholder: "e.g., error bars, confidence intervals, variance across runs",
|
119 |
+
},
|
120 |
+
axesConsistency: { label: "Axes Consistency", placeholder: "e.g., consistent 0-100 scale, no truncated axes" },
|
121 |
+
sampleSizes: { label: "Sample Sizes", placeholder: "e.g., n=1000 test samples, 5 random seeds" },
|
122 |
+
selectionCriteria: { label: "Selection Criteria", placeholder: "e.g., all results shown, no cherry-picking" },
|
123 |
+
},
|
124 |
+
B8: {
|
125 |
+
triggers: {
|
126 |
+
label: "Re-evaluation Triggers",
|
127 |
+
placeholder: "e.g., model updates, data drift >5%, security incidents",
|
128 |
+
},
|
129 |
+
versionedSpecs: { label: "Versioned Eval Specs", placeholder: "e.g., eval spec v2.1, change log maintained" },
|
130 |
+
auditTrail: { label: "Audit Trail", placeholder: "e.g., all changes logged with timestamps and rationale" },
|
131 |
+
mitigationProtocols: {
|
132 |
+
label: "Mitigation Protocols",
|
133 |
+
placeholder: "e.g., automated rollback, manual review process",
|
134 |
+
},
|
135 |
+
retestProcedures: {
|
136 |
+
label: "Retest Procedures",
|
137 |
+
placeholder: "e.g., full eval suite after fixes, regression testing",
|
138 |
+
},
|
139 |
+
},
|
140 |
+
}
|
141 |
+
|
142 |
+
return configs[questionId]?.[fieldType] || { label: fieldType, placeholder: "" }
|
143 |
+
}
|
144 |
+
|
145 |
+
const config = getFieldConfig(questionId, fieldType)
|
146 |
+
|
147 |
+
return (
|
148 |
+
<div>
|
149 |
+
<Label className="text-xs font-medium">{config.label}</Label>
|
150 |
+
<Textarea
|
151 |
+
placeholder={config.placeholder}
|
152 |
+
value={value}
|
153 |
+
onChange={(e) => onChange(e.target.value)}
|
154 |
+
rows={2}
|
155 |
+
className="mt-1"
|
156 |
+
/>
|
157 |
+
</div>
|
158 |
+
)
|
159 |
+
}
|
160 |
+
|
161 |
+
// Local types used by this component (kept minimal for readability)
|
162 |
+
export type Source = {
|
163 |
+
id: string
|
164 |
+
url: string
|
165 |
+
description: string
|
166 |
+
sourceType: string
|
167 |
+
benchmarkName?: string
|
168 |
+
metrics?: string
|
169 |
+
score?: string
|
170 |
+
confidenceInterval?: string
|
171 |
+
version?: string
|
172 |
+
taskVariants?: string
|
173 |
+
customFields: Record<string, string>
|
174 |
+
}
|
175 |
+
|
176 |
+
export type DocumentationSource = {
|
177 |
+
id: string
|
178 |
+
url: string
|
179 |
+
description: string
|
180 |
+
sourceType: string
|
181 |
+
documentType?: string
|
182 |
+
title?: string
|
183 |
+
author?: string
|
184 |
+
organization?: string
|
185 |
+
date?: string
|
186 |
+
customFields: Record<string, string>
|
187 |
+
}
|
188 |
+
|
189 |
+
export type CategoryEvaluationProps = {
|
190 |
+
category: { id: string; name: string; description: string; type: string; detailedGuidance?: string }
|
191 |
+
score?: CategoryScore | null
|
192 |
+
onScoreUpdate: (score: CategoryScore) => void
|
193 |
+
}
|
194 |
+
|
195 |
+
export function CategoryEvaluation({ category, score, onScoreUpdate }: CategoryEvaluationProps) {
|
196 |
+
const [benchmarkAnswers, setBenchmarkAnswers] = useState<Record<string, string>>({})
|
197 |
+
const [processAnswers, setProcessAnswers] = useState<Record<string, string>>({})
|
198 |
+
const [benchmarkSources, setBenchmarkSources] = useState<Record<string, Source[]>>({})
|
199 |
+
const [processSources, setProcessSources] = useState<Record<string, DocumentationSource[]>>({})
|
200 |
+
const [additionalAspects, setAdditionalAspects] = useState<string>("")
|
201 |
+
const [naExplanations, setNaExplanations] = useState<Record<string, string>>({})
|
202 |
+
|
203 |
+
useEffect(() => {
|
204 |
+
if (score) {
|
205 |
+
// This would be populated from saved data in a real implementation
|
206 |
+
// For now, we'll calculate based on the scores
|
207 |
+
}
|
208 |
+
}, [score])
|
209 |
+
|
210 |
+
const addSource = (questionId: string, section: "benchmark" | "process") => {
|
211 |
+
if (section === "benchmark") {
|
212 |
+
const newSource: Source = {
|
213 |
+
id: Date.now().toString(),
|
214 |
+
url: "",
|
215 |
+
description: "",
|
216 |
+
sourceType: "internal",
|
217 |
+
benchmarkName: "",
|
218 |
+
metrics: "",
|
219 |
+
score: "",
|
220 |
+
confidenceInterval: "",
|
221 |
+
version: "",
|
222 |
+
taskVariants: "",
|
223 |
+
customFields: {},
|
224 |
+
}
|
225 |
+
setBenchmarkSources((prev) => ({
|
226 |
+
...prev,
|
227 |
+
[questionId]: [...(prev[questionId] || []), newSource],
|
228 |
+
}))
|
229 |
+
} else {
|
230 |
+
const newDocSource: DocumentationSource = {
|
231 |
+
id: Date.now().toString(),
|
232 |
+
url: "",
|
233 |
+
description: "",
|
234 |
+
sourceType: "internal",
|
235 |
+
documentType: "",
|
236 |
+
title: "",
|
237 |
+
author: "",
|
238 |
+
organization: "",
|
239 |
+
date: "",
|
240 |
+
customFields: {},
|
241 |
+
}
|
242 |
+
setProcessSources((prev) => ({
|
243 |
+
...prev,
|
244 |
+
[questionId]: [...(prev[questionId] || []), newDocSource],
|
245 |
+
}))
|
246 |
+
}
|
247 |
+
}
|
248 |
+
|
249 |
+
const removeSource = (questionId: string, sourceId: string, section: "benchmark" | "process") => {
|
250 |
+
if (section === "benchmark") {
|
251 |
+
setBenchmarkSources((prev) => ({
|
252 |
+
...prev,
|
253 |
+
[questionId]: (prev[questionId] || []).filter((s) => s.id !== sourceId),
|
254 |
+
}))
|
255 |
+
} else {
|
256 |
+
setProcessSources((prev) => ({
|
257 |
+
...prev,
|
258 |
+
[questionId]: (prev[questionId] || []).filter((s) => s.id !== sourceId),
|
259 |
+
}))
|
260 |
+
}
|
261 |
+
}
|
262 |
+
|
263 |
+
const updateSource = (
|
264 |
+
questionId: string,
|
265 |
+
sourceId: string,
|
266 |
+
field: string,
|
267 |
+
value: string,
|
268 |
+
section: "benchmark" | "process",
|
269 |
+
) => {
|
270 |
+
if (section === "benchmark") {
|
271 |
+
setBenchmarkSources((prev) => ({
|
272 |
+
...prev,
|
273 |
+
[questionId]: (prev[questionId] || []).map((source) =>
|
274 |
+
source.id === sourceId ? { ...source, [field]: value } : source,
|
275 |
+
),
|
276 |
+
}))
|
277 |
+
} else {
|
278 |
+
setProcessSources((prev) => ({
|
279 |
+
...prev,
|
280 |
+
[questionId]: (prev[questionId] || []).map((source) =>
|
281 |
+
source.id === sourceId ? { ...source, [field]: value } : source,
|
282 |
+
),
|
283 |
+
}))
|
284 |
+
}
|
285 |
+
}
|
286 |
+
|
287 |
+
const updateSourceCustomField = (
|
288 |
+
questionId: string,
|
289 |
+
sourceId: string,
|
290 |
+
fieldType: string,
|
291 |
+
value: string,
|
292 |
+
section: "benchmark" | "process",
|
293 |
+
) => {
|
294 |
+
if (section === "benchmark") {
|
295 |
+
setBenchmarkSources((prev) => ({
|
296 |
+
...prev,
|
297 |
+
[questionId]: (prev[questionId] || []).map((source) =>
|
298 |
+
source.id === sourceId
|
299 |
+
? {
|
300 |
+
...source,
|
301 |
+
customFields: {
|
302 |
+
...source.customFields,
|
303 |
+
[fieldType]: value,
|
304 |
+
},
|
305 |
+
}
|
306 |
+
: source,
|
307 |
+
),
|
308 |
+
}))
|
309 |
+
} else {
|
310 |
+
setProcessSources((prev) => ({
|
311 |
+
...prev,
|
312 |
+
[questionId]: (prev[questionId] || []).map((source) =>
|
313 |
+
source.id === sourceId
|
314 |
+
? {
|
315 |
+
...source,
|
316 |
+
customFields: {
|
317 |
+
...source.customFields,
|
318 |
+
[fieldType]: value,
|
319 |
+
},
|
320 |
+
}
|
321 |
+
: source,
|
322 |
+
),
|
323 |
+
}))
|
324 |
+
}
|
325 |
+
}
|
326 |
+
|
327 |
+
const currentScore = useMemo(() => {
|
328 |
+
// Calculate counts
|
329 |
+
const totalBenchmarkQuestions = BENCHMARK_QUESTIONS.length
|
330 |
+
const totalProcessQuestions = PROCESS_QUESTIONS.length
|
331 |
+
const totalQuestions = totalBenchmarkQuestions + totalProcessQuestions
|
332 |
+
|
333 |
+
const benchmarkYesCount = Object.values(benchmarkAnswers).filter((answer) => answer === "yes").length
|
334 |
+
const processYesCount = Object.values(processAnswers).filter((answer) => answer === "yes").length
|
335 |
+
|
336 |
+
const benchmarkNaCount = Object.values(benchmarkAnswers).filter((answer) => answer === "na").length
|
337 |
+
const processNaCount = Object.values(processAnswers).filter((answer) => answer === "na").length
|
338 |
+
|
339 |
+
const naCount = benchmarkNaCount + processNaCount
|
340 |
+
const totalYes = benchmarkYesCount + processYesCount
|
341 |
+
|
342 |
+
// Denominator = total questions in the category minus NA answers
|
343 |
+
const totalApplicable = Math.max(0, totalQuestions - naCount)
|
344 |
+
|
345 |
+
const scorePercentage = totalApplicable > 0 ? totalYes / totalApplicable : 0
|
346 |
+
|
347 |
+
let status: CategoryScore["status"]
|
348 |
+
if (scorePercentage >= 0.8) status = "strong"
|
349 |
+
else if (scorePercentage >= 0.6) status = "adequate"
|
350 |
+
else if (scorePercentage >= 0.4) status = "weak"
|
351 |
+
else status = "insufficient"
|
352 |
+
|
353 |
+
const result = {
|
354 |
+
benchmarkScore: benchmarkYesCount,
|
355 |
+
processScore: processYesCount,
|
356 |
+
totalScore: totalYes,
|
357 |
+
status,
|
358 |
+
totalQuestions,
|
359 |
+
totalApplicable,
|
360 |
+
naCount,
|
361 |
+
}
|
362 |
+
|
363 |
+
return result
|
364 |
+
}, [benchmarkAnswers, processAnswers])
|
365 |
+
|
366 |
+
const handleAnswerChange = (questionId: string, value: string, section: "benchmark" | "process") => {
|
367 |
+
if (section === "benchmark") {
|
368 |
+
setBenchmarkAnswers((prev) => ({ ...prev, [questionId]: value }))
|
369 |
+
if (value !== "yes") {
|
370 |
+
setBenchmarkSources((prev) => ({ ...prev, [questionId]: [] }))
|
371 |
+
}
|
372 |
+
if (value !== "na") {
|
373 |
+
setNaExplanations((prev) => {
|
374 |
+
const newExplanations = { ...prev }
|
375 |
+
delete newExplanations[questionId]
|
376 |
+
return newExplanations
|
377 |
+
})
|
378 |
+
}
|
379 |
+
} else {
|
380 |
+
setProcessAnswers((prev) => ({ ...prev, [questionId]: value }))
|
381 |
+
if (value !== "yes") {
|
382 |
+
setProcessSources((prev) => ({ ...prev, [questionId]: [] }))
|
383 |
+
}
|
384 |
+
if (value !== "na") {
|
385 |
+
setNaExplanations((prev) => {
|
386 |
+
const newExplanations = { ...prev }
|
387 |
+
delete newExplanations[questionId]
|
388 |
+
return newExplanations
|
389 |
+
})
|
390 |
+
}
|
391 |
+
}
|
392 |
+
}
|
393 |
+
|
394 |
+
const handleNaExplanationChange = (questionId: string, explanation: string) => {
|
395 |
+
setNaExplanations((prev) => ({ ...prev, [questionId]: explanation }))
|
396 |
+
}
|
397 |
+
|
398 |
+
const handleSave = () => {
|
399 |
+
const allAnswers = { ...benchmarkAnswers, ...processAnswers }
|
400 |
+
const missingExplanations = Object.entries(allAnswers)
|
401 |
+
.filter(([_, answer]) => answer === "na")
|
402 |
+
.filter(([questionId, _]) => !naExplanations[questionId]?.trim())
|
403 |
+
.map(([questionId, _]) => questionId)
|
404 |
+
|
405 |
+
if (missingExplanations.length > 0) {
|
406 |
+
alert(
|
407 |
+
`Please provide explanations for why the following questions are not applicable: ${missingExplanations.join(", ")}`,
|
408 |
+
)
|
409 |
+
return
|
410 |
+
}
|
411 |
+
|
412 |
+
console.log("[v0] Saving category evaluation")
|
413 |
+
console.log("[v0] Calling onScoreUpdate with:", currentScore)
|
414 |
+
onScoreUpdate(currentScore)
|
415 |
+
}
|
416 |
+
|
417 |
+
const isComplete =
|
418 |
+
Object.keys(benchmarkAnswers).length + Object.keys(processAnswers).length === BENCHMARK_QUESTIONS.length + PROCESS_QUESTIONS.length
|
419 |
+
|
420 |
+
return (
|
421 |
+
<TooltipProvider>
|
422 |
+
<div className="space-y-6">
|
423 |
+
<Card>
|
424 |
+
<CardHeader>
|
425 |
+
<div className="flex items-center justify-between">
|
426 |
+
<div className="flex-1">
|
427 |
+
<CardTitle className="font-heading flex items-center gap-2">
|
428 |
+
{category.name}
|
429 |
+
<Badge variant={category.type === "capability" ? "secondary" : "destructive"}>{category.type}</Badge>
|
430 |
+
</CardTitle>
|
431 |
+
<CardDescription className="mt-2">{category.description}</CardDescription>
|
432 |
+
</div>
|
433 |
+
{isComplete && (
|
434 |
+
<div className="text-right">
|
435 |
+
<div className="flex items-center gap-2 mb-1">
|
436 |
+
<CheckCircle className="h-5 w-5 text-green-600" />
|
437 |
+
<span className="font-medium">Score: {currentScore.totalScore}/{currentScore.totalApplicable || currentScore.totalQuestions}</span>
|
438 |
+
</div>
|
439 |
+
<Badge
|
440 |
+
variant={
|
441 |
+
currentScore.status === "strong"
|
442 |
+
? "default"
|
443 |
+
: currentScore.status === "adequate"
|
444 |
+
? "secondary"
|
445 |
+
: currentScore.status === "weak"
|
446 |
+
? "outline"
|
447 |
+
: "destructive"
|
448 |
+
}
|
449 |
+
>
|
450 |
+
{currentScore.status.charAt(0).toUpperCase() + currentScore.status.slice(1)}
|
451 |
+
</Badge>
|
452 |
+
</div>
|
453 |
+
)}
|
454 |
+
</div>
|
455 |
+
</CardHeader>
|
456 |
+
<CardContent>
|
457 |
+
<div className="space-y-4">
|
458 |
+
<div className="bg-muted/30 p-4 rounded-lg">
|
459 |
+
<h4 className="font-medium mb-2">Source Types</h4>
|
460 |
+
<div className="grid gap-2 text-sm">
|
461 |
+
{Object.entries(SOURCE_TYPES).map(([key, type]) => (
|
462 |
+
<div key={key}>
|
463 |
+
<span className="font-medium">{type.label}:</span> {type.description}
|
464 |
+
</div>
|
465 |
+
))}
|
466 |
+
</div>
|
467 |
+
</div>
|
468 |
+
<div className="bg-muted/30 p-4 rounded-lg">
|
469 |
+
<h4 className="font-medium mb-2">Evaluation Guidance</h4>
|
470 |
+
<p className="text-sm mb-2 font-medium">
|
471 |
+
Note: The benchmarks and evaluations listed below are suggested examples, not exhaustive requirements.
|
472 |
+
You may use other relevant benchmarks and evaluation methods appropriate for your system.
|
473 |
+
</p>
|
474 |
+
<div className="text-sm whitespace-pre-line">{category.detailedGuidance}</div>
|
475 |
+
</div>
|
476 |
+
</div>
|
477 |
+
</CardContent>
|
478 |
+
</Card>
|
479 |
+
|
480 |
+
<Card>
|
481 |
+
<CardHeader>
|
482 |
+
<CardTitle className="text-lg">Part A: Benchmark & Testing Evaluation</CardTitle>
|
483 |
+
<CardDescription>
|
484 |
+
Quantitative assessment through standardized tests and measurements ({currentScore.benchmarkScore}/6)
|
485 |
+
</CardDescription>
|
486 |
+
</CardHeader>
|
487 |
+
<CardContent className="space-y-6">
|
488 |
+
{BENCHMARK_QUESTIONS.map((question) => (
|
489 |
+
<div key={question.id} className="space-y-3">
|
490 |
+
<div className="flex items-start gap-2">
|
491 |
+
<Label className="text-sm font-medium flex-1">
|
492 |
+
{question.id}. {question.text}
|
493 |
+
</Label>
|
494 |
+
<Tooltip>
|
495 |
+
<TooltipTrigger>
|
496 |
+
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
497 |
+
</TooltipTrigger>
|
498 |
+
<TooltipContent className="max-w-sm">
|
499 |
+
<p>{question.tooltip}</p>
|
500 |
+
</TooltipContent>
|
501 |
+
</Tooltip>
|
502 |
+
</div>
|
503 |
+
|
504 |
+
<RadioGroup
|
505 |
+
value={benchmarkAnswers[question.id] || ""}
|
506 |
+
onValueChange={(value) => handleAnswerChange(question.id, value, "benchmark")}
|
507 |
+
>
|
508 |
+
<div className="flex items-center space-x-2">
|
509 |
+
<RadioGroupItem value="yes" id={`${question.id}-yes`} />
|
510 |
+
<Label htmlFor={`${question.id}-yes`}>Yes</Label>
|
511 |
+
</div>
|
512 |
+
<div className="flex items-center space-x-2">
|
513 |
+
<RadioGroupItem value="no" id={`${question.id}-no`} />
|
514 |
+
<Label htmlFor={`${question.id}-no`}>No</Label>
|
515 |
+
</div>
|
516 |
+
<div className="flex items-center space-x-2">
|
517 |
+
<RadioGroupItem value="na" id={`${question.id}-na`} />
|
518 |
+
<Label htmlFor={`${question.id}-na`}>Not Applicable</Label>
|
519 |
+
</div>
|
520 |
+
</RadioGroup>
|
521 |
+
|
522 |
+
{benchmarkAnswers[question.id] === "na" && (
|
523 |
+
<div className="ml-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
524 |
+
<Label className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
525 |
+
Explanation Required: Why is this question not applicable?
|
526 |
+
</Label>
|
527 |
+
<Textarea
|
528 |
+
placeholder="Please explain why this question/category is not applicable to your system. This explanation will be included in the evaluation documentation."
|
529 |
+
value={naExplanations[question.id] || ""}
|
530 |
+
onChange={(e) => handleNaExplanationChange(question.id, e.target.value)}
|
531 |
+
rows={3}
|
532 |
+
className="mt-2 border-yellow-300 dark:border-yellow-700"
|
533 |
+
required
|
534 |
+
/>
|
535 |
+
</div>
|
536 |
+
)}
|
537 |
+
|
538 |
+
{benchmarkAnswers[question.id] === "yes" && (
|
539 |
+
<div className="space-y-4 ml-4 p-4 bg-muted/30 rounded-lg">
|
540 |
+
<div className="flex items-center justify-between">
|
541 |
+
<Label className="text-sm font-medium">Sources & Evidence</Label>
|
542 |
+
<Button
|
543 |
+
type="button"
|
544 |
+
variant="outline"
|
545 |
+
size="sm"
|
546 |
+
onClick={() => addSource(question.id, "benchmark")}
|
547 |
+
className="flex items-center gap-1"
|
548 |
+
>
|
549 |
+
<Plus className="h-3 w-3" />
|
550 |
+
Add Source
|
551 |
+
</Button>
|
552 |
+
</div>
|
553 |
+
|
554 |
+
{(benchmarkSources[question.id] || []).map((source, index) => (
|
555 |
+
<div key={source.id} className="space-y-3 p-3 border rounded-lg bg-background">
|
556 |
+
<div className="flex items-center justify-between">
|
557 |
+
<span className="text-sm font-medium">Source {index + 1}</span>
|
558 |
+
<Button
|
559 |
+
type="button"
|
560 |
+
variant="ghost"
|
561 |
+
size="sm"
|
562 |
+
onClick={() => removeSource(question.id, source.id, "benchmark")}
|
563 |
+
>
|
564 |
+
<Trash2 className="h-3 w-3" />
|
565 |
+
</Button>
|
566 |
+
</div>
|
567 |
+
|
568 |
+
<div className="grid gap-3">
|
569 |
+
{/* no structured hint here; description has contextual hints */}
|
570 |
+
|
571 |
+
<div>
|
572 |
+
<Label className="text-xs">Benchmark/Dataset Name</Label>
|
573 |
+
<Input
|
574 |
+
placeholder={getFieldPlaceholder(category.id, question.id, "benchmarkName")}
|
575 |
+
value={source.benchmarkName || ""}
|
576 |
+
onChange={(e) =>
|
577 |
+
updateSource(question.id, source.id, "benchmarkName", e.target.value, "benchmark")
|
578 |
+
}
|
579 |
+
/>
|
580 |
+
</div>
|
581 |
+
|
582 |
+
<div className="grid grid-cols-2 gap-3">
|
583 |
+
<div>
|
584 |
+
<Label className="text-xs">Version</Label>
|
585 |
+
<Input
|
586 |
+
placeholder="e.g., v1.2, 2024-01"
|
587 |
+
value={source.version || ""}
|
588 |
+
onChange={(e) =>
|
589 |
+
updateSource(question.id, source.id, "version", e.target.value, "benchmark")
|
590 |
+
}
|
591 |
+
/>
|
592 |
+
</div>
|
593 |
+
<div>
|
594 |
+
<Label className="text-xs">Task Variants</Label>
|
595 |
+
<Input
|
596 |
+
placeholder="e.g., multiple choice, generation"
|
597 |
+
value={source.taskVariants || ""}
|
598 |
+
onChange={(e) =>
|
599 |
+
updateSource(question.id, source.id, "taskVariants", e.target.value, "benchmark")
|
600 |
+
}
|
601 |
+
/>
|
602 |
+
</div>
|
603 |
+
</div>
|
604 |
+
|
605 |
+
<div>
|
606 |
+
<Label className="text-xs">Metrics</Label>
|
607 |
+
<Input
|
608 |
+
placeholder={getFieldPlaceholder(category.id, question.id, "metrics")}
|
609 |
+
value={source.metrics || ""}
|
610 |
+
onChange={(e) =>
|
611 |
+
updateSource(question.id, source.id, "metrics", e.target.value, "benchmark")
|
612 |
+
}
|
613 |
+
/>
|
614 |
+
</div>
|
615 |
+
|
616 |
+
<div>
|
617 |
+
<Label className="text-xs">URL</Label>
|
618 |
+
<Input
|
619 |
+
placeholder="https://..."
|
620 |
+
value={source.url}
|
621 |
+
onChange={(e) => updateSource(question.id, source.id, "url", e.target.value, "benchmark")}
|
622 |
+
/>
|
623 |
+
</div>
|
624 |
+
|
625 |
+
<div>
|
626 |
+
<Label className="text-xs">Description</Label>
|
627 |
+
<Textarea
|
628 |
+
placeholder="Describe the benchmark, test, or evaluation method..."
|
629 |
+
value={source.description}
|
630 |
+
onChange={(e) =>
|
631 |
+
updateSource(question.id, source.id, "description", e.target.value, "benchmark")
|
632 |
+
}
|
633 |
+
rows={2}
|
634 |
+
/>
|
635 |
+
<p className="text-xs text-muted-foreground mt-1">
|
636 |
+
{getHint(category.id, question.id, "benchmark")}
|
637 |
+
</p>
|
638 |
+
</div>
|
639 |
+
|
640 |
+
<div className="grid grid-cols-2 gap-3">
|
641 |
+
<div>
|
642 |
+
<Label className="text-xs">Source Type</Label>
|
643 |
+
<RadioGroup
|
644 |
+
value={source.sourceType}
|
645 |
+
onValueChange={(value) =>
|
646 |
+
updateSource(question.id, source.id, "sourceType", value, "benchmark")
|
647 |
+
}
|
648 |
+
>
|
649 |
+
{Object.entries(SOURCE_TYPES).map(([key, type]) => (
|
650 |
+
<div key={key} className="flex items-center space-x-2">
|
651 |
+
<RadioGroupItem value={key} id={`${source.id}-${key}`} />
|
652 |
+
<Label htmlFor={`${source.id}-${key}`} className="text-xs">
|
653 |
+
{type.label}
|
654 |
+
</Label>
|
655 |
+
</div>
|
656 |
+
))}
|
657 |
+
</RadioGroup>
|
658 |
+
</div>
|
659 |
+
|
660 |
+
<div>
|
661 |
+
<Label className="text-xs">Score (if applicable)</Label>
|
662 |
+
<Input
|
663 |
+
placeholder="e.g., 85%, 0.92, Pass"
|
664 |
+
value={source.score || ""}
|
665 |
+
onChange={(e) =>
|
666 |
+
updateSource(question.id, source.id, "score", e.target.value, "benchmark")
|
667 |
+
}
|
668 |
+
/>
|
669 |
+
<Label className="text-xs mt-2">Confidence Interval (optional)</Label>
|
670 |
+
<Input
|
671 |
+
placeholder="e.g., 95% CI [90,94]"
|
672 |
+
value={(source as any).confidenceInterval || ""}
|
673 |
+
onChange={(e) =>
|
674 |
+
updateSource(question.id, source.id, "confidenceInterval", e.target.value, "benchmark")
|
675 |
+
}
|
676 |
+
/>
|
677 |
+
</div>
|
678 |
+
</div>
|
679 |
+
</div>
|
680 |
+
</div>
|
681 |
+
))}
|
682 |
+
|
683 |
+
{(benchmarkSources[question.id] || []).length === 0 && (
|
684 |
+
<div className="text-center py-4 text-muted-foreground text-sm">
|
685 |
+
Click "Add Source" to document benchmarks and evidence
|
686 |
+
</div>
|
687 |
+
)}
|
688 |
+
</div>
|
689 |
+
)}
|
690 |
+
|
691 |
+
<Separator />
|
692 |
+
</div>
|
693 |
+
))}
|
694 |
+
</CardContent>
|
695 |
+
</Card>
|
696 |
+
|
697 |
+
<Card>
|
698 |
+
<CardHeader>
|
699 |
+
<CardTitle className="text-lg">Part B: Documentation & Process Evaluation</CardTitle>
|
700 |
+
<CardDescription>
|
701 |
+
Governance, transparency, and risk management processes ({currentScore.processScore}/5)
|
702 |
+
</CardDescription>
|
703 |
+
</CardHeader>
|
704 |
+
<CardContent className="space-y-6">
|
705 |
+
{PROCESS_QUESTIONS.map((question) => (
|
706 |
+
<div key={question.id} className="space-y-3">
|
707 |
+
<div className="flex items-start gap-2">
|
708 |
+
<Label className="text-sm font-medium flex-1">
|
709 |
+
{question.id}. {question.text}
|
710 |
+
</Label>
|
711 |
+
<Tooltip>
|
712 |
+
<TooltipTrigger>
|
713 |
+
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
714 |
+
</TooltipTrigger>
|
715 |
+
<TooltipContent className="max-w-sm">
|
716 |
+
<p>{question.tooltip}</p>
|
717 |
+
</TooltipContent>
|
718 |
+
</Tooltip>
|
719 |
+
</div>
|
720 |
+
|
721 |
+
<RadioGroup
|
722 |
+
value={processAnswers[question.id] || ""}
|
723 |
+
onValueChange={(value) => handleAnswerChange(question.id, value, "process")}
|
724 |
+
>
|
725 |
+
<div className="flex items-center space-x-2">
|
726 |
+
<RadioGroupItem value="yes" id={`${question.id}-yes`} />
|
727 |
+
<Label htmlFor={`${question.id}-yes`}>Yes</Label>
|
728 |
+
</div>
|
729 |
+
<div className="flex items-center space-x-2">
|
730 |
+
<RadioGroupItem value="no" id={`${question.id}-no`} />
|
731 |
+
<Label htmlFor={`${question.id}-no`}>No</Label>
|
732 |
+
</div>
|
733 |
+
<div className="flex items-center space-x-2">
|
734 |
+
<RadioGroupItem value="na" id={`${question.id}-na`} />
|
735 |
+
<Label htmlFor={`${question.id}-na`}>Not Applicable</Label>
|
736 |
+
</div>
|
737 |
+
</RadioGroup>
|
738 |
+
|
739 |
+
{processAnswers[question.id] === "na" && (
|
740 |
+
<div className="ml-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
741 |
+
<Label className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
742 |
+
Explanation Required: Why is this question not applicable?
|
743 |
+
</Label>
|
744 |
+
<Textarea
|
745 |
+
placeholder="Please explain why this question/category is not applicable to your system. This explanation will be included in the evaluation documentation."
|
746 |
+
value={naExplanations[question.id] || ""}
|
747 |
+
onChange={(e) => handleNaExplanationChange(question.id, e.target.value)}
|
748 |
+
rows={3}
|
749 |
+
className="mt-2 border-yellow-300 dark:border-yellow-700"
|
750 |
+
required
|
751 |
+
/>
|
752 |
+
</div>
|
753 |
+
)}
|
754 |
+
|
755 |
+
{processAnswers[question.id] === "yes" && (
|
756 |
+
<div className="space-y-4 ml-4 p-4 bg-muted/30 rounded-lg">
|
757 |
+
<div className="flex items-center justify-between">
|
758 |
+
<Label className="text-sm font-medium">Documentation & Evidence</Label>
|
759 |
+
<Button
|
760 |
+
type="button"
|
761 |
+
variant="outline"
|
762 |
+
size="sm"
|
763 |
+
onClick={() => addSource(question.id, "process")}
|
764 |
+
className="flex items-center gap-1"
|
765 |
+
>
|
766 |
+
<Plus className="h-3 w-3" />
|
767 |
+
Add Documentation
|
768 |
+
</Button>
|
769 |
+
</div>
|
770 |
+
|
771 |
+
{(processSources[question.id] || []).map((source, index) => (
|
772 |
+
<div key={source.id} className="space-y-3 p-3 border rounded-lg bg-background">
|
773 |
+
<div className="flex items-center justify-between">
|
774 |
+
<span className="text-sm font-medium">Document {index + 1}</span>
|
775 |
+
<Button
|
776 |
+
type="button"
|
777 |
+
variant="ghost"
|
778 |
+
size="sm"
|
779 |
+
onClick={() => removeSource(question.id, source.id, "process")}
|
780 |
+
>
|
781 |
+
<Trash2 className="h-3 w-3" />
|
782 |
+
</Button>
|
783 |
+
</div>
|
784 |
+
|
785 |
+
<div className="grid gap-3">
|
786 |
+
{/* no structured hint here; description has contextual hints */}
|
787 |
+
|
788 |
+
<div>
|
789 |
+
<Label className="text-xs">URL</Label>
|
790 |
+
<Input
|
791 |
+
placeholder="https://..."
|
792 |
+
value={source.url}
|
793 |
+
onChange={(e) => updateSource(question.id, source.id, "url", e.target.value, "process")}
|
794 |
+
/>
|
795 |
+
</div>
|
796 |
+
|
797 |
+
<div>
|
798 |
+
<Label className="text-xs">Description</Label>
|
799 |
+
<Textarea
|
800 |
+
placeholder="Describe the documentation, policy, or process..."
|
801 |
+
value={source.description}
|
802 |
+
onChange={(e) =>
|
803 |
+
updateSource(question.id, source.id, "description", e.target.value, "process")
|
804 |
+
}
|
805 |
+
rows={2}
|
806 |
+
/>
|
807 |
+
<p className="text-xs text-muted-foreground mt-1">
|
808 |
+
{getHint(category.id, question.id, "process")}
|
809 |
+
</p>
|
810 |
+
</div>
|
811 |
+
|
812 |
+
<div className="grid grid-cols-2 gap-3">
|
813 |
+
<div>
|
814 |
+
<Label className="text-xs">Source Type</Label>
|
815 |
+
<RadioGroup
|
816 |
+
value={source.sourceType}
|
817 |
+
onValueChange={(value) =>
|
818 |
+
updateSource(question.id, source.id, "sourceType", value, "process")
|
819 |
+
}
|
820 |
+
>
|
821 |
+
{Object.entries(SOURCE_TYPES).map(([key, type]) => (
|
822 |
+
<div key={key} className="flex items-center space-x-2">
|
823 |
+
<RadioGroupItem value={key} id={`${source.id}-${key}`} />
|
824 |
+
<Label htmlFor={`${source.id}-${key}`} className="text-xs">
|
825 |
+
{type.label}
|
826 |
+
</Label>
|
827 |
+
</div>
|
828 |
+
))}
|
829 |
+
</RadioGroup>
|
830 |
+
</div>
|
831 |
+
|
832 |
+
<div>
|
833 |
+
<Label className="text-xs">Document Type</Label>
|
834 |
+
<Input
|
835 |
+
placeholder="e.g., Policy, Procedure, Report"
|
836 |
+
value={source.documentType || ""}
|
837 |
+
onChange={(e) =>
|
838 |
+
updateSource(question.id, source.id, "documentType", e.target.value, "process")
|
839 |
+
}
|
840 |
+
/>
|
841 |
+
</div>
|
842 |
+
</div>
|
843 |
+
|
844 |
+
<div className="grid grid-cols-2 gap-3 mt-2">
|
845 |
+
<div>
|
846 |
+
<Label className="text-xs">Title</Label>
|
847 |
+
<Input
|
848 |
+
placeholder="Document title"
|
849 |
+
value={(source as any).title || ""}
|
850 |
+
onChange={(e) => updateSource(question.id, source.id, "title", e.target.value, "process")}
|
851 |
+
/>
|
852 |
+
</div>
|
853 |
+
<div>
|
854 |
+
<Label className="text-xs">Author</Label>
|
855 |
+
<Input
|
856 |
+
placeholder="Author or owner"
|
857 |
+
value={(source as any).author || ""}
|
858 |
+
onChange={(e) => updateSource(question.id, source.id, "author", e.target.value, "process")}
|
859 |
+
/>
|
860 |
+
</div>
|
861 |
+
</div>
|
862 |
+
|
863 |
+
<div className="grid grid-cols-2 gap-3 mt-2">
|
864 |
+
<div>
|
865 |
+
<Label className="text-xs">Organization</Label>
|
866 |
+
<Input
|
867 |
+
placeholder="Owning org"
|
868 |
+
value={(source as any).organization || ""}
|
869 |
+
onChange={(e) => updateSource(question.id, source.id, "organization", e.target.value, "process")}
|
870 |
+
/>
|
871 |
+
</div>
|
872 |
+
<div>
|
873 |
+
<Label className="text-xs">Date</Label>
|
874 |
+
<Input
|
875 |
+
placeholder="YYYY-MM-DD"
|
876 |
+
value={(source as any).date || ""}
|
877 |
+
onChange={(e) => updateSource(question.id, source.id, "date", e.target.value, "process")}
|
878 |
+
/>
|
879 |
+
</div>
|
880 |
+
</div>
|
881 |
+
|
882 |
+
|
883 |
+
</div>
|
884 |
+
</div>
|
885 |
+
))}
|
886 |
+
|
887 |
+
{(processSources[question.id] || []).length === 0 && (
|
888 |
+
<div className="text-center py-4 text-muted-foreground text-sm">
|
889 |
+
Click "Add Documentation" to document policies and processes
|
890 |
+
</div>
|
891 |
+
)}
|
892 |
+
</div>
|
893 |
+
)}
|
894 |
+
|
895 |
+
<Separator />
|
896 |
+
</div>
|
897 |
+
))}
|
898 |
+
</CardContent>
|
899 |
+
</Card>
|
900 |
+
|
901 |
+
<Card>
|
902 |
+
<CardHeader>
|
903 |
+
<CardTitle className="text-lg">Part C: Additional Evaluation Aspects</CardTitle>
|
904 |
+
<CardDescription>{ADDITIONAL_ASPECTS_SECTION.description}</CardDescription>
|
905 |
+
</CardHeader>
|
906 |
+
<CardContent>
|
907 |
+
<div className="space-y-3">
|
908 |
+
<Label className="text-sm font-medium">
|
909 |
+
Additional evaluation aspects, methods, or considerations for this category:
|
910 |
+
</Label>
|
911 |
+
<Textarea
|
912 |
+
placeholder="Document any other evaluation approaches, considerations, or aspects that may not have been captured by the structured questions above. This could include novel evaluation methods, domain-specific considerations, or unique aspects of your system's evaluation..."
|
913 |
+
value={additionalAspects}
|
914 |
+
onChange={(e) => setAdditionalAspects(e.target.value)}
|
915 |
+
rows={6}
|
916 |
+
className="min-h-[120px]"
|
917 |
+
/>
|
918 |
+
<p className="text-xs text-muted-foreground">
|
919 |
+
This section is for documentation purposes and will not affect the numerical score but will be included
|
920 |
+
in the final evaluation report.
|
921 |
+
</p>
|
922 |
+
</div>
|
923 |
+
</CardContent>
|
924 |
+
</Card>
|
925 |
+
|
926 |
+
<div className="flex justify-center">
|
927 |
+
<Button onClick={handleSave} disabled={!isComplete} size="lg" className="w-full max-w-md">
|
928 |
+
{score ? "Update" : "Save"} Category Evaluation
|
929 |
+
</Button>
|
930 |
+
</div>
|
931 |
+
</div>
|
932 |
+
</TooltipProvider>
|
933 |
+
)
|
934 |
+
}
|
components/category-selection.tsx
ADDED
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import { useState } from "react"
|
4 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
5 |
+
import { Button } from "@/components/ui/button"
|
6 |
+
import { Checkbox } from "@/components/ui/checkbox"
|
7 |
+
import { Badge } from "@/components/ui/badge"
|
8 |
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
9 |
+
import { Brain, Shield, HelpCircle } from "lucide-react"
|
10 |
+
import { Label } from "@/components/ui/label"
|
11 |
+
import { Textarea } from "@/components/ui/textarea"
|
12 |
+
|
13 |
+
interface Category {
|
14 |
+
id: string
|
15 |
+
name: string
|
16 |
+
type: "capability" | "risk"
|
17 |
+
description: string
|
18 |
+
}
|
19 |
+
|
20 |
+
interface CategorySelectionProps {
|
21 |
+
categories: Category[]
|
22 |
+
selectedCategories: string[]
|
23 |
+
onSelectionChange: (categories: string[], excludedReasons: Record<string, string>) => void
|
24 |
+
}
|
25 |
+
|
26 |
+
export function CategorySelection({ categories, selectedCategories, onSelectionChange }: CategorySelectionProps) {
|
27 |
+
const allCategoryIds = categories.map((c) => c.id)
|
28 |
+
const [localSelection, setLocalSelection] = useState<string[]>(
|
29 |
+
selectedCategories && selectedCategories.length > 0 ? selectedCategories : allCategoryIds,
|
30 |
+
)
|
31 |
+
const [localReasons, setLocalReasons] = useState<Record<string, string>>({})
|
32 |
+
|
33 |
+
const capabilityCategories = categories.filter((c) => c.type === "capability")
|
34 |
+
const riskCategories = categories.filter((c) => c.type === "risk")
|
35 |
+
|
36 |
+
const handleCategoryToggle = (categoryId: string, checked: boolean) => {
|
37 |
+
setLocalSelection((prev) => {
|
38 |
+
if (checked) {
|
39 |
+
// if re-selecting, remove any existing reason
|
40 |
+
setLocalReasons((r) => {
|
41 |
+
const copy = { ...r }
|
42 |
+
delete copy[categoryId]
|
43 |
+
return copy
|
44 |
+
})
|
45 |
+
return [...prev, categoryId]
|
46 |
+
}
|
47 |
+
// when unselecting, initialize an empty reason entry to make it required
|
48 |
+
setLocalReasons((r) => ({ ...r, [categoryId]: r[categoryId] || "" }))
|
49 |
+
return prev.filter((id) => id !== categoryId)
|
50 |
+
})
|
51 |
+
}
|
52 |
+
|
53 |
+
const handleSelectAll = (type: "capability" | "risk") => {
|
54 |
+
const categoryIds = categories.filter((c) => c.type === type).map((c) => c.id)
|
55 |
+
const allSelected = categoryIds.every((id) => localSelection.includes(id))
|
56 |
+
|
57 |
+
if (allSelected) {
|
58 |
+
// deselect these categories and initialize empty reasons for them
|
59 |
+
setLocalSelection((prev) => {
|
60 |
+
const next = prev.filter((id) => !categoryIds.includes(id))
|
61 |
+
setLocalReasons((r) => {
|
62 |
+
const copy = { ...r }
|
63 |
+
categoryIds.forEach((id) => {
|
64 |
+
if (!(id in copy)) copy[id] = ""
|
65 |
+
})
|
66 |
+
return copy
|
67 |
+
})
|
68 |
+
return next
|
69 |
+
})
|
70 |
+
} else {
|
71 |
+
setLocalSelection((prev) => {
|
72 |
+
// when selecting these, remove any reasons for them
|
73 |
+
setLocalReasons((r) => {
|
74 |
+
const copy = { ...r }
|
75 |
+
categoryIds.forEach((id) => delete copy[id])
|
76 |
+
return copy
|
77 |
+
})
|
78 |
+
return [...new Set([...prev, ...categoryIds])]
|
79 |
+
})
|
80 |
+
}
|
81 |
+
}
|
82 |
+
|
83 |
+
const handleContinue = () => {
|
84 |
+
onSelectionChange(localSelection, localReasons)
|
85 |
+
}
|
86 |
+
|
87 |
+
return (
|
88 |
+
<TooltipProvider>
|
89 |
+
<div className="h-full min-h-0 flex flex-col">
|
90 |
+
<Card>
|
91 |
+
<CardHeader>
|
92 |
+
<CardTitle className="font-heading">Category Applicability Assessment</CardTitle>
|
93 |
+
<CardDescription>
|
94 |
+
Select which categories are applicable to your AI system. Only complete the detailed evaluation for
|
95 |
+
categories marked as applicable.
|
96 |
+
</CardDescription>
|
97 |
+
</CardHeader>
|
98 |
+
<CardContent>
|
99 |
+
<div className="flex items-center justify-between mb-4">
|
100 |
+
<div className="flex items-center gap-2">
|
101 |
+
<span className="text-sm font-medium">Selected Categories:</span>
|
102 |
+
<Badge variant="secondary">{localSelection.length}/20</Badge>
|
103 |
+
</div>
|
104 |
+
</div>
|
105 |
+
</CardContent>
|
106 |
+
</Card>
|
107 |
+
|
108 |
+
<div className="flex-1 overflow-auto space-y-6 py-4 pr-2 min-h-0">
|
109 |
+
{/* Capability Categories */}
|
110 |
+
<Card>
|
111 |
+
<CardHeader>
|
112 |
+
<div className="flex items-center justify-between">
|
113 |
+
<div className="flex items-center gap-2">
|
114 |
+
<Brain className="h-5 w-5 text-accent" />
|
115 |
+
<CardTitle className="font-heading">Capability Categories</CardTitle>
|
116 |
+
</div>
|
117 |
+
<Button variant="outline" size="sm" onClick={() => handleSelectAll("capability")}>
|
118 |
+
{capabilityCategories.every((c) => localSelection.includes(c.id)) ? "Deselect All" : "Select All"}
|
119 |
+
</Button>
|
120 |
+
</div>
|
121 |
+
<CardDescription>Core functional capabilities and performance areas</CardDescription>
|
122 |
+
</CardHeader>
|
123 |
+
<CardContent>
|
124 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
125 |
+
{capabilityCategories.map((category) => (
|
126 |
+
<div key={category.id} className="p-3 rounded-lg border hover:bg-muted/50 transition-colors">
|
127 |
+
<div className="flex items-center space-x-3">
|
128 |
+
<Checkbox
|
129 |
+
id={category.id}
|
130 |
+
checked={localSelection.includes(category.id)}
|
131 |
+
onCheckedChange={(checked) => handleCategoryToggle(category.id, checked as boolean)}
|
132 |
+
/>
|
133 |
+
<label htmlFor={category.id} className="text-sm font-medium cursor-pointer flex-1">
|
134 |
+
{category.name}
|
135 |
+
</label>
|
136 |
+
<Tooltip>
|
137 |
+
<TooltipTrigger>
|
138 |
+
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
139 |
+
</TooltipTrigger>
|
140 |
+
<TooltipContent className="max-w-sm">
|
141 |
+
<p>{category.description}</p>
|
142 |
+
</TooltipContent>
|
143 |
+
</Tooltip>
|
144 |
+
</div>
|
145 |
+
|
146 |
+
{/* If unchecked, require a reason */}
|
147 |
+
{!localSelection.includes(category.id) && (
|
148 |
+
<div className="mt-3">
|
149 |
+
<div className="ml-0 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
150 |
+
<Label className="text-sm font-medium text-yellow-800 dark:text-yellow-200">Reason for excluding this category *</Label>
|
151 |
+
<Textarea
|
152 |
+
placeholder="Brief reason why this category is not applicable"
|
153 |
+
value={localReasons[category.id] || ""}
|
154 |
+
onChange={(e) => setLocalReasons((prev) => ({ ...prev, [category.id]: e.target.value }))}
|
155 |
+
rows={2}
|
156 |
+
className="mt-2 border-yellow-300 dark:border-yellow-700"
|
157 |
+
required
|
158 |
+
/>
|
159 |
+
</div>
|
160 |
+
</div>
|
161 |
+
)}
|
162 |
+
</div>
|
163 |
+
))}
|
164 |
+
</div>
|
165 |
+
</CardContent>
|
166 |
+
</Card>
|
167 |
+
|
168 |
+
{/* Risk Categories */}
|
169 |
+
<Card>
|
170 |
+
<CardHeader>
|
171 |
+
<div className="flex items-center justify-between">
|
172 |
+
<div className="flex items-center gap-2">
|
173 |
+
<Shield className="h-5 w-5 text-destructive" />
|
174 |
+
<CardTitle className="font-heading">Risk Categories</CardTitle>
|
175 |
+
</div>
|
176 |
+
<Button variant="outline" size="sm" onClick={() => handleSelectAll("risk")}>
|
177 |
+
{riskCategories.every((c) => localSelection.includes(c.id)) ? "Deselect All" : "Select All"}
|
178 |
+
</Button>
|
179 |
+
</div>
|
180 |
+
<CardDescription>Potential risks, safety concerns, and ethical considerations</CardDescription>
|
181 |
+
</CardHeader>
|
182 |
+
<CardContent>
|
183 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
184 |
+
{riskCategories.map((category) => (
|
185 |
+
<div key={category.id} className="p-3 rounded-lg border hover:bg-muted/50 transition-colors">
|
186 |
+
<div className="flex items-center space-x-3">
|
187 |
+
<Checkbox
|
188 |
+
id={category.id}
|
189 |
+
checked={localSelection.includes(category.id)}
|
190 |
+
onCheckedChange={(checked) => handleCategoryToggle(category.id, checked as boolean)}
|
191 |
+
/>
|
192 |
+
<label htmlFor={category.id} className="text-sm font-medium cursor-pointer flex-1">
|
193 |
+
{category.name}
|
194 |
+
</label>
|
195 |
+
<Tooltip>
|
196 |
+
<TooltipTrigger>
|
197 |
+
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
198 |
+
</TooltipTrigger>
|
199 |
+
<TooltipContent className="max-w-sm">
|
200 |
+
<p>{category.description}</p>
|
201 |
+
</TooltipContent>
|
202 |
+
</Tooltip>
|
203 |
+
</div>
|
204 |
+
|
205 |
+
{!localSelection.includes(category.id) && (
|
206 |
+
<div className="mt-3">
|
207 |
+
<div className="ml-0 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
208 |
+
<Label className="text-sm font-medium text-yellow-800 dark:text-yellow-200">Reason for excluding this category *</Label>
|
209 |
+
<Textarea
|
210 |
+
placeholder="Brief reason why this category is not applicable"
|
211 |
+
value={localReasons[category.id] || ""}
|
212 |
+
onChange={(e) => setLocalReasons((prev) => ({ ...prev, [category.id]: e.target.value }))}
|
213 |
+
rows={2}
|
214 |
+
className="mt-2 border-yellow-300 dark:border-yellow-700"
|
215 |
+
required
|
216 |
+
/>
|
217 |
+
</div>
|
218 |
+
</div>
|
219 |
+
)}
|
220 |
+
</div>
|
221 |
+
))}
|
222 |
+
</div>
|
223 |
+
</CardContent>
|
224 |
+
</Card>
|
225 |
+
</div>
|
226 |
+
<div className="flex justify-center py-4">
|
227 |
+
<div className="flex flex-col items-center w-full">
|
228 |
+
{Object.keys(localReasons).some((id) => !localSelection.includes(id) && !(localReasons[id] || "")) && (
|
229 |
+
<p className="text-sm text-destructive mb-2">Please provide reasons for all excluded categories.</p>
|
230 |
+
)}
|
231 |
+
<Button
|
232 |
+
onClick={handleContinue}
|
233 |
+
disabled={
|
234 |
+
localSelection.length === 0 ||
|
235 |
+
Object.keys(localReasons).some((id) => !localSelection.includes(id) && !(localReasons[id] || ""))
|
236 |
+
}
|
237 |
+
size="lg"
|
238 |
+
>
|
239 |
+
Continue to Evaluation ({localSelection.length} categories selected)
|
240 |
+
</Button>
|
241 |
+
</div>
|
242 |
+
</div>
|
243 |
+
</div>
|
244 |
+
</TooltipProvider>
|
245 |
+
)
|
246 |
+
}
|
components/evaluation-card.tsx
ADDED
@@ -0,0 +1,445 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import { useState } from "react"
|
4 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
5 |
+
import { Badge } from "@/components/ui/badge"
|
6 |
+
import { Button } from "@/components/ui/button"
|
7 |
+
import { MoreHorizontal, Eye, Download, Trash2 } from "lucide-react"
|
8 |
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
9 |
+
import { useRouter } from "next/navigation"
|
10 |
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
11 |
+
|
12 |
+
export type EvaluationCardData = {
|
13 |
+
id: string
|
14 |
+
systemName: string
|
15 |
+
provider: string
|
16 |
+
modality: string // Added modality field
|
17 |
+
completedDate: string
|
18 |
+
applicableCategories: number
|
19 |
+
completedCategories: number
|
20 |
+
status: "strong" | "adequate" | "weak" | "insufficient"
|
21 |
+
capabilityEval: {
|
22 |
+
strong: number
|
23 |
+
adequate: number
|
24 |
+
weak: number
|
25 |
+
insufficient: number
|
26 |
+
strongCategories: string[]
|
27 |
+
adequateCategories: string[]
|
28 |
+
weakCategories: string[]
|
29 |
+
insufficientCategories: string[]
|
30 |
+
totalApplicable: number
|
31 |
+
}
|
32 |
+
riskEval: {
|
33 |
+
strong: number
|
34 |
+
adequate: number
|
35 |
+
weak: number
|
36 |
+
insufficient: number
|
37 |
+
strongCategories: string[]
|
38 |
+
adequateCategories: string[]
|
39 |
+
weakCategories: string[]
|
40 |
+
insufficientCategories: string[]
|
41 |
+
totalApplicable: number
|
42 |
+
}
|
43 |
+
priorityAreas?: string[]
|
44 |
+
priorityDetails?: Record<
|
45 |
+
string,
|
46 |
+
{
|
47 |
+
yes: string[]
|
48 |
+
negative: { text: string; status: "no" | "na"; reason?: string }[]
|
49 |
+
}
|
50 |
+
>
|
51 |
+
}
|
52 |
+
|
53 |
+
interface EvaluationCardProps {
|
54 |
+
evaluation: EvaluationCardData
|
55 |
+
onView: (id: string) => void
|
56 |
+
onDelete: (id: string) => void
|
57 |
+
}
|
58 |
+
|
59 |
+
const getCompletenessColor = (score: number) => {
|
60 |
+
if (score >= 85) return "bg-emerald-500 text-white"
|
61 |
+
if (score >= 70) return "bg-blue-500 text-white"
|
62 |
+
if (score >= 55) return "bg-amber-500 text-white"
|
63 |
+
return "bg-red-500 text-white"
|
64 |
+
}
|
65 |
+
|
66 |
+
export function EvaluationCard({ evaluation, onView, onDelete }: EvaluationCardProps) {
|
67 |
+
const [expandedAreas, setExpandedAreas] = useState<Record<string, boolean>>({})
|
68 |
+
const toggleArea = (area: string) => setExpandedAreas((p) => ({ ...p, [area]: !p[area] }))
|
69 |
+
const router = useRouter()
|
70 |
+
const modalityMap: Record<string, { label: string; emoji?: string; variant?: string }> = {
|
71 |
+
"text-to-text": { label: "Text → Text", emoji: "📝" },
|
72 |
+
"text-to-image": { label: "Text → Image", emoji: "🖼️" },
|
73 |
+
multimodal: { label: "Multimodal", emoji: "🤖" },
|
74 |
+
"speech-to-text": { label: "Speech → Text", emoji: "🗣️" },
|
75 |
+
"speech-to-speech": { label: "Speech → Speech", emoji: "🔊" },
|
76 |
+
"image-to-text": { label: "Image → Text", emoji: "📷" },
|
77 |
+
code: { label: "Code", emoji: "💻" },
|
78 |
+
}
|
79 |
+
const getUniqueCount = (lists: string[][]) => {
|
80 |
+
const set = new Set<string>()
|
81 |
+
lists.forEach((list) => (list || []).forEach((item) => set.add(item)))
|
82 |
+
return set.size
|
83 |
+
}
|
84 |
+
|
85 |
+
const capTotalComputed = getUniqueCount([
|
86 |
+
evaluation.capabilityEval.strongCategories,
|
87 |
+
evaluation.capabilityEval.adequateCategories,
|
88 |
+
evaluation.capabilityEval.weakCategories,
|
89 |
+
evaluation.capabilityEval.insufficientCategories,
|
90 |
+
])
|
91 |
+
|
92 |
+
const riskTotalComputed = getUniqueCount([
|
93 |
+
evaluation.riskEval.strongCategories,
|
94 |
+
evaluation.riskEval.adequateCategories,
|
95 |
+
evaluation.riskEval.weakCategories,
|
96 |
+
evaluation.riskEval.insufficientCategories,
|
97 |
+
])
|
98 |
+
const calculateCompletenessScore = () => {
|
99 |
+
const weights = { strong: 4, adequate: 3, weak: 2, insufficient: 1 }
|
100 |
+
const capTotal = capTotalComputed
|
101 |
+
const riskTotal = riskTotalComputed
|
102 |
+
|
103 |
+
if (capTotal === 0 && riskTotal === 0) {
|
104 |
+
return "0.0"
|
105 |
+
}
|
106 |
+
|
107 |
+
let capScore = 0
|
108 |
+
if (capTotal > 0) {
|
109 |
+
capScore =
|
110 |
+
((evaluation.capabilityEval.strong * weights.strong +
|
111 |
+
evaluation.capabilityEval.adequate * weights.adequate +
|
112 |
+
evaluation.capabilityEval.weak * weights.weak +
|
113 |
+
evaluation.capabilityEval.insufficient * weights.insufficient) /
|
114 |
+
(capTotal * 4)) *
|
115 |
+
100
|
116 |
+
}
|
117 |
+
|
118 |
+
let riskScore = 0
|
119 |
+
if (riskTotal > 0) {
|
120 |
+
riskScore =
|
121 |
+
((evaluation.riskEval.strong * weights.strong +
|
122 |
+
evaluation.riskEval.adequate * weights.adequate +
|
123 |
+
evaluation.riskEval.weak * weights.weak +
|
124 |
+
evaluation.riskEval.insufficient * weights.insufficient) /
|
125 |
+
(riskTotal * 4)) *
|
126 |
+
100
|
127 |
+
}
|
128 |
+
|
129 |
+
const totalApplicable = capTotal + riskTotal
|
130 |
+
const weightedScore = (capScore * capTotal + riskScore * riskTotal) / totalApplicable
|
131 |
+
|
132 |
+
// Ensure we return a valid number
|
133 |
+
return isNaN(weightedScore) ? "0.0" : weightedScore.toFixed(1)
|
134 |
+
}
|
135 |
+
|
136 |
+
const handleViewDetails = () => {
|
137 |
+
router.push(`/evaluation/${evaluation.id}`)
|
138 |
+
}
|
139 |
+
|
140 |
+
const completenessScore = Number.parseFloat(calculateCompletenessScore())
|
141 |
+
|
142 |
+
return (
|
143 |
+
<TooltipProvider>
|
144 |
+
<Card className="hover:shadow-lg transition-shadow duration-200">
|
145 |
+
<CardHeader className="pb-3">
|
146 |
+
<div className="flex items-start justify-between">
|
147 |
+
<div className="space-y-1 flex-1 min-w-0">
|
148 |
+
<CardTitle className="text-lg font-heading truncate">{evaluation.systemName}</CardTitle>
|
149 |
+
<p className="text-sm text-muted-foreground truncate">{evaluation.provider}</p>
|
150 |
+
{/* Pretty modality badge with emoji */}
|
151 |
+
{(() => {
|
152 |
+
const info = modalityMap[evaluation.modality] || { label: evaluation.modality }
|
153 |
+
return (
|
154 |
+
<Badge variant={info.variant as any || "secondary"} className="text-xs px-2 py-1 w-fit flex items-center gap-2">
|
155 |
+
{info.emoji ? <span aria-hidden>{info.emoji}</span> : null}
|
156 |
+
<span className="whitespace-nowrap">{info.label}</span>
|
157 |
+
</Badge>
|
158 |
+
)
|
159 |
+
})()}
|
160 |
+
</div>
|
161 |
+
<div className="flex items-center gap-2 flex-shrink-0">
|
162 |
+
<DropdownMenu>
|
163 |
+
<DropdownMenuTrigger asChild>
|
164 |
+
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
165 |
+
<MoreHorizontal className="h-4 w-4" />
|
166 |
+
</Button>
|
167 |
+
</DropdownMenuTrigger>
|
168 |
+
<DropdownMenuContent align="end">
|
169 |
+
<DropdownMenuItem onClick={handleViewDetails}>
|
170 |
+
<Eye className="h-4 w-4 mr-2" />
|
171 |
+
View Details
|
172 |
+
</DropdownMenuItem>
|
173 |
+
<DropdownMenuItem>
|
174 |
+
<Download className="h-4 w-4 mr-2" />
|
175 |
+
Export Report
|
176 |
+
</DropdownMenuItem>
|
177 |
+
<DropdownMenuItem onClick={() => onDelete(evaluation.id)} className="text-destructive">
|
178 |
+
<Trash2 className="h-4 w-4 mr-2" />
|
179 |
+
Delete
|
180 |
+
</DropdownMenuItem>
|
181 |
+
</DropdownMenuContent>
|
182 |
+
</DropdownMenu>
|
183 |
+
</div>
|
184 |
+
</div>
|
185 |
+
</CardHeader>
|
186 |
+
<CardContent className="space-y-4">
|
187 |
+
<div className="grid grid-cols-2 gap-4">
|
188 |
+
<div className="space-y-1">
|
189 |
+
<span className="text-sm text-muted-foreground font-medium">Completeness</span>
|
190 |
+
<Tooltip>
|
191 |
+
<TooltipTrigger asChild>
|
192 |
+
<div
|
193 |
+
className={`px-2 py-1 rounded text-sm font-semibold cursor-help ${getCompletenessColor(completenessScore)}`}
|
194 |
+
>
|
195 |
+
{completenessScore}%
|
196 |
+
</div>
|
197 |
+
</TooltipTrigger>
|
198 |
+
<TooltipContent className="max-w-xs">
|
199 |
+
<p className="text-xs">
|
200 |
+
Weighted average: (Strong×4 + Adequate×3 + Weak×2 + Insufficient×1) ÷ (Total×4) × 100
|
201 |
+
<br />
|
202 |
+
Capability: {evaluation.capabilityEval.totalApplicable} categories
|
203 |
+
<br />
|
204 |
+
Risk: {evaluation.riskEval.totalApplicable} categories
|
205 |
+
</p>
|
206 |
+
</TooltipContent>
|
207 |
+
</Tooltip>
|
208 |
+
</div>
|
209 |
+
<div className="space-y-1">
|
210 |
+
<span className="text-sm text-muted-foreground font-medium">Date</span>
|
211 |
+
<p className="text-sm font-semibold">{evaluation.completedDate}</p>
|
212 |
+
</div>
|
213 |
+
</div>
|
214 |
+
|
215 |
+
<div className="space-y-3">
|
216 |
+
<div className="space-y-2">
|
217 |
+
<p className="text-sm text-muted-foreground">
|
218 |
+
Capability Eval ({evaluation.capabilityEval.totalApplicable} applicable)
|
219 |
+
</p>
|
220 |
+
<div className="grid grid-cols-4 gap-4 text-xs items-end">
|
221 |
+
{/* Vertical histogram bars for Capability */}
|
222 |
+
<Tooltip>
|
223 |
+
<TooltipTrigger asChild>
|
224 |
+
<div className="text-center cursor-help">
|
225 |
+
<div className="w-full h-20 flex items-end justify-center mb-2">
|
226 |
+
<div
|
227 |
+
className="w-full rounded-t-md bg-emerald-500"
|
228 |
+
style={{
|
229 |
+
height: `${Math.round(
|
230 |
+
(evaluation.capabilityEval.strong / Math.max(1, capTotalComputed)) * 100
|
231 |
+
)}%`,
|
232 |
+
}}
|
233 |
+
/>
|
234 |
+
</div>
|
235 |
+
<div className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Strong</div>
|
236 |
+
<p className="font-medium">{evaluation.capabilityEval.strong}</p>
|
237 |
+
</div>
|
238 |
+
</TooltipTrigger>
|
239 |
+
<TooltipContent>
|
240 |
+
<p className="text-xs">
|
241 |
+
{evaluation.capabilityEval.strongCategories.length > 0
|
242 |
+
? evaluation.capabilityEval.strongCategories.join(", ")
|
243 |
+
: "No categories"}
|
244 |
+
</p>
|
245 |
+
</TooltipContent>
|
246 |
+
</Tooltip>
|
247 |
+
|
248 |
+
<Tooltip>
|
249 |
+
<TooltipTrigger asChild>
|
250 |
+
<div className="text-center cursor-help">
|
251 |
+
<div className="w-full h-20 flex items-end justify-center mb-2">
|
252 |
+
<div
|
253 |
+
className="w-full rounded-t-md bg-blue-500"
|
254 |
+
style={{
|
255 |
+
height: `${Math.round(
|
256 |
+
(evaluation.capabilityEval.adequate / Math.max(1, capTotalComputed)) * 100
|
257 |
+
)}%`,
|
258 |
+
}}
|
259 |
+
/>
|
260 |
+
</div>
|
261 |
+
<div className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Adequate</div>
|
262 |
+
<p className="font-medium">{evaluation.capabilityEval.adequate}</p>
|
263 |
+
</div>
|
264 |
+
</TooltipTrigger>
|
265 |
+
<TooltipContent>
|
266 |
+
<p className="text-xs">
|
267 |
+
{evaluation.capabilityEval.adequateCategories.length > 0
|
268 |
+
? evaluation.capabilityEval.adequateCategories.join(", ")
|
269 |
+
: "No categories"}
|
270 |
+
</p>
|
271 |
+
</TooltipContent>
|
272 |
+
</Tooltip>
|
273 |
+
|
274 |
+
<Tooltip>
|
275 |
+
<TooltipTrigger asChild>
|
276 |
+
<div className="text-center cursor-help">
|
277 |
+
<div className="w-full h-20 flex items-end justify-center mb-2">
|
278 |
+
<div
|
279 |
+
className="w-full rounded-t-md bg-amber-500"
|
280 |
+
style={{
|
281 |
+
height: `${Math.round(
|
282 |
+
(evaluation.capabilityEval.weak / Math.max(1, capTotalComputed)) * 100
|
283 |
+
)}%`,
|
284 |
+
}}
|
285 |
+
/>
|
286 |
+
</div>
|
287 |
+
<div className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Weak</div>
|
288 |
+
<p className="font-medium">{evaluation.capabilityEval.weak}</p>
|
289 |
+
</div>
|
290 |
+
</TooltipTrigger>
|
291 |
+
<TooltipContent>
|
292 |
+
<p className="text-xs">
|
293 |
+
{evaluation.capabilityEval.weakCategories.length > 0
|
294 |
+
? evaluation.capabilityEval.weakCategories.join(", ")
|
295 |
+
: "No categories"}
|
296 |
+
</p>
|
297 |
+
</TooltipContent>
|
298 |
+
</Tooltip>
|
299 |
+
|
300 |
+
<Tooltip>
|
301 |
+
<TooltipTrigger asChild>
|
302 |
+
<div className="text-center cursor-help">
|
303 |
+
<div className="w-full h-20 flex items-end justify-center mb-2">
|
304 |
+
<div
|
305 |
+
className="w-full rounded-t-md bg-red-500"
|
306 |
+
style={{
|
307 |
+
height: `${Math.round(
|
308 |
+
(evaluation.capabilityEval.insufficient / Math.max(1, capTotalComputed)) * 100
|
309 |
+
)}%`,
|
310 |
+
}}
|
311 |
+
/>
|
312 |
+
</div>
|
313 |
+
<div className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Insufficient</div>
|
314 |
+
<p className="font-medium">{evaluation.capabilityEval.insufficient}</p>
|
315 |
+
</div>
|
316 |
+
</TooltipTrigger>
|
317 |
+
<TooltipContent>
|
318 |
+
<p className="text-xs">
|
319 |
+
{evaluation.capabilityEval.insufficientCategories.length > 0
|
320 |
+
? evaluation.capabilityEval.insufficientCategories.join(", ")
|
321 |
+
: "No categories"}
|
322 |
+
</p>
|
323 |
+
</TooltipContent>
|
324 |
+
</Tooltip>
|
325 |
+
</div>
|
326 |
+
</div>
|
327 |
+
|
328 |
+
<div className="space-y-2">
|
329 |
+
<p className="text-sm text-muted-foreground">
|
330 |
+
Risk Eval ({evaluation.riskEval.totalApplicable} applicable)
|
331 |
+
</p>
|
332 |
+
<div className="grid grid-cols-4 gap-4 text-xs items-end">
|
333 |
+
{/* Vertical histogram bars for Risk */}
|
334 |
+
<Tooltip>
|
335 |
+
<TooltipTrigger asChild>
|
336 |
+
<div className="text-center cursor-help">
|
337 |
+
<div className="w-full h-20 flex items-end justify-center mb-2">
|
338 |
+
<div
|
339 |
+
className="w-full rounded-t-md bg-emerald-500"
|
340 |
+
style={{
|
341 |
+
height: `${Math.round(
|
342 |
+
(evaluation.riskEval.strong / Math.max(1, riskTotalComputed)) * 100
|
343 |
+
)}%`,
|
344 |
+
}}
|
345 |
+
/>
|
346 |
+
</div>
|
347 |
+
<div className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Strong</div>
|
348 |
+
<p className="font-medium">{evaluation.riskEval.strong}</p>
|
349 |
+
</div>
|
350 |
+
</TooltipTrigger>
|
351 |
+
<TooltipContent>
|
352 |
+
<p className="text-xs">
|
353 |
+
{evaluation.riskEval.strongCategories.length > 0
|
354 |
+
? evaluation.riskEval.strongCategories.join(", ")
|
355 |
+
: "No categories"}
|
356 |
+
</p>
|
357 |
+
</TooltipContent>
|
358 |
+
</Tooltip>
|
359 |
+
|
360 |
+
<Tooltip>
|
361 |
+
<TooltipTrigger asChild>
|
362 |
+
<div className="text-center cursor-help">
|
363 |
+
<div className="w-full h-20 flex items-end justify-center mb-2">
|
364 |
+
<div
|
365 |
+
className="w-full rounded-t-md bg-blue-500"
|
366 |
+
style={{
|
367 |
+
height: `${Math.round(
|
368 |
+
(evaluation.riskEval.adequate / Math.max(1, riskTotalComputed)) * 100
|
369 |
+
)}%`,
|
370 |
+
}}
|
371 |
+
/>
|
372 |
+
</div>
|
373 |
+
<div className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Adequate</div>
|
374 |
+
<p className="font-medium">{evaluation.riskEval.adequate}</p>
|
375 |
+
</div>
|
376 |
+
</TooltipTrigger>
|
377 |
+
<TooltipContent>
|
378 |
+
<p className="text-xs">
|
379 |
+
{evaluation.riskEval.adequateCategories.length > 0
|
380 |
+
? evaluation.riskEval.adequateCategories.join(", ")
|
381 |
+
: "No categories"}
|
382 |
+
</p>
|
383 |
+
</TooltipContent>
|
384 |
+
</Tooltip>
|
385 |
+
|
386 |
+
<Tooltip>
|
387 |
+
<TooltipTrigger asChild>
|
388 |
+
<div className="text-center cursor-help">
|
389 |
+
<div className="w-full h-20 flex items-end justify-center mb-2">
|
390 |
+
<div
|
391 |
+
className="w-full rounded-t-md bg-amber-500"
|
392 |
+
style={{
|
393 |
+
height: `${Math.round(
|
394 |
+
(evaluation.riskEval.weak / Math.max(1, riskTotalComputed)) * 100
|
395 |
+
)}%`,
|
396 |
+
}}
|
397 |
+
/>
|
398 |
+
</div>
|
399 |
+
<div className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Weak</div>
|
400 |
+
<p className="font-medium">{evaluation.riskEval.weak}</p>
|
401 |
+
</div>
|
402 |
+
</TooltipTrigger>
|
403 |
+
<TooltipContent>
|
404 |
+
<p className="text-xs">
|
405 |
+
{evaluation.riskEval.weakCategories.length > 0
|
406 |
+
? evaluation.riskEval.weakCategories.join(", ")
|
407 |
+
: "No categories"}
|
408 |
+
</p>
|
409 |
+
</TooltipContent>
|
410 |
+
</Tooltip>
|
411 |
+
|
412 |
+
<Tooltip>
|
413 |
+
<TooltipTrigger asChild>
|
414 |
+
<div className="text-center cursor-help">
|
415 |
+
<div className="w-full h-20 flex items-end justify-center mb-2">
|
416 |
+
<div
|
417 |
+
className="w-full rounded-t-md bg-red-500"
|
418 |
+
style={{
|
419 |
+
height: `${Math.round(
|
420 |
+
(evaluation.riskEval.insufficient / Math.max(1, riskTotalComputed)) * 100
|
421 |
+
)}%`,
|
422 |
+
}}
|
423 |
+
/>
|
424 |
+
</div>
|
425 |
+
<div className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Insufficient</div>
|
426 |
+
<p className="font-medium">{evaluation.riskEval.insufficient}</p>
|
427 |
+
</div>
|
428 |
+
</TooltipTrigger>
|
429 |
+
<TooltipContent>
|
430 |
+
<p className="text-xs">
|
431 |
+
{evaluation.riskEval.insufficientCategories.length > 0
|
432 |
+
? evaluation.riskEval.insufficientCategories.join(", ")
|
433 |
+
: "No categories"}
|
434 |
+
</p>
|
435 |
+
</TooltipContent>
|
436 |
+
</Tooltip>
|
437 |
+
</div>
|
438 |
+
</div>
|
439 |
+
</div>
|
440 |
+
</CardContent>
|
441 |
+
{/* priorityAreas/details intentionally removed from summary card — shown on details page */}
|
442 |
+
</Card>
|
443 |
+
</TooltipProvider>
|
444 |
+
)
|
445 |
+
}
|
components/evaluation-form.tsx
ADDED
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import { useState } from "react"
|
4 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
5 |
+
import { Button } from "@/components/ui/button"
|
6 |
+
import { Badge } from "@/components/ui/badge"
|
7 |
+
import { Progress } from "@/components/ui/progress"
|
8 |
+
import { CategoryEvaluation } from "@/components/category-evaluation"
|
9 |
+
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
10 |
+
import type { CategoryScore } from "@/components/ai-evaluation-dashboard"
|
11 |
+
import { CheckCircle, Circle, ArrowRight, ArrowLeft } from "lucide-react"
|
12 |
+
|
13 |
+
interface Category {
|
14 |
+
id: string
|
15 |
+
name: string
|
16 |
+
type: "capability" | "risk"
|
17 |
+
description: string
|
18 |
+
detailedGuidance: string
|
19 |
+
}
|
20 |
+
|
21 |
+
interface EvaluationFormProps {
|
22 |
+
categories: Category[]
|
23 |
+
selectedCategories: string[]
|
24 |
+
categoryScores: Record<string, CategoryScore>
|
25 |
+
onScoreUpdate: (categoryId: string, score: CategoryScore) => void
|
26 |
+
onComplete: () => void
|
27 |
+
}
|
28 |
+
|
29 |
+
export function EvaluationForm({
|
30 |
+
categories,
|
31 |
+
selectedCategories,
|
32 |
+
categoryScores,
|
33 |
+
onScoreUpdate,
|
34 |
+
onComplete,
|
35 |
+
}: EvaluationFormProps) {
|
36 |
+
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0)
|
37 |
+
|
38 |
+
const selectedCategoryObjects = categories.filter((c) => selectedCategories.includes(c.id))
|
39 |
+
const currentCategory = selectedCategoryObjects[currentCategoryIndex]
|
40 |
+
|
41 |
+
const completedCount = Object.keys(categoryScores).length
|
42 |
+
const totalCount = selectedCategories.length
|
43 |
+
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
|
44 |
+
|
45 |
+
const handleNext = () => {
|
46 |
+
if (currentCategoryIndex < selectedCategoryObjects.length - 1) {
|
47 |
+
setCurrentCategoryIndex((prev) => prev + 1)
|
48 |
+
}
|
49 |
+
}
|
50 |
+
|
51 |
+
const handlePrevious = () => {
|
52 |
+
if (currentCategoryIndex > 0) {
|
53 |
+
setCurrentCategoryIndex((prev) => prev - 1)
|
54 |
+
}
|
55 |
+
}
|
56 |
+
|
57 |
+
const handleCategorySelect = (categoryId: string) => {
|
58 |
+
const index = selectedCategoryObjects.findIndex((c) => c.id === categoryId)
|
59 |
+
if (index !== -1) {
|
60 |
+
setCurrentCategoryIndex(index)
|
61 |
+
}
|
62 |
+
}
|
63 |
+
|
64 |
+
const isCurrentCategoryCompleted = currentCategory && categoryScores[currentCategory.id]
|
65 |
+
const allCompleted = completedCount === totalCount
|
66 |
+
|
67 |
+
return (
|
68 |
+
<div className="space-y-6 h-full min-h-0 flex flex-col">
|
69 |
+
{/* Progress Header */}
|
70 |
+
<Card>
|
71 |
+
<CardHeader>
|
72 |
+
<div className="flex items-center justify-between">
|
73 |
+
<div>
|
74 |
+
<CardTitle className="font-heading">Detailed Evaluation</CardTitle>
|
75 |
+
<CardDescription>Complete the evaluation for each selected category</CardDescription>
|
76 |
+
</div>
|
77 |
+
<div className="text-right">
|
78 |
+
<div className="flex items-center gap-2 mb-2">
|
79 |
+
<span className="text-sm font-medium">Progress:</span>
|
80 |
+
<Badge variant="secondary">
|
81 |
+
{completedCount}/{totalCount}
|
82 |
+
</Badge>
|
83 |
+
</div>
|
84 |
+
<Progress value={progress} className="w-32" />
|
85 |
+
</div>
|
86 |
+
</div>
|
87 |
+
</CardHeader>
|
88 |
+
</Card>
|
89 |
+
|
90 |
+
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 flex-1 min-h-0">
|
91 |
+
{/* Category Navigation Sidebar as Tabs */}
|
92 |
+
<Card className="lg:col-span-1 h-full">
|
93 |
+
<CardHeader>
|
94 |
+
<CardTitle className="text-lg">Categories</CardTitle>
|
95 |
+
</CardHeader>
|
96 |
+
<CardContent className="h-full min-h-0 flex flex-col">
|
97 |
+
<Tabs defaultValue={selectedCategoryObjects[0]?.id || ""} orientation="vertical" value={selectedCategoryObjects[currentCategoryIndex]?.id || ""} onValueChange={(val) => handleCategorySelect(val)}>
|
98 |
+
<TabsList className="flex-col w-full flex-1 min-h-0 overflow-auto">
|
99 |
+
{selectedCategoryObjects.map((category, index) => {
|
100 |
+
const isCompleted = categoryScores[category.id]
|
101 |
+
return (
|
102 |
+
<TabsTrigger
|
103 |
+
key={category.id}
|
104 |
+
value={category.id}
|
105 |
+
className={`w-full text-left p-3 rounded-md ${
|
106 |
+
category.type === "capability"
|
107 |
+
? "bg-purple-50 data-[state=active]:bg-purple-200 data-[state=active]:border-l-4 data-[state=active]:border-l-purple-600"
|
108 |
+
: "bg-red-50 data-[state=active]:bg-red-200 data-[state=active]:border-l-4 data-[state=active]:border-l-red-600"
|
109 |
+
}`}
|
110 |
+
>
|
111 |
+
<div className="flex items-start gap-3 w-full">
|
112 |
+
<div className="flex items-center">
|
113 |
+
{isCompleted ? <CheckCircle className="h-4 w-4 text-green-600" /> : <Circle className="h-4 w-4 text-muted-foreground" />}
|
114 |
+
</div>
|
115 |
+
|
116 |
+
<div className="flex-1 min-w-0">
|
117 |
+
<div className="flex flex-col">
|
118 |
+
<span className="text-sm font-medium leading-snug truncate">{category.name}</span>
|
119 |
+
<span
|
120 |
+
className={`inline-block text-xs font-medium mt-1 px-2 py-0.5 rounded-full w-max ${
|
121 |
+
category.type === "capability" ? "bg-purple-100 text-purple-800" : "bg-red-100 text-red-800"
|
122 |
+
}`}
|
123 |
+
>
|
124 |
+
{category.type}
|
125 |
+
</span>
|
126 |
+
</div>
|
127 |
+
</div>
|
128 |
+
|
129 |
+
{isCompleted && (
|
130 |
+
<div className="flex items-center">
|
131 |
+
<span className="text-xs text-muted-foreground">
|
132 |
+
{categoryScores[category.id].totalScore}/{categoryScores[category.id].totalApplicable || categoryScores[category.id].totalQuestions || 0}
|
133 |
+
</span>
|
134 |
+
</div>
|
135 |
+
)}
|
136 |
+
</div>
|
137 |
+
</TabsTrigger>
|
138 |
+
)
|
139 |
+
})}
|
140 |
+
</TabsList>
|
141 |
+
</Tabs>
|
142 |
+
</CardContent>
|
143 |
+
</Card>
|
144 |
+
|
145 |
+
{/* Current Category Evaluation */}
|
146 |
+
<div className="lg:col-span-3">
|
147 |
+
{currentCategory && (
|
148 |
+
<CategoryEvaluation
|
149 |
+
key={currentCategory.id}
|
150 |
+
category={currentCategory}
|
151 |
+
score={categoryScores[currentCategory.id]}
|
152 |
+
onScoreUpdate={(score) => onScoreUpdate(currentCategory.id, score)}
|
153 |
+
/>
|
154 |
+
)}
|
155 |
+
|
156 |
+
{/* Navigation Controls */}
|
157 |
+
<Card className="mt-6">
|
158 |
+
<CardContent className="pt-6">
|
159 |
+
<div className="flex items-center justify-between">
|
160 |
+
<Button variant="outline" onClick={handlePrevious} disabled={currentCategoryIndex === 0}>
|
161 |
+
<ArrowLeft className="h-4 w-4 mr-2" />
|
162 |
+
Previous
|
163 |
+
</Button>
|
164 |
+
|
165 |
+
<div className="text-center">
|
166 |
+
<p className="text-sm text-muted-foreground">
|
167 |
+
Category {currentCategoryIndex + 1} of {totalCount}
|
168 |
+
</p>
|
169 |
+
{currentCategory && <p className="font-medium">{currentCategory.name}</p>}
|
170 |
+
</div>
|
171 |
+
|
172 |
+
{currentCategoryIndex < selectedCategoryObjects.length - 1 ? (
|
173 |
+
<Button onClick={handleNext} disabled={!isCurrentCategoryCompleted}>
|
174 |
+
Next
|
175 |
+
<ArrowRight className="h-4 w-4 ml-2" />
|
176 |
+
</Button>
|
177 |
+
) : (
|
178 |
+
<Button onClick={onComplete} disabled={!allCompleted} className="bg-green-600 hover:bg-green-700">
|
179 |
+
View Results
|
180 |
+
<ArrowRight className="h-4 w-4 ml-2" />
|
181 |
+
</Button>
|
182 |
+
)}
|
183 |
+
</div>
|
184 |
+
</CardContent>
|
185 |
+
</Card>
|
186 |
+
</div>
|
187 |
+
</div>
|
188 |
+
</div>
|
189 |
+
)
|
190 |
+
}
|
components/results-dashboard.tsx
ADDED
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
4 |
+
import { Badge } from "@/components/ui/badge"
|
5 |
+
import { Button } from "@/components/ui/button"
|
6 |
+
import type { SystemInfo, CategoryScore } from "@/components/ai-evaluation-dashboard"
|
7 |
+
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from "recharts"
|
8 |
+
import { Download, AlertTriangle, CheckCircle, AlertCircle, XCircle } from "lucide-react"
|
9 |
+
|
10 |
+
interface Category {
|
11 |
+
id: string
|
12 |
+
name: string
|
13 |
+
type: "capability" | "risk"
|
14 |
+
}
|
15 |
+
|
16 |
+
interface ResultsDashboardProps {
|
17 |
+
systemInfo: SystemInfo | null
|
18 |
+
categories: Category[]
|
19 |
+
selectedCategories: string[]
|
20 |
+
categoryScores: Record<string, CategoryScore>
|
21 |
+
excludedCategoryReasons?: Record<string, string>
|
22 |
+
}
|
23 |
+
|
24 |
+
const STATUS_COLORS = {
|
25 |
+
strong: "#22c55e",
|
26 |
+
adequate: "#3b82f6",
|
27 |
+
weak: "#f59e0b",
|
28 |
+
insufficient: "#ef4444",
|
29 |
+
}
|
30 |
+
|
31 |
+
const STATUS_ICONS = {
|
32 |
+
strong: CheckCircle,
|
33 |
+
adequate: CheckCircle,
|
34 |
+
weak: AlertCircle,
|
35 |
+
insufficient: XCircle,
|
36 |
+
}
|
37 |
+
|
38 |
+
export function ResultsDashboard({
|
39 |
+
systemInfo,
|
40 |
+
categories,
|
41 |
+
selectedCategories,
|
42 |
+
categoryScores,
|
43 |
+
excludedCategoryReasons,
|
44 |
+
}: ResultsDashboardProps) {
|
45 |
+
const safeCategories = categories || []
|
46 |
+
const safeSelectedCategories = selectedCategories || []
|
47 |
+
const safeCategoryScores = categoryScores || {}
|
48 |
+
|
49 |
+
console.log("[v0] ResultsDashboard rendering with:", {
|
50 |
+
systemInfo,
|
51 |
+
categoriesCount: safeCategories.length,
|
52 |
+
selectedCount: safeSelectedCategories.length,
|
53 |
+
scoresCount: Object.keys(safeCategoryScores).length,
|
54 |
+
scores: safeCategoryScores,
|
55 |
+
})
|
56 |
+
|
57 |
+
const selectedCategoryObjects = safeCategories.filter((c) => safeSelectedCategories.includes(c.id))
|
58 |
+
|
59 |
+
const getStatusCounts = () => {
|
60 |
+
const scores = Object.values(safeCategoryScores)
|
61 |
+
return {
|
62 |
+
strong: scores.filter((s) => s.status === "strong").length,
|
63 |
+
adequate: scores.filter((s) => s.status === "adequate").length,
|
64 |
+
weak: scores.filter((s) => s.status === "weak").length,
|
65 |
+
insufficient: scores.filter((s) => s.status === "insufficient").length,
|
66 |
+
}
|
67 |
+
}
|
68 |
+
|
69 |
+
const getCapabilityRiskBreakdown = () => {
|
70 |
+
const capability = selectedCategoryObjects.filter((c) => c.type === "capability")
|
71 |
+
const risk = selectedCategoryObjects.filter((c) => c.type === "risk")
|
72 |
+
|
73 |
+
const capabilityScores = capability.map((c) => safeCategoryScores[c.id]).filter(Boolean)
|
74 |
+
const riskScores = risk.map((c) => safeCategoryScores[c.id]).filter(Boolean)
|
75 |
+
|
76 |
+
const avgCapability =
|
77 |
+
capabilityScores.length > 0
|
78 |
+
? capabilityScores.reduce((sum, s) => sum + (s.totalScore || 0), 0) / capabilityScores.length
|
79 |
+
: 0
|
80 |
+
const avgRisk =
|
81 |
+
riskScores.length > 0 ? riskScores.reduce((sum, s) => sum + (s.totalScore || 0), 0) / riskScores.length : 0
|
82 |
+
|
83 |
+
const safeCapability = isNaN(avgCapability) || !isFinite(avgCapability) ? 0 : avgCapability
|
84 |
+
const safeRisk = isNaN(avgRisk) || !isFinite(avgRisk) ? 0 : avgRisk
|
85 |
+
|
86 |
+
console.log("[v0] Capability/Risk breakdown:", {
|
87 |
+
capabilityScores: capabilityScores.length,
|
88 |
+
riskScores: riskScores.length,
|
89 |
+
avgCapability,
|
90 |
+
avgRisk,
|
91 |
+
safeCapability,
|
92 |
+
safeRisk,
|
93 |
+
})
|
94 |
+
|
95 |
+
return {
|
96 |
+
capability: safeCapability,
|
97 |
+
risk: safeRisk,
|
98 |
+
}
|
99 |
+
}
|
100 |
+
|
101 |
+
const getChartData = () => {
|
102 |
+
return selectedCategoryObjects
|
103 |
+
.map((category) => {
|
104 |
+
const score = safeCategoryScores[category.id]
|
105 |
+
return {
|
106 |
+
name: category.name.length > 20 ? category.name.substring(0, 20) + "..." : category.name,
|
107 |
+
fullName: category.name,
|
108 |
+
benchmarkScore: score?.benchmarkScore || 0,
|
109 |
+
processScore: score?.processScore || 0,
|
110 |
+
totalScore: score?.totalScore || 0,
|
111 |
+
type: category.type,
|
112 |
+
}
|
113 |
+
})
|
114 |
+
.sort((a, b) => b.totalScore - a.totalScore)
|
115 |
+
}
|
116 |
+
|
117 |
+
const getPieData = () => {
|
118 |
+
const counts = getStatusCounts()
|
119 |
+
return [
|
120 |
+
{ name: "Strong (12-15)", value: counts.strong, color: STATUS_COLORS.strong },
|
121 |
+
{ name: "Adequate (8-11)", value: counts.adequate, color: STATUS_COLORS.adequate },
|
122 |
+
{ name: "Weak (4-7)", value: counts.weak, color: STATUS_COLORS.weak },
|
123 |
+
{ name: "Insufficient (0-3)", value: counts.insufficient, color: STATUS_COLORS.insufficient },
|
124 |
+
].filter((item) => item.value > 0)
|
125 |
+
}
|
126 |
+
|
127 |
+
const statusCounts = getStatusCounts()
|
128 |
+
const breakdown = getCapabilityRiskBreakdown()
|
129 |
+
const chartData = getChartData()
|
130 |
+
const pieData = getPieData()
|
131 |
+
|
132 |
+
const totalEvaluated = Object.keys(safeCategoryScores).length
|
133 |
+
const overallAverage =
|
134 |
+
totalEvaluated > 0
|
135 |
+
? Object.values(safeCategoryScores).reduce((sum, s) => sum + (s.totalScore || 0), 0) / totalEvaluated
|
136 |
+
: 0
|
137 |
+
|
138 |
+
const safeOverallAverage = isNaN(overallAverage) || !isFinite(overallAverage) ? 0 : overallAverage
|
139 |
+
|
140 |
+
console.log("[v0] Overall average calculation:", {
|
141 |
+
totalEvaluated,
|
142 |
+
overallAverage,
|
143 |
+
safeOverallAverage,
|
144 |
+
})
|
145 |
+
|
146 |
+
const safeToFixed = (value: number, digits = 1): string => {
|
147 |
+
if (isNaN(value) || !isFinite(value)) {
|
148 |
+
console.log("[v0] Warning: Invalid value for toFixed:", value)
|
149 |
+
return "0.0"
|
150 |
+
}
|
151 |
+
return value.toFixed(digits)
|
152 |
+
}
|
153 |
+
|
154 |
+
const exportResults = () => {
|
155 |
+
const results = {
|
156 |
+
systemInfo,
|
157 |
+
evaluationDate: new Date().toISOString(),
|
158 |
+
summary: {
|
159 |
+
totalCategories: safeSelectedCategories.length,
|
160 |
+
evaluatedCategories: totalEvaluated,
|
161 |
+
overallAverage: safeOverallAverage.toFixed(1),
|
162 |
+
statusBreakdown: statusCounts,
|
163 |
+
},
|
164 |
+
categoryResults: safeCategories.map((category) => ({
|
165 |
+
...category,
|
166 |
+
score: safeCategoryScores[category.id] || null,
|
167 |
+
excludedReason: !safeSelectedCategories.includes(category.id)
|
168 |
+
? excludedCategoryReasons?.[category.id] || null
|
169 |
+
: null,
|
170 |
+
})),
|
171 |
+
}
|
172 |
+
|
173 |
+
const blob = new Blob([JSON.stringify(results, null, 2)], { type: "application/json" })
|
174 |
+
const url = URL.createObjectURL(blob)
|
175 |
+
const a = document.createElement("a")
|
176 |
+
a.href = url
|
177 |
+
a.download = `ai-evaluation-${systemInfo?.name || "system"}-${new Date().toISOString().split("T")[0]}.json`
|
178 |
+
a.click()
|
179 |
+
URL.revokeObjectURL(url)
|
180 |
+
}
|
181 |
+
|
182 |
+
return (
|
183 |
+
<div className="space-y-6">
|
184 |
+
{/* System Overview */}
|
185 |
+
<Card>
|
186 |
+
<CardHeader>
|
187 |
+
<div className="flex items-center justify-between">
|
188 |
+
<div>
|
189 |
+
<CardTitle className="font-heading">Evaluation Results</CardTitle>
|
190 |
+
<CardDescription>Comprehensive assessment results for {systemInfo?.name}</CardDescription>
|
191 |
+
</div>
|
192 |
+
<Button onClick={exportResults} variant="outline">
|
193 |
+
<Download className="h-4 w-4 mr-2" />
|
194 |
+
Export Results
|
195 |
+
</Button>
|
196 |
+
</div>
|
197 |
+
</CardHeader>
|
198 |
+
<CardContent>
|
199 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
200 |
+
<div className="text-center">
|
201 |
+
<div className="text-2xl font-bold text-foreground">{totalEvaluated}</div>
|
202 |
+
<div className="text-sm text-muted-foreground">Categories Evaluated</div>
|
203 |
+
</div>
|
204 |
+
<div className="text-center">
|
205 |
+
<div className="text-2xl font-bold text-foreground">{safeToFixed(safeOverallAverage)}/15</div>
|
206 |
+
<div className="text-sm text-muted-foreground">Overall Average</div>
|
207 |
+
</div>
|
208 |
+
<div className="text-center">
|
209 |
+
<div className="text-2xl font-bold text-foreground">{safeToFixed(breakdown.capability)}/15</div>
|
210 |
+
<div className="text-sm text-muted-foreground">Capability Average</div>
|
211 |
+
</div>
|
212 |
+
<div className="text-center">
|
213 |
+
<div className="text-2xl font-bold text-foreground">{safeToFixed(breakdown.risk)}/15</div>
|
214 |
+
<div className="text-sm text-muted-foreground">Risk Average</div>
|
215 |
+
</div>
|
216 |
+
</div>
|
217 |
+
</CardContent>
|
218 |
+
</Card>
|
219 |
+
|
220 |
+
{/* Status Overview */}
|
221 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
222 |
+
{Object.entries(statusCounts).map(([status, count]) => {
|
223 |
+
const key = (status as string) as keyof typeof STATUS_ICONS
|
224 |
+
const Icon = STATUS_ICONS[key] ?? AlertTriangle
|
225 |
+
return (
|
226 |
+
<Card key={status}>
|
227 |
+
<CardContent className="pt-6">
|
228 |
+
<div className="flex items-center justify-between">
|
229 |
+
<div>
|
230 |
+
<div className="text-2xl font-bold">{count}</div>
|
231 |
+
<div className="text-sm text-muted-foreground capitalize">{status}</div>
|
232 |
+
</div>
|
233 |
+
<Icon className="h-8 w-8" style={{ color: STATUS_COLORS[key] ?? STATUS_COLORS.insufficient }} />
|
234 |
+
</div>
|
235 |
+
</CardContent>
|
236 |
+
</Card>
|
237 |
+
)
|
238 |
+
})}
|
239 |
+
</div>
|
240 |
+
|
241 |
+
{/* Charts */}
|
242 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
243 |
+
{/* Score Distribution */}
|
244 |
+
<Card>
|
245 |
+
<CardHeader>
|
246 |
+
<CardTitle>Score Distribution</CardTitle>
|
247 |
+
<CardDescription>Categories by evaluation status</CardDescription>
|
248 |
+
</CardHeader>
|
249 |
+
<CardContent>
|
250 |
+
<ResponsiveContainer width="100%" height={300}>
|
251 |
+
<PieChart>
|
252 |
+
<Pie
|
253 |
+
data={pieData}
|
254 |
+
cx="50%"
|
255 |
+
cy="50%"
|
256 |
+
outerRadius={80}
|
257 |
+
dataKey="value"
|
258 |
+
label={({ name, value }) => `${name}: ${value}`}
|
259 |
+
>
|
260 |
+
{pieData.map((entry, index) => (
|
261 |
+
<Cell key={`cell-${index}`} fill={entry.color} />
|
262 |
+
))}
|
263 |
+
</Pie>
|
264 |
+
<Tooltip />
|
265 |
+
</PieChart>
|
266 |
+
</ResponsiveContainer>
|
267 |
+
</CardContent>
|
268 |
+
</Card>
|
269 |
+
|
270 |
+
{/* Category Scores */}
|
271 |
+
<Card>
|
272 |
+
<CardHeader>
|
273 |
+
<CardTitle>Category Scores</CardTitle>
|
274 |
+
<CardDescription>Benchmark vs Process scores by category</CardDescription>
|
275 |
+
</CardHeader>
|
276 |
+
<CardContent>
|
277 |
+
<ResponsiveContainer width="100%" height={300}>
|
278 |
+
<BarChart data={chartData.slice(0, 8)} layout="horizontal">
|
279 |
+
<CartesianGrid strokeDasharray="3 3" />
|
280 |
+
<XAxis type="number" domain={[0, 15]} />
|
281 |
+
<YAxis dataKey="name" type="category" width={100} />
|
282 |
+
<Tooltip labelFormatter={(label) => chartData.find((d) => d.name === label)?.fullName || label} />
|
283 |
+
<Bar dataKey="benchmarkScore" stackId="a" fill="#3b82f6" name="Benchmark" />
|
284 |
+
<Bar dataKey="processScore" stackId="a" fill="#8b5cf6" name="Process" />
|
285 |
+
</BarChart>
|
286 |
+
</ResponsiveContainer>
|
287 |
+
</CardContent>
|
288 |
+
</Card>
|
289 |
+
</div>
|
290 |
+
|
291 |
+
{/* Detailed Results */}
|
292 |
+
<Card>
|
293 |
+
<CardHeader>
|
294 |
+
<CardTitle>Detailed Category Results</CardTitle>
|
295 |
+
<CardDescription>Complete breakdown of all evaluated categories</CardDescription>
|
296 |
+
</CardHeader>
|
297 |
+
<CardContent>
|
298 |
+
<div className="space-y-4">
|
299 |
+
{selectedCategoryObjects.map((category) => {
|
300 |
+
const score = safeCategoryScores[category.id]
|
301 |
+
if (!score) return null
|
302 |
+
|
303 |
+
const key = (score.status as string) as keyof typeof STATUS_ICONS
|
304 |
+
const Icon = (STATUS_ICONS as any)[key] ?? AlertTriangle
|
305 |
+
const color = (STATUS_COLORS as any)[key] ?? STATUS_COLORS.insufficient
|
306 |
+
|
307 |
+
return (
|
308 |
+
<div key={category.id} className="flex items-center justify-between p-4 border rounded-lg">
|
309 |
+
<div className="flex items-center gap-3">
|
310 |
+
<Icon className="h-5 w-5" style={{ color }} />
|
311 |
+
<div>
|
312 |
+
<div className="font-medium">{category.name}</div>
|
313 |
+
<div className="flex items-center gap-2 mt-1">
|
314 |
+
<Badge
|
315 |
+
variant={category.type === "capability" ? "secondary" : "destructive"}
|
316 |
+
className="text-xs"
|
317 |
+
>
|
318 |
+
{category.type}
|
319 |
+
</Badge>
|
320 |
+
<span className="text-sm text-muted-foreground">
|
321 |
+
Benchmark: {score.benchmarkScore}/7 | Process: {score.processScore}/8
|
322 |
+
</span>
|
323 |
+
</div>
|
324 |
+
</div>
|
325 |
+
</div>
|
326 |
+
<div className="text-right">
|
327 |
+
<div className="text-lg font-bold">{score.totalScore}/15</div>
|
328 |
+
<Badge
|
329 |
+
variant={
|
330 |
+
score.status === "strong"
|
331 |
+
? "default"
|
332 |
+
: score.status === "adequate"
|
333 |
+
? "secondary"
|
334 |
+
: score.status === "weak"
|
335 |
+
? "outline"
|
336 |
+
: "destructive"
|
337 |
+
}
|
338 |
+
className="text-xs"
|
339 |
+
>
|
340 |
+
{score.status}
|
341 |
+
</Badge>
|
342 |
+
</div>
|
343 |
+
</div>
|
344 |
+
)
|
345 |
+
})}
|
346 |
+
</div>
|
347 |
+
</CardContent>
|
348 |
+
</Card>
|
349 |
+
|
350 |
+
{/* Excluded Categories with Reasons */}
|
351 |
+
<Card>
|
352 |
+
<CardHeader>
|
353 |
+
<CardTitle>Excluded Categories & Reasons</CardTitle>
|
354 |
+
<CardDescription>Categories you marked as not applicable and the rationale provided</CardDescription>
|
355 |
+
</CardHeader>
|
356 |
+
<CardContent>
|
357 |
+
<div className="space-y-3">
|
358 |
+
{safeCategories
|
359 |
+
.filter((c) => !safeSelectedCategories.includes(c.id))
|
360 |
+
.map((c) => (
|
361 |
+
<div key={c.id} className="p-3 border rounded-md">
|
362 |
+
<div className="font-medium">{c.name}</div>
|
363 |
+
<div className="text-sm text-muted-foreground mt-1">
|
364 |
+
{excludedCategoryReasons?.[c.id] || "No reason provided"}
|
365 |
+
</div>
|
366 |
+
</div>
|
367 |
+
))}
|
368 |
+
</div>
|
369 |
+
</CardContent>
|
370 |
+
</Card>
|
371 |
+
|
372 |
+
{/* Priority Actions */}
|
373 |
+
<Card>
|
374 |
+
<CardHeader>
|
375 |
+
<CardTitle className="flex items-center gap-2">
|
376 |
+
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
377 |
+
Priority Action Areas
|
378 |
+
</CardTitle>
|
379 |
+
<CardDescription>Categories requiring immediate attention</CardDescription>
|
380 |
+
</CardHeader>
|
381 |
+
<CardContent>
|
382 |
+
<div className="space-y-4">
|
383 |
+
{statusCounts.insufficient > 0 && (
|
384 |
+
<div>
|
385 |
+
<h4 className="font-medium text-destructive mb-2">
|
386 |
+
Critical - Insufficient Categories ({statusCounts.insufficient})
|
387 |
+
</h4>
|
388 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
389 |
+
{selectedCategoryObjects
|
390 |
+
.filter((c) => safeCategoryScores[c.id]?.status === "insufficient")
|
391 |
+
.map((category) => (
|
392 |
+
<Badge key={category.id} variant="destructive" className="justify-start">
|
393 |
+
{category.name}
|
394 |
+
</Badge>
|
395 |
+
))}
|
396 |
+
</div>
|
397 |
+
</div>
|
398 |
+
)}
|
399 |
+
|
400 |
+
{statusCounts.weak > 0 && (
|
401 |
+
<div>
|
402 |
+
<h4 className="font-medium text-amber-600 mb-2">
|
403 |
+
High Priority - Weak Categories ({statusCounts.weak})
|
404 |
+
</h4>
|
405 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
406 |
+
{selectedCategoryObjects
|
407 |
+
.filter((c) => safeCategoryScores[c.id]?.status === "weak")
|
408 |
+
.map((category) => (
|
409 |
+
<Badge key={category.id} variant="outline" className="justify-start">
|
410 |
+
{category.name}
|
411 |
+
</Badge>
|
412 |
+
))}
|
413 |
+
</div>
|
414 |
+
</div>
|
415 |
+
)}
|
416 |
+
|
417 |
+
{statusCounts.insufficient === 0 && statusCounts.weak === 0 && (
|
418 |
+
<div className="text-center py-8 text-muted-foreground">
|
419 |
+
<CheckCircle className="h-12 w-12 mx-auto mb-2 text-green-600" />
|
420 |
+
<p>No critical priority areas identified. All categories scored adequate or above.</p>
|
421 |
+
</div>
|
422 |
+
)}
|
423 |
+
</div>
|
424 |
+
</CardContent>
|
425 |
+
</Card>
|
426 |
+
</div>
|
427 |
+
)
|
428 |
+
}
|
components/system-info-form.tsx
ADDED
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import type React from "react"
|
4 |
+
|
5 |
+
import { useState } from "react"
|
6 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
7 |
+
import { Button } from "@/components/ui/button"
|
8 |
+
import { Input } from "@/components/ui/input"
|
9 |
+
import { Label } from "@/components/ui/label"
|
10 |
+
import { Checkbox } from "@/components/ui/checkbox"
|
11 |
+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
12 |
+
import type { SystemInfo } from "@/components/ai-evaluation-dashboard"
|
13 |
+
|
14 |
+
interface SystemInfoFormProps {
|
15 |
+
onSubmit: (data: SystemInfo) => void
|
16 |
+
initialData: SystemInfo | null
|
17 |
+
}
|
18 |
+
|
19 |
+
const SYSTEM_TYPES = [
|
20 |
+
"Text-to-Text (e.g., chatbots, language models)",
|
21 |
+
"Text-to-Image (e.g., image generation)",
|
22 |
+
"Image-to-Text (e.g., image captioning, OCR)",
|
23 |
+
"Image-to-Image (e.g., image editing, style transfer)",
|
24 |
+
"Audio/Speech (e.g., speech recognition, text-to-speech)",
|
25 |
+
"Video (e.g., video generation, analysis)",
|
26 |
+
"Multimodal",
|
27 |
+
"Robotic/Embodied AI",
|
28 |
+
"Other",
|
29 |
+
]
|
30 |
+
|
31 |
+
const DEPLOYMENT_CONTEXTS = [
|
32 |
+
"Research/Academic",
|
33 |
+
"Internal/Enterprise Use",
|
34 |
+
"Public/Consumer-Facing",
|
35 |
+
"High-Risk Applications",
|
36 |
+
"Other",
|
37 |
+
]
|
38 |
+
|
39 |
+
export function SystemInfoForm({ onSubmit, initialData }: SystemInfoFormProps) {
|
40 |
+
const [formData, setFormData] = useState<SystemInfo>({
|
41 |
+
name: initialData?.name || "",
|
42 |
+
url: initialData?.url || "",
|
43 |
+
provider: initialData?.provider || "",
|
44 |
+
systemTypes: initialData?.systemTypes || [],
|
45 |
+
deploymentContexts: initialData?.deploymentContexts || [],
|
46 |
+
modality: initialData?.modality || "text",
|
47 |
+
modelTag: (initialData as any)?.modelTag || "",
|
48 |
+
knowledgeCutoff: (initialData as any)?.knowledgeCutoff || "",
|
49 |
+
modelType: (initialData as any)?.modelType || "na",
|
50 |
+
inputModalities: (initialData as any)?.inputModalities || [],
|
51 |
+
outputModalities: (initialData as any)?.outputModalities || [],
|
52 |
+
})
|
53 |
+
|
54 |
+
const handleSubmit = (e: React.FormEvent) => {
|
55 |
+
e.preventDefault()
|
56 |
+
onSubmit(formData)
|
57 |
+
}
|
58 |
+
|
59 |
+
const handleSystemTypeChange = (type: string, checked: boolean) => {
|
60 |
+
setFormData((prev: SystemInfo) => ({
|
61 |
+
...prev,
|
62 |
+
systemTypes: checked ? [...prev.systemTypes, type] : prev.systemTypes.filter((t) => t !== type),
|
63 |
+
}))
|
64 |
+
}
|
65 |
+
|
66 |
+
const handleDeploymentContextChange = (context: string, checked: boolean) => {
|
67 |
+
setFormData((prev: SystemInfo) => ({
|
68 |
+
...prev,
|
69 |
+
deploymentContexts: checked
|
70 |
+
? [...prev.deploymentContexts, context]
|
71 |
+
: prev.deploymentContexts.filter((c) => c !== context),
|
72 |
+
}))
|
73 |
+
}
|
74 |
+
|
75 |
+
return (
|
76 |
+
<Card>
|
77 |
+
<CardHeader>
|
78 |
+
<CardTitle className="font-heading">System Information</CardTitle>
|
79 |
+
<CardDescription>Provide basic information about the AI system you want to evaluate</CardDescription>
|
80 |
+
</CardHeader>
|
81 |
+
<CardContent>
|
82 |
+
<form onSubmit={handleSubmit} className="space-y-6">
|
83 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
84 |
+
<div className="space-y-2">
|
85 |
+
<Label htmlFor="name">AI System Name *</Label>
|
86 |
+
<Input
|
87 |
+
id="name"
|
88 |
+
value={formData.name}
|
89 |
+
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
90 |
+
placeholder="e.g., GPT-4, Claude, Custom Model"
|
91 |
+
required
|
92 |
+
/>
|
93 |
+
</div>
|
94 |
+
<div className="space-y-2">
|
95 |
+
<Label htmlFor="url">AI System URL</Label>
|
96 |
+
<Input
|
97 |
+
id="url"
|
98 |
+
type="url"
|
99 |
+
value={formData.url}
|
100 |
+
onChange={(e) => setFormData((prev) => ({ ...prev, url: e.target.value }))}
|
101 |
+
placeholder="https://example.com"
|
102 |
+
/>
|
103 |
+
</div>
|
104 |
+
</div>
|
105 |
+
|
106 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
107 |
+
<div className="space-y-2">
|
108 |
+
<Label htmlFor="modelTag">Model Tag / Version</Label>
|
109 |
+
<Input
|
110 |
+
id="modelTag"
|
111 |
+
value={formData.modelTag}
|
112 |
+
onChange={(e) => setFormData((prev) => ({ ...prev, modelTag: e.target.value }))}
|
113 |
+
placeholder="e.g., gpt-4-1-2025-04-14"
|
114 |
+
/>
|
115 |
+
</div>
|
116 |
+
<div className="space-y-2">
|
117 |
+
<Label htmlFor="knowledgeCutoff">Knowledge Cutoff Date</Label>
|
118 |
+
<Input
|
119 |
+
id="knowledgeCutoff"
|
120 |
+
value={formData.knowledgeCutoff}
|
121 |
+
onChange={(e) => setFormData((prev) => ({ ...prev, knowledgeCutoff: e.target.value }))}
|
122 |
+
placeholder="YYYY-MM-DD"
|
123 |
+
/>
|
124 |
+
</div>
|
125 |
+
</div>
|
126 |
+
|
127 |
+
<div className="space-y-2">
|
128 |
+
<Label htmlFor="provider">Provider/Organization *</Label>
|
129 |
+
<Input
|
130 |
+
id="provider"
|
131 |
+
value={formData.provider}
|
132 |
+
onChange={(e) => setFormData((prev) => ({ ...prev, provider: e.target.value }))}
|
133 |
+
placeholder="e.g., OpenAI, Anthropic, Internal Team"
|
134 |
+
required
|
135 |
+
/>
|
136 |
+
</div>
|
137 |
+
|
138 |
+
<div className="space-y-4">
|
139 |
+
<div>
|
140 |
+
<Label className="text-base font-medium">Model Type</Label>
|
141 |
+
<div className="mt-3">
|
142 |
+
<RadioGroup value={formData.modelType} onValueChange={(val) => setFormData((prev) => ({ ...prev, modelType: val as any }))}>
|
143 |
+
<div className="flex items-center gap-4">
|
144 |
+
<div className="flex items-center gap-2">
|
145 |
+
<RadioGroupItem value="foundational" id="mt-foundational" />
|
146 |
+
<Label htmlFor="mt-foundational" className="text-sm">Foundational Model</Label>
|
147 |
+
</div>
|
148 |
+
<div className="flex items-center gap-2">
|
149 |
+
<RadioGroupItem value="fine-tuned" id="mt-finetuned" />
|
150 |
+
<Label htmlFor="mt-finetuned" className="text-sm">Fine-tuned Model</Label>
|
151 |
+
</div>
|
152 |
+
<div className="flex items-center gap-2">
|
153 |
+
<RadioGroupItem value="na" id="mt-na" />
|
154 |
+
<Label htmlFor="mt-na" className="text-sm">Doesn't apply</Label>
|
155 |
+
</div>
|
156 |
+
</div>
|
157 |
+
</RadioGroup>
|
158 |
+
</div>
|
159 |
+
</div>
|
160 |
+
|
161 |
+
<div>
|
162 |
+
<Label className="text-base font-medium">Input modalities (select all that apply) *</Label>
|
163 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
164 |
+
{['Text','Image','Audio','Video','Tabular','Robotics/Action','Other'].map((m) => (
|
165 |
+
<div key={m} className="flex items-center gap-2">
|
166 |
+
<Checkbox
|
167 |
+
id={`in-${m}`}
|
168 |
+
checked={(formData.inputModalities || []).includes(m)}
|
169 |
+
onCheckedChange={(checked) =>
|
170 |
+
setFormData((prev) => ({
|
171 |
+
...prev,
|
172 |
+
inputModalities: checked
|
173 |
+
? [...(prev.inputModalities || []), m]
|
174 |
+
: (prev.inputModalities || []).filter((x) => x !== m),
|
175 |
+
}))
|
176 |
+
}
|
177 |
+
/>
|
178 |
+
<Label htmlFor={`in-${m}`} className="text-sm">
|
179 |
+
{m}
|
180 |
+
</Label>
|
181 |
+
</div>
|
182 |
+
))}
|
183 |
+
</div>
|
184 |
+
</div>
|
185 |
+
|
186 |
+
<div>
|
187 |
+
<Label className="text-base font-medium">Output modalities (select all that apply) *</Label>
|
188 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
189 |
+
{['Text','Image','Audio','Video','Tabular','Robotics/Action','Other'].map((m) => (
|
190 |
+
<div key={m} className="flex items-center gap-2">
|
191 |
+
<Checkbox
|
192 |
+
id={`out-${m}`}
|
193 |
+
checked={(formData.outputModalities || []).includes(m)}
|
194 |
+
onCheckedChange={(checked) =>
|
195 |
+
setFormData((prev) => ({
|
196 |
+
...prev,
|
197 |
+
outputModalities: checked
|
198 |
+
? [...(prev.outputModalities || []), m]
|
199 |
+
: (prev.outputModalities || []).filter((x) => x !== m),
|
200 |
+
}))
|
201 |
+
}
|
202 |
+
/>
|
203 |
+
<Label htmlFor={`out-${m}`} className="text-sm">
|
204 |
+
{m}
|
205 |
+
</Label>
|
206 |
+
</div>
|
207 |
+
))}
|
208 |
+
</div>
|
209 |
+
</div>
|
210 |
+
|
211 |
+
<div>
|
212 |
+
<Label className="text-base font-medium">Deployment Context (select all that apply) *</Label>
|
213 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
214 |
+
{DEPLOYMENT_CONTEXTS.map((context) => (
|
215 |
+
<div key={context} className="flex items-center space-x-2">
|
216 |
+
<Checkbox
|
217 |
+
id={`context-${context}`}
|
218 |
+
checked={formData.deploymentContexts.includes(context)}
|
219 |
+
onCheckedChange={(checked) => handleDeploymentContextChange(context, checked as boolean)}
|
220 |
+
/>
|
221 |
+
<Label htmlFor={`context-${context}`} className="text-sm font-normal">
|
222 |
+
{context}
|
223 |
+
</Label>
|
224 |
+
</div>
|
225 |
+
))}
|
226 |
+
</div>
|
227 |
+
</div>
|
228 |
+
</div>
|
229 |
+
|
230 |
+
|
231 |
+
|
232 |
+
<Button
|
233 |
+
type="submit"
|
234 |
+
className="w-full"
|
235 |
+
disabled={
|
236 |
+
!formData.name ||
|
237 |
+
!formData.provider ||
|
238 |
+
(formData.inputModalities || []).length === 0 ||
|
239 |
+
(formData.outputModalities || []).length === 0 ||
|
240 |
+
formData.deploymentContexts.length === 0
|
241 |
+
}
|
242 |
+
>
|
243 |
+
Continue to Category Selection
|
244 |
+
</Button>
|
245 |
+
</form>
|
246 |
+
</CardContent>
|
247 |
+
</Card>
|
248 |
+
)
|
249 |
+
}
|
components/theme-provider.tsx
ADDED
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
3 |
+
import type { ThemeProviderProps } from "next-themes"
|
4 |
+
|
5 |
+
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
6 |
+
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
7 |
+
}
|
components/ui/accordion.tsx
ADDED
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
5 |
+
import { ChevronDownIcon } from "lucide-react"
|
6 |
+
|
7 |
+
import { cn } from "@/lib/utils"
|
8 |
+
|
9 |
+
function Accordion({
|
10 |
+
...props
|
11 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
12 |
+
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
13 |
+
}
|
14 |
+
|
15 |
+
function AccordionItem({
|
16 |
+
className,
|
17 |
+
...props
|
18 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
19 |
+
return (
|
20 |
+
<AccordionPrimitive.Item
|
21 |
+
data-slot="accordion-item"
|
22 |
+
className={cn("border-b last:border-b-0", className)}
|
23 |
+
{...props}
|
24 |
+
/>
|
25 |
+
)
|
26 |
+
}
|
27 |
+
|
28 |
+
function AccordionTrigger({
|
29 |
+
className,
|
30 |
+
children,
|
31 |
+
...props
|
32 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
33 |
+
return (
|
34 |
+
<AccordionPrimitive.Header className="flex">
|
35 |
+
<AccordionPrimitive.Trigger
|
36 |
+
data-slot="accordion-trigger"
|
37 |
+
className={cn(
|
38 |
+
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
39 |
+
className
|
40 |
+
)}
|
41 |
+
{...props}
|
42 |
+
>
|
43 |
+
{children}
|
44 |
+
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
45 |
+
</AccordionPrimitive.Trigger>
|
46 |
+
</AccordionPrimitive.Header>
|
47 |
+
)
|
48 |
+
}
|
49 |
+
|
50 |
+
function AccordionContent({
|
51 |
+
className,
|
52 |
+
children,
|
53 |
+
...props
|
54 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
55 |
+
return (
|
56 |
+
<AccordionPrimitive.Content
|
57 |
+
data-slot="accordion-content"
|
58 |
+
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
59 |
+
{...props}
|
60 |
+
>
|
61 |
+
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
62 |
+
</AccordionPrimitive.Content>
|
63 |
+
)
|
64 |
+
}
|
65 |
+
|
66 |
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
components/ui/alert-dialog.tsx
ADDED
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
5 |
+
|
6 |
+
import { cn } from "@/lib/utils"
|
7 |
+
import { buttonVariants } from "@/components/ui/button"
|
8 |
+
|
9 |
+
function AlertDialog({
|
10 |
+
...props
|
11 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
12 |
+
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
13 |
+
}
|
14 |
+
|
15 |
+
function AlertDialogTrigger({
|
16 |
+
...props
|
17 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
18 |
+
return (
|
19 |
+
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
20 |
+
)
|
21 |
+
}
|
22 |
+
|
23 |
+
function AlertDialogPortal({
|
24 |
+
...props
|
25 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
26 |
+
return (
|
27 |
+
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
28 |
+
)
|
29 |
+
}
|
30 |
+
|
31 |
+
function AlertDialogOverlay({
|
32 |
+
className,
|
33 |
+
...props
|
34 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
35 |
+
return (
|
36 |
+
<AlertDialogPrimitive.Overlay
|
37 |
+
data-slot="alert-dialog-overlay"
|
38 |
+
className={cn(
|
39 |
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
40 |
+
className
|
41 |
+
)}
|
42 |
+
{...props}
|
43 |
+
/>
|
44 |
+
)
|
45 |
+
}
|
46 |
+
|
47 |
+
function AlertDialogContent({
|
48 |
+
className,
|
49 |
+
...props
|
50 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
51 |
+
return (
|
52 |
+
<AlertDialogPortal>
|
53 |
+
<AlertDialogOverlay />
|
54 |
+
<AlertDialogPrimitive.Content
|
55 |
+
data-slot="alert-dialog-content"
|
56 |
+
className={cn(
|
57 |
+
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
58 |
+
className
|
59 |
+
)}
|
60 |
+
{...props}
|
61 |
+
/>
|
62 |
+
</AlertDialogPortal>
|
63 |
+
)
|
64 |
+
}
|
65 |
+
|
66 |
+
function AlertDialogHeader({
|
67 |
+
className,
|
68 |
+
...props
|
69 |
+
}: React.ComponentProps<"div">) {
|
70 |
+
return (
|
71 |
+
<div
|
72 |
+
data-slot="alert-dialog-header"
|
73 |
+
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
74 |
+
{...props}
|
75 |
+
/>
|
76 |
+
)
|
77 |
+
}
|
78 |
+
|
79 |
+
function AlertDialogFooter({
|
80 |
+
className,
|
81 |
+
...props
|
82 |
+
}: React.ComponentProps<"div">) {
|
83 |
+
return (
|
84 |
+
<div
|
85 |
+
data-slot="alert-dialog-footer"
|
86 |
+
className={cn(
|
87 |
+
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
88 |
+
className
|
89 |
+
)}
|
90 |
+
{...props}
|
91 |
+
/>
|
92 |
+
)
|
93 |
+
}
|
94 |
+
|
95 |
+
function AlertDialogTitle({
|
96 |
+
className,
|
97 |
+
...props
|
98 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
99 |
+
return (
|
100 |
+
<AlertDialogPrimitive.Title
|
101 |
+
data-slot="alert-dialog-title"
|
102 |
+
className={cn("text-lg font-semibold", className)}
|
103 |
+
{...props}
|
104 |
+
/>
|
105 |
+
)
|
106 |
+
}
|
107 |
+
|
108 |
+
function AlertDialogDescription({
|
109 |
+
className,
|
110 |
+
...props
|
111 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
112 |
+
return (
|
113 |
+
<AlertDialogPrimitive.Description
|
114 |
+
data-slot="alert-dialog-description"
|
115 |
+
className={cn("text-muted-foreground text-sm", className)}
|
116 |
+
{...props}
|
117 |
+
/>
|
118 |
+
)
|
119 |
+
}
|
120 |
+
|
121 |
+
function AlertDialogAction({
|
122 |
+
className,
|
123 |
+
...props
|
124 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
125 |
+
return (
|
126 |
+
<AlertDialogPrimitive.Action
|
127 |
+
className={cn(buttonVariants(), className)}
|
128 |
+
{...props}
|
129 |
+
/>
|
130 |
+
)
|
131 |
+
}
|
132 |
+
|
133 |
+
function AlertDialogCancel({
|
134 |
+
className,
|
135 |
+
...props
|
136 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
137 |
+
return (
|
138 |
+
<AlertDialogPrimitive.Cancel
|
139 |
+
className={cn(buttonVariants({ variant: "outline" }), className)}
|
140 |
+
{...props}
|
141 |
+
/>
|
142 |
+
)
|
143 |
+
}
|
144 |
+
|
145 |
+
export {
|
146 |
+
AlertDialog,
|
147 |
+
AlertDialogPortal,
|
148 |
+
AlertDialogOverlay,
|
149 |
+
AlertDialogTrigger,
|
150 |
+
AlertDialogContent,
|
151 |
+
AlertDialogHeader,
|
152 |
+
AlertDialogFooter,
|
153 |
+
AlertDialogTitle,
|
154 |
+
AlertDialogDescription,
|
155 |
+
AlertDialogAction,
|
156 |
+
AlertDialogCancel,
|
157 |
+
}
|
components/ui/alert.tsx
ADDED
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
3 |
+
|
4 |
+
import { cn } from "@/lib/utils"
|
5 |
+
|
6 |
+
const alertVariants = cva(
|
7 |
+
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
8 |
+
{
|
9 |
+
variants: {
|
10 |
+
variant: {
|
11 |
+
default: "bg-card text-card-foreground",
|
12 |
+
destructive:
|
13 |
+
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
14 |
+
},
|
15 |
+
},
|
16 |
+
defaultVariants: {
|
17 |
+
variant: "default",
|
18 |
+
},
|
19 |
+
}
|
20 |
+
)
|
21 |
+
|
22 |
+
function Alert({
|
23 |
+
className,
|
24 |
+
variant,
|
25 |
+
...props
|
26 |
+
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
27 |
+
return (
|
28 |
+
<div
|
29 |
+
data-slot="alert"
|
30 |
+
role="alert"
|
31 |
+
className={cn(alertVariants({ variant }), className)}
|
32 |
+
{...props}
|
33 |
+
/>
|
34 |
+
)
|
35 |
+
}
|
36 |
+
|
37 |
+
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
38 |
+
return (
|
39 |
+
<div
|
40 |
+
data-slot="alert-title"
|
41 |
+
className={cn(
|
42 |
+
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
43 |
+
className
|
44 |
+
)}
|
45 |
+
{...props}
|
46 |
+
/>
|
47 |
+
)
|
48 |
+
}
|
49 |
+
|
50 |
+
function AlertDescription({
|
51 |
+
className,
|
52 |
+
...props
|
53 |
+
}: React.ComponentProps<"div">) {
|
54 |
+
return (
|
55 |
+
<div
|
56 |
+
data-slot="alert-description"
|
57 |
+
className={cn(
|
58 |
+
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
59 |
+
className
|
60 |
+
)}
|
61 |
+
{...props}
|
62 |
+
/>
|
63 |
+
)
|
64 |
+
}
|
65 |
+
|
66 |
+
export { Alert, AlertTitle, AlertDescription }
|
components/ui/aspect-ratio.tsx
ADDED
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
4 |
+
|
5 |
+
function AspectRatio({
|
6 |
+
...props
|
7 |
+
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
8 |
+
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
9 |
+
}
|
10 |
+
|
11 |
+
export { AspectRatio }
|
components/ui/avatar.tsx
ADDED
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
5 |
+
|
6 |
+
import { cn } from "@/lib/utils"
|
7 |
+
|
8 |
+
function Avatar({
|
9 |
+
className,
|
10 |
+
...props
|
11 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
12 |
+
return (
|
13 |
+
<AvatarPrimitive.Root
|
14 |
+
data-slot="avatar"
|
15 |
+
className={cn(
|
16 |
+
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
17 |
+
className
|
18 |
+
)}
|
19 |
+
{...props}
|
20 |
+
/>
|
21 |
+
)
|
22 |
+
}
|
23 |
+
|
24 |
+
function AvatarImage({
|
25 |
+
className,
|
26 |
+
...props
|
27 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
28 |
+
return (
|
29 |
+
<AvatarPrimitive.Image
|
30 |
+
data-slot="avatar-image"
|
31 |
+
className={cn("aspect-square size-full", className)}
|
32 |
+
{...props}
|
33 |
+
/>
|
34 |
+
)
|
35 |
+
}
|
36 |
+
|
37 |
+
function AvatarFallback({
|
38 |
+
className,
|
39 |
+
...props
|
40 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
41 |
+
return (
|
42 |
+
<AvatarPrimitive.Fallback
|
43 |
+
data-slot="avatar-fallback"
|
44 |
+
className={cn(
|
45 |
+
"bg-muted flex size-full items-center justify-center rounded-full",
|
46 |
+
className
|
47 |
+
)}
|
48 |
+
{...props}
|
49 |
+
/>
|
50 |
+
)
|
51 |
+
}
|
52 |
+
|
53 |
+
export { Avatar, AvatarImage, AvatarFallback }
|
components/ui/badge.tsx
ADDED
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import { Slot } from "@radix-ui/react-slot"
|
3 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
4 |
+
|
5 |
+
import { cn } from "@/lib/utils"
|
6 |
+
|
7 |
+
const badgeVariants = cva(
|
8 |
+
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
9 |
+
{
|
10 |
+
variants: {
|
11 |
+
variant: {
|
12 |
+
default:
|
13 |
+
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
14 |
+
secondary:
|
15 |
+
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
16 |
+
destructive:
|
17 |
+
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
18 |
+
outline:
|
19 |
+
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
20 |
+
},
|
21 |
+
},
|
22 |
+
defaultVariants: {
|
23 |
+
variant: "default",
|
24 |
+
},
|
25 |
+
}
|
26 |
+
)
|
27 |
+
|
28 |
+
function Badge({
|
29 |
+
className,
|
30 |
+
variant,
|
31 |
+
asChild = false,
|
32 |
+
...props
|
33 |
+
}: React.ComponentProps<"span"> &
|
34 |
+
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
35 |
+
const Comp = asChild ? Slot : "span"
|
36 |
+
|
37 |
+
return (
|
38 |
+
<Comp
|
39 |
+
data-slot="badge"
|
40 |
+
className={cn(badgeVariants({ variant }), className)}
|
41 |
+
{...props}
|
42 |
+
/>
|
43 |
+
)
|
44 |
+
}
|
45 |
+
|
46 |
+
export { Badge, badgeVariants }
|
components/ui/breadcrumb.tsx
ADDED
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import { Slot } from "@radix-ui/react-slot"
|
3 |
+
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
4 |
+
|
5 |
+
import { cn } from "@/lib/utils"
|
6 |
+
|
7 |
+
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
8 |
+
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
9 |
+
}
|
10 |
+
|
11 |
+
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
12 |
+
return (
|
13 |
+
<ol
|
14 |
+
data-slot="breadcrumb-list"
|
15 |
+
className={cn(
|
16 |
+
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
17 |
+
className
|
18 |
+
)}
|
19 |
+
{...props}
|
20 |
+
/>
|
21 |
+
)
|
22 |
+
}
|
23 |
+
|
24 |
+
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
25 |
+
return (
|
26 |
+
<li
|
27 |
+
data-slot="breadcrumb-item"
|
28 |
+
className={cn("inline-flex items-center gap-1.5", className)}
|
29 |
+
{...props}
|
30 |
+
/>
|
31 |
+
)
|
32 |
+
}
|
33 |
+
|
34 |
+
function BreadcrumbLink({
|
35 |
+
asChild,
|
36 |
+
className,
|
37 |
+
...props
|
38 |
+
}: React.ComponentProps<"a"> & {
|
39 |
+
asChild?: boolean
|
40 |
+
}) {
|
41 |
+
const Comp = asChild ? Slot : "a"
|
42 |
+
|
43 |
+
return (
|
44 |
+
<Comp
|
45 |
+
data-slot="breadcrumb-link"
|
46 |
+
className={cn("hover:text-foreground transition-colors", className)}
|
47 |
+
{...props}
|
48 |
+
/>
|
49 |
+
)
|
50 |
+
}
|
51 |
+
|
52 |
+
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
53 |
+
return (
|
54 |
+
<span
|
55 |
+
data-slot="breadcrumb-page"
|
56 |
+
role="link"
|
57 |
+
aria-disabled="true"
|
58 |
+
aria-current="page"
|
59 |
+
className={cn("text-foreground font-normal", className)}
|
60 |
+
{...props}
|
61 |
+
/>
|
62 |
+
)
|
63 |
+
}
|
64 |
+
|
65 |
+
function BreadcrumbSeparator({
|
66 |
+
children,
|
67 |
+
className,
|
68 |
+
...props
|
69 |
+
}: React.ComponentProps<"li">) {
|
70 |
+
return (
|
71 |
+
<li
|
72 |
+
data-slot="breadcrumb-separator"
|
73 |
+
role="presentation"
|
74 |
+
aria-hidden="true"
|
75 |
+
className={cn("[&>svg]:size-3.5", className)}
|
76 |
+
{...props}
|
77 |
+
>
|
78 |
+
{children ?? <ChevronRight />}
|
79 |
+
</li>
|
80 |
+
)
|
81 |
+
}
|
82 |
+
|
83 |
+
function BreadcrumbEllipsis({
|
84 |
+
className,
|
85 |
+
...props
|
86 |
+
}: React.ComponentProps<"span">) {
|
87 |
+
return (
|
88 |
+
<span
|
89 |
+
data-slot="breadcrumb-ellipsis"
|
90 |
+
role="presentation"
|
91 |
+
aria-hidden="true"
|
92 |
+
className={cn("flex size-9 items-center justify-center", className)}
|
93 |
+
{...props}
|
94 |
+
>
|
95 |
+
<MoreHorizontal className="size-4" />
|
96 |
+
<span className="sr-only">More</span>
|
97 |
+
</span>
|
98 |
+
)
|
99 |
+
}
|
100 |
+
|
101 |
+
export {
|
102 |
+
Breadcrumb,
|
103 |
+
BreadcrumbList,
|
104 |
+
BreadcrumbItem,
|
105 |
+
BreadcrumbLink,
|
106 |
+
BreadcrumbPage,
|
107 |
+
BreadcrumbSeparator,
|
108 |
+
BreadcrumbEllipsis,
|
109 |
+
}
|
components/ui/button.tsx
ADDED
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import { Slot } from "@radix-ui/react-slot"
|
3 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
4 |
+
|
5 |
+
import { cn } from "@/lib/utils"
|
6 |
+
|
7 |
+
const buttonVariants = cva(
|
8 |
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
9 |
+
{
|
10 |
+
variants: {
|
11 |
+
variant: {
|
12 |
+
default:
|
13 |
+
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
14 |
+
destructive:
|
15 |
+
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
16 |
+
outline:
|
17 |
+
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
18 |
+
secondary:
|
19 |
+
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
20 |
+
ghost:
|
21 |
+
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
22 |
+
link: "text-primary underline-offset-4 hover:underline",
|
23 |
+
},
|
24 |
+
size: {
|
25 |
+
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
26 |
+
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
27 |
+
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
28 |
+
icon: "size-9",
|
29 |
+
},
|
30 |
+
},
|
31 |
+
defaultVariants: {
|
32 |
+
variant: "default",
|
33 |
+
size: "default",
|
34 |
+
},
|
35 |
+
}
|
36 |
+
)
|
37 |
+
|
38 |
+
function Button({
|
39 |
+
className,
|
40 |
+
variant,
|
41 |
+
size,
|
42 |
+
asChild = false,
|
43 |
+
...props
|
44 |
+
}: React.ComponentProps<"button"> &
|
45 |
+
VariantProps<typeof buttonVariants> & {
|
46 |
+
asChild?: boolean
|
47 |
+
}) {
|
48 |
+
const Comp = asChild ? Slot : "button"
|
49 |
+
|
50 |
+
return (
|
51 |
+
<Comp
|
52 |
+
data-slot="button"
|
53 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
54 |
+
{...props}
|
55 |
+
/>
|
56 |
+
)
|
57 |
+
}
|
58 |
+
|
59 |
+
export { Button, buttonVariants }
|
components/ui/calendar.tsx
ADDED
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import {
|
5 |
+
ChevronDownIcon,
|
6 |
+
ChevronLeftIcon,
|
7 |
+
ChevronRightIcon,
|
8 |
+
} from "lucide-react"
|
9 |
+
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
10 |
+
|
11 |
+
import { cn } from "@/lib/utils"
|
12 |
+
import { Button, buttonVariants } from "@/components/ui/button"
|
13 |
+
|
14 |
+
function Calendar({
|
15 |
+
className,
|
16 |
+
classNames,
|
17 |
+
showOutsideDays = true,
|
18 |
+
captionLayout = "label",
|
19 |
+
buttonVariant = "ghost",
|
20 |
+
formatters,
|
21 |
+
components,
|
22 |
+
...props
|
23 |
+
}: React.ComponentProps<typeof DayPicker> & {
|
24 |
+
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
25 |
+
}) {
|
26 |
+
const defaultClassNames = getDefaultClassNames()
|
27 |
+
|
28 |
+
return (
|
29 |
+
<DayPicker
|
30 |
+
showOutsideDays={showOutsideDays}
|
31 |
+
className={cn(
|
32 |
+
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
33 |
+
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
34 |
+
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
35 |
+
className
|
36 |
+
)}
|
37 |
+
captionLayout={captionLayout}
|
38 |
+
formatters={{
|
39 |
+
formatMonthDropdown: (date) =>
|
40 |
+
date.toLocaleString("default", { month: "short" }),
|
41 |
+
...formatters,
|
42 |
+
}}
|
43 |
+
classNames={{
|
44 |
+
root: cn("w-fit", defaultClassNames.root),
|
45 |
+
months: cn(
|
46 |
+
"flex gap-4 flex-col md:flex-row relative",
|
47 |
+
defaultClassNames.months
|
48 |
+
),
|
49 |
+
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
50 |
+
nav: cn(
|
51 |
+
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
52 |
+
defaultClassNames.nav
|
53 |
+
),
|
54 |
+
button_previous: cn(
|
55 |
+
buttonVariants({ variant: buttonVariant }),
|
56 |
+
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
57 |
+
defaultClassNames.button_previous
|
58 |
+
),
|
59 |
+
button_next: cn(
|
60 |
+
buttonVariants({ variant: buttonVariant }),
|
61 |
+
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
62 |
+
defaultClassNames.button_next
|
63 |
+
),
|
64 |
+
month_caption: cn(
|
65 |
+
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
66 |
+
defaultClassNames.month_caption
|
67 |
+
),
|
68 |
+
dropdowns: cn(
|
69 |
+
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
70 |
+
defaultClassNames.dropdowns
|
71 |
+
),
|
72 |
+
dropdown_root: cn(
|
73 |
+
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
74 |
+
defaultClassNames.dropdown_root
|
75 |
+
),
|
76 |
+
dropdown: cn(
|
77 |
+
"absolute bg-popover inset-0 opacity-0",
|
78 |
+
defaultClassNames.dropdown
|
79 |
+
),
|
80 |
+
caption_label: cn(
|
81 |
+
"select-none font-medium",
|
82 |
+
captionLayout === "label"
|
83 |
+
? "text-sm"
|
84 |
+
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
85 |
+
defaultClassNames.caption_label
|
86 |
+
),
|
87 |
+
table: "w-full border-collapse",
|
88 |
+
weekdays: cn("flex", defaultClassNames.weekdays),
|
89 |
+
weekday: cn(
|
90 |
+
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
91 |
+
defaultClassNames.weekday
|
92 |
+
),
|
93 |
+
week: cn("flex w-full mt-2", defaultClassNames.week),
|
94 |
+
week_number_header: cn(
|
95 |
+
"select-none w-(--cell-size)",
|
96 |
+
defaultClassNames.week_number_header
|
97 |
+
),
|
98 |
+
week_number: cn(
|
99 |
+
"text-[0.8rem] select-none text-muted-foreground",
|
100 |
+
defaultClassNames.week_number
|
101 |
+
),
|
102 |
+
day: cn(
|
103 |
+
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
104 |
+
defaultClassNames.day
|
105 |
+
),
|
106 |
+
range_start: cn(
|
107 |
+
"rounded-l-md bg-accent",
|
108 |
+
defaultClassNames.range_start
|
109 |
+
),
|
110 |
+
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
111 |
+
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
112 |
+
today: cn(
|
113 |
+
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
114 |
+
defaultClassNames.today
|
115 |
+
),
|
116 |
+
outside: cn(
|
117 |
+
"text-muted-foreground aria-selected:text-muted-foreground",
|
118 |
+
defaultClassNames.outside
|
119 |
+
),
|
120 |
+
disabled: cn(
|
121 |
+
"text-muted-foreground opacity-50",
|
122 |
+
defaultClassNames.disabled
|
123 |
+
),
|
124 |
+
hidden: cn("invisible", defaultClassNames.hidden),
|
125 |
+
...classNames,
|
126 |
+
}}
|
127 |
+
components={{
|
128 |
+
Root: ({ className, rootRef, ...props }) => {
|
129 |
+
return (
|
130 |
+
<div
|
131 |
+
data-slot="calendar"
|
132 |
+
ref={rootRef}
|
133 |
+
className={cn(className)}
|
134 |
+
{...props}
|
135 |
+
/>
|
136 |
+
)
|
137 |
+
},
|
138 |
+
Chevron: ({ className, orientation, ...props }) => {
|
139 |
+
if (orientation === "left") {
|
140 |
+
return (
|
141 |
+
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
142 |
+
)
|
143 |
+
}
|
144 |
+
|
145 |
+
if (orientation === "right") {
|
146 |
+
return (
|
147 |
+
<ChevronRightIcon
|
148 |
+
className={cn("size-4", className)}
|
149 |
+
{...props}
|
150 |
+
/>
|
151 |
+
)
|
152 |
+
}
|
153 |
+
|
154 |
+
return (
|
155 |
+
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
156 |
+
)
|
157 |
+
},
|
158 |
+
DayButton: CalendarDayButton,
|
159 |
+
WeekNumber: ({ children, ...props }) => {
|
160 |
+
return (
|
161 |
+
<td {...props}>
|
162 |
+
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
163 |
+
{children}
|
164 |
+
</div>
|
165 |
+
</td>
|
166 |
+
)
|
167 |
+
},
|
168 |
+
...components,
|
169 |
+
}}
|
170 |
+
{...props}
|
171 |
+
/>
|
172 |
+
)
|
173 |
+
}
|
174 |
+
|
175 |
+
function CalendarDayButton({
|
176 |
+
className,
|
177 |
+
day,
|
178 |
+
modifiers,
|
179 |
+
...props
|
180 |
+
}: React.ComponentProps<typeof DayButton>) {
|
181 |
+
const defaultClassNames = getDefaultClassNames()
|
182 |
+
|
183 |
+
const ref = React.useRef<HTMLButtonElement>(null)
|
184 |
+
React.useEffect(() => {
|
185 |
+
if (modifiers.focused) ref.current?.focus()
|
186 |
+
}, [modifiers.focused])
|
187 |
+
|
188 |
+
return (
|
189 |
+
<Button
|
190 |
+
ref={ref}
|
191 |
+
variant="ghost"
|
192 |
+
size="icon"
|
193 |
+
data-day={day.date.toLocaleDateString()}
|
194 |
+
data-selected-single={
|
195 |
+
modifiers.selected &&
|
196 |
+
!modifiers.range_start &&
|
197 |
+
!modifiers.range_end &&
|
198 |
+
!modifiers.range_middle
|
199 |
+
}
|
200 |
+
data-range-start={modifiers.range_start}
|
201 |
+
data-range-end={modifiers.range_end}
|
202 |
+
data-range-middle={modifiers.range_middle}
|
203 |
+
className={cn(
|
204 |
+
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
205 |
+
defaultClassNames.day,
|
206 |
+
className
|
207 |
+
)}
|
208 |
+
{...props}
|
209 |
+
/>
|
210 |
+
)
|
211 |
+
}
|
212 |
+
|
213 |
+
export { Calendar, CalendarDayButton }
|
components/ui/card.tsx
ADDED
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
|
3 |
+
import { cn } from "@/lib/utils"
|
4 |
+
|
5 |
+
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
6 |
+
return (
|
7 |
+
<div
|
8 |
+
data-slot="card"
|
9 |
+
className={cn(
|
10 |
+
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
11 |
+
className
|
12 |
+
)}
|
13 |
+
{...props}
|
14 |
+
/>
|
15 |
+
)
|
16 |
+
}
|
17 |
+
|
18 |
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
19 |
+
return (
|
20 |
+
<div
|
21 |
+
data-slot="card-header"
|
22 |
+
className={cn(
|
23 |
+
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
24 |
+
className
|
25 |
+
)}
|
26 |
+
{...props}
|
27 |
+
/>
|
28 |
+
)
|
29 |
+
}
|
30 |
+
|
31 |
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
32 |
+
return (
|
33 |
+
<div
|
34 |
+
data-slot="card-title"
|
35 |
+
className={cn("leading-none font-semibold", className)}
|
36 |
+
{...props}
|
37 |
+
/>
|
38 |
+
)
|
39 |
+
}
|
40 |
+
|
41 |
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
42 |
+
return (
|
43 |
+
<div
|
44 |
+
data-slot="card-description"
|
45 |
+
className={cn("text-muted-foreground text-sm", className)}
|
46 |
+
{...props}
|
47 |
+
/>
|
48 |
+
)
|
49 |
+
}
|
50 |
+
|
51 |
+
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
52 |
+
return (
|
53 |
+
<div
|
54 |
+
data-slot="card-action"
|
55 |
+
className={cn(
|
56 |
+
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
57 |
+
className
|
58 |
+
)}
|
59 |
+
{...props}
|
60 |
+
/>
|
61 |
+
)
|
62 |
+
}
|
63 |
+
|
64 |
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
65 |
+
return (
|
66 |
+
<div
|
67 |
+
data-slot="card-content"
|
68 |
+
className={cn("px-6", className)}
|
69 |
+
{...props}
|
70 |
+
/>
|
71 |
+
)
|
72 |
+
}
|
73 |
+
|
74 |
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
75 |
+
return (
|
76 |
+
<div
|
77 |
+
data-slot="card-footer"
|
78 |
+
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
79 |
+
{...props}
|
80 |
+
/>
|
81 |
+
)
|
82 |
+
}
|
83 |
+
|
84 |
+
export {
|
85 |
+
Card,
|
86 |
+
CardHeader,
|
87 |
+
CardFooter,
|
88 |
+
CardTitle,
|
89 |
+
CardAction,
|
90 |
+
CardDescription,
|
91 |
+
CardContent,
|
92 |
+
}
|
components/ui/carousel.tsx
ADDED
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import useEmblaCarousel, {
|
5 |
+
type UseEmblaCarouselType,
|
6 |
+
} from "embla-carousel-react"
|
7 |
+
import { ArrowLeft, ArrowRight } from "lucide-react"
|
8 |
+
|
9 |
+
import { cn } from "@/lib/utils"
|
10 |
+
import { Button } from "@/components/ui/button"
|
11 |
+
|
12 |
+
type CarouselApi = UseEmblaCarouselType[1]
|
13 |
+
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
14 |
+
type CarouselOptions = UseCarouselParameters[0]
|
15 |
+
type CarouselPlugin = UseCarouselParameters[1]
|
16 |
+
|
17 |
+
type CarouselProps = {
|
18 |
+
opts?: CarouselOptions
|
19 |
+
plugins?: CarouselPlugin
|
20 |
+
orientation?: "horizontal" | "vertical"
|
21 |
+
setApi?: (api: CarouselApi) => void
|
22 |
+
}
|
23 |
+
|
24 |
+
type CarouselContextProps = {
|
25 |
+
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
26 |
+
api: ReturnType<typeof useEmblaCarousel>[1]
|
27 |
+
scrollPrev: () => void
|
28 |
+
scrollNext: () => void
|
29 |
+
canScrollPrev: boolean
|
30 |
+
canScrollNext: boolean
|
31 |
+
} & CarouselProps
|
32 |
+
|
33 |
+
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
34 |
+
|
35 |
+
function useCarousel() {
|
36 |
+
const context = React.useContext(CarouselContext)
|
37 |
+
|
38 |
+
if (!context) {
|
39 |
+
throw new Error("useCarousel must be used within a <Carousel />")
|
40 |
+
}
|
41 |
+
|
42 |
+
return context
|
43 |
+
}
|
44 |
+
|
45 |
+
function Carousel({
|
46 |
+
orientation = "horizontal",
|
47 |
+
opts,
|
48 |
+
setApi,
|
49 |
+
plugins,
|
50 |
+
className,
|
51 |
+
children,
|
52 |
+
...props
|
53 |
+
}: React.ComponentProps<"div"> & CarouselProps) {
|
54 |
+
const [carouselRef, api] = useEmblaCarousel(
|
55 |
+
{
|
56 |
+
...opts,
|
57 |
+
axis: orientation === "horizontal" ? "x" : "y",
|
58 |
+
},
|
59 |
+
plugins
|
60 |
+
)
|
61 |
+
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
62 |
+
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
63 |
+
|
64 |
+
const onSelect = React.useCallback((api: CarouselApi) => {
|
65 |
+
if (!api) return
|
66 |
+
setCanScrollPrev(api.canScrollPrev())
|
67 |
+
setCanScrollNext(api.canScrollNext())
|
68 |
+
}, [])
|
69 |
+
|
70 |
+
const scrollPrev = React.useCallback(() => {
|
71 |
+
api?.scrollPrev()
|
72 |
+
}, [api])
|
73 |
+
|
74 |
+
const scrollNext = React.useCallback(() => {
|
75 |
+
api?.scrollNext()
|
76 |
+
}, [api])
|
77 |
+
|
78 |
+
const handleKeyDown = React.useCallback(
|
79 |
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
80 |
+
if (event.key === "ArrowLeft") {
|
81 |
+
event.preventDefault()
|
82 |
+
scrollPrev()
|
83 |
+
} else if (event.key === "ArrowRight") {
|
84 |
+
event.preventDefault()
|
85 |
+
scrollNext()
|
86 |
+
}
|
87 |
+
},
|
88 |
+
[scrollPrev, scrollNext]
|
89 |
+
)
|
90 |
+
|
91 |
+
React.useEffect(() => {
|
92 |
+
if (!api || !setApi) return
|
93 |
+
setApi(api)
|
94 |
+
}, [api, setApi])
|
95 |
+
|
96 |
+
React.useEffect(() => {
|
97 |
+
if (!api) return
|
98 |
+
onSelect(api)
|
99 |
+
api.on("reInit", onSelect)
|
100 |
+
api.on("select", onSelect)
|
101 |
+
|
102 |
+
return () => {
|
103 |
+
api?.off("select", onSelect)
|
104 |
+
}
|
105 |
+
}, [api, onSelect])
|
106 |
+
|
107 |
+
return (
|
108 |
+
<CarouselContext.Provider
|
109 |
+
value={{
|
110 |
+
carouselRef,
|
111 |
+
api: api,
|
112 |
+
opts,
|
113 |
+
orientation:
|
114 |
+
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
115 |
+
scrollPrev,
|
116 |
+
scrollNext,
|
117 |
+
canScrollPrev,
|
118 |
+
canScrollNext,
|
119 |
+
}}
|
120 |
+
>
|
121 |
+
<div
|
122 |
+
onKeyDownCapture={handleKeyDown}
|
123 |
+
className={cn("relative", className)}
|
124 |
+
role="region"
|
125 |
+
aria-roledescription="carousel"
|
126 |
+
data-slot="carousel"
|
127 |
+
{...props}
|
128 |
+
>
|
129 |
+
{children}
|
130 |
+
</div>
|
131 |
+
</CarouselContext.Provider>
|
132 |
+
)
|
133 |
+
}
|
134 |
+
|
135 |
+
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
136 |
+
const { carouselRef, orientation } = useCarousel()
|
137 |
+
|
138 |
+
return (
|
139 |
+
<div
|
140 |
+
ref={carouselRef}
|
141 |
+
className="overflow-hidden"
|
142 |
+
data-slot="carousel-content"
|
143 |
+
>
|
144 |
+
<div
|
145 |
+
className={cn(
|
146 |
+
"flex",
|
147 |
+
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
148 |
+
className
|
149 |
+
)}
|
150 |
+
{...props}
|
151 |
+
/>
|
152 |
+
</div>
|
153 |
+
)
|
154 |
+
}
|
155 |
+
|
156 |
+
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
157 |
+
const { orientation } = useCarousel()
|
158 |
+
|
159 |
+
return (
|
160 |
+
<div
|
161 |
+
role="group"
|
162 |
+
aria-roledescription="slide"
|
163 |
+
data-slot="carousel-item"
|
164 |
+
className={cn(
|
165 |
+
"min-w-0 shrink-0 grow-0 basis-full",
|
166 |
+
orientation === "horizontal" ? "pl-4" : "pt-4",
|
167 |
+
className
|
168 |
+
)}
|
169 |
+
{...props}
|
170 |
+
/>
|
171 |
+
)
|
172 |
+
}
|
173 |
+
|
174 |
+
function CarouselPrevious({
|
175 |
+
className,
|
176 |
+
variant = "outline",
|
177 |
+
size = "icon",
|
178 |
+
...props
|
179 |
+
}: React.ComponentProps<typeof Button>) {
|
180 |
+
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
181 |
+
|
182 |
+
return (
|
183 |
+
<Button
|
184 |
+
data-slot="carousel-previous"
|
185 |
+
variant={variant}
|
186 |
+
size={size}
|
187 |
+
className={cn(
|
188 |
+
"absolute size-8 rounded-full",
|
189 |
+
orientation === "horizontal"
|
190 |
+
? "top-1/2 -left-12 -translate-y-1/2"
|
191 |
+
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
192 |
+
className
|
193 |
+
)}
|
194 |
+
disabled={!canScrollPrev}
|
195 |
+
onClick={scrollPrev}
|
196 |
+
{...props}
|
197 |
+
>
|
198 |
+
<ArrowLeft />
|
199 |
+
<span className="sr-only">Previous slide</span>
|
200 |
+
</Button>
|
201 |
+
)
|
202 |
+
}
|
203 |
+
|
204 |
+
function CarouselNext({
|
205 |
+
className,
|
206 |
+
variant = "outline",
|
207 |
+
size = "icon",
|
208 |
+
...props
|
209 |
+
}: React.ComponentProps<typeof Button>) {
|
210 |
+
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
211 |
+
|
212 |
+
return (
|
213 |
+
<Button
|
214 |
+
data-slot="carousel-next"
|
215 |
+
variant={variant}
|
216 |
+
size={size}
|
217 |
+
className={cn(
|
218 |
+
"absolute size-8 rounded-full",
|
219 |
+
orientation === "horizontal"
|
220 |
+
? "top-1/2 -right-12 -translate-y-1/2"
|
221 |
+
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
222 |
+
className
|
223 |
+
)}
|
224 |
+
disabled={!canScrollNext}
|
225 |
+
onClick={scrollNext}
|
226 |
+
{...props}
|
227 |
+
>
|
228 |
+
<ArrowRight />
|
229 |
+
<span className="sr-only">Next slide</span>
|
230 |
+
</Button>
|
231 |
+
)
|
232 |
+
}
|
233 |
+
|
234 |
+
export {
|
235 |
+
type CarouselApi,
|
236 |
+
Carousel,
|
237 |
+
CarouselContent,
|
238 |
+
CarouselItem,
|
239 |
+
CarouselPrevious,
|
240 |
+
CarouselNext,
|
241 |
+
}
|
components/ui/chart.tsx
ADDED
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import * as RechartsPrimitive from "recharts"
|
5 |
+
|
6 |
+
import { cn } from "@/lib/utils"
|
7 |
+
|
8 |
+
// Format: { THEME_NAME: CSS_SELECTOR }
|
9 |
+
const THEMES = { light: "", dark: ".dark" } as const
|
10 |
+
|
11 |
+
export type ChartConfig = {
|
12 |
+
[k in string]: {
|
13 |
+
label?: React.ReactNode
|
14 |
+
icon?: React.ComponentType
|
15 |
+
} & (
|
16 |
+
| { color?: string; theme?: never }
|
17 |
+
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
18 |
+
)
|
19 |
+
}
|
20 |
+
|
21 |
+
type ChartContextProps = {
|
22 |
+
config: ChartConfig
|
23 |
+
}
|
24 |
+
|
25 |
+
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
26 |
+
|
27 |
+
function useChart() {
|
28 |
+
const context = React.useContext(ChartContext)
|
29 |
+
|
30 |
+
if (!context) {
|
31 |
+
throw new Error("useChart must be used within a <ChartContainer />")
|
32 |
+
}
|
33 |
+
|
34 |
+
return context
|
35 |
+
}
|
36 |
+
|
37 |
+
function ChartContainer({
|
38 |
+
id,
|
39 |
+
className,
|
40 |
+
children,
|
41 |
+
config,
|
42 |
+
...props
|
43 |
+
}: React.ComponentProps<"div"> & {
|
44 |
+
config: ChartConfig
|
45 |
+
children: React.ComponentProps<
|
46 |
+
typeof RechartsPrimitive.ResponsiveContainer
|
47 |
+
>["children"]
|
48 |
+
}) {
|
49 |
+
const uniqueId = React.useId()
|
50 |
+
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
51 |
+
|
52 |
+
return (
|
53 |
+
<ChartContext.Provider value={{ config }}>
|
54 |
+
<div
|
55 |
+
data-slot="chart"
|
56 |
+
data-chart={chartId}
|
57 |
+
className={cn(
|
58 |
+
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
59 |
+
className
|
60 |
+
)}
|
61 |
+
{...props}
|
62 |
+
>
|
63 |
+
<ChartStyle id={chartId} config={config} />
|
64 |
+
<RechartsPrimitive.ResponsiveContainer>
|
65 |
+
{children}
|
66 |
+
</RechartsPrimitive.ResponsiveContainer>
|
67 |
+
</div>
|
68 |
+
</ChartContext.Provider>
|
69 |
+
)
|
70 |
+
}
|
71 |
+
|
72 |
+
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
73 |
+
const colorConfig = Object.entries(config).filter(
|
74 |
+
([, config]) => config.theme || config.color
|
75 |
+
)
|
76 |
+
|
77 |
+
if (!colorConfig.length) {
|
78 |
+
return null
|
79 |
+
}
|
80 |
+
|
81 |
+
return (
|
82 |
+
<style
|
83 |
+
dangerouslySetInnerHTML={{
|
84 |
+
__html: Object.entries(THEMES)
|
85 |
+
.map(
|
86 |
+
([theme, prefix]) => `
|
87 |
+
${prefix} [data-chart=${id}] {
|
88 |
+
${colorConfig
|
89 |
+
.map(([key, itemConfig]) => {
|
90 |
+
const color =
|
91 |
+
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
92 |
+
itemConfig.color
|
93 |
+
return color ? ` --color-${key}: ${color};` : null
|
94 |
+
})
|
95 |
+
.join("\n")}
|
96 |
+
}
|
97 |
+
`
|
98 |
+
)
|
99 |
+
.join("\n"),
|
100 |
+
}}
|
101 |
+
/>
|
102 |
+
)
|
103 |
+
}
|
104 |
+
|
105 |
+
const ChartTooltip = RechartsPrimitive.Tooltip
|
106 |
+
|
107 |
+
function ChartTooltipContent(props: any) {
|
108 |
+
const {
|
109 |
+
active,
|
110 |
+
payload,
|
111 |
+
className,
|
112 |
+
indicator = "dot",
|
113 |
+
hideLabel = false,
|
114 |
+
hideIndicator = false,
|
115 |
+
label,
|
116 |
+
labelFormatter,
|
117 |
+
labelClassName,
|
118 |
+
formatter,
|
119 |
+
color,
|
120 |
+
nameKey,
|
121 |
+
labelKey,
|
122 |
+
} = props
|
123 |
+
|
124 |
+
const { config } = useChart()
|
125 |
+
|
126 |
+
const tooltipLabel = React.useMemo(() => {
|
127 |
+
if (hideLabel || !payload?.length) {
|
128 |
+
return null
|
129 |
+
}
|
130 |
+
|
131 |
+
const [item] = payload
|
132 |
+
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
133 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
134 |
+
const value =
|
135 |
+
!labelKey && typeof label === "string"
|
136 |
+
? config[label as keyof typeof config]?.label || label
|
137 |
+
: itemConfig?.label
|
138 |
+
|
139 |
+
if (labelFormatter) {
|
140 |
+
return (
|
141 |
+
<div className={cn("font-medium", labelClassName)}>
|
142 |
+
{labelFormatter(value, payload)}
|
143 |
+
</div>
|
144 |
+
)
|
145 |
+
}
|
146 |
+
|
147 |
+
if (!value) {
|
148 |
+
return null
|
149 |
+
}
|
150 |
+
|
151 |
+
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
152 |
+
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey])
|
153 |
+
|
154 |
+
if (!active || !payload?.length) {
|
155 |
+
return null
|
156 |
+
}
|
157 |
+
|
158 |
+
const nestLabel = payload.length === 1 && indicator !== "dot"
|
159 |
+
|
160 |
+
return (
|
161 |
+
<div
|
162 |
+
className={cn(
|
163 |
+
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
164 |
+
className
|
165 |
+
)}
|
166 |
+
>
|
167 |
+
{!nestLabel ? tooltipLabel : null}
|
168 |
+
<div className="grid gap-1.5">
|
169 |
+
{payload.map((item: any, index: number) => {
|
170 |
+
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
171 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
172 |
+
const indicatorColor = color || item?.payload?.fill || item?.color
|
173 |
+
|
174 |
+
return (
|
175 |
+
<div
|
176 |
+
key={item.dataKey ?? index}
|
177 |
+
className={cn(
|
178 |
+
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
179 |
+
indicator === "dot" && "items-center"
|
180 |
+
)}
|
181 |
+
>
|
182 |
+
{formatter && item?.value !== undefined && item.name ? (
|
183 |
+
formatter(item.value, item.name, item, index, item.payload)
|
184 |
+
) : (
|
185 |
+
<>
|
186 |
+
{itemConfig?.icon ? (
|
187 |
+
<itemConfig.icon />
|
188 |
+
) : (
|
189 |
+
!hideIndicator && (
|
190 |
+
<div
|
191 |
+
className={cn(
|
192 |
+
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
193 |
+
{
|
194 |
+
"h-2.5 w-2.5": indicator === "dot",
|
195 |
+
"w-1": indicator === "line",
|
196 |
+
"w-0 border-[1.5px] border-dashed bg-transparent":
|
197 |
+
indicator === "dashed",
|
198 |
+
"my-0.5": nestLabel && indicator === "dashed",
|
199 |
+
}
|
200 |
+
)}
|
201 |
+
style={
|
202 |
+
{
|
203 |
+
"--color-bg": indicatorColor,
|
204 |
+
"--color-border": indicatorColor,
|
205 |
+
} as React.CSSProperties
|
206 |
+
}
|
207 |
+
/>
|
208 |
+
)
|
209 |
+
)}
|
210 |
+
<div
|
211 |
+
className={cn(
|
212 |
+
"flex flex-1 justify-between leading-none",
|
213 |
+
nestLabel ? "items-end" : "items-center"
|
214 |
+
)}
|
215 |
+
>
|
216 |
+
<div className="grid gap-1.5">
|
217 |
+
{nestLabel ? tooltipLabel : null}
|
218 |
+
<span className="text-muted-foreground">
|
219 |
+
{itemConfig?.label || item.name}
|
220 |
+
</span>
|
221 |
+
</div>
|
222 |
+
{item.value !== undefined && (
|
223 |
+
<span className="text-foreground font-mono font-medium tabular-nums">
|
224 |
+
{typeof item.value === "number"
|
225 |
+
? item.value.toLocaleString()
|
226 |
+
: String(item.value)}
|
227 |
+
</span>
|
228 |
+
)}
|
229 |
+
</div>
|
230 |
+
</>
|
231 |
+
)}
|
232 |
+
</div>
|
233 |
+
)
|
234 |
+
})}
|
235 |
+
</div>
|
236 |
+
</div>
|
237 |
+
)
|
238 |
+
}
|
239 |
+
|
240 |
+
const ChartLegend = RechartsPrimitive.Legend
|
241 |
+
|
242 |
+
function ChartLegendContent(props: any) {
|
243 |
+
const { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey } = props
|
244 |
+
const { config } = useChart()
|
245 |
+
|
246 |
+
if (!payload?.length) {
|
247 |
+
return null
|
248 |
+
}
|
249 |
+
|
250 |
+
return (
|
251 |
+
<div
|
252 |
+
className={cn(
|
253 |
+
"flex items-center justify-center gap-4",
|
254 |
+
verticalAlign === "top" ? "pb-3" : "pt-3",
|
255 |
+
className
|
256 |
+
)}
|
257 |
+
>
|
258 |
+
{payload.map((item: any) => {
|
259 |
+
const key = `${nameKey || item.dataKey || "value"}`
|
260 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
261 |
+
|
262 |
+
return (
|
263 |
+
<div
|
264 |
+
key={item.value}
|
265 |
+
className={cn(
|
266 |
+
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
267 |
+
)}
|
268 |
+
>
|
269 |
+
{itemConfig?.icon && !hideIcon ? (
|
270 |
+
<itemConfig.icon />
|
271 |
+
) : (
|
272 |
+
<div
|
273 |
+
className="h-2 w-2 shrink-0 rounded-[2px]"
|
274 |
+
style={{
|
275 |
+
backgroundColor: item.color,
|
276 |
+
}}
|
277 |
+
/>
|
278 |
+
)}
|
279 |
+
{itemConfig?.label}
|
280 |
+
</div>
|
281 |
+
)
|
282 |
+
})}
|
283 |
+
</div>
|
284 |
+
)
|
285 |
+
}
|
286 |
+
|
287 |
+
// Helper to extract item config from a payload.
|
288 |
+
function getPayloadConfigFromPayload(
|
289 |
+
config: ChartConfig,
|
290 |
+
payload: unknown,
|
291 |
+
key: string
|
292 |
+
) {
|
293 |
+
if (typeof payload !== "object" || payload === null) {
|
294 |
+
return undefined
|
295 |
+
}
|
296 |
+
|
297 |
+
// Attempt to find a label key inside the payload or payload.payload
|
298 |
+
const payloadPayload =
|
299 |
+
typeof (payload as any).payload === "object" && (payload as any).payload !== null
|
300 |
+
? (payload as any).payload
|
301 |
+
: undefined
|
302 |
+
|
303 |
+
let configLabelKey: string = key
|
304 |
+
|
305 |
+
if (key in (payload as any) && typeof (payload as any)[key] === "string") {
|
306 |
+
configLabelKey = (payload as any)[key] as string
|
307 |
+
} else if (
|
308 |
+
payloadPayload &&
|
309 |
+
key in payloadPayload &&
|
310 |
+
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
311 |
+
) {
|
312 |
+
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string
|
313 |
+
}
|
314 |
+
|
315 |
+
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]
|
316 |
+
}
|
317 |
+
|
318 |
+
export {
|
319 |
+
ChartContainer,
|
320 |
+
ChartTooltip,
|
321 |
+
ChartTooltipContent,
|
322 |
+
ChartLegend,
|
323 |
+
ChartLegendContent,
|
324 |
+
ChartStyle,
|
325 |
+
}
|
components/ui/checkbox.tsx
ADDED
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
5 |
+
import { CheckIcon } from "lucide-react"
|
6 |
+
|
7 |
+
import { cn } from "@/lib/utils"
|
8 |
+
|
9 |
+
function Checkbox({
|
10 |
+
className,
|
11 |
+
...props
|
12 |
+
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
13 |
+
return (
|
14 |
+
<CheckboxPrimitive.Root
|
15 |
+
data-slot="checkbox"
|
16 |
+
className={cn(
|
17 |
+
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
18 |
+
className
|
19 |
+
)}
|
20 |
+
{...props}
|
21 |
+
>
|
22 |
+
<CheckboxPrimitive.Indicator
|
23 |
+
data-slot="checkbox-indicator"
|
24 |
+
className="flex items-center justify-center text-current transition-none"
|
25 |
+
>
|
26 |
+
<CheckIcon className="size-3.5" />
|
27 |
+
</CheckboxPrimitive.Indicator>
|
28 |
+
</CheckboxPrimitive.Root>
|
29 |
+
)
|
30 |
+
}
|
31 |
+
|
32 |
+
export { Checkbox }
|
components/ui/collapsible.tsx
ADDED
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
4 |
+
|
5 |
+
function Collapsible({
|
6 |
+
...props
|
7 |
+
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
8 |
+
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
9 |
+
}
|
10 |
+
|
11 |
+
function CollapsibleTrigger({
|
12 |
+
...props
|
13 |
+
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
14 |
+
return (
|
15 |
+
<CollapsiblePrimitive.CollapsibleTrigger
|
16 |
+
data-slot="collapsible-trigger"
|
17 |
+
{...props}
|
18 |
+
/>
|
19 |
+
)
|
20 |
+
}
|
21 |
+
|
22 |
+
function CollapsibleContent({
|
23 |
+
...props
|
24 |
+
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
25 |
+
return (
|
26 |
+
<CollapsiblePrimitive.CollapsibleContent
|
27 |
+
data-slot="collapsible-content"
|
28 |
+
{...props}
|
29 |
+
/>
|
30 |
+
)
|
31 |
+
}
|
32 |
+
|
33 |
+
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
components/ui/command.tsx
ADDED
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import { Command as CommandPrimitive } from "cmdk"
|
5 |
+
import { SearchIcon } from "lucide-react"
|
6 |
+
|
7 |
+
import { cn } from "@/lib/utils"
|
8 |
+
import {
|
9 |
+
Dialog,
|
10 |
+
DialogContent,
|
11 |
+
DialogDescription,
|
12 |
+
DialogHeader,
|
13 |
+
DialogTitle,
|
14 |
+
} from "@/components/ui/dialog"
|
15 |
+
|
16 |
+
function Command({
|
17 |
+
className,
|
18 |
+
...props
|
19 |
+
}: React.ComponentProps<typeof CommandPrimitive>) {
|
20 |
+
return (
|
21 |
+
<CommandPrimitive
|
22 |
+
data-slot="command"
|
23 |
+
className={cn(
|
24 |
+
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
25 |
+
className
|
26 |
+
)}
|
27 |
+
{...props}
|
28 |
+
/>
|
29 |
+
)
|
30 |
+
}
|
31 |
+
|
32 |
+
function CommandDialog({
|
33 |
+
title = "Command Palette",
|
34 |
+
description = "Search for a command to run...",
|
35 |
+
children,
|
36 |
+
className,
|
37 |
+
showCloseButton = true,
|
38 |
+
...props
|
39 |
+
}: React.ComponentProps<typeof Dialog> & {
|
40 |
+
title?: string
|
41 |
+
description?: string
|
42 |
+
className?: string
|
43 |
+
showCloseButton?: boolean
|
44 |
+
}) {
|
45 |
+
return (
|
46 |
+
<Dialog {...props}>
|
47 |
+
<DialogHeader className="sr-only">
|
48 |
+
<DialogTitle>{title}</DialogTitle>
|
49 |
+
<DialogDescription>{description}</DialogDescription>
|
50 |
+
</DialogHeader>
|
51 |
+
<DialogContent
|
52 |
+
className={cn("overflow-hidden p-0", className)}
|
53 |
+
showCloseButton={showCloseButton}
|
54 |
+
>
|
55 |
+
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
56 |
+
{children}
|
57 |
+
</Command>
|
58 |
+
</DialogContent>
|
59 |
+
</Dialog>
|
60 |
+
)
|
61 |
+
}
|
62 |
+
|
63 |
+
function CommandInput({
|
64 |
+
className,
|
65 |
+
...props
|
66 |
+
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
67 |
+
return (
|
68 |
+
<div
|
69 |
+
data-slot="command-input-wrapper"
|
70 |
+
className="flex h-9 items-center gap-2 border-b px-3"
|
71 |
+
>
|
72 |
+
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
73 |
+
<CommandPrimitive.Input
|
74 |
+
data-slot="command-input"
|
75 |
+
className={cn(
|
76 |
+
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
77 |
+
className
|
78 |
+
)}
|
79 |
+
{...props}
|
80 |
+
/>
|
81 |
+
</div>
|
82 |
+
)
|
83 |
+
}
|
84 |
+
|
85 |
+
function CommandList({
|
86 |
+
className,
|
87 |
+
...props
|
88 |
+
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
89 |
+
return (
|
90 |
+
<CommandPrimitive.List
|
91 |
+
data-slot="command-list"
|
92 |
+
className={cn(
|
93 |
+
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
94 |
+
className
|
95 |
+
)}
|
96 |
+
{...props}
|
97 |
+
/>
|
98 |
+
)
|
99 |
+
}
|
100 |
+
|
101 |
+
function CommandEmpty({
|
102 |
+
...props
|
103 |
+
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
104 |
+
return (
|
105 |
+
<CommandPrimitive.Empty
|
106 |
+
data-slot="command-empty"
|
107 |
+
className="py-6 text-center text-sm"
|
108 |
+
{...props}
|
109 |
+
/>
|
110 |
+
)
|
111 |
+
}
|
112 |
+
|
113 |
+
function CommandGroup({
|
114 |
+
className,
|
115 |
+
...props
|
116 |
+
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
117 |
+
return (
|
118 |
+
<CommandPrimitive.Group
|
119 |
+
data-slot="command-group"
|
120 |
+
className={cn(
|
121 |
+
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
122 |
+
className
|
123 |
+
)}
|
124 |
+
{...props}
|
125 |
+
/>
|
126 |
+
)
|
127 |
+
}
|
128 |
+
|
129 |
+
function CommandSeparator({
|
130 |
+
className,
|
131 |
+
...props
|
132 |
+
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
133 |
+
return (
|
134 |
+
<CommandPrimitive.Separator
|
135 |
+
data-slot="command-separator"
|
136 |
+
className={cn("bg-border -mx-1 h-px", className)}
|
137 |
+
{...props}
|
138 |
+
/>
|
139 |
+
)
|
140 |
+
}
|
141 |
+
|
142 |
+
function CommandItem({
|
143 |
+
className,
|
144 |
+
...props
|
145 |
+
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
146 |
+
return (
|
147 |
+
<CommandPrimitive.Item
|
148 |
+
data-slot="command-item"
|
149 |
+
className={cn(
|
150 |
+
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
151 |
+
className
|
152 |
+
)}
|
153 |
+
{...props}
|
154 |
+
/>
|
155 |
+
)
|
156 |
+
}
|
157 |
+
|
158 |
+
function CommandShortcut({
|
159 |
+
className,
|
160 |
+
...props
|
161 |
+
}: React.ComponentProps<"span">) {
|
162 |
+
return (
|
163 |
+
<span
|
164 |
+
data-slot="command-shortcut"
|
165 |
+
className={cn(
|
166 |
+
"text-muted-foreground ml-auto text-xs tracking-widest",
|
167 |
+
className
|
168 |
+
)}
|
169 |
+
{...props}
|
170 |
+
/>
|
171 |
+
)
|
172 |
+
}
|
173 |
+
|
174 |
+
export {
|
175 |
+
Command,
|
176 |
+
CommandDialog,
|
177 |
+
CommandInput,
|
178 |
+
CommandList,
|
179 |
+
CommandEmpty,
|
180 |
+
CommandGroup,
|
181 |
+
CommandItem,
|
182 |
+
CommandShortcut,
|
183 |
+
CommandSeparator,
|
184 |
+
}
|
components/ui/context-menu.tsx
ADDED
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
5 |
+
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
6 |
+
|
7 |
+
import { cn } from "@/lib/utils"
|
8 |
+
|
9 |
+
function ContextMenu({
|
10 |
+
...props
|
11 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
12 |
+
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
13 |
+
}
|
14 |
+
|
15 |
+
function ContextMenuTrigger({
|
16 |
+
...props
|
17 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
18 |
+
return (
|
19 |
+
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
20 |
+
)
|
21 |
+
}
|
22 |
+
|
23 |
+
function ContextMenuGroup({
|
24 |
+
...props
|
25 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
26 |
+
return (
|
27 |
+
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
28 |
+
)
|
29 |
+
}
|
30 |
+
|
31 |
+
function ContextMenuPortal({
|
32 |
+
...props
|
33 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
34 |
+
return (
|
35 |
+
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
36 |
+
)
|
37 |
+
}
|
38 |
+
|
39 |
+
function ContextMenuSub({
|
40 |
+
...props
|
41 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
42 |
+
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
43 |
+
}
|
44 |
+
|
45 |
+
function ContextMenuRadioGroup({
|
46 |
+
...props
|
47 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
48 |
+
return (
|
49 |
+
<ContextMenuPrimitive.RadioGroup
|
50 |
+
data-slot="context-menu-radio-group"
|
51 |
+
{...props}
|
52 |
+
/>
|
53 |
+
)
|
54 |
+
}
|
55 |
+
|
56 |
+
function ContextMenuSubTrigger({
|
57 |
+
className,
|
58 |
+
inset,
|
59 |
+
children,
|
60 |
+
...props
|
61 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
62 |
+
inset?: boolean
|
63 |
+
}) {
|
64 |
+
return (
|
65 |
+
<ContextMenuPrimitive.SubTrigger
|
66 |
+
data-slot="context-menu-sub-trigger"
|
67 |
+
data-inset={inset}
|
68 |
+
className={cn(
|
69 |
+
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
70 |
+
className
|
71 |
+
)}
|
72 |
+
{...props}
|
73 |
+
>
|
74 |
+
{children}
|
75 |
+
<ChevronRightIcon className="ml-auto" />
|
76 |
+
</ContextMenuPrimitive.SubTrigger>
|
77 |
+
)
|
78 |
+
}
|
79 |
+
|
80 |
+
function ContextMenuSubContent({
|
81 |
+
className,
|
82 |
+
...props
|
83 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
84 |
+
return (
|
85 |
+
<ContextMenuPrimitive.SubContent
|
86 |
+
data-slot="context-menu-sub-content"
|
87 |
+
className={cn(
|
88 |
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
89 |
+
className
|
90 |
+
)}
|
91 |
+
{...props}
|
92 |
+
/>
|
93 |
+
)
|
94 |
+
}
|
95 |
+
|
96 |
+
function ContextMenuContent({
|
97 |
+
className,
|
98 |
+
...props
|
99 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
100 |
+
return (
|
101 |
+
<ContextMenuPrimitive.Portal>
|
102 |
+
<ContextMenuPrimitive.Content
|
103 |
+
data-slot="context-menu-content"
|
104 |
+
className={cn(
|
105 |
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
106 |
+
className
|
107 |
+
)}
|
108 |
+
{...props}
|
109 |
+
/>
|
110 |
+
</ContextMenuPrimitive.Portal>
|
111 |
+
)
|
112 |
+
}
|
113 |
+
|
114 |
+
function ContextMenuItem({
|
115 |
+
className,
|
116 |
+
inset,
|
117 |
+
variant = "default",
|
118 |
+
...props
|
119 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
120 |
+
inset?: boolean
|
121 |
+
variant?: "default" | "destructive"
|
122 |
+
}) {
|
123 |
+
return (
|
124 |
+
<ContextMenuPrimitive.Item
|
125 |
+
data-slot="context-menu-item"
|
126 |
+
data-inset={inset}
|
127 |
+
data-variant={variant}
|
128 |
+
className={cn(
|
129 |
+
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
130 |
+
className
|
131 |
+
)}
|
132 |
+
{...props}
|
133 |
+
/>
|
134 |
+
)
|
135 |
+
}
|
136 |
+
|
137 |
+
function ContextMenuCheckboxItem({
|
138 |
+
className,
|
139 |
+
children,
|
140 |
+
checked,
|
141 |
+
...props
|
142 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
143 |
+
return (
|
144 |
+
<ContextMenuPrimitive.CheckboxItem
|
145 |
+
data-slot="context-menu-checkbox-item"
|
146 |
+
className={cn(
|
147 |
+
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
148 |
+
className
|
149 |
+
)}
|
150 |
+
checked={checked}
|
151 |
+
{...props}
|
152 |
+
>
|
153 |
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
154 |
+
<ContextMenuPrimitive.ItemIndicator>
|
155 |
+
<CheckIcon className="size-4" />
|
156 |
+
</ContextMenuPrimitive.ItemIndicator>
|
157 |
+
</span>
|
158 |
+
{children}
|
159 |
+
</ContextMenuPrimitive.CheckboxItem>
|
160 |
+
)
|
161 |
+
}
|
162 |
+
|
163 |
+
function ContextMenuRadioItem({
|
164 |
+
className,
|
165 |
+
children,
|
166 |
+
...props
|
167 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
168 |
+
return (
|
169 |
+
<ContextMenuPrimitive.RadioItem
|
170 |
+
data-slot="context-menu-radio-item"
|
171 |
+
className={cn(
|
172 |
+
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
173 |
+
className
|
174 |
+
)}
|
175 |
+
{...props}
|
176 |
+
>
|
177 |
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
178 |
+
<ContextMenuPrimitive.ItemIndicator>
|
179 |
+
<CircleIcon className="size-2 fill-current" />
|
180 |
+
</ContextMenuPrimitive.ItemIndicator>
|
181 |
+
</span>
|
182 |
+
{children}
|
183 |
+
</ContextMenuPrimitive.RadioItem>
|
184 |
+
)
|
185 |
+
}
|
186 |
+
|
187 |
+
function ContextMenuLabel({
|
188 |
+
className,
|
189 |
+
inset,
|
190 |
+
...props
|
191 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
192 |
+
inset?: boolean
|
193 |
+
}) {
|
194 |
+
return (
|
195 |
+
<ContextMenuPrimitive.Label
|
196 |
+
data-slot="context-menu-label"
|
197 |
+
data-inset={inset}
|
198 |
+
className={cn(
|
199 |
+
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
200 |
+
className
|
201 |
+
)}
|
202 |
+
{...props}
|
203 |
+
/>
|
204 |
+
)
|
205 |
+
}
|
206 |
+
|
207 |
+
function ContextMenuSeparator({
|
208 |
+
className,
|
209 |
+
...props
|
210 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
211 |
+
return (
|
212 |
+
<ContextMenuPrimitive.Separator
|
213 |
+
data-slot="context-menu-separator"
|
214 |
+
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
215 |
+
{...props}
|
216 |
+
/>
|
217 |
+
)
|
218 |
+
}
|
219 |
+
|
220 |
+
function ContextMenuShortcut({
|
221 |
+
className,
|
222 |
+
...props
|
223 |
+
}: React.ComponentProps<"span">) {
|
224 |
+
return (
|
225 |
+
<span
|
226 |
+
data-slot="context-menu-shortcut"
|
227 |
+
className={cn(
|
228 |
+
"text-muted-foreground ml-auto text-xs tracking-widest",
|
229 |
+
className
|
230 |
+
)}
|
231 |
+
{...props}
|
232 |
+
/>
|
233 |
+
)
|
234 |
+
}
|
235 |
+
|
236 |
+
export {
|
237 |
+
ContextMenu,
|
238 |
+
ContextMenuTrigger,
|
239 |
+
ContextMenuContent,
|
240 |
+
ContextMenuItem,
|
241 |
+
ContextMenuCheckboxItem,
|
242 |
+
ContextMenuRadioItem,
|
243 |
+
ContextMenuLabel,
|
244 |
+
ContextMenuSeparator,
|
245 |
+
ContextMenuShortcut,
|
246 |
+
ContextMenuGroup,
|
247 |
+
ContextMenuPortal,
|
248 |
+
ContextMenuSub,
|
249 |
+
ContextMenuSubContent,
|
250 |
+
ContextMenuSubTrigger,
|
251 |
+
ContextMenuRadioGroup,
|
252 |
+
}
|
components/ui/dialog.tsx
ADDED
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
5 |
+
import { XIcon } from "lucide-react"
|
6 |
+
|
7 |
+
import { cn } from "@/lib/utils"
|
8 |
+
|
9 |
+
function Dialog({
|
10 |
+
...props
|
11 |
+
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
12 |
+
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
13 |
+
}
|
14 |
+
|
15 |
+
function DialogTrigger({
|
16 |
+
...props
|
17 |
+
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
18 |
+
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
19 |
+
}
|
20 |
+
|
21 |
+
function DialogPortal({
|
22 |
+
...props
|
23 |
+
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
24 |
+
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
25 |
+
}
|
26 |
+
|
27 |
+
function DialogClose({
|
28 |
+
...props
|
29 |
+
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
30 |
+
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
31 |
+
}
|
32 |
+
|
33 |
+
function DialogOverlay({
|
34 |
+
className,
|
35 |
+
...props
|
36 |
+
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
37 |
+
return (
|
38 |
+
<DialogPrimitive.Overlay
|
39 |
+
data-slot="dialog-overlay"
|
40 |
+
className={cn(
|
41 |
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
42 |
+
className
|
43 |
+
)}
|
44 |
+
{...props}
|
45 |
+
/>
|
46 |
+
)
|
47 |
+
}
|
48 |
+
|
49 |
+
function DialogContent({
|
50 |
+
className,
|
51 |
+
children,
|
52 |
+
showCloseButton = true,
|
53 |
+
...props
|
54 |
+
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
55 |
+
showCloseButton?: boolean
|
56 |
+
}) {
|
57 |
+
return (
|
58 |
+
<DialogPortal data-slot="dialog-portal">
|
59 |
+
<DialogOverlay />
|
60 |
+
<DialogPrimitive.Content
|
61 |
+
data-slot="dialog-content"
|
62 |
+
className={cn(
|
63 |
+
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
64 |
+
className
|
65 |
+
)}
|
66 |
+
{...props}
|
67 |
+
>
|
68 |
+
{children}
|
69 |
+
{showCloseButton && (
|
70 |
+
<DialogPrimitive.Close
|
71 |
+
data-slot="dialog-close"
|
72 |
+
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
73 |
+
>
|
74 |
+
<XIcon />
|
75 |
+
<span className="sr-only">Close</span>
|
76 |
+
</DialogPrimitive.Close>
|
77 |
+
)}
|
78 |
+
</DialogPrimitive.Content>
|
79 |
+
</DialogPortal>
|
80 |
+
)
|
81 |
+
}
|
82 |
+
|
83 |
+
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
84 |
+
return (
|
85 |
+
<div
|
86 |
+
data-slot="dialog-header"
|
87 |
+
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
88 |
+
{...props}
|
89 |
+
/>
|
90 |
+
)
|
91 |
+
}
|
92 |
+
|
93 |
+
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
94 |
+
return (
|
95 |
+
<div
|
96 |
+
data-slot="dialog-footer"
|
97 |
+
className={cn(
|
98 |
+
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
99 |
+
className
|
100 |
+
)}
|
101 |
+
{...props}
|
102 |
+
/>
|
103 |
+
)
|
104 |
+
}
|
105 |
+
|
106 |
+
function DialogTitle({
|
107 |
+
className,
|
108 |
+
...props
|
109 |
+
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
110 |
+
return (
|
111 |
+
<DialogPrimitive.Title
|
112 |
+
data-slot="dialog-title"
|
113 |
+
className={cn("text-lg leading-none font-semibold", className)}
|
114 |
+
{...props}
|
115 |
+
/>
|
116 |
+
)
|
117 |
+
}
|
118 |
+
|
119 |
+
function DialogDescription({
|
120 |
+
className,
|
121 |
+
...props
|
122 |
+
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
123 |
+
return (
|
124 |
+
<DialogPrimitive.Description
|
125 |
+
data-slot="dialog-description"
|
126 |
+
className={cn("text-muted-foreground text-sm", className)}
|
127 |
+
{...props}
|
128 |
+
/>
|
129 |
+
)
|
130 |
+
}
|
131 |
+
|
132 |
+
export {
|
133 |
+
Dialog,
|
134 |
+
DialogClose,
|
135 |
+
DialogContent,
|
136 |
+
DialogDescription,
|
137 |
+
DialogFooter,
|
138 |
+
DialogHeader,
|
139 |
+
DialogOverlay,
|
140 |
+
DialogPortal,
|
141 |
+
DialogTitle,
|
142 |
+
DialogTrigger,
|
143 |
+
}
|
components/ui/drawer.tsx
ADDED
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import { Drawer as DrawerPrimitive } from "vaul"
|
5 |
+
|
6 |
+
import { cn } from "@/lib/utils"
|
7 |
+
|
8 |
+
function Drawer({
|
9 |
+
...props
|
10 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
11 |
+
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
12 |
+
}
|
13 |
+
|
14 |
+
function DrawerTrigger({
|
15 |
+
...props
|
16 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
17 |
+
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
18 |
+
}
|
19 |
+
|
20 |
+
function DrawerPortal({
|
21 |
+
...props
|
22 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
23 |
+
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
24 |
+
}
|
25 |
+
|
26 |
+
function DrawerClose({
|
27 |
+
...props
|
28 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
29 |
+
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
30 |
+
}
|
31 |
+
|
32 |
+
function DrawerOverlay({
|
33 |
+
className,
|
34 |
+
...props
|
35 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
36 |
+
return (
|
37 |
+
<DrawerPrimitive.Overlay
|
38 |
+
data-slot="drawer-overlay"
|
39 |
+
className={cn(
|
40 |
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
41 |
+
className
|
42 |
+
)}
|
43 |
+
{...props}
|
44 |
+
/>
|
45 |
+
)
|
46 |
+
}
|
47 |
+
|
48 |
+
function DrawerContent({
|
49 |
+
className,
|
50 |
+
children,
|
51 |
+
...props
|
52 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
53 |
+
return (
|
54 |
+
<DrawerPortal data-slot="drawer-portal">
|
55 |
+
<DrawerOverlay />
|
56 |
+
<DrawerPrimitive.Content
|
57 |
+
data-slot="drawer-content"
|
58 |
+
className={cn(
|
59 |
+
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
60 |
+
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
61 |
+
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
62 |
+
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
63 |
+
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
64 |
+
className
|
65 |
+
)}
|
66 |
+
{...props}
|
67 |
+
>
|
68 |
+
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
69 |
+
{children}
|
70 |
+
</DrawerPrimitive.Content>
|
71 |
+
</DrawerPortal>
|
72 |
+
)
|
73 |
+
}
|
74 |
+
|
75 |
+
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
76 |
+
return (
|
77 |
+
<div
|
78 |
+
data-slot="drawer-header"
|
79 |
+
className={cn(
|
80 |
+
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
81 |
+
className
|
82 |
+
)}
|
83 |
+
{...props}
|
84 |
+
/>
|
85 |
+
)
|
86 |
+
}
|
87 |
+
|
88 |
+
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
89 |
+
return (
|
90 |
+
<div
|
91 |
+
data-slot="drawer-footer"
|
92 |
+
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
93 |
+
{...props}
|
94 |
+
/>
|
95 |
+
)
|
96 |
+
}
|
97 |
+
|
98 |
+
function DrawerTitle({
|
99 |
+
className,
|
100 |
+
...props
|
101 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
102 |
+
return (
|
103 |
+
<DrawerPrimitive.Title
|
104 |
+
data-slot="drawer-title"
|
105 |
+
className={cn("text-foreground font-semibold", className)}
|
106 |
+
{...props}
|
107 |
+
/>
|
108 |
+
)
|
109 |
+
}
|
110 |
+
|
111 |
+
function DrawerDescription({
|
112 |
+
className,
|
113 |
+
...props
|
114 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
115 |
+
return (
|
116 |
+
<DrawerPrimitive.Description
|
117 |
+
data-slot="drawer-description"
|
118 |
+
className={cn("text-muted-foreground text-sm", className)}
|
119 |
+
{...props}
|
120 |
+
/>
|
121 |
+
)
|
122 |
+
}
|
123 |
+
|
124 |
+
export {
|
125 |
+
Drawer,
|
126 |
+
DrawerPortal,
|
127 |
+
DrawerOverlay,
|
128 |
+
DrawerTrigger,
|
129 |
+
DrawerClose,
|
130 |
+
DrawerContent,
|
131 |
+
DrawerHeader,
|
132 |
+
DrawerFooter,
|
133 |
+
DrawerTitle,
|
134 |
+
DrawerDescription,
|
135 |
+
}
|
components/ui/dropdown-menu.tsx
ADDED
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
5 |
+
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
6 |
+
|
7 |
+
import { cn } from "@/lib/utils"
|
8 |
+
|
9 |
+
function DropdownMenu({
|
10 |
+
...props
|
11 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
12 |
+
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
13 |
+
}
|
14 |
+
|
15 |
+
function DropdownMenuPortal({
|
16 |
+
...props
|
17 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
18 |
+
return (
|
19 |
+
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
20 |
+
)
|
21 |
+
}
|
22 |
+
|
23 |
+
function DropdownMenuTrigger({
|
24 |
+
...props
|
25 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
26 |
+
return (
|
27 |
+
<DropdownMenuPrimitive.Trigger
|
28 |
+
data-slot="dropdown-menu-trigger"
|
29 |
+
{...props}
|
30 |
+
/>
|
31 |
+
)
|
32 |
+
}
|
33 |
+
|
34 |
+
function DropdownMenuContent({
|
35 |
+
className,
|
36 |
+
sideOffset = 4,
|
37 |
+
...props
|
38 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
39 |
+
return (
|
40 |
+
<DropdownMenuPrimitive.Portal>
|
41 |
+
<DropdownMenuPrimitive.Content
|
42 |
+
data-slot="dropdown-menu-content"
|
43 |
+
sideOffset={sideOffset}
|
44 |
+
className={cn(
|
45 |
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
46 |
+
className
|
47 |
+
)}
|
48 |
+
{...props}
|
49 |
+
/>
|
50 |
+
</DropdownMenuPrimitive.Portal>
|
51 |
+
)
|
52 |
+
}
|
53 |
+
|
54 |
+
function DropdownMenuGroup({
|
55 |
+
...props
|
56 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
57 |
+
return (
|
58 |
+
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
59 |
+
)
|
60 |
+
}
|
61 |
+
|
62 |
+
function DropdownMenuItem({
|
63 |
+
className,
|
64 |
+
inset,
|
65 |
+
variant = "default",
|
66 |
+
...props
|
67 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
68 |
+
inset?: boolean
|
69 |
+
variant?: "default" | "destructive"
|
70 |
+
}) {
|
71 |
+
return (
|
72 |
+
<DropdownMenuPrimitive.Item
|
73 |
+
data-slot="dropdown-menu-item"
|
74 |
+
data-inset={inset}
|
75 |
+
data-variant={variant}
|
76 |
+
className={cn(
|
77 |
+
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
78 |
+
className
|
79 |
+
)}
|
80 |
+
{...props}
|
81 |
+
/>
|
82 |
+
)
|
83 |
+
}
|
84 |
+
|
85 |
+
function DropdownMenuCheckboxItem({
|
86 |
+
className,
|
87 |
+
children,
|
88 |
+
checked,
|
89 |
+
...props
|
90 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
91 |
+
return (
|
92 |
+
<DropdownMenuPrimitive.CheckboxItem
|
93 |
+
data-slot="dropdown-menu-checkbox-item"
|
94 |
+
className={cn(
|
95 |
+
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
96 |
+
className
|
97 |
+
)}
|
98 |
+
checked={checked}
|
99 |
+
{...props}
|
100 |
+
>
|
101 |
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
102 |
+
<DropdownMenuPrimitive.ItemIndicator>
|
103 |
+
<CheckIcon className="size-4" />
|
104 |
+
</DropdownMenuPrimitive.ItemIndicator>
|
105 |
+
</span>
|
106 |
+
{children}
|
107 |
+
</DropdownMenuPrimitive.CheckboxItem>
|
108 |
+
)
|
109 |
+
}
|
110 |
+
|
111 |
+
function DropdownMenuRadioGroup({
|
112 |
+
...props
|
113 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
114 |
+
return (
|
115 |
+
<DropdownMenuPrimitive.RadioGroup
|
116 |
+
data-slot="dropdown-menu-radio-group"
|
117 |
+
{...props}
|
118 |
+
/>
|
119 |
+
)
|
120 |
+
}
|
121 |
+
|
122 |
+
function DropdownMenuRadioItem({
|
123 |
+
className,
|
124 |
+
children,
|
125 |
+
...props
|
126 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
127 |
+
return (
|
128 |
+
<DropdownMenuPrimitive.RadioItem
|
129 |
+
data-slot="dropdown-menu-radio-item"
|
130 |
+
className={cn(
|
131 |
+
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
132 |
+
className
|
133 |
+
)}
|
134 |
+
{...props}
|
135 |
+
>
|
136 |
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
137 |
+
<DropdownMenuPrimitive.ItemIndicator>
|
138 |
+
<CircleIcon className="size-2 fill-current" />
|
139 |
+
</DropdownMenuPrimitive.ItemIndicator>
|
140 |
+
</span>
|
141 |
+
{children}
|
142 |
+
</DropdownMenuPrimitive.RadioItem>
|
143 |
+
)
|
144 |
+
}
|
145 |
+
|
146 |
+
function DropdownMenuLabel({
|
147 |
+
className,
|
148 |
+
inset,
|
149 |
+
...props
|
150 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
151 |
+
inset?: boolean
|
152 |
+
}) {
|
153 |
+
return (
|
154 |
+
<DropdownMenuPrimitive.Label
|
155 |
+
data-slot="dropdown-menu-label"
|
156 |
+
data-inset={inset}
|
157 |
+
className={cn(
|
158 |
+
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
159 |
+
className
|
160 |
+
)}
|
161 |
+
{...props}
|
162 |
+
/>
|
163 |
+
)
|
164 |
+
}
|
165 |
+
|
166 |
+
function DropdownMenuSeparator({
|
167 |
+
className,
|
168 |
+
...props
|
169 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
170 |
+
return (
|
171 |
+
<DropdownMenuPrimitive.Separator
|
172 |
+
data-slot="dropdown-menu-separator"
|
173 |
+
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
174 |
+
{...props}
|
175 |
+
/>
|
176 |
+
)
|
177 |
+
}
|
178 |
+
|
179 |
+
function DropdownMenuShortcut({
|
180 |
+
className,
|
181 |
+
...props
|
182 |
+
}: React.ComponentProps<"span">) {
|
183 |
+
return (
|
184 |
+
<span
|
185 |
+
data-slot="dropdown-menu-shortcut"
|
186 |
+
className={cn(
|
187 |
+
"text-muted-foreground ml-auto text-xs tracking-widest",
|
188 |
+
className
|
189 |
+
)}
|
190 |
+
{...props}
|
191 |
+
/>
|
192 |
+
)
|
193 |
+
}
|
194 |
+
|
195 |
+
function DropdownMenuSub({
|
196 |
+
...props
|
197 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
198 |
+
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
199 |
+
}
|
200 |
+
|
201 |
+
function DropdownMenuSubTrigger({
|
202 |
+
className,
|
203 |
+
inset,
|
204 |
+
children,
|
205 |
+
...props
|
206 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
207 |
+
inset?: boolean
|
208 |
+
}) {
|
209 |
+
return (
|
210 |
+
<DropdownMenuPrimitive.SubTrigger
|
211 |
+
data-slot="dropdown-menu-sub-trigger"
|
212 |
+
data-inset={inset}
|
213 |
+
className={cn(
|
214 |
+
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
215 |
+
className
|
216 |
+
)}
|
217 |
+
{...props}
|
218 |
+
>
|
219 |
+
{children}
|
220 |
+
<ChevronRightIcon className="ml-auto size-4" />
|
221 |
+
</DropdownMenuPrimitive.SubTrigger>
|
222 |
+
)
|
223 |
+
}
|
224 |
+
|
225 |
+
function DropdownMenuSubContent({
|
226 |
+
className,
|
227 |
+
...props
|
228 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
229 |
+
return (
|
230 |
+
<DropdownMenuPrimitive.SubContent
|
231 |
+
data-slot="dropdown-menu-sub-content"
|
232 |
+
className={cn(
|
233 |
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
234 |
+
className
|
235 |
+
)}
|
236 |
+
{...props}
|
237 |
+
/>
|
238 |
+
)
|
239 |
+
}
|
240 |
+
|
241 |
+
export {
|
242 |
+
DropdownMenu,
|
243 |
+
DropdownMenuPortal,
|
244 |
+
DropdownMenuTrigger,
|
245 |
+
DropdownMenuContent,
|
246 |
+
DropdownMenuGroup,
|
247 |
+
DropdownMenuLabel,
|
248 |
+
DropdownMenuItem,
|
249 |
+
DropdownMenuCheckboxItem,
|
250 |
+
DropdownMenuRadioGroup,
|
251 |
+
DropdownMenuRadioItem,
|
252 |
+
DropdownMenuSeparator,
|
253 |
+
DropdownMenuShortcut,
|
254 |
+
DropdownMenuSub,
|
255 |
+
DropdownMenuSubTrigger,
|
256 |
+
DropdownMenuSubContent,
|
257 |
+
}
|
components/ui/form.tsx
ADDED
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import * as LabelPrimitive from "@radix-ui/react-label"
|
5 |
+
import { Slot } from "@radix-ui/react-slot"
|
6 |
+
import {
|
7 |
+
Controller,
|
8 |
+
FormProvider,
|
9 |
+
useFormContext,
|
10 |
+
useFormState,
|
11 |
+
type ControllerProps,
|
12 |
+
type FieldPath,
|
13 |
+
type FieldValues,
|
14 |
+
} from "react-hook-form"
|
15 |
+
|
16 |
+
import { cn } from "@/lib/utils"
|
17 |
+
import { Label } from "@/components/ui/label"
|
18 |
+
|
19 |
+
const Form = FormProvider
|
20 |
+
|
21 |
+
type FormFieldContextValue<
|
22 |
+
TFieldValues extends FieldValues = FieldValues,
|
23 |
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
24 |
+
> = {
|
25 |
+
name: TName
|
26 |
+
}
|
27 |
+
|
28 |
+
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
29 |
+
{} as FormFieldContextValue
|
30 |
+
)
|
31 |
+
|
32 |
+
const FormField = <
|
33 |
+
TFieldValues extends FieldValues = FieldValues,
|
34 |
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
35 |
+
>({
|
36 |
+
...props
|
37 |
+
}: ControllerProps<TFieldValues, TName>) => {
|
38 |
+
return (
|
39 |
+
<FormFieldContext.Provider value={{ name: props.name }}>
|
40 |
+
<Controller {...props} />
|
41 |
+
</FormFieldContext.Provider>
|
42 |
+
)
|
43 |
+
}
|
44 |
+
|
45 |
+
const useFormField = () => {
|
46 |
+
const fieldContext = React.useContext(FormFieldContext)
|
47 |
+
const itemContext = React.useContext(FormItemContext)
|
48 |
+
const { getFieldState } = useFormContext()
|
49 |
+
const formState = useFormState({ name: fieldContext.name })
|
50 |
+
const fieldState = getFieldState(fieldContext.name, formState)
|
51 |
+
|
52 |
+
if (!fieldContext) {
|
53 |
+
throw new Error("useFormField should be used within <FormField>")
|
54 |
+
}
|
55 |
+
|
56 |
+
const { id } = itemContext
|
57 |
+
|
58 |
+
return {
|
59 |
+
id,
|
60 |
+
name: fieldContext.name,
|
61 |
+
formItemId: `${id}-form-item`,
|
62 |
+
formDescriptionId: `${id}-form-item-description`,
|
63 |
+
formMessageId: `${id}-form-item-message`,
|
64 |
+
...fieldState,
|
65 |
+
}
|
66 |
+
}
|
67 |
+
|
68 |
+
type FormItemContextValue = {
|
69 |
+
id: string
|
70 |
+
}
|
71 |
+
|
72 |
+
const FormItemContext = React.createContext<FormItemContextValue>(
|
73 |
+
{} as FormItemContextValue
|
74 |
+
)
|
75 |
+
|
76 |
+
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
77 |
+
const id = React.useId()
|
78 |
+
|
79 |
+
return (
|
80 |
+
<FormItemContext.Provider value={{ id }}>
|
81 |
+
<div
|
82 |
+
data-slot="form-item"
|
83 |
+
className={cn("grid gap-2", className)}
|
84 |
+
{...props}
|
85 |
+
/>
|
86 |
+
</FormItemContext.Provider>
|
87 |
+
)
|
88 |
+
}
|
89 |
+
|
90 |
+
function FormLabel({
|
91 |
+
className,
|
92 |
+
...props
|
93 |
+
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
94 |
+
const { error, formItemId } = useFormField()
|
95 |
+
|
96 |
+
return (
|
97 |
+
<Label
|
98 |
+
data-slot="form-label"
|
99 |
+
data-error={!!error}
|
100 |
+
className={cn("data-[error=true]:text-destructive", className)}
|
101 |
+
htmlFor={formItemId}
|
102 |
+
{...props}
|
103 |
+
/>
|
104 |
+
)
|
105 |
+
}
|
106 |
+
|
107 |
+
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
108 |
+
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
109 |
+
|
110 |
+
return (
|
111 |
+
<Slot
|
112 |
+
data-slot="form-control"
|
113 |
+
id={formItemId}
|
114 |
+
aria-describedby={
|
115 |
+
!error
|
116 |
+
? `${formDescriptionId}`
|
117 |
+
: `${formDescriptionId} ${formMessageId}`
|
118 |
+
}
|
119 |
+
aria-invalid={!!error}
|
120 |
+
{...props}
|
121 |
+
/>
|
122 |
+
)
|
123 |
+
}
|
124 |
+
|
125 |
+
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
126 |
+
const { formDescriptionId } = useFormField()
|
127 |
+
|
128 |
+
return (
|
129 |
+
<p
|
130 |
+
data-slot="form-description"
|
131 |
+
id={formDescriptionId}
|
132 |
+
className={cn("text-muted-foreground text-sm", className)}
|
133 |
+
{...props}
|
134 |
+
/>
|
135 |
+
)
|
136 |
+
}
|
137 |
+
|
138 |
+
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
139 |
+
const { error, formMessageId } = useFormField()
|
140 |
+
const body = error ? String(error?.message ?? "") : props.children
|
141 |
+
|
142 |
+
if (!body) {
|
143 |
+
return null
|
144 |
+
}
|
145 |
+
|
146 |
+
return (
|
147 |
+
<p
|
148 |
+
data-slot="form-message"
|
149 |
+
id={formMessageId}
|
150 |
+
className={cn("text-destructive text-sm", className)}
|
151 |
+
{...props}
|
152 |
+
>
|
153 |
+
{body}
|
154 |
+
</p>
|
155 |
+
)
|
156 |
+
}
|
157 |
+
|
158 |
+
export {
|
159 |
+
useFormField,
|
160 |
+
Form,
|
161 |
+
FormItem,
|
162 |
+
FormLabel,
|
163 |
+
FormControl,
|
164 |
+
FormDescription,
|
165 |
+
FormMessage,
|
166 |
+
FormField,
|
167 |
+
}
|
components/ui/hover-card.tsx
ADDED
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
5 |
+
|
6 |
+
import { cn } from "@/lib/utils"
|
7 |
+
|
8 |
+
function HoverCard({
|
9 |
+
...props
|
10 |
+
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
11 |
+
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
12 |
+
}
|
13 |
+
|
14 |
+
function HoverCardTrigger({
|
15 |
+
...props
|
16 |
+
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
17 |
+
return (
|
18 |
+
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
19 |
+
)
|
20 |
+
}
|
21 |
+
|
22 |
+
function HoverCardContent({
|
23 |
+
className,
|
24 |
+
align = "center",
|
25 |
+
sideOffset = 4,
|
26 |
+
...props
|
27 |
+
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
28 |
+
return (
|
29 |
+
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
30 |
+
<HoverCardPrimitive.Content
|
31 |
+
data-slot="hover-card-content"
|
32 |
+
align={align}
|
33 |
+
sideOffset={sideOffset}
|
34 |
+
className={cn(
|
35 |
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
36 |
+
className
|
37 |
+
)}
|
38 |
+
{...props}
|
39 |
+
/>
|
40 |
+
</HoverCardPrimitive.Portal>
|
41 |
+
)
|
42 |
+
}
|
43 |
+
|
44 |
+
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
components/ui/input-otp.tsx
ADDED
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import { OTPInput, OTPInputContext } from "input-otp"
|
5 |
+
import { MinusIcon } from "lucide-react"
|
6 |
+
|
7 |
+
import { cn } from "@/lib/utils"
|
8 |
+
|
9 |
+
function InputOTP({
|
10 |
+
className,
|
11 |
+
containerClassName,
|
12 |
+
...props
|
13 |
+
}: React.ComponentProps<typeof OTPInput> & {
|
14 |
+
containerClassName?: string
|
15 |
+
}) {
|
16 |
+
return (
|
17 |
+
<OTPInput
|
18 |
+
data-slot="input-otp"
|
19 |
+
containerClassName={cn(
|
20 |
+
"flex items-center gap-2 has-disabled:opacity-50",
|
21 |
+
containerClassName
|
22 |
+
)}
|
23 |
+
className={cn("disabled:cursor-not-allowed", className)}
|
24 |
+
{...props}
|
25 |
+
/>
|
26 |
+
)
|
27 |
+
}
|
28 |
+
|
29 |
+
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
30 |
+
return (
|
31 |
+
<div
|
32 |
+
data-slot="input-otp-group"
|
33 |
+
className={cn("flex items-center", className)}
|
34 |
+
{...props}
|
35 |
+
/>
|
36 |
+
)
|
37 |
+
}
|
38 |
+
|
39 |
+
function InputOTPSlot({
|
40 |
+
index,
|
41 |
+
className,
|
42 |
+
...props
|
43 |
+
}: React.ComponentProps<"div"> & {
|
44 |
+
index: number
|
45 |
+
}) {
|
46 |
+
const inputOTPContext = React.useContext(OTPInputContext)
|
47 |
+
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
48 |
+
|
49 |
+
return (
|
50 |
+
<div
|
51 |
+
data-slot="input-otp-slot"
|
52 |
+
data-active={isActive}
|
53 |
+
className={cn(
|
54 |
+
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
55 |
+
className
|
56 |
+
)}
|
57 |
+
{...props}
|
58 |
+
>
|
59 |
+
{char}
|
60 |
+
{hasFakeCaret && (
|
61 |
+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
62 |
+
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
63 |
+
</div>
|
64 |
+
)}
|
65 |
+
</div>
|
66 |
+
)
|
67 |
+
}
|
68 |
+
|
69 |
+
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
70 |
+
return (
|
71 |
+
<div data-slot="input-otp-separator" role="separator" {...props}>
|
72 |
+
<MinusIcon />
|
73 |
+
</div>
|
74 |
+
)
|
75 |
+
}
|
76 |
+
|
77 |
+
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
components/ui/input.tsx
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
|
3 |
+
import { cn } from "@/lib/utils"
|
4 |
+
|
5 |
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
6 |
+
return (
|
7 |
+
<input
|
8 |
+
type={type}
|
9 |
+
data-slot="input"
|
10 |
+
className={cn(
|
11 |
+
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
12 |
+
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
13 |
+
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
14 |
+
className
|
15 |
+
)}
|
16 |
+
{...props}
|
17 |
+
/>
|
18 |
+
)
|
19 |
+
}
|
20 |
+
|
21 |
+
export { Input }
|
components/ui/label.tsx
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import * as LabelPrimitive from "@radix-ui/react-label"
|
5 |
+
|
6 |
+
import { cn } from "@/lib/utils"
|
7 |
+
|
8 |
+
function Label({
|
9 |
+
className,
|
10 |
+
...props
|
11 |
+
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
12 |
+
return (
|
13 |
+
<LabelPrimitive.Root
|
14 |
+
data-slot="label"
|
15 |
+
className={cn(
|
16 |
+
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
17 |
+
className
|
18 |
+
)}
|
19 |
+
{...props}
|
20 |
+
/>
|
21 |
+
)
|
22 |
+
}
|
23 |
+
|
24 |
+
export { Label }
|
components/ui/menubar.tsx
ADDED
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
5 |
+
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
6 |
+
|
7 |
+
import { cn } from "@/lib/utils"
|
8 |
+
|
9 |
+
function Menubar({
|
10 |
+
className,
|
11 |
+
...props
|
12 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
13 |
+
return (
|
14 |
+
<MenubarPrimitive.Root
|
15 |
+
data-slot="menubar"
|
16 |
+
className={cn(
|
17 |
+
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
|
18 |
+
className
|
19 |
+
)}
|
20 |
+
{...props}
|
21 |
+
/>
|
22 |
+
)
|
23 |
+
}
|
24 |
+
|
25 |
+
function MenubarMenu({
|
26 |
+
...props
|
27 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
28 |
+
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
|
29 |
+
}
|
30 |
+
|
31 |
+
function MenubarGroup({
|
32 |
+
...props
|
33 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
34 |
+
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
|
35 |
+
}
|
36 |
+
|
37 |
+
function MenubarPortal({
|
38 |
+
...props
|
39 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
40 |
+
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
|
41 |
+
}
|
42 |
+
|
43 |
+
function MenubarRadioGroup({
|
44 |
+
...props
|
45 |
+
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
46 |
+
return (
|
47 |
+
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
48 |
+
)
|
49 |
+
}
|
50 |
+
|
51 |
+
function MenubarTrigger({
|
52 |
+
className,
|
53 |
+
...props
|
54 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
|
55 |
+
return (
|
56 |
+
<MenubarPrimitive.Trigger
|
57 |
+
data-slot="menubar-trigger"
|
58 |
+
className={cn(
|
59 |
+
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
|
60 |
+
className
|
61 |
+
)}
|
62 |
+
{...props}
|
63 |
+
/>
|
64 |
+
)
|
65 |
+
}
|
66 |
+
|
67 |
+
function MenubarContent({
|
68 |
+
className,
|
69 |
+
align = "start",
|
70 |
+
alignOffset = -4,
|
71 |
+
sideOffset = 8,
|
72 |
+
...props
|
73 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
|
74 |
+
return (
|
75 |
+
<MenubarPortal>
|
76 |
+
<MenubarPrimitive.Content
|
77 |
+
data-slot="menubar-content"
|
78 |
+
align={align}
|
79 |
+
alignOffset={alignOffset}
|
80 |
+
sideOffset={sideOffset}
|
81 |
+
className={cn(
|
82 |
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
|
83 |
+
className
|
84 |
+
)}
|
85 |
+
{...props}
|
86 |
+
/>
|
87 |
+
</MenubarPortal>
|
88 |
+
)
|
89 |
+
}
|
90 |
+
|
91 |
+
function MenubarItem({
|
92 |
+
className,
|
93 |
+
inset,
|
94 |
+
variant = "default",
|
95 |
+
...props
|
96 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
97 |
+
inset?: boolean
|
98 |
+
variant?: "default" | "destructive"
|
99 |
+
}) {
|
100 |
+
return (
|
101 |
+
<MenubarPrimitive.Item
|
102 |
+
data-slot="menubar-item"
|
103 |
+
data-inset={inset}
|
104 |
+
data-variant={variant}
|
105 |
+
className={cn(
|
106 |
+
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
107 |
+
className
|
108 |
+
)}
|
109 |
+
{...props}
|
110 |
+
/>
|
111 |
+
)
|
112 |
+
}
|
113 |
+
|
114 |
+
function MenubarCheckboxItem({
|
115 |
+
className,
|
116 |
+
children,
|
117 |
+
checked,
|
118 |
+
...props
|
119 |
+
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
|
120 |
+
return (
|
121 |
+
<MenubarPrimitive.CheckboxItem
|
122 |
+
data-slot="menubar-checkbox-item"
|
123 |
+
className={cn(
|
124 |
+
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
125 |
+
className
|
126 |
+
)}
|
127 |
+
checked={checked}
|
128 |
+
{...props}
|
129 |
+
>
|
130 |
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
131 |
+
<MenubarPrimitive.ItemIndicator>
|
132 |
+
<CheckIcon className="size-4" />
|
133 |
+
</MenubarPrimitive.ItemIndicator>
|
134 |
+
</span>
|
135 |
+
{children}
|
136 |
+
</MenubarPrimitive.CheckboxItem>
|
137 |
+
)
|
138 |
+
}
|
139 |
+
|
140 |
+
function MenubarRadioItem({
|
141 |
+
className,
|
142 |
+
children,
|
143 |
+
...props
|
144 |
+
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
|
145 |
+
return (
|
146 |
+
<MenubarPrimitive.RadioItem
|
147 |
+
data-slot="menubar-radio-item"
|
148 |
+
className={cn(
|
149 |
+
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
150 |
+
className
|
151 |
+
)}
|
152 |
+
{...props}
|
153 |
+
>
|
154 |
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
155 |
+
<MenubarPrimitive.ItemIndicator>
|
156 |
+
<CircleIcon className="size-2 fill-current" />
|
157 |
+
</MenubarPrimitive.ItemIndicator>
|
158 |
+
</span>
|
159 |
+
{children}
|
160 |
+
</MenubarPrimitive.RadioItem>
|
161 |
+
)
|
162 |
+
}
|
163 |
+
|
164 |
+
function MenubarLabel({
|
165 |
+
className,
|
166 |
+
inset,
|
167 |
+
...props
|
168 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
169 |
+
inset?: boolean
|
170 |
+
}) {
|
171 |
+
return (
|
172 |
+
<MenubarPrimitive.Label
|
173 |
+
data-slot="menubar-label"
|
174 |
+
data-inset={inset}
|
175 |
+
className={cn(
|
176 |
+
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
177 |
+
className
|
178 |
+
)}
|
179 |
+
{...props}
|
180 |
+
/>
|
181 |
+
)
|
182 |
+
}
|
183 |
+
|
184 |
+
function MenubarSeparator({
|
185 |
+
className,
|
186 |
+
...props
|
187 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
|
188 |
+
return (
|
189 |
+
<MenubarPrimitive.Separator
|
190 |
+
data-slot="menubar-separator"
|
191 |
+
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
192 |
+
{...props}
|
193 |
+
/>
|
194 |
+
)
|
195 |
+
}
|
196 |
+
|
197 |
+
function MenubarShortcut({
|
198 |
+
className,
|
199 |
+
...props
|
200 |
+
}: React.ComponentProps<"span">) {
|
201 |
+
return (
|
202 |
+
<span
|
203 |
+
data-slot="menubar-shortcut"
|
204 |
+
className={cn(
|
205 |
+
"text-muted-foreground ml-auto text-xs tracking-widest",
|
206 |
+
className
|
207 |
+
)}
|
208 |
+
{...props}
|
209 |
+
/>
|
210 |
+
)
|
211 |
+
}
|
212 |
+
|
213 |
+
function MenubarSub({
|
214 |
+
...props
|
215 |
+
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
216 |
+
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
|
217 |
+
}
|
218 |
+
|
219 |
+
function MenubarSubTrigger({
|
220 |
+
className,
|
221 |
+
inset,
|
222 |
+
children,
|
223 |
+
...props
|
224 |
+
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
225 |
+
inset?: boolean
|
226 |
+
}) {
|
227 |
+
return (
|
228 |
+
<MenubarPrimitive.SubTrigger
|
229 |
+
data-slot="menubar-sub-trigger"
|
230 |
+
data-inset={inset}
|
231 |
+
className={cn(
|
232 |
+
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
|
233 |
+
className
|
234 |
+
)}
|
235 |
+
{...props}
|
236 |
+
>
|
237 |
+
{children}
|
238 |
+
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
239 |
+
</MenubarPrimitive.SubTrigger>
|
240 |
+
)
|
241 |
+
}
|
242 |
+
|
243 |
+
function MenubarSubContent({
|
244 |
+
className,
|
245 |
+
...props
|
246 |
+
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
247 |
+
return (
|
248 |
+
<MenubarPrimitive.SubContent
|
249 |
+
data-slot="menubar-sub-content"
|
250 |
+
className={cn(
|
251 |
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
252 |
+
className
|
253 |
+
)}
|
254 |
+
{...props}
|
255 |
+
/>
|
256 |
+
)
|
257 |
+
}
|
258 |
+
|
259 |
+
export {
|
260 |
+
Menubar,
|
261 |
+
MenubarPortal,
|
262 |
+
MenubarMenu,
|
263 |
+
MenubarTrigger,
|
264 |
+
MenubarContent,
|
265 |
+
MenubarGroup,
|
266 |
+
MenubarSeparator,
|
267 |
+
MenubarLabel,
|
268 |
+
MenubarItem,
|
269 |
+
MenubarShortcut,
|
270 |
+
MenubarCheckboxItem,
|
271 |
+
MenubarRadioGroup,
|
272 |
+
MenubarRadioItem,
|
273 |
+
MenubarSub,
|
274 |
+
MenubarSubTrigger,
|
275 |
+
MenubarSubContent,
|
276 |
+
}
|
components/ui/navigation-menu.tsx
ADDED
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
3 |
+
import { cva } from "class-variance-authority"
|
4 |
+
import { ChevronDownIcon } from "lucide-react"
|
5 |
+
|
6 |
+
import { cn } from "@/lib/utils"
|
7 |
+
|
8 |
+
function NavigationMenu({
|
9 |
+
className,
|
10 |
+
children,
|
11 |
+
viewport = true,
|
12 |
+
...props
|
13 |
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
14 |
+
viewport?: boolean
|
15 |
+
}) {
|
16 |
+
return (
|
17 |
+
<NavigationMenuPrimitive.Root
|
18 |
+
data-slot="navigation-menu"
|
19 |
+
data-viewport={viewport}
|
20 |
+
className={cn(
|
21 |
+
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
22 |
+
className
|
23 |
+
)}
|
24 |
+
{...props}
|
25 |
+
>
|
26 |
+
{children}
|
27 |
+
{viewport && <NavigationMenuViewport />}
|
28 |
+
</NavigationMenuPrimitive.Root>
|
29 |
+
)
|
30 |
+
}
|
31 |
+
|
32 |
+
function NavigationMenuList({
|
33 |
+
className,
|
34 |
+
...props
|
35 |
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
36 |
+
return (
|
37 |
+
<NavigationMenuPrimitive.List
|
38 |
+
data-slot="navigation-menu-list"
|
39 |
+
className={cn(
|
40 |
+
"group flex flex-1 list-none items-center justify-center gap-1",
|
41 |
+
className
|
42 |
+
)}
|
43 |
+
{...props}
|
44 |
+
/>
|
45 |
+
)
|
46 |
+
}
|
47 |
+
|
48 |
+
function NavigationMenuItem({
|
49 |
+
className,
|
50 |
+
...props
|
51 |
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
52 |
+
return (
|
53 |
+
<NavigationMenuPrimitive.Item
|
54 |
+
data-slot="navigation-menu-item"
|
55 |
+
className={cn("relative", className)}
|
56 |
+
{...props}
|
57 |
+
/>
|
58 |
+
)
|
59 |
+
}
|
60 |
+
|
61 |
+
const navigationMenuTriggerStyle = cva(
|
62 |
+
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
|
63 |
+
)
|
64 |
+
|
65 |
+
function NavigationMenuTrigger({
|
66 |
+
className,
|
67 |
+
children,
|
68 |
+
...props
|
69 |
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
70 |
+
return (
|
71 |
+
<NavigationMenuPrimitive.Trigger
|
72 |
+
data-slot="navigation-menu-trigger"
|
73 |
+
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
74 |
+
{...props}
|
75 |
+
>
|
76 |
+
{children}{" "}
|
77 |
+
<ChevronDownIcon
|
78 |
+
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
79 |
+
aria-hidden="true"
|
80 |
+
/>
|
81 |
+
</NavigationMenuPrimitive.Trigger>
|
82 |
+
)
|
83 |
+
}
|
84 |
+
|
85 |
+
function NavigationMenuContent({
|
86 |
+
className,
|
87 |
+
...props
|
88 |
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
89 |
+
return (
|
90 |
+
<NavigationMenuPrimitive.Content
|
91 |
+
data-slot="navigation-menu-content"
|
92 |
+
className={cn(
|
93 |
+
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
94 |
+
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
95 |
+
className
|
96 |
+
)}
|
97 |
+
{...props}
|
98 |
+
/>
|
99 |
+
)
|
100 |
+
}
|
101 |
+
|
102 |
+
function NavigationMenuViewport({
|
103 |
+
className,
|
104 |
+
...props
|
105 |
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
106 |
+
return (
|
107 |
+
<div
|
108 |
+
className={cn(
|
109 |
+
"absolute top-full left-0 isolate z-50 flex justify-center"
|
110 |
+
)}
|
111 |
+
>
|
112 |
+
<NavigationMenuPrimitive.Viewport
|
113 |
+
data-slot="navigation-menu-viewport"
|
114 |
+
className={cn(
|
115 |
+
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
116 |
+
className
|
117 |
+
)}
|
118 |
+
{...props}
|
119 |
+
/>
|
120 |
+
</div>
|
121 |
+
)
|
122 |
+
}
|
123 |
+
|
124 |
+
function NavigationMenuLink({
|
125 |
+
className,
|
126 |
+
...props
|
127 |
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
128 |
+
return (
|
129 |
+
<NavigationMenuPrimitive.Link
|
130 |
+
data-slot="navigation-menu-link"
|
131 |
+
className={cn(
|
132 |
+
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
133 |
+
className
|
134 |
+
)}
|
135 |
+
{...props}
|
136 |
+
/>
|
137 |
+
)
|
138 |
+
}
|
139 |
+
|
140 |
+
function NavigationMenuIndicator({
|
141 |
+
className,
|
142 |
+
...props
|
143 |
+
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
144 |
+
return (
|
145 |
+
<NavigationMenuPrimitive.Indicator
|
146 |
+
data-slot="navigation-menu-indicator"
|
147 |
+
className={cn(
|
148 |
+
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
149 |
+
className
|
150 |
+
)}
|
151 |
+
{...props}
|
152 |
+
>
|
153 |
+
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
154 |
+
</NavigationMenuPrimitive.Indicator>
|
155 |
+
)
|
156 |
+
}
|
157 |
+
|
158 |
+
export {
|
159 |
+
NavigationMenu,
|
160 |
+
NavigationMenuList,
|
161 |
+
NavigationMenuItem,
|
162 |
+
NavigationMenuContent,
|
163 |
+
NavigationMenuTrigger,
|
164 |
+
NavigationMenuLink,
|
165 |
+
NavigationMenuIndicator,
|
166 |
+
NavigationMenuViewport,
|
167 |
+
navigationMenuTriggerStyle,
|
168 |
+
}
|
components/ui/pagination.tsx
ADDED
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import {
|
3 |
+
ChevronLeftIcon,
|
4 |
+
ChevronRightIcon,
|
5 |
+
MoreHorizontalIcon,
|
6 |
+
} from "lucide-react"
|
7 |
+
|
8 |
+
import { cn } from "@/lib/utils"
|
9 |
+
import { Button, buttonVariants } from "@/components/ui/button"
|
10 |
+
|
11 |
+
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
12 |
+
return (
|
13 |
+
<nav
|
14 |
+
role="navigation"
|
15 |
+
aria-label="pagination"
|
16 |
+
data-slot="pagination"
|
17 |
+
className={cn("mx-auto flex w-full justify-center", className)}
|
18 |
+
{...props}
|
19 |
+
/>
|
20 |
+
)
|
21 |
+
}
|
22 |
+
|
23 |
+
function PaginationContent({
|
24 |
+
className,
|
25 |
+
...props
|
26 |
+
}: React.ComponentProps<"ul">) {
|
27 |
+
return (
|
28 |
+
<ul
|
29 |
+
data-slot="pagination-content"
|
30 |
+
className={cn("flex flex-row items-center gap-1", className)}
|
31 |
+
{...props}
|
32 |
+
/>
|
33 |
+
)
|
34 |
+
}
|
35 |
+
|
36 |
+
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
37 |
+
return <li data-slot="pagination-item" {...props} />
|
38 |
+
}
|
39 |
+
|
40 |
+
type PaginationLinkProps = {
|
41 |
+
isActive?: boolean
|
42 |
+
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
43 |
+
React.ComponentProps<"a">
|
44 |
+
|
45 |
+
function PaginationLink({
|
46 |
+
className,
|
47 |
+
isActive,
|
48 |
+
size = "icon",
|
49 |
+
...props
|
50 |
+
}: PaginationLinkProps) {
|
51 |
+
return (
|
52 |
+
<a
|
53 |
+
aria-current={isActive ? "page" : undefined}
|
54 |
+
data-slot="pagination-link"
|
55 |
+
data-active={isActive}
|
56 |
+
className={cn(
|
57 |
+
buttonVariants({
|
58 |
+
variant: isActive ? "outline" : "ghost",
|
59 |
+
size,
|
60 |
+
}),
|
61 |
+
className
|
62 |
+
)}
|
63 |
+
{...props}
|
64 |
+
/>
|
65 |
+
)
|
66 |
+
}
|
67 |
+
|
68 |
+
function PaginationPrevious({
|
69 |
+
className,
|
70 |
+
...props
|
71 |
+
}: React.ComponentProps<typeof PaginationLink>) {
|
72 |
+
return (
|
73 |
+
<PaginationLink
|
74 |
+
aria-label="Go to previous page"
|
75 |
+
size="default"
|
76 |
+
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
77 |
+
{...props}
|
78 |
+
>
|
79 |
+
<ChevronLeftIcon />
|
80 |
+
<span className="hidden sm:block">Previous</span>
|
81 |
+
</PaginationLink>
|
82 |
+
)
|
83 |
+
}
|
84 |
+
|
85 |
+
function PaginationNext({
|
86 |
+
className,
|
87 |
+
...props
|
88 |
+
}: React.ComponentProps<typeof PaginationLink>) {
|
89 |
+
return (
|
90 |
+
<PaginationLink
|
91 |
+
aria-label="Go to next page"
|
92 |
+
size="default"
|
93 |
+
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
94 |
+
{...props}
|
95 |
+
>
|
96 |
+
<span className="hidden sm:block">Next</span>
|
97 |
+
<ChevronRightIcon />
|
98 |
+
</PaginationLink>
|
99 |
+
)
|
100 |
+
}
|
101 |
+
|
102 |
+
function PaginationEllipsis({
|
103 |
+
className,
|
104 |
+
...props
|
105 |
+
}: React.ComponentProps<"span">) {
|
106 |
+
return (
|
107 |
+
<span
|
108 |
+
aria-hidden
|
109 |
+
data-slot="pagination-ellipsis"
|
110 |
+
className={cn("flex size-9 items-center justify-center", className)}
|
111 |
+
{...props}
|
112 |
+
>
|
113 |
+
<MoreHorizontalIcon className="size-4" />
|
114 |
+
<span className="sr-only">More pages</span>
|
115 |
+
</span>
|
116 |
+
)
|
117 |
+
}
|
118 |
+
|
119 |
+
export {
|
120 |
+
Pagination,
|
121 |
+
PaginationContent,
|
122 |
+
PaginationLink,
|
123 |
+
PaginationItem,
|
124 |
+
PaginationPrevious,
|
125 |
+
PaginationNext,
|
126 |
+
PaginationEllipsis,
|
127 |
+
}
|
components/ui/popover.tsx
ADDED
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
5 |
+
|
6 |
+
import { cn } from "@/lib/utils"
|
7 |
+
|
8 |
+
function Popover({
|
9 |
+
...props
|
10 |
+
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
11 |
+
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
12 |
+
}
|
13 |
+
|
14 |
+
function PopoverTrigger({
|
15 |
+
...props
|
16 |
+
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
17 |
+
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
18 |
+
}
|
19 |
+
|
20 |
+
function PopoverContent({
|
21 |
+
className,
|
22 |
+
align = "center",
|
23 |
+
sideOffset = 4,
|
24 |
+
...props
|
25 |
+
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
26 |
+
return (
|
27 |
+
<PopoverPrimitive.Portal>
|
28 |
+
<PopoverPrimitive.Content
|
29 |
+
data-slot="popover-content"
|
30 |
+
align={align}
|
31 |
+
sideOffset={sideOffset}
|
32 |
+
className={cn(
|
33 |
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
34 |
+
className
|
35 |
+
)}
|
36 |
+
{...props}
|
37 |
+
/>
|
38 |
+
</PopoverPrimitive.Portal>
|
39 |
+
)
|
40 |
+
}
|
41 |
+
|
42 |
+
function PopoverAnchor({
|
43 |
+
...props
|
44 |
+
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
45 |
+
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
46 |
+
}
|
47 |
+
|
48 |
+
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
components/ui/progress.tsx
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
5 |
+
|
6 |
+
import { cn } from "@/lib/utils"
|
7 |
+
|
8 |
+
function Progress({
|
9 |
+
className,
|
10 |
+
value,
|
11 |
+
...props
|
12 |
+
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
13 |
+
return (
|
14 |
+
<ProgressPrimitive.Root
|
15 |
+
data-slot="progress"
|
16 |
+
className={cn(
|
17 |
+
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
18 |
+
className
|
19 |
+
)}
|
20 |
+
{...props}
|
21 |
+
>
|
22 |
+
<ProgressPrimitive.Indicator
|
23 |
+
data-slot="progress-indicator"
|
24 |
+
className="bg-primary h-full w-full flex-1 transition-all"
|
25 |
+
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
26 |
+
/>
|
27 |
+
</ProgressPrimitive.Root>
|
28 |
+
)
|
29 |
+
}
|
30 |
+
|
31 |
+
export { Progress }
|
components/ui/radio-group.tsx
ADDED
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
5 |
+
import { CircleIcon } from "lucide-react"
|
6 |
+
|
7 |
+
import { cn } from "@/lib/utils"
|
8 |
+
|
9 |
+
function RadioGroup({
|
10 |
+
className,
|
11 |
+
...props
|
12 |
+
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
13 |
+
return (
|
14 |
+
<RadioGroupPrimitive.Root
|
15 |
+
data-slot="radio-group"
|
16 |
+
className={cn("grid gap-3", className)}
|
17 |
+
{...props}
|
18 |
+
/>
|
19 |
+
)
|
20 |
+
}
|
21 |
+
|
22 |
+
function RadioGroupItem({
|
23 |
+
className,
|
24 |
+
...props
|
25 |
+
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
26 |
+
return (
|
27 |
+
<RadioGroupPrimitive.Item
|
28 |
+
data-slot="radio-group-item"
|
29 |
+
className={cn(
|
30 |
+
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
31 |
+
className
|
32 |
+
)}
|
33 |
+
{...props}
|
34 |
+
>
|
35 |
+
<RadioGroupPrimitive.Indicator
|
36 |
+
data-slot="radio-group-indicator"
|
37 |
+
className="relative flex items-center justify-center"
|
38 |
+
>
|
39 |
+
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
40 |
+
</RadioGroupPrimitive.Indicator>
|
41 |
+
</RadioGroupPrimitive.Item>
|
42 |
+
)
|
43 |
+
}
|
44 |
+
|
45 |
+
export { RadioGroup, RadioGroupItem }
|