TimLukaHorstmann commited on
Commit
8c85576
·
1 Parent(s): 8b7d435
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. .gitignore +4 -0
  3. Dockerfile +66 -0
  4. README.md +94 -4
  5. audio_ratings_db.json +9 -0
  6. components.json +21 -0
  7. next-env.d.ts +5 -0
  8. next.config.ts +58 -0
  9. package-lock.json +0 -0
  10. package.json +69 -0
  11. postcss.config.mjs +8 -0
  12. public/audio/FEMALE_VOICES-fvoice1_segment1/improved.wav +3 -0
  13. public/audio/FEMALE_VOICES-fvoice1_segment1/raw.wav +3 -0
  14. public/audio/FEMALE_VOICES-fvoice2_segment2/improved.wav +3 -0
  15. public/audio/FEMALE_VOICES-fvoice2_segment2/raw.wav +3 -0
  16. public/audio/FEMALE_VOICES-fvoice3_segment3/improved.wav +3 -0
  17. public/audio/FEMALE_VOICES-fvoice3_segment3/raw.wav +3 -0
  18. public/audio/MALE_VOICES-mvoice1_segment1/improved.wav +3 -0
  19. public/audio/MALE_VOICES-mvoice1_segment1/raw.wav +3 -0
  20. public/audio/MALE_VOICES-mvoice2_segment2/improved.wav +3 -0
  21. public/audio/MALE_VOICES-mvoice2_segment2/raw.wav +3 -0
  22. public/audio/MALE_VOICES-mvoice3_segment3/improved.wav +3 -0
  23. public/audio/MALE_VOICES-mvoice3_segment3/raw.wav +3 -0
  24. src/ai/ai-instance.ts +12 -0
  25. src/ai/db/schema.ts +23 -0
  26. src/ai/dev.ts +1 -0
  27. src/app/error.js +12 -0
  28. src/app/favicon.ico +0 -0
  29. src/app/globals.css +97 -0
  30. src/app/layout.tsx +32 -0
  31. src/app/page.tsx +818 -0
  32. src/components/icons.ts +38 -0
  33. src/components/ui/accordion.tsx +58 -0
  34. src/components/ui/alert-dialog.tsx +141 -0
  35. src/components/ui/alert.tsx +59 -0
  36. src/components/ui/avatar.tsx +50 -0
  37. src/components/ui/badge.tsx +36 -0
  38. src/components/ui/button.tsx +56 -0
  39. src/components/ui/calendar.tsx +70 -0
  40. src/components/ui/card.tsx +79 -0
  41. src/components/ui/chart.tsx +365 -0
  42. src/components/ui/checkbox.tsx +30 -0
  43. src/components/ui/dialog.tsx +122 -0
  44. src/components/ui/dropdown-menu.tsx +200 -0
  45. src/components/ui/form.tsx +178 -0
  46. src/components/ui/input.tsx +22 -0
  47. src/components/ui/label.tsx +26 -0
  48. src/components/ui/menubar.tsx +256 -0
  49. src/components/ui/popover.tsx +31 -0
  50. src/components/ui/progress.tsx +28 -0
.gitattributes CHANGED
@@ -33,4 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
36
  public/banner.png filter=lfs diff=lfs merge=lfs -text
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.wav filter=lfs diff=lfs merge=lfs -text
37
  public/banner.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ node_modules/
2
+ .next/
3
+ .env.local
4
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## ─── 1) Base image ───────────────────────────────────────────────────────────
2
+ FROM node:18-slim AS base
3
+ WORKDIR /home/node/app
4
+
5
+ ## ─── 2) Install dependencies ─────────────────────────────────────────────────
6
+ FROM base AS deps
7
+ # Make sure the directory exists and is owned by the node user
8
+ RUN mkdir -p /home/node/app && chown -R node:node /home/node/app
9
+ WORKDIR /home/node/app
10
+ # Copy package files first
11
+ COPY --chown=node:node package.json package-lock.json* ./
12
+ # Switch to node user before npm operations
13
+ USER node
14
+ RUN npm ci
15
+
16
+ ## ─── 3) Build the standalone app ─────────────────────────────────────────────
17
+ FROM base AS builder
18
+ WORKDIR /home/node/app
19
+ # Make sure the directory is owned by node
20
+ RUN chown -R node:node /home/node/app
21
+ # Copy node_modules from deps stage
22
+ COPY --from=deps --chown=node:node /home/node/app/node_modules ./node_modules
23
+ # Copy source files
24
+ COPY --chown=node:node . .
25
+ USER node
26
+ ENV NEXT_TELEMETRY_DISABLED=1
27
+ # We'll use runtime variables instead
28
+ RUN NODE_OPTIONS="--max-old-space-size=4096" npm run hf-build
29
+
30
+ ## ─── 4) Runtime image ───────────────────────────────────────────────────────
31
+ FROM node:18-slim AS runner
32
+ ENV NODE_ENV=production \
33
+ NEXT_TELEMETRY_DISABLED=1
34
+ WORKDIR /home/node/app
35
+
36
+ # Install OS packages needed at runtime
37
+ RUN apt-get update \
38
+ && apt-get install -y git wget \
39
+ && rm -rf /var/lib/apt/lists/* \
40
+ && mkdir -p /home/node/app \
41
+ && chown -R node:node /home/node/app
42
+
43
+ # Copy the standalone build
44
+ COPY --from=builder --chown=node:node /home/node/app/.next/standalone ./
45
+ COPY --from=builder --chown=node:node /home/node/app/.next/static ./.next/static
46
+ COPY --from=builder --chown=node:node /home/node/app/public ./public
47
+
48
+ # Ensure correct permissions
49
+ RUN chmod -R 755 /home/node/app
50
+
51
+ # Pass runtime environment variables that will be accessed via server API
52
+ ENV PORT=3000 \
53
+ NEXT_PUBLIC_HOSTING_SERVICE="huggingface" \
54
+ # These will be available to the server-side API
55
+ USER_PASSWORD="${USER_PASSWORD}" \
56
+ ADMIN_PASSWORD="${ADMIN_PASSWORD}" \
57
+ ADMIN_NAME="${ADMIN_NAME}" \
58
+ ADMIN_EMAIL="${ADMIN_EMAIL}" \
59
+ SENDGRID_API_KEY="${SENDGRID_API_KEY}"
60
+
61
+ # Switch to node user for running the app
62
+ USER node
63
+
64
+ EXPOSE 3000
65
+
66
+ CMD ["node", "server.js"]
README.md CHANGED
@@ -1,12 +1,102 @@
1
  ---
2
  title: AudioABTestPlatform
3
- emoji: 🐢
4
- colorFrom: purple
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
8
  license: cc-by-nc-4.0
9
  short_description: Convenient way of conducting AB test between audio files.
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: AudioABTestPlatform
3
+ emoji: 🏢
4
+ colorFrom: gray
5
+ colorTo: red
6
  sdk: docker
7
+ app_port: 3000
8
  pinned: false
9
  license: cc-by-nc-4.0
10
  short_description: Convenient way of conducting AB test between audio files.
11
  ---
12
 
13
+ # ABTest
14
+
15
+ A modern web application for conducting A/B tests on audio files. This tool allows users to compare pairs of audio samples (raw vs. improved versions) and provide ratings that are stored locally for analysis.
16
+
17
+ ## Features
18
+
19
+ - Password-protected access to keep your audio files secure
20
+ - Random ordering of A/B samples to prevent bias
21
+ - Persistent storage of user ratings in a local JSON database
22
+ - Admin interface for exporting results as CSV
23
+ - Responsive design for desktop and mobile use
24
+
25
+ ## Installation
26
+
27
+ ### Prerequisites
28
+
29
+ - Node.js 18 or higher
30
+ - npm (comes with Node.js)
31
+
32
+ ### Setup
33
+
34
+ 1. Clone the repository:
35
+ ```bash
36
+ git clone https://github.com/yourusername/ABTest.git
37
+ cd ABTest
38
+ ```
39
+
40
+ 2. Install dependencies:
41
+ ```bash
42
+ npm install
43
+ ```
44
+
45
+ 3. Configure environment variables by creating a `.env.local` file in the project root:
46
+ ```
47
+ # User credentials
48
+ USER_PASSWORD=your_user_password
49
+ ADMIN_PASSWORD=your_admin_password
50
+ ADMIN_NAME=your_admin_name
51
+ ADMIN_EMAIL=your_admin_email
52
+
53
+ # Optional: SendGrid for email exports
54
+ SENDGRID_API_KEY=your_sendgrid_api_key
55
+
56
+ # Next.js settings
57
+ NEXT_PUBLIC_HOSTING_SERVICE=local
58
+ ```
59
+
60
+ ## Running Locally
61
+
62
+ Start the development server:
63
+
64
+ ```bash
65
+ npm run dev
66
+ ```
67
+
68
+ The application will be available at http://localhost:9002
69
+
70
+ ## Adding Audio Files
71
+
72
+ ### Audio File Structure
73
+
74
+ 1. Create a folder structure inside `public/audio/`:
75
+ ```
76
+ public/audio/
77
+ ├── FEMALE_VOICES-alice_segment1/
78
+ │ ├── improved.wav
79
+ │ └── raw.wav
80
+ ├── MALE_VOICES-bob_segment1/
81
+ │ ├── improved.wav
82
+ │ └── raw.wav
83
+ ├── FEMALE_VOICES-sarah_segment2/
84
+ │ ├── improved.wav
85
+ │ └── raw.wav
86
+ ```
87
+
88
+ 2. **Folder Naming Convention**: `[TYPE]-[voicename]_segment[number]`
89
+ - Examples: `FEMALE_VOICES-alice_segment1`, `MALE_VOICES-john_segment2`
90
+
91
+ 3. **Required Files**: Each folder must contain exactly two files:
92
+ - `improved.wav` - Enhanced/processed audio version
93
+ - `raw.wav` - Original/unprocessed audio version
94
+
95
+ 4. **File Format**: Audio files must be in WAV format
96
+
97
+ 5. **For Hugging Face Spaces**: Make sure to commit these audio files to your repository so they're available when deployed to Hugging Face Spaces.
98
+
99
+ ### Testing Locally
100
+
101
+ Once you've added audio files to `public/audio/`, restart your development server and the app should automatically detect and load them for the AB test.
102
+
audio_ratings_db.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "users": {
3
+ "test": {
4
+ "name": "test",
5
+ "email": "test"
6
+ }
7
+ },
8
+ "ratings": []
9
+ }
components.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "default",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "tailwind.config.ts",
8
+ "css": "src/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
+ }
next-env.d.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+
4
+ // NOTE: This file should not be edited
5
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
next.config.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {NextConfig} from 'next';
2
+ import path from 'path';
3
+ const nextConfig: NextConfig = {
4
+ /* config options here */
5
+ // typescript: {
6
+ // ignoreBuildErrors: true,
7
+ // },
8
+ // eslint: {
9
+ // ignoreDuringBuilds: true,
10
+ // },
11
+ //assetPrefix: process.env.NODE_ENV === 'production' ? '/' : '',
12
+ output: 'standalone',
13
+ images: {
14
+ remotePatterns: [
15
+ {
16
+ protocol: 'https',
17
+ hostname: 'www.hi-paris.fr',
18
+ port: '',
19
+ pathname: '/wp-content/uploads/**',
20
+ },
21
+ {
22
+ protocol: 'https',
23
+ hostname: '1drv.ms',
24
+ port: '',
25
+ pathname: '/f/c/**',
26
+ },
27
+ {
28
+ protocol: 'https',
29
+ hostname: 'drive.google.com',
30
+ port: '',
31
+ pathname: '/**',
32
+ },
33
+ {
34
+ protocol: 'https',
35
+ hostname: '*.googleusercontent.com',
36
+ port: '',
37
+ pathname: '/**',
38
+ },
39
+ ],
40
+ unoptimized: true, // Required for Hugging Face Spaces
41
+ },
42
+ async headers() {
43
+ return [
44
+ {
45
+ // Allow cross-origin requests for audio files
46
+ source: '/:path*',
47
+ headers: [
48
+ {
49
+ key: 'Access-Control-Allow-Origin',
50
+ value: '*', // In production, consider limiting this to specific domains
51
+ },
52
+ ],
53
+ },
54
+ ];
55
+ },
56
+ };
57
+
58
+ export default nextConfig;
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "nextn",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev --turbopack -p 9002 -H 0.0.0.0",
7
+ "genkit:dev": "genkit start -- tsx src/ai/dev.ts",
8
+ "genkit:watch": "genkit start -- tsx --watch src/ai/dev.ts",
9
+ "build": "next build",
10
+ "start": "next start",
11
+ "lint": "next lint",
12
+ "typecheck": "tsc --noEmit",
13
+ "hf-build": "NODE_ENV=production next build"
14
+ },
15
+ "dependencies": {
16
+ "@genkit-ai/googleai": "^1.0.4",
17
+ "@genkit-ai/next": "^1.0.4",
18
+ "@hookform/resolvers": "^4.1.3",
19
+ "@radix-ui/react-accordion": "^1.2.3",
20
+ "@radix-ui/react-alert-dialog": "^1.1.6",
21
+ "@radix-ui/react-avatar": "^1.1.3",
22
+ "@radix-ui/react-checkbox": "^1.1.4",
23
+ "@radix-ui/react-dialog": "^1.1.6",
24
+ "@radix-ui/react-dropdown-menu": "^2.1.6",
25
+ "@radix-ui/react-label": "^2.1.2",
26
+ "@radix-ui/react-menubar": "^1.1.6",
27
+ "@radix-ui/react-popover": "^1.1.6",
28
+ "@radix-ui/react-progress": "^1.1.2",
29
+ "@radix-ui/react-radio-group": "^1.2.3",
30
+ "@radix-ui/react-scroll-area": "^1.2.3",
31
+ "@radix-ui/react-select": "^2.1.6",
32
+ "@radix-ui/react-separator": "^1.1.6",
33
+ "@radix-ui/react-slider": "^1.2.3",
34
+ "@radix-ui/react-slot": "^1.1.2",
35
+ "@radix-ui/react-switch": "^1.1.3",
36
+ "@radix-ui/react-tabs": "^1.1.3",
37
+ "@radix-ui/react-toast": "^1.2.6",
38
+ "@radix-ui/react-tooltip": "^1.1.8",
39
+ "@sendgrid/mail": "^7.7.0",
40
+ "@tanstack-query-firebase/react": "^1.0.5",
41
+ "@tanstack/react-query": "^5.66.0",
42
+ "axios": "^1.9.0",
43
+ "class-variance-authority": "^0.7.1",
44
+ "clsx": "^2.1.1",
45
+ "date-fns": "^3.6.0",
46
+ "firebase": "^11.3.0",
47
+ "genkit": "^1.0.4",
48
+ "lucide-react": "^0.475.0",
49
+ "patch-package": "^8.0.0",
50
+ "react": "^18.3.1",
51
+ "react-day-picker": "^8.10.1",
52
+ "react-dom": "^18.3.1",
53
+ "react-hook-form": "^7.54.2",
54
+ "recharts": "^2.15.1",
55
+ "tailwind-merge": "^3.0.1",
56
+ "tailwindcss-animate": "^1.0.7",
57
+ "zod": "^3.24.2"
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "^20.17.47",
61
+ "@types/react": "^18",
62
+ "@types/react-dom": "^18",
63
+ "genkit-cli": "^1.0.4",
64
+ "next": "^15.3.2",
65
+ "postcss": "^8",
66
+ "tailwindcss": "^3.4.1",
67
+ "typescript": "^5"
68
+ }
69
+ }
postcss.config.mjs ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('postcss-load-config').Config} */
2
+ const config = {
3
+ plugins: {
4
+ tailwindcss: {},
5
+ },
6
+ };
7
+
8
+ export default config;
public/audio/FEMALE_VOICES-fvoice1_segment1/improved.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:685c3038ca7d48f1141973cc6b51a31ff6b89822990fdbc89b30ba0c3e9f2329
3
+ size 60231
public/audio/FEMALE_VOICES-fvoice1_segment1/raw.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:00412fa070773af878c9118fe9a941402e7bed5d657fc9e02f77c441c9348ad4
3
+ size 63156
public/audio/FEMALE_VOICES-fvoice2_segment2/improved.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:58a62c7b0c55553a928b21b6196c65d7092fb5a3969e20a963b5786a8f051d39
3
+ size 70680
public/audio/FEMALE_VOICES-fvoice2_segment2/raw.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ef3d2cee9bc701ccb8d74a994a28b59b1ca01d29991f1c09e94365a10624d456
3
+ size 73605
public/audio/FEMALE_VOICES-fvoice3_segment3/improved.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4c3406a9458254a212bd54b5a14c6ec3924e0b19b8cb178dddf4bcf6ea053b59
3
+ size 66082
public/audio/FEMALE_VOICES-fvoice3_segment3/raw.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:44b20d5e72e620c9fc4d0626f9adc619ec2f68b017e0fea525bc3563c3129eb0
3
+ size 74859
public/audio/MALE_VOICES-mvoice1_segment1/improved.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5cdf3cf0293191a42e52210d2b8414df0e9cdb9e454da012a3d271021eb7584e
3
+ size 63156
public/audio/MALE_VOICES-mvoice1_segment1/raw.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8fc958090809e24eb14d8ed9632e77a38dbe59a40045396411126adb9a7b652e
3
+ size 69008
public/audio/MALE_VOICES-mvoice2_segment2/improved.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fbf71ef75c23b1a13e84971953e8009d55e1804f442d8a8a1dce03789c6c18d5
3
+ size 71098
public/audio/MALE_VOICES-mvoice2_segment2/raw.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8477dbbfd72a9083119f9c933d42cfb4f708f5496300334be8b9f3c24c6c644f
3
+ size 74023
public/audio/MALE_VOICES-mvoice3_segment3/improved.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8b20080b7049c4280c0775c33c6deda5cef97c2977f79c58be24e4cc845336b2
3
+ size 64410
public/audio/MALE_VOICES-mvoice3_segment3/raw.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:75e669664bc097c7b71f597e07c3e78195ecbd7b044c3505b70aa91c7190b9e0
3
+ size 68172
src/ai/ai-instance.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {genkit} from 'genkit';
2
+ import {googleAI} from '@genkit-ai/googleai';
3
+
4
+ export const ai = genkit({
5
+ promptDir: './prompts',
6
+ plugins: [
7
+ googleAI({
8
+ apiKey: process.env.GOOGLE_GENAI_API_KEY,
9
+ }),
10
+ ],
11
+ model: 'googleai/gemini-2.0-flash',
12
+ });
src/ai/db/schema.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @fileOverview Zod schema for the local database.
3
+ *
4
+ * - UserInfoSchema - Schema for user information.
5
+ * - AudioRatingSchema - Schema for audio ratings.
6
+ * - UserInfo - Type for user information.
7
+ * - AudioRating - Type for audio ratings.
8
+ */
9
+ import {z} from 'zod';
10
+
11
+ export const UserInfoSchema = z.object({
12
+ name: z.string(),
13
+ email: z.string().email(),
14
+ });
15
+ export type UserInfo = z.infer<typeof UserInfoSchema>;
16
+
17
+ export const AudioRatingSchema = z.object({
18
+ audioA: z.string(),
19
+ audioB: z.string(),
20
+ ratingA: z.number().min(1).max(5),
21
+ ratingB: z.number().min(1).max(5),
22
+ });
23
+ export type AudioRating = z.infer<typeof AudioRatingSchema>;
src/ai/dev.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ // Flows will be imported for their side effects in this file.
src/app/error.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ export default function Error({error, reset}) {
4
+ return (
5
+ <div>
6
+ <h4>
7
+ Something went wrong! {error.message}
8
+ </h4>
9
+ <button onClick={() => reset()}>Try again</button>
10
+ </div>
11
+ );
12
+ }
src/app/favicon.ico ADDED
src/app/globals.css ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ body {
6
+ font-family: Arial, Helvetica, sans-serif;
7
+ }
8
+
9
+ @layer base {
10
+ :root {
11
+ --background: 0 0% 100%;
12
+ --foreground: 0 0% 3.9%;
13
+ --card: 0 0% 100%;
14
+ --card-foreground: 0 0% 3.9%;
15
+ --popover: 0 0% 100%;
16
+ --popover-foreground: 0 0% 3.9%;
17
+ --primary: 0 0% 9%;
18
+ --primary-foreground: 0 0% 98%;
19
+ --secondary: 0 0% 96.1%;
20
+ --secondary-foreground: 0 0% 9%;
21
+ --muted: 0 0% 96.1%;
22
+ --muted-foreground: 0 0% 45.1%;
23
+ --accent: 0 0% 96.1%;
24
+ --accent-foreground: 0 0% 9%;
25
+ --destructive: 0 84.2% 60.2%;
26
+ --destructive-foreground: 0 0% 98%;
27
+ --border: 0 0% 89.8%;
28
+ --input: 0 0% 89.8%;
29
+ --ring: 0 0% 3.9%;
30
+ --chart-1: 12 76% 61%;
31
+ --chart-2: 173 58% 39%;
32
+ --chart-3: 197 37% 24%;
33
+ --chart-4: 43 74% 66%;
34
+ --chart-5: 27 87% 67%;
35
+ --radius: 0.5rem;
36
+ --sidebar-background: 0 0% 98%;
37
+ --sidebar-foreground: 240 5.3% 26.1%;
38
+ --sidebar-primary: 240 5.9% 10%;
39
+ --sidebar-primary-foreground: 0 0% 98%;
40
+ --sidebar-accent: 240 4.8% 95.9%;
41
+ --sidebar-accent-foreground: 240 5.9% 10%;
42
+ --sidebar-border: 220 13% 91%;
43
+ --sidebar-ring: 217.2 91.2% 59.8%;
44
+
45
+ /* Updated color scheme */
46
+ --background: 0 0% 96.1%; /* Light gray */
47
+ --foreground: 210 16% 14.1%; /* Dark blue */
48
+ --primary: 210 16% 14.1%; /* Dark blue */
49
+ --primary-foreground: 0 0% 98%; /* White */
50
+ --accent: 166.7 59.1% 46.1%; /* Teal */
51
+ --accent-foreground: 0 0% 98%; /* White */
52
+ }
53
+
54
+ .dark {
55
+ --background: 210 16% 14.1%; /* Dark blue */
56
+ --foreground: 0 0% 98%; /* White */
57
+ --card: 0 0% 3.9%;
58
+ --card-foreground: 0 0% 98%;
59
+ --popover: 0 0% 3.9%;
60
+ --popover-foreground: 0 0% 98%;
61
+ --primary: 0 0% 98%;
62
+ --primary-foreground: 210 16% 14.1%;
63
+ --secondary: 0 0% 14.9%;
64
+ --secondary-foreground: 0 0% 98%;
65
+ --muted: 0 0% 14.9%;
66
+ --muted-foreground: 0 0% 63.9%;
67
+ --accent: 0 0% 14.9%;
68
+ --accent-foreground: 0 0% 98%;
69
+ --destructive: 0 62.8% 30.6%;
70
+ --destructive-foreground: 0 0% 98%;
71
+ --border: 0 0% 14.9%;
72
+ --input: 0 0% 14.9%;
73
+ --ring: 0 0% 83.1%;
74
+ --chart-1: 220 70% 50%;
75
+ --chart-2: 160 60% 45%;
76
+ --chart-3: 30 80% 55%;
77
+ --chart-4: 280 65% 60%;
78
+ --chart-5: 340 75% 55%;
79
+ --sidebar-background: 240 5.9% 10%;
80
+ --sidebar-foreground: 240 4.8% 95.9%;
81
+ --sidebar-primary: 224.3 76.3% 48%;
82
+ --sidebar-primary-foreground: 0 0% 100%;
83
+ --sidebar-accent: 240 3.7% 15.9%;
84
+ --sidebar-accent-foreground: 240 4.8% 95.9%;
85
+ --sidebar-border: 240 3.7% 15.9%;
86
+ --sidebar-ring: 217.2 91.2% 59.8%;
87
+ }
88
+ }
89
+
90
+ @layer base {
91
+ * {
92
+ @apply border-border;
93
+ }
94
+ body {
95
+ @apply bg-background text-foreground;
96
+ }
97
+ }
src/app/layout.tsx ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {Metadata} from 'next';
2
+ import {Geist, Geist_Mono} from 'next/font/google';
3
+ import './globals.css';
4
+
5
+ const geistSans = Geist({
6
+ variable: '--font-geist-sans',
7
+ subsets: ['latin'],
8
+ });
9
+
10
+ const geistMono = Geist_Mono({
11
+ variable: '--font-geist-mono',
12
+ subsets: ['latin'],
13
+ });
14
+
15
+ export const metadata: Metadata = {
16
+ title: 'AB Test',
17
+ description: 'Author: Tim Luka Horstmann',
18
+ };
19
+
20
+ export default function RootLayout({
21
+ children,
22
+ }: Readonly<{
23
+ children: React.ReactNode;
24
+ }>) {
25
+ return (
26
+ <html lang="en" suppressHydrationWarning>
27
+ <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
28
+ {children}
29
+ </body>
30
+ </html>
31
+ );
32
+ }
src/app/page.tsx ADDED
@@ -0,0 +1,818 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import {useState, useCallback, ChangeEvent, useEffect, useRef} from 'react';
4
+ import {Button} from '@/components/ui/button';
5
+ import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
6
+ import {Input} from '@/components/ui/input';
7
+ import {Label} from '@/components/ui/label';
8
+ import {Slider} from '@/components/ui/slider';
9
+ import {useToast} from '@/hooks/use-toast';
10
+ import {Toaster} from '@/components/ui/toaster';
11
+ import {Icons} from '@/components/icons';
12
+ import {Separator} from '@/components/ui/separator';
13
+ import {
14
+ Sidebar,
15
+ SidebarContent,
16
+ SidebarFooter,
17
+ SidebarHeader,
18
+ SidebarMenu,
19
+ SidebarMenuItem,
20
+ SidebarProvider,
21
+ } from '@/components/ui/sidebar';
22
+ import {Progress} from '@/components/ui/progress';
23
+ import {
24
+ exportToCsv,
25
+ getAllRatings,
26
+ saveRating,
27
+ saveUserInfo,
28
+ } from '@/services/rating-service';
29
+ import {UserInfo} from '@/ai/db/schema';
30
+ import {Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table';
31
+ import {fetchAudioFiles} from '@/services/audio-service';
32
+ import {fetchDriveAudioFiles} from '@/services/drive-service';
33
+ import Image from 'next/image';
34
+
35
+ // Define the type based on the return value of getAllRatings()
36
+ type RatingEntry = {
37
+ userId: string;
38
+ userInfo?: { name: string; email: string; };
39
+ audioA: string;
40
+ audioB: string;
41
+ rating: {
42
+ audioA: string;
43
+ audioB: string;
44
+ ratingA: number;
45
+ ratingB: number;
46
+ };
47
+ };
48
+
49
+ const PasswordPage = () => {
50
+ const [password, setPassword] = useState('');
51
+ const [name, setName] = useState('');
52
+ const [email, setEmail] = useState('');
53
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
54
+ const [isDeveloper, setIsDeveloper] = useState(false);
55
+ const [loginError, setLoginError] = useState<string | null>(null);
56
+ const {toast} = useToast();
57
+ // Add this state for storing runtime config
58
+ const [config, setConfig] = useState<{
59
+ userPassword?: string;
60
+ adminPassword?: string;
61
+ adminName?: string;
62
+ adminEmail?: string;
63
+ }>({});
64
+ const [configLoaded, setConfigLoaded] = useState(false);
65
+
66
+ const passwordInputRef = useRef<HTMLInputElement>(null);
67
+
68
+ // Add this effect to fetch config from API
69
+ useEffect(() => {
70
+ fetch('/api/config')
71
+ .then(res => res.json())
72
+ .then(data => {
73
+ console.log('Config loaded:', {
74
+ userPwd: data.userPassword ? 'Set' : 'Not set',
75
+ adminPwd: data.adminPassword ? 'Set' : 'Not set',
76
+ adminName: data.adminName ? 'Set' : 'Not set',
77
+ adminEmail: data.adminEmail ? 'Set' : 'Not set'
78
+ });
79
+ setConfig(data);
80
+ setConfigLoaded(true);
81
+ })
82
+ .catch(err => {
83
+ console.error('Failed to load config:', err);
84
+ // Use fallback values if config fetch fails
85
+ setConfig({
86
+ userPassword: process.env.USER_PASSWORD || 'demo',
87
+ adminPassword: process.env.ADMIN_PASSWORD || 'admin',
88
+ adminName: process.env.ADMIN_NAME || 'Admin',
89
+ adminEmail: process.env.ADMIN_EMAIL || 'admin@example.com',
90
+ });
91
+ setConfigLoaded(true);
92
+ });
93
+ }, []);
94
+
95
+ useEffect(() => {
96
+ // Focus on the password input when the component mounts
97
+ passwordInputRef.current?.focus();
98
+ }, []);
99
+
100
+ // Update handlePasswordSubmit to use the config
101
+ const handlePasswordSubmit = async () => {
102
+ try {
103
+ // Use config for credentials instead of env variables
104
+ const userPassword = config.userPassword || process.env.USER_PASSWORD || 'demo';
105
+ const adminPassword = config.adminPassword || process.env.ADMIN_PASSWORD || 'admin';
106
+ const adminName = config.adminName || process.env.ADMIN_NAME || 'Admin';
107
+ const adminEmail = config.adminEmail || process.env.ADMIN_EMAIL || 'admin@example.com';
108
+
109
+ // Alert for debugging
110
+ toast({
111
+ title: 'Debug info',
112
+ description: `Checking credentials: ${userPassword?.substring(0,2)}... / ${adminPassword?.substring(0,2)}...`,
113
+ });
114
+
115
+ if (password === userPassword) {
116
+ setIsAuthenticated(true);
117
+ setIsDeveloper(false);
118
+ setLoginError(null);
119
+ const userInfo: UserInfo = {name, email};
120
+ await saveUserInfo(email, userInfo); // Use email as userId
121
+ toast({
122
+ title: 'Login successful!',
123
+ description: `Welcome, ${name}!`,
124
+ });
125
+ } else if (
126
+ password === adminPassword &&
127
+ name === adminName &&
128
+ email === adminEmail
129
+ ) {
130
+ setIsAuthenticated(true);
131
+ setIsDeveloper(true);
132
+ setLoginError(null);
133
+ toast({
134
+ title: 'Developer login successful!',
135
+ description: `Welcome, ${name}!`,
136
+ });
137
+ } else {
138
+ setIsAuthenticated(false);
139
+ setIsDeveloper(false);
140
+ setLoginError('Incorrect credentials');
141
+ toast({
142
+ title: 'Login failed',
143
+ description: 'Incorrect credentials.',
144
+ });
145
+ }
146
+ } catch (error: any) {
147
+ console.error('Email validation error:', error.message);
148
+ toast({
149
+ title: 'Login failed',
150
+ description: 'Invalid credentials.',
151
+ });
152
+ }
153
+ };
154
+
155
+ const handleEnterKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
156
+ if (e.key === 'Enter') {
157
+ handlePasswordSubmit();
158
+ }
159
+ };
160
+
161
+ if (!isAuthenticated) {
162
+ return (
163
+ <div className="flex items-center justify-center h-screen bg-secondary">
164
+ <Card className="w-96">
165
+ <CardHeader>
166
+ <CardTitle className="flex items-center space-x-2">
167
+ <Image
168
+ src="https://www.hi-paris.fr/wp-content/uploads/2020/09/logo-hi-paris-retina.png"
169
+ alt="Hi! Paris Logo"
170
+ width={50}
171
+ height={50}
172
+ className="rounded-full"
173
+ />
174
+ <span>Enter Credentials</span>
175
+ </CardTitle>
176
+ </CardHeader>
177
+ <CardContent className="flex flex-col space-y-4">
178
+ <div className="grid gap-2">
179
+ <Label htmlFor="name">Name</Label>
180
+ <Input
181
+ type="text"
182
+ placeholder="Name"
183
+ value={name}
184
+ onChange={e => setName(e.target.value)}
185
+ />
186
+ </div>
187
+ <div className="grid gap-2">
188
+ <Label htmlFor="email">Email</Label>
189
+ <Input
190
+ type="email"
191
+ placeholder="Email"
192
+ value={email}
193
+ onChange={e => setEmail(e.target.value)}
194
+ />
195
+ </div>
196
+ <div className="grid gap-2">
197
+ <Label htmlFor="password">Password</Label>
198
+ <Input
199
+ type="password"
200
+ placeholder="Password"
201
+ value={password}
202
+ onChange={e => setPassword(e.target.value)}
203
+ onKeyDown={handleEnterKeyPress}
204
+ ref={passwordInputRef}
205
+ />
206
+ </div>
207
+ {loginError && <p className="text-red-500">{loginError}</p>}
208
+ <Button onClick={handlePasswordSubmit}>Submit</Button>
209
+ </CardContent>
210
+ </Card>
211
+ </div>
212
+ );
213
+ }
214
+
215
+ return <AudioRaterApp
216
+ isDeveloper={isDeveloper}
217
+ userEmail={email}
218
+ userName={name}
219
+ config={config} // Pass the config as a prop
220
+ />;
221
+ };
222
+
223
+ const AudioRaterApp = ({
224
+ isDeveloper,
225
+ userEmail,
226
+ userName,
227
+ config, // Add config to props
228
+ }: {
229
+ isDeveloper: boolean;
230
+ userEmail: string;
231
+ userName: string;
232
+ config: { // Define the config type
233
+ userPassword?: string;
234
+ adminPassword?: string;
235
+ adminName?: string;
236
+ adminEmail?: string;
237
+ };
238
+ }) => {
239
+ const [audioPairs, setAudioPairs] = useState<[string, string, string, boolean][]>([]);
240
+ const [currentPairIndex, setCurrentPairIndex] = useState(0);
241
+ const [ratings, setRatings] = useState<{ a: number; b: number }[]>([]);
242
+ const {toast} = useToast();
243
+ const [ratedPairs, setRatedPairs] = useState<number[]>([]);
244
+ const [tempRatings, setTempRatings] = useState<{ a: number; b: number }>({
245
+ a: 0,
246
+ b: 0,
247
+ }); // Temporary ratings before submission
248
+ const [ratingSubmitted, setRatingSubmitted] = useState(false);
249
+ const [allRatings, setAllRatings] = useState<RatingEntry[]>([]);
250
+ const [loading, setLoading] = useState(true);
251
+ const [audioLoading, setAudioLoading] = useState(true);
252
+ const [audioError, setAudioError] = useState<string | null>(null);
253
+ const [testCompleted, setTestCompleted] = useState(false); // State to track test completion
254
+
255
+ useEffect(() => {
256
+ const loadAudioFiles = async () => {
257
+ setAudioLoading(true);
258
+ setAudioError(null); // Clear any previous errors
259
+
260
+ try {
261
+ // Only use local files - remove Google Drive fetch
262
+ const localFiles = await fetchAudioFiles();
263
+
264
+ if (localFiles && localFiles.length > 0) {
265
+ setAudioPairs(localFiles);
266
+ } else {
267
+ setAudioError('No audio files found.');
268
+ toast({
269
+ title: 'No audio files found',
270
+ description: 'Please check the audio files.'
271
+ });
272
+ }
273
+ } catch (error) {
274
+ console.error('Error loading audio files:', error);
275
+ setAudioError('Failed to load audio files.');
276
+ toast({
277
+ title: 'Error loading audio files',
278
+ description: 'Failed to load audio files. Please try again.'
279
+ });
280
+ } finally {
281
+ setAudioLoading(false);
282
+ }
283
+ };
284
+
285
+ loadAudioFiles();
286
+ }, [toast]);
287
+
288
+ useEffect(() => {
289
+ loadInitialRatings();
290
+ }, [userEmail, audioPairs]);
291
+
292
+ const loadInitialRatings = async () => {
293
+ setLoading(true);
294
+ try {
295
+ const loadedRatings = await getAllRatings();
296
+ setAllRatings(loadedRatings);
297
+
298
+ // Initialize ratings state with existing ratings for the current user
299
+ const initialRatings = audioPairs.map(([audioA, audioB, audioName]) => {
300
+ const existingRating = loadedRatings.find(
301
+ rating =>
302
+ rating.userId === userEmail &&
303
+ rating.audioA === audioA &&
304
+ rating.audioB === audioB
305
+ )?.rating;
306
+ return existingRating ? {a: existingRating.ratingA, b: existingRating.ratingB} : {a: 0, b: 0};
307
+ });
308
+
309
+ setRatings(initialRatings);
310
+ setRatedPairs(
311
+ initialRatings.reduce((acc: number[], rating, index) => {
312
+ if (rating.a > 0 && rating.b > 0) {
313
+ acc.push(index);
314
+ }
315
+ return acc;
316
+ }, [])
317
+ );
318
+
319
+ // Set initial temporary ratings if there are existing ratings
320
+ if (initialRatings[currentPairIndex]) {
321
+ setTempRatings({
322
+ a: initialRatings[currentPairIndex].a || 0,
323
+ b: initialRatings[currentPairIndex].b || 0,
324
+ });
325
+ }
326
+ } catch (error) {
327
+ console.error('Error loading ratings:', error);
328
+ toast({
329
+ title: 'Error loading ratings',
330
+ description: 'Failed to load existing ratings. Please try again.',
331
+ });
332
+ } finally {
333
+ setLoading(false);
334
+ }
335
+ };
336
+
337
+ const handleRatingChange = (version: 'a' | 'b', rating: number) => {
338
+ setTempRatings({...tempRatings, [version]: rating});
339
+ };
340
+
341
+ const handleSubmitRating = async () => {
342
+ try {
343
+ const {a, b} = tempRatings;
344
+ if (a < 1 || a > 5 || b < 1 || b > 5) {
345
+ toast({
346
+ title: 'Invalid rating',
347
+ description: 'Please rate both versions between 1 and 5.',
348
+ });
349
+ return;
350
+ }
351
+
352
+ const [audioA, audioB, audioName, swapped] = audioPairs[currentPairIndex];
353
+
354
+ // When saving to database, ensure the original file order is maintained
355
+ await saveRating(
356
+ userEmail,
357
+ swapped ? audioB : audioA, // If swapped, audioB is the improved version
358
+ swapped ? audioA : audioB, // If swapped, audioA is the raw version
359
+ {
360
+ ratingA: swapped ? tempRatings.b : tempRatings.a, // Map rating to correct audio
361
+ ratingB: swapped ? tempRatings.a : tempRatings.b // Map rating to correct audio
362
+ }
363
+ );
364
+
365
+ const newRatings = [...ratings];
366
+ newRatings[currentPairIndex] = {a: tempRatings.a, b: tempRatings.b};
367
+ setRatings(newRatings);
368
+
369
+ if (!ratedPairs.includes(currentPairIndex)) {
370
+ setRatedPairs([...ratedPairs, currentPairIndex]);
371
+ }
372
+
373
+ toast({
374
+ title: 'Rating saved!',
375
+ description: 'Your rating has been successfully saved.',
376
+ });
377
+
378
+ setRatingSubmitted(true);
379
+ // Move to the next pair automatically
380
+ const nextIndex = (currentPairIndex + 1) % audioPairs.length;
381
+ handlePairSelect(nextIndex);
382
+ } catch (error: any) {
383
+ console.error('Error submitting rating:', error.message);
384
+ toast({
385
+ title: 'Error saving rating',
386
+ description: 'Failed to save your rating. Please try again.',
387
+ });
388
+ }
389
+ };
390
+
391
+ const handlePairSelect = (index: number) => {
392
+ setCurrentPairIndex(index);
393
+ setRatingSubmitted(false);
394
+ setTempRatings({
395
+ a: ratings[index]?.a || 0,
396
+ b: ratings[index]?.b || 0,
397
+ });
398
+ };
399
+
400
+ const ratingPercentage =
401
+ audioPairs.length > 0 ? (ratedPairs.length / audioPairs.length) * 100 : 0;
402
+
403
+ const currentPair = audioPairs[currentPairIndex];
404
+
405
+ const handleExportCsv = async () => {
406
+ try {
407
+ const csvData = await exportToCsv();
408
+ if (!csvData) {
409
+ toast({title: 'No data to export', description: 'No ratings found.'});
410
+ return;
411
+ }
412
+ const blob = new Blob([csvData], {type: 'text/csv'});
413
+ const url = window.URL.createObjectURL(blob);
414
+ const a = document.createElement('a');
415
+ a.setAttribute('href', url);
416
+ a.setAttribute('download', 'audio_ratings.csv');
417
+ document.body.appendChild(a);
418
+ a.click();
419
+ document.body.removeChild(a);
420
+ window.URL.revokeObjectURL(url);
421
+ toast({title: 'CSV Exported', description: 'Data exported successfully.'});
422
+ } catch (error: any) {
423
+ console.error('CSV Export Error:', error);
424
+ toast({title: 'Export Failed', description: 'Error exporting data.'});
425
+ }
426
+ };
427
+
428
+ const handleFinishTest = async () => {
429
+ if (ratedPairs.length === audioPairs.length) {
430
+ setTestCompleted(true);
431
+
432
+ // Send export email to admin
433
+ try {
434
+ const adminEmail = config.adminEmail || 'tim.horstmann@ip-paris.fr';
435
+
436
+ const response = await fetch('/api/send-export', {
437
+ method: 'POST',
438
+ headers: {
439
+ 'Content-Type': 'application/json',
440
+ },
441
+ body: JSON.stringify({
442
+ adminEmail: adminEmail,
443
+ }),
444
+ });
445
+
446
+ if (response.ok) {
447
+ console.log('Export email sent successfully');
448
+ } else {
449
+ console.error('Failed to send export email');
450
+ }
451
+ } catch (error) {
452
+ console.error('Error sending export email:', error);
453
+ }
454
+ } else {
455
+ toast({
456
+ title: 'Cannot finish A/B test',
457
+ description: 'Please rate all audio pairs before finishing.',
458
+ });
459
+ }
460
+ };
461
+
462
+ if (isDeveloper) {
463
+ return (
464
+ <div className="container mx-auto p-4">
465
+ <h1 className="text-2xl font-bold mb-4">Developer View - All Ratings</h1>
466
+ <Button onClick={handleExportCsv} className="mb-4">
467
+ Export to CSV
468
+ </Button>
469
+ {loading ? (
470
+ <div>Loading...</div>
471
+ ) : (
472
+ <Table>
473
+ <TableCaption>A list of all audio ratings in the database.</TableCaption>
474
+ <TableHeader>
475
+ <TableRow>
476
+ <TableHead>User ID</TableHead>
477
+ <TableHead>User Name</TableHead>
478
+ <TableHead>User Email</TableHead>
479
+ <TableHead>Audio A</TableHead>
480
+ <TableHead>Audio B</TableHead>
481
+ <TableHead>Rating A</TableHead>
482
+ <TableHead>Rating B</TableHead>
483
+ </TableRow>
484
+ </TableHeader>
485
+ <TableBody>
486
+ {allRatings.map((rating, index) => (
487
+ <TableRow key={index}>
488
+ <TableCell>{rating.userId}</TableCell>
489
+ <TableCell>{rating.userInfo?.name}</TableCell>
490
+ <TableCell>{rating.userInfo?.email}</TableCell>
491
+ <TableCell>{rating.audioA}</TableCell>
492
+ <TableCell>{rating.audioB}</TableCell>
493
+ <TableCell>{rating.rating.ratingA}</TableCell>
494
+ <TableCell>{rating.rating.ratingB}</TableCell>
495
+ </TableRow>
496
+ ))}
497
+ </TableBody>
498
+ </Table>
499
+ )}
500
+ <Toaster />
501
+ </div>
502
+ );
503
+ }
504
+
505
+ if (testCompleted) {
506
+ return (
507
+ <div className="flex flex-col items-center justify-center min-h-screen p-4 bg-secondary">
508
+ <Card className="w-full max-w-md">
509
+ <CardHeader>
510
+ <CardTitle>Thank You!</CardTitle>
511
+ </CardHeader>
512
+ <CardContent className="text-center">
513
+ <p className="mb-4">Thank you for your participation in this A/B testing!</p>
514
+ <div className="flex justify-center space-x-4">
515
+ <Button onClick={() => setTestCompleted(false)}>
516
+ Review Ratings
517
+ </Button>
518
+ <Button variant="outline" onClick={() => window.location.href = 'https://www.hi-paris.fr/'}>
519
+ Leave Website
520
+ </Button>
521
+ </div>
522
+ </CardContent>
523
+ </Card>
524
+ </div>
525
+ );
526
+ }
527
+
528
+ return (
529
+ <SidebarProvider>
530
+ <Sidebar>
531
+ <SidebarHeader>
532
+ <CardTitle>Audio Pairs</CardTitle>
533
+ </SidebarHeader>
534
+ <SidebarContent>
535
+ <SidebarMenu>
536
+ {audioPairs.map((pair, index) => (
537
+ <SidebarMenuItem key={index}>
538
+ <Button
539
+ variant={index === currentPairIndex ? "default" : "ghost"}
540
+ onClick={() => handlePairSelect(index)}
541
+ className={`w-full justify-start ${
542
+ index === currentPairIndex
543
+ ? 'bg-accent text-accent-foreground border-l-4 border-primary rounded-l-none'
544
+ : 'hover:bg-accent/50'
545
+ } transition-all duration-200`}
546
+ >
547
+ <div className="flex items-center w-full">
548
+ <span className="mr-2 text-sm font-semibold">{index + 1}.</span>
549
+ <span className="truncate flex-1">{pair[2]}</span>
550
+ {ratedPairs.includes(index) && (
551
+ <Icons.check className="ml-auto h-4 w-4 text-primary" />
552
+ )}
553
+ </div>
554
+ </Button>
555
+ </SidebarMenuItem>
556
+ ))}
557
+ </SidebarMenu>
558
+ </SidebarContent>
559
+ <SidebarFooter></SidebarFooter>
560
+ </Sidebar>
561
+ <div className="md:pl-[16rem] flex flex-col items-center min-h-screen p-6 bg-gradient-to-b from-background to-secondary">
562
+ <Toaster />
563
+
564
+ {/* Header with progress */}
565
+ <div className="w-full max-w-6xl mb-8">
566
+ <div className="flex justify-between items-center mb-4">
567
+ <h1 className="text-3xl font-bold">Audio A/B Test</h1>
568
+ <Image
569
+ src="https://www.hi-paris.fr/wp-content/uploads/2020/09/logo-hi-paris-retina.png"
570
+ alt="Hi! Paris Logo"
571
+ width={70}
572
+ height={70}
573
+ className="rounded-full shadow-md"
574
+ />
575
+ </div>
576
+
577
+ <Card className="p-4 mb-6">
578
+ <div className="mb-2">
579
+ <div className="flex justify-between items-center mb-1">
580
+ <span className="text-sm font-medium">Your progress</span>
581
+ <span className="text-sm font-medium">{ratedPairs.length}/{audioPairs.length} pairs rated</span>
582
+ </div>
583
+ <Progress value={ratingPercentage} className="h-3" />
584
+ </div>
585
+
586
+ {currentPair && (
587
+ <p className="text-sm text-muted-foreground">
588
+ Currently rating: <span className="font-semibold">{currentPair[2]}</span>
589
+ </p>
590
+ )}
591
+ </Card>
592
+ </div>
593
+
594
+ {audioLoading ? (
595
+ <Card className="w-full max-w-6xl p-12 flex justify-center">
596
+ <div className="flex flex-col items-center">
597
+ <Icons.loader className="h-12 w-12 animate-spin text-primary mb-4" />
598
+ <p>Loading audio files...</p>
599
+ </div>
600
+ </Card>
601
+ ) : audioError ? (
602
+ <Card className="w-full max-w-6xl p-12 bg-destructive/10">
603
+ <div className="flex flex-col items-center text-destructive">
604
+ <Icons.alertCircle className="h-12 w-12 mb-4" />
605
+ <p className="font-semibold">Error: {audioError}</p>
606
+ </div>
607
+ </Card>
608
+ ) : currentPair ? (
609
+ <Card className="w-full max-w-6xl shadow-lg border-t-4 border-primary">
610
+ <CardHeader className="pb-2">
611
+ <div className="flex justify-between items-center">
612
+ <CardTitle className="text-2xl">
613
+ Audio Pair {currentPairIndex + 1} of {audioPairs.length}
614
+ </CardTitle>
615
+ <span className="text-sm bg-secondary px-3 py-1 rounded-full font-medium">
616
+ {currentPair[2]}
617
+ </span>
618
+ </div>
619
+ </CardHeader>
620
+
621
+ <CardContent className="pt-6 pb-8 space-y-8">
622
+ {/* Enhanced Audio Players - Stacked for more space */}
623
+ <div className="space-y-8">
624
+ <EnhancedAudioPlayer
625
+ src={`/${currentPair[0]}`}
626
+ title="Version A"
627
+ rating={tempRatings.a}
628
+ onRatingChange={(rating) => handleRatingChange('a', rating)}
629
+ />
630
+
631
+ <Separator className="my-8" />
632
+
633
+ <EnhancedAudioPlayer
634
+ src={`/${currentPair[1]}`}
635
+ title="Version B"
636
+ rating={tempRatings.b}
637
+ onRatingChange={(rating) => handleRatingChange('b', rating)}
638
+ />
639
+ </div>
640
+
641
+ <div className="pt-4 flex justify-between items-center">
642
+ <div className="text-sm text-muted-foreground">
643
+ {ratingSubmitted ? (
644
+ <div className="flex items-center text-primary">
645
+ <Icons.check className="mr-2 h-4 w-4" />
646
+ Rating submitted
647
+ </div>
648
+ ) : (
649
+ "Please rate both versions to continue"
650
+ )}
651
+ </div>
652
+
653
+ <div className="space-x-4">
654
+ {currentPairIndex > 0 && (
655
+ <Button
656
+ variant="outline"
657
+ onClick={() => handlePairSelect(currentPairIndex - 1)}
658
+ className="gap-2"
659
+ >
660
+ <Icons.chevronLeft className="h-4 w-4" />
661
+ Previous
662
+ </Button>
663
+ )}
664
+
665
+ <Button
666
+ onClick={handleSubmitRating}
667
+ disabled={ratingSubmitted || tempRatings.a < 1 || tempRatings.b < 1}
668
+ className="gap-2"
669
+ >
670
+ {ratingSubmitted ? 'Submitted' : 'Submit Rating'}
671
+ {!ratingSubmitted && <Icons.arrowRight className="h-4 w-4" />}
672
+ </Button>
673
+
674
+ {ratingSubmitted && (
675
+ <Button
676
+ variant="secondary"
677
+ onClick={() => handlePairSelect((currentPairIndex + 1) % audioPairs.length)}
678
+ className="gap-2"
679
+ >
680
+ Next Pair
681
+ <Icons.chevronRight className="h-4 w-4" />
682
+ </Button>
683
+ )}
684
+ </div>
685
+ </div>
686
+ </CardContent>
687
+ </Card>
688
+ ) : (
689
+ <Card className="w-full max-w-6xl p-12">
690
+ <div className="text-center">
691
+ <Icons.audioLines className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
692
+ <p>No audio pairs available.</p>
693
+ </div>
694
+ </Card>
695
+ )}
696
+
697
+ {/* Finish test button - repositioned */}
698
+ <Card className="w-full max-w-6xl mt-6 bg-background/80">
699
+ <CardContent className="flex justify-between items-center p-6">
700
+ <div className="text-sm max-w-md">
701
+ <p className="font-medium">Ready to complete the test?</p>
702
+ <p className="text-muted-foreground">After finishing, your ratings will be submitted for analysis.</p>
703
+ </div>
704
+ <Button
705
+ onClick={handleFinishTest}
706
+ disabled={ratedPairs.length !== audioPairs.length}
707
+ size="lg"
708
+ className={`${ratedPairs.length === audioPairs.length ? 'bg-primary hover:bg-primary/90' : ''}`}
709
+ >
710
+ {ratedPairs.length === audioPairs.length ? 'Finish A/B Test' : `Rate ${audioPairs.length - ratedPairs.length} more pairs to finish`}
711
+ </Button>
712
+ </CardContent>
713
+ </Card>
714
+ </div>
715
+ </SidebarProvider>
716
+ );
717
+ };
718
+
719
+ const EnhancedAudioPlayer = ({
720
+ src,
721
+ title,
722
+ rating,
723
+ onRatingChange
724
+ }: {
725
+ src: string;
726
+ title: string;
727
+ rating: number;
728
+ onRatingChange: (rating: number) => void;
729
+ }) => {
730
+ // For Google Drive URLs, use the full URL directly, for local files normalize the path
731
+ const audioSrc = src.startsWith('https://') ? src : src.replace(/\\/g, '/');
732
+
733
+ return (
734
+ <div className="space-y-6">
735
+ <div className="bg-muted/30 p-4 rounded-lg border border-muted">
736
+ <div className="flex items-center justify-between mb-4">
737
+ <h3 className="text-lg font-semibold flex items-center gap-2">
738
+ <span className="bg-primary text-primary-foreground h-6 w-6 rounded-full flex items-center justify-center text-sm">
739
+ {title === "Version A" ? "A" : "B"}
740
+ </span>
741
+ {title}
742
+ </h3>
743
+ </div>
744
+
745
+ <audio
746
+ controls
747
+ src={audioSrc}
748
+ className="w-full h-12 rounded-md"
749
+ >
750
+ Your browser does not support the audio element.
751
+ </audio>
752
+ </div>
753
+
754
+ <div className="space-y-3">
755
+ <div className="flex justify-between items-center">
756
+ <Label className="text-sm font-medium">Rate this audio:</Label>
757
+ <span className="bg-primary/10 text-primary text-sm font-semibold px-2 py-1 rounded">
758
+ Score: {rating}
759
+ </span>
760
+ </div>
761
+
762
+ <div className="flex items-center space-x-2">
763
+ <span className="text-sm font-medium text-muted-foreground">Poor</span>
764
+ <Slider
765
+ value={[rating]}
766
+ min={1}
767
+ max={5}
768
+ step={1}
769
+ onValueChange={(value) => onRatingChange(value[0])}
770
+ className="flex-1"
771
+ />
772
+ <span className="text-sm font-medium text-muted-foreground">Excellent</span>
773
+ </div>
774
+ </div>
775
+ </div>
776
+ );
777
+ };
778
+
779
+ const AudioPlayer = ({src, title}: { src: string; title: string }) => {
780
+ // For Google Drive URLs, use the full URL directly, for local files normalize the path
781
+ const audioSrc = src.startsWith('https://') ? src : src.replace(/\\/g, '/');
782
+
783
+ return (
784
+ <div className="space-y-2">
785
+ <Label>{title}</Label>
786
+ <audio controls src={audioSrc} className="w-full">
787
+ Your browser does not support the audio element.
788
+ </audio>
789
+ </div>
790
+ );
791
+ };
792
+
793
+ const RatingSelector = ({
794
+ version,
795
+ onChange,
796
+ currentRating,
797
+ }: {
798
+ version: 'a' | 'b';
799
+ onChange: (version: 'a' | 'b', rating: number) => void;
800
+ currentRating: number;
801
+ }) => {
802
+ return (
803
+ <div className="flex items-center space-x-4">
804
+ <Label className="w-24">Rating (1-5):</Label>
805
+ <Slider
806
+ defaultValue={[currentRating]}
807
+ max={5}
808
+ min={1}
809
+ step={1}
810
+ onValueChange={value => onChange(version, value[0])}
811
+ className="max-w-xs"
812
+ />
813
+ <span>{currentRating}</span>
814
+ </div>
815
+ );
816
+ };
817
+
818
+ export default PasswordPage;
src/components/icons.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {ArrowRight, Check, ChevronsUpDown, Circle, Copy, Edit, ExternalLink, File, HelpCircle, Home, Loader2, Mail, MessageSquare, Moon, Plus, PlusCircle, Search, Server, Settings, Share2, Shield, Sun, Trash, User, X, Workflow, ChevronLeft, ChevronRight, AlertCircle, AudioLines, Star} from 'lucide-react';
2
+
3
+ const Icons = {
4
+ arrowRight: ArrowRight,
5
+ check: Check,
6
+ chevronDown: ChevronsUpDown,
7
+ circle: Circle,
8
+ workflow: Workflow,
9
+ close: X,
10
+ copy: Copy,
11
+ dark: Moon,
12
+ edit: Edit,
13
+ externalLink: ExternalLink,
14
+ file: File,
15
+ help: HelpCircle,
16
+ home: Home,
17
+ light: Sun,
18
+ loader: Loader2,
19
+ mail: Mail,
20
+ messageSquare: MessageSquare,
21
+ plus: Plus,
22
+ plusCircle: PlusCircle,
23
+ search: Search,
24
+ server: Server,
25
+ settings: Settings,
26
+ share: Share2,
27
+ shield: Shield,
28
+ spinner: Loader2,
29
+ trash: Trash,
30
+ user: User,
31
+ chevronLeft: ChevronLeft,
32
+ chevronRight: ChevronRight,
33
+ alertCircle: AlertCircle,
34
+ audioLines: AudioLines,
35
+ star: Star,
36
+ };
37
+
38
+ export {Icons};
src/components/ui/accordion.tsx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as AccordionPrimitive from "@radix-ui/react-accordion"
5
+ import { ChevronDown } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ const Accordion = AccordionPrimitive.Root
10
+
11
+ const AccordionItem = React.forwardRef<
12
+ React.ElementRef<typeof AccordionPrimitive.Item>,
13
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
14
+ >(({ className, ...props }, ref) => (
15
+ <AccordionPrimitive.Item
16
+ ref={ref}
17
+ className={cn("border-b", className)}
18
+ {...props}
19
+ />
20
+ ))
21
+ AccordionItem.displayName = "AccordionItem"
22
+
23
+ const AccordionTrigger = React.forwardRef<
24
+ React.ElementRef<typeof AccordionPrimitive.Trigger>,
25
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
26
+ >(({ className, children, ...props }, ref) => (
27
+ <AccordionPrimitive.Header className="flex">
28
+ <AccordionPrimitive.Trigger
29
+ ref={ref}
30
+ className={cn(
31
+ "flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
32
+ className
33
+ )}
34
+ {...props}
35
+ >
36
+ {children}
37
+ <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
38
+ </AccordionPrimitive.Trigger>
39
+ </AccordionPrimitive.Header>
40
+ ))
41
+ AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
42
+
43
+ const AccordionContent = React.forwardRef<
44
+ React.ElementRef<typeof AccordionPrimitive.Content>,
45
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
46
+ >(({ className, children, ...props }, ref) => (
47
+ <AccordionPrimitive.Content
48
+ ref={ref}
49
+ className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
50
+ {...props}
51
+ >
52
+ <div className={cn("pb-4 pt-0", className)}>{children}</div>
53
+ </AccordionPrimitive.Content>
54
+ ))
55
+
56
+ AccordionContent.displayName = AccordionPrimitive.Content.displayName
57
+
58
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
src/components/ui/alert-dialog.tsx ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ const AlertDialog = AlertDialogPrimitive.Root
10
+
11
+ const AlertDialogTrigger = AlertDialogPrimitive.Trigger
12
+
13
+ const AlertDialogPortal = AlertDialogPrimitive.Portal
14
+
15
+ const AlertDialogOverlay = React.forwardRef<
16
+ React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
17
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
18
+ >(({ className, ...props }, ref) => (
19
+ <AlertDialogPrimitive.Overlay
20
+ className={cn(
21
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
22
+ className
23
+ )}
24
+ {...props}
25
+ ref={ref}
26
+ />
27
+ ))
28
+ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
29
+
30
+ const AlertDialogContent = React.forwardRef<
31
+ React.ElementRef<typeof AlertDialogPrimitive.Content>,
32
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
33
+ >(({ className, ...props }, ref) => (
34
+ <AlertDialogPortal>
35
+ <AlertDialogOverlay />
36
+ <AlertDialogPrimitive.Content
37
+ ref={ref}
38
+ className={cn(
39
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
40
+ className
41
+ )}
42
+ {...props}
43
+ />
44
+ </AlertDialogPortal>
45
+ ))
46
+ AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
47
+
48
+ const AlertDialogHeader = ({
49
+ className,
50
+ ...props
51
+ }: React.HTMLAttributes<HTMLDivElement>) => (
52
+ <div
53
+ className={cn(
54
+ "flex flex-col space-y-2 text-center sm:text-left",
55
+ className
56
+ )}
57
+ {...props}
58
+ />
59
+ )
60
+ AlertDialogHeader.displayName = "AlertDialogHeader"
61
+
62
+ const AlertDialogFooter = ({
63
+ className,
64
+ ...props
65
+ }: React.HTMLAttributes<HTMLDivElement>) => (
66
+ <div
67
+ className={cn(
68
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
69
+ className
70
+ )}
71
+ {...props}
72
+ />
73
+ )
74
+ AlertDialogFooter.displayName = "AlertDialogFooter"
75
+
76
+ const AlertDialogTitle = React.forwardRef<
77
+ React.ElementRef<typeof AlertDialogPrimitive.Title>,
78
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
79
+ >(({ className, ...props }, ref) => (
80
+ <AlertDialogPrimitive.Title
81
+ ref={ref}
82
+ className={cn("text-lg font-semibold", className)}
83
+ {...props}
84
+ />
85
+ ))
86
+ AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
87
+
88
+ const AlertDialogDescription = React.forwardRef<
89
+ React.ElementRef<typeof AlertDialogPrimitive.Description>,
90
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
91
+ >(({ className, ...props }, ref) => (
92
+ <AlertDialogPrimitive.Description
93
+ ref={ref}
94
+ className={cn("text-sm text-muted-foreground", className)}
95
+ {...props}
96
+ />
97
+ ))
98
+ AlertDialogDescription.displayName =
99
+ AlertDialogPrimitive.Description.displayName
100
+
101
+ const AlertDialogAction = React.forwardRef<
102
+ React.ElementRef<typeof AlertDialogPrimitive.Action>,
103
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
104
+ >(({ className, ...props }, ref) => (
105
+ <AlertDialogPrimitive.Action
106
+ ref={ref}
107
+ className={cn(buttonVariants(), className)}
108
+ {...props}
109
+ />
110
+ ))
111
+ AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
112
+
113
+ const AlertDialogCancel = React.forwardRef<
114
+ React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
115
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
116
+ >(({ className, ...props }, ref) => (
117
+ <AlertDialogPrimitive.Cancel
118
+ ref={ref}
119
+ className={cn(
120
+ buttonVariants({ variant: "outline" }),
121
+ "mt-2 sm:mt-0",
122
+ className
123
+ )}
124
+ {...props}
125
+ />
126
+ ))
127
+ AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
128
+
129
+ export {
130
+ AlertDialog,
131
+ AlertDialogPortal,
132
+ AlertDialogOverlay,
133
+ AlertDialogTrigger,
134
+ AlertDialogContent,
135
+ AlertDialogHeader,
136
+ AlertDialogFooter,
137
+ AlertDialogTitle,
138
+ AlertDialogDescription,
139
+ AlertDialogAction,
140
+ AlertDialogCancel,
141
+ }
src/components/ui/alert.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-background text-foreground",
12
+ destructive:
13
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14
+ },
15
+ },
16
+ defaultVariants: {
17
+ variant: "default",
18
+ },
19
+ }
20
+ )
21
+
22
+ const Alert = React.forwardRef<
23
+ HTMLDivElement,
24
+ React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
25
+ >(({ className, variant, ...props }, ref) => (
26
+ <div
27
+ ref={ref}
28
+ role="alert"
29
+ className={cn(alertVariants({ variant }), className)}
30
+ {...props}
31
+ />
32
+ ))
33
+ Alert.displayName = "Alert"
34
+
35
+ const AlertTitle = React.forwardRef<
36
+ HTMLParagraphElement,
37
+ React.HTMLAttributes<HTMLHeadingElement>
38
+ >(({ className, ...props }, ref) => (
39
+ <h5
40
+ ref={ref}
41
+ className={cn("mb-1 font-medium leading-none tracking-tight", className)}
42
+ {...props}
43
+ />
44
+ ))
45
+ AlertTitle.displayName = "AlertTitle"
46
+
47
+ const AlertDescription = React.forwardRef<
48
+ HTMLParagraphElement,
49
+ React.HTMLAttributes<HTMLParagraphElement>
50
+ >(({ className, ...props }, ref) => (
51
+ <div
52
+ ref={ref}
53
+ className={cn("text-sm [&_p]:leading-relaxed", className)}
54
+ {...props}
55
+ />
56
+ ))
57
+ AlertDescription.displayName = "AlertDescription"
58
+
59
+ export { Alert, AlertTitle, AlertDescription }
src/components/ui/avatar.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ const Avatar = React.forwardRef<
9
+ React.ElementRef<typeof AvatarPrimitive.Root>,
10
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
11
+ >(({ className, ...props }, ref) => (
12
+ <AvatarPrimitive.Root
13
+ ref={ref}
14
+ className={cn(
15
+ "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
16
+ className
17
+ )}
18
+ {...props}
19
+ />
20
+ ))
21
+ Avatar.displayName = AvatarPrimitive.Root.displayName
22
+
23
+ const AvatarImage = React.forwardRef<
24
+ React.ElementRef<typeof AvatarPrimitive.Image>,
25
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
26
+ >(({ className, ...props }, ref) => (
27
+ <AvatarPrimitive.Image
28
+ ref={ref}
29
+ className={cn("aspect-square h-full w-full", className)}
30
+ {...props}
31
+ />
32
+ ))
33
+ AvatarImage.displayName = AvatarPrimitive.Image.displayName
34
+
35
+ const AvatarFallback = React.forwardRef<
36
+ React.ElementRef<typeof AvatarPrimitive.Fallback>,
37
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
38
+ >(({ className, ...props }, ref) => (
39
+ <AvatarPrimitive.Fallback
40
+ ref={ref}
41
+ className={cn(
42
+ "flex h-full w-full items-center justify-center rounded-full bg-muted",
43
+ className
44
+ )}
45
+ {...props}
46
+ />
47
+ ))
48
+ AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49
+
50
+ export { Avatar, AvatarImage, AvatarFallback }
src/components/ui/badge.tsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 badgeVariants = cva(
7
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default:
12
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13
+ secondary:
14
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15
+ destructive:
16
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17
+ outline: "text-foreground",
18
+ },
19
+ },
20
+ defaultVariants: {
21
+ variant: "default",
22
+ },
23
+ }
24
+ )
25
+
26
+ export interface BadgeProps
27
+ extends React.HTMLAttributes<HTMLDivElement>,
28
+ VariantProps<typeof badgeVariants> {}
29
+
30
+ function Badge({ className, variant, ...props }: BadgeProps) {
31
+ return (
32
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
33
+ )
34
+ }
35
+
36
+ export { Badge, badgeVariants }
src/components/ui/button.tsx ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
13
+ destructive:
14
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15
+ outline:
16
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17
+ secondary:
18
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19
+ ghost: "hover:bg-accent hover:text-accent-foreground",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ },
22
+ size: {
23
+ default: "h-10 px-4 py-2",
24
+ sm: "h-9 rounded-md px-3",
25
+ lg: "h-11 rounded-md px-8",
26
+ icon: "h-10 w-10",
27
+ },
28
+ },
29
+ defaultVariants: {
30
+ variant: "default",
31
+ size: "default",
32
+ },
33
+ }
34
+ )
35
+
36
+ export interface ButtonProps
37
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
38
+ VariantProps<typeof buttonVariants> {
39
+ asChild?: boolean
40
+ }
41
+
42
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
43
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
44
+ const Comp = asChild ? Slot : "button"
45
+ return (
46
+ <Comp
47
+ className={cn(buttonVariants({ variant, size, className }))}
48
+ ref={ref}
49
+ {...props}
50
+ />
51
+ )
52
+ }
53
+ )
54
+ Button.displayName = "Button"
55
+
56
+ export { Button, buttonVariants }
src/components/ui/calendar.tsx ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { ChevronLeft, ChevronRight } from "lucide-react"
5
+ import { DayPicker } from "react-day-picker"
6
+
7
+ import { cn } from "@/lib/utils"
8
+ import { buttonVariants } from "@/components/ui/button"
9
+
10
+ export type CalendarProps = React.ComponentProps<typeof DayPicker>
11
+
12
+ function Calendar({
13
+ className,
14
+ classNames,
15
+ showOutsideDays = true,
16
+ ...props
17
+ }: CalendarProps) {
18
+ return (
19
+ <DayPicker
20
+ showOutsideDays={showOutsideDays}
21
+ className={cn("p-3", className)}
22
+ classNames={{
23
+ months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
24
+ month: "space-y-4",
25
+ caption: "flex justify-center pt-1 relative items-center",
26
+ caption_label: "text-sm font-medium",
27
+ nav: "space-x-1 flex items-center",
28
+ nav_button: cn(
29
+ buttonVariants({ variant: "outline" }),
30
+ "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
31
+ ),
32
+ nav_button_previous: "absolute left-1",
33
+ nav_button_next: "absolute right-1",
34
+ table: "w-full border-collapse space-y-1",
35
+ head_row: "flex",
36
+ head_cell:
37
+ "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
38
+ row: "flex w-full mt-2",
39
+ cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
40
+ day: cn(
41
+ buttonVariants({ variant: "ghost" }),
42
+ "h-9 w-9 p-0 font-normal aria-selected:opacity-100"
43
+ ),
44
+ day_range_end: "day-range-end",
45
+ day_selected:
46
+ "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
47
+ day_today: "bg-accent text-accent-foreground",
48
+ day_outside:
49
+ "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
50
+ day_disabled: "text-muted-foreground opacity-50",
51
+ day_range_middle:
52
+ "aria-selected:bg-accent aria-selected:text-accent-foreground",
53
+ day_hidden: "invisible",
54
+ ...classNames,
55
+ }}
56
+ components={{
57
+ IconLeft: ({ className, ...props }) => (
58
+ <ChevronLeft className={cn("h-4 w-4", className)} {...props} />
59
+ ),
60
+ IconRight: ({ className, ...props }) => (
61
+ <ChevronRight className={cn("h-4 w-4", className)} {...props} />
62
+ ),
63
+ }}
64
+ {...props}
65
+ />
66
+ )
67
+ }
68
+ Calendar.displayName = "Calendar"
69
+
70
+ export { Calendar }
src/components/ui/card.tsx ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ const Card = React.forwardRef<
6
+ HTMLDivElement,
7
+ React.HTMLAttributes<HTMLDivElement>
8
+ >(({ className, ...props }, ref) => (
9
+ <div
10
+ ref={ref}
11
+ className={cn(
12
+ "rounded-lg border bg-card text-card-foreground shadow-sm",
13
+ className
14
+ )}
15
+ {...props}
16
+ />
17
+ ))
18
+ Card.displayName = "Card"
19
+
20
+ const CardHeader = React.forwardRef<
21
+ HTMLDivElement,
22
+ React.HTMLAttributes<HTMLDivElement>
23
+ >(({ className, ...props }, ref) => (
24
+ <div
25
+ ref={ref}
26
+ className={cn("flex flex-col space-y-1.5 p-6", className)}
27
+ {...props}
28
+ />
29
+ ))
30
+ CardHeader.displayName = "CardHeader"
31
+
32
+ const CardTitle = React.forwardRef<
33
+ HTMLDivElement,
34
+ React.HTMLAttributes<HTMLDivElement>
35
+ >(({ className, ...props }, ref) => (
36
+ <div
37
+ ref={ref}
38
+ className={cn(
39
+ "text-2xl font-semibold leading-none tracking-tight",
40
+ className
41
+ )}
42
+ {...props}
43
+ />
44
+ ))
45
+ CardTitle.displayName = "CardTitle"
46
+
47
+ const CardDescription = React.forwardRef<
48
+ HTMLDivElement,
49
+ React.HTMLAttributes<HTMLDivElement>
50
+ >(({ className, ...props }, ref) => (
51
+ <div
52
+ ref={ref}
53
+ className={cn("text-sm text-muted-foreground", className)}
54
+ {...props}
55
+ />
56
+ ))
57
+ CardDescription.displayName = "CardDescription"
58
+
59
+ const CardContent = React.forwardRef<
60
+ HTMLDivElement,
61
+ React.HTMLAttributes<HTMLDivElement>
62
+ >(({ className, ...props }, ref) => (
63
+ <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
64
+ ))
65
+ CardContent.displayName = "CardContent"
66
+
67
+ const CardFooter = React.forwardRef<
68
+ HTMLDivElement,
69
+ React.HTMLAttributes<HTMLDivElement>
70
+ >(({ className, ...props }, ref) => (
71
+ <div
72
+ ref={ref}
73
+ className={cn("flex items-center p-6 pt-0", className)}
74
+ {...props}
75
+ />
76
+ ))
77
+ CardFooter.displayName = "CardFooter"
78
+
79
+ export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
src/components/ui/chart.tsx ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ const ChartContainer = React.forwardRef<
38
+ HTMLDivElement,
39
+ React.ComponentProps<"div"> & {
40
+ config: ChartConfig
41
+ children: React.ComponentProps<
42
+ typeof RechartsPrimitive.ResponsiveContainer
43
+ >["children"]
44
+ }
45
+ >(({ id, className, children, config, ...props }, ref) => {
46
+ const uniqueId = React.useId()
47
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
48
+
49
+ return (
50
+ <ChartContext.Provider value={{ config }}>
51
+ <div
52
+ data-chart={chartId}
53
+ ref={ref}
54
+ className={cn(
55
+ "flex aspect-video justify-center text-xs [&_.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-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.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 [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
56
+ className
57
+ )}
58
+ {...props}
59
+ >
60
+ <ChartStyle id={chartId} config={config} />
61
+ <RechartsPrimitive.ResponsiveContainer>
62
+ {children}
63
+ </RechartsPrimitive.ResponsiveContainer>
64
+ </div>
65
+ </ChartContext.Provider>
66
+ )
67
+ })
68
+ ChartContainer.displayName = "Chart"
69
+
70
+ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
71
+ const colorConfig = Object.entries(config).filter(
72
+ ([, config]) => config.theme || config.color
73
+ )
74
+
75
+ if (!colorConfig.length) {
76
+ return null
77
+ }
78
+
79
+ return (
80
+ <style
81
+ dangerouslySetInnerHTML={{
82
+ __html: Object.entries(THEMES)
83
+ .map(
84
+ ([theme, prefix]) => `
85
+ ${prefix} [data-chart=${id}] {
86
+ ${colorConfig
87
+ .map(([key, itemConfig]) => {
88
+ const color =
89
+ itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
90
+ itemConfig.color
91
+ return color ? ` --color-${key}: ${color};` : null
92
+ })
93
+ .join("\n")}
94
+ }
95
+ `
96
+ )
97
+ .join("\n"),
98
+ }}
99
+ />
100
+ )
101
+ }
102
+
103
+ const ChartTooltip = RechartsPrimitive.Tooltip
104
+
105
+ const ChartTooltipContent = React.forwardRef<
106
+ HTMLDivElement,
107
+ React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
108
+ React.ComponentProps<"div"> & {
109
+ hideLabel?: boolean
110
+ hideIndicator?: boolean
111
+ indicator?: "line" | "dot" | "dashed"
112
+ nameKey?: string
113
+ labelKey?: string
114
+ }
115
+ >(
116
+ (
117
+ {
118
+ active,
119
+ payload,
120
+ className,
121
+ indicator = "dot",
122
+ hideLabel = false,
123
+ hideIndicator = false,
124
+ label,
125
+ labelFormatter,
126
+ labelClassName,
127
+ formatter,
128
+ color,
129
+ nameKey,
130
+ labelKey,
131
+ },
132
+ ref
133
+ ) => {
134
+ const { config } = useChart()
135
+
136
+ const tooltipLabel = React.useMemo(() => {
137
+ if (hideLabel || !payload?.length) {
138
+ return null
139
+ }
140
+
141
+ const [item] = payload
142
+ const key = `${labelKey || item.dataKey || item.name || "value"}`
143
+ const itemConfig = getPayloadConfigFromPayload(config, item, key)
144
+ const value =
145
+ !labelKey && typeof label === "string"
146
+ ? config[label as keyof typeof config]?.label || label
147
+ : itemConfig?.label
148
+
149
+ if (labelFormatter) {
150
+ return (
151
+ <div className={cn("font-medium", labelClassName)}>
152
+ {labelFormatter(value, payload)}
153
+ </div>
154
+ )
155
+ }
156
+
157
+ if (!value) {
158
+ return null
159
+ }
160
+
161
+ return <div className={cn("font-medium", labelClassName)}>{value}</div>
162
+ }, [
163
+ label,
164
+ labelFormatter,
165
+ payload,
166
+ hideLabel,
167
+ labelClassName,
168
+ config,
169
+ labelKey,
170
+ ])
171
+
172
+ if (!active || !payload?.length) {
173
+ return null
174
+ }
175
+
176
+ const nestLabel = payload.length === 1 && indicator !== "dot"
177
+
178
+ return (
179
+ <div
180
+ ref={ref}
181
+ className={cn(
182
+ "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
183
+ className
184
+ )}
185
+ >
186
+ {!nestLabel ? tooltipLabel : null}
187
+ <div className="grid gap-1.5">
188
+ {payload.map((item, index) => {
189
+ const key = `${nameKey || item.name || item.dataKey || "value"}`
190
+ const itemConfig = getPayloadConfigFromPayload(config, item, key)
191
+ const indicatorColor = color || item.payload.fill || item.color
192
+
193
+ return (
194
+ <div
195
+ key={item.dataKey}
196
+ className={cn(
197
+ "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
198
+ indicator === "dot" && "items-center"
199
+ )}
200
+ >
201
+ {formatter && item?.value !== undefined && item.name ? (
202
+ formatter(item.value, item.name, item, index, item.payload)
203
+ ) : (
204
+ <>
205
+ {itemConfig?.icon ? (
206
+ <itemConfig.icon />
207
+ ) : (
208
+ !hideIndicator && (
209
+ <div
210
+ className={cn(
211
+ "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
212
+ {
213
+ "h-2.5 w-2.5": indicator === "dot",
214
+ "w-1": indicator === "line",
215
+ "w-0 border-[1.5px] border-dashed bg-transparent":
216
+ indicator === "dashed",
217
+ "my-0.5": nestLabel && indicator === "dashed",
218
+ }
219
+ )}
220
+ style={
221
+ {
222
+ "--color-bg": indicatorColor,
223
+ "--color-border": indicatorColor,
224
+ } as React.CSSProperties
225
+ }
226
+ />
227
+ )
228
+ )}
229
+ <div
230
+ className={cn(
231
+ "flex flex-1 justify-between leading-none",
232
+ nestLabel ? "items-end" : "items-center"
233
+ )}
234
+ >
235
+ <div className="grid gap-1.5">
236
+ {nestLabel ? tooltipLabel : null}
237
+ <span className="text-muted-foreground">
238
+ {itemConfig?.label || item.name}
239
+ </span>
240
+ </div>
241
+ {item.value && (
242
+ <span className="font-mono font-medium tabular-nums text-foreground">
243
+ {item.value.toLocaleString()}
244
+ </span>
245
+ )}
246
+ </div>
247
+ </>
248
+ )}
249
+ </div>
250
+ )
251
+ })}
252
+ </div>
253
+ </div>
254
+ )
255
+ }
256
+ )
257
+ ChartTooltipContent.displayName = "ChartTooltip"
258
+
259
+ const ChartLegend = RechartsPrimitive.Legend
260
+
261
+ const ChartLegendContent = React.forwardRef<
262
+ HTMLDivElement,
263
+ React.ComponentProps<"div"> &
264
+ Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
265
+ hideIcon?: boolean
266
+ nameKey?: string
267
+ }
268
+ >(
269
+ (
270
+ { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
271
+ ref
272
+ ) => {
273
+ const { config } = useChart()
274
+
275
+ if (!payload?.length) {
276
+ return null
277
+ }
278
+
279
+ return (
280
+ <div
281
+ ref={ref}
282
+ className={cn(
283
+ "flex items-center justify-center gap-4",
284
+ verticalAlign === "top" ? "pb-3" : "pt-3",
285
+ className
286
+ )}
287
+ >
288
+ {payload.map((item) => {
289
+ const key = `${nameKey || item.dataKey || "value"}`
290
+ const itemConfig = getPayloadConfigFromPayload(config, item, key)
291
+
292
+ return (
293
+ <div
294
+ key={item.value}
295
+ className={cn(
296
+ "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
297
+ )}
298
+ >
299
+ {itemConfig?.icon && !hideIcon ? (
300
+ <itemConfig.icon />
301
+ ) : (
302
+ <div
303
+ className="h-2 w-2 shrink-0 rounded-[2px]"
304
+ style={{
305
+ backgroundColor: item.color,
306
+ }}
307
+ />
308
+ )}
309
+ {itemConfig?.label}
310
+ </div>
311
+ )
312
+ })}
313
+ </div>
314
+ )
315
+ }
316
+ )
317
+ ChartLegendContent.displayName = "ChartLegend"
318
+
319
+ // Helper to extract item config from a payload.
320
+ function getPayloadConfigFromPayload(
321
+ config: ChartConfig,
322
+ payload: unknown,
323
+ key: string
324
+ ) {
325
+ if (typeof payload !== "object" || payload === null) {
326
+ return undefined
327
+ }
328
+
329
+ const payloadPayload =
330
+ "payload" in payload &&
331
+ typeof payload.payload === "object" &&
332
+ payload.payload !== null
333
+ ? payload.payload
334
+ : undefined
335
+
336
+ let configLabelKey: string = key
337
+
338
+ if (
339
+ key in payload &&
340
+ typeof payload[key as keyof typeof payload] === "string"
341
+ ) {
342
+ configLabelKey = payload[key as keyof typeof payload] as string
343
+ } else if (
344
+ payloadPayload &&
345
+ key in payloadPayload &&
346
+ typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
347
+ ) {
348
+ configLabelKey = payloadPayload[
349
+ key as keyof typeof payloadPayload
350
+ ] as string
351
+ }
352
+
353
+ return configLabelKey in config
354
+ ? config[configLabelKey]
355
+ : config[key as keyof typeof config]
356
+ }
357
+
358
+ export {
359
+ ChartContainer,
360
+ ChartTooltip,
361
+ ChartTooltipContent,
362
+ ChartLegend,
363
+ ChartLegendContent,
364
+ ChartStyle,
365
+ }
src/components/ui/checkbox.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
5
+ import { Check } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ const Checkbox = React.forwardRef<
10
+ React.ElementRef<typeof CheckboxPrimitive.Root>,
11
+ React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
12
+ >(({ className, ...props }, ref) => (
13
+ <CheckboxPrimitive.Root
14
+ ref={ref}
15
+ className={cn(
16
+ "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
17
+ className
18
+ )}
19
+ {...props}
20
+ >
21
+ <CheckboxPrimitive.Indicator
22
+ className={cn("flex items-center justify-center text-current")}
23
+ >
24
+ <Check className="h-4 w-4" />
25
+ </CheckboxPrimitive.Indicator>
26
+ </CheckboxPrimitive.Root>
27
+ ))
28
+ Checkbox.displayName = CheckboxPrimitive.Root.displayName
29
+
30
+ export { Checkbox }
src/components/ui/dialog.tsx ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as DialogPrimitive from "@radix-ui/react-dialog"
5
+ import { X } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ const Dialog = DialogPrimitive.Root
10
+
11
+ const DialogTrigger = DialogPrimitive.Trigger
12
+
13
+ const DialogPortal = DialogPrimitive.Portal
14
+
15
+ const DialogClose = DialogPrimitive.Close
16
+
17
+ const DialogOverlay = React.forwardRef<
18
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
19
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
20
+ >(({ className, ...props }, ref) => (
21
+ <DialogPrimitive.Overlay
22
+ ref={ref}
23
+ className={cn(
24
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
25
+ className
26
+ )}
27
+ {...props}
28
+ />
29
+ ))
30
+ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31
+
32
+ const DialogContent = React.forwardRef<
33
+ React.ElementRef<typeof DialogPrimitive.Content>,
34
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
35
+ >(({ className, children, ...props }, ref) => (
36
+ <DialogPortal>
37
+ <DialogOverlay />
38
+ <DialogPrimitive.Content
39
+ ref={ref}
40
+ className={cn(
41
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
42
+ className
43
+ )}
44
+ {...props}
45
+ >
46
+ {children}
47
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
48
+ <X className="h-4 w-4" />
49
+ <span className="sr-only">Close</span>
50
+ </DialogPrimitive.Close>
51
+ </DialogPrimitive.Content>
52
+ </DialogPortal>
53
+ ))
54
+ DialogContent.displayName = DialogPrimitive.Content.displayName
55
+
56
+ const DialogHeader = ({
57
+ className,
58
+ ...props
59
+ }: React.HTMLAttributes<HTMLDivElement>) => (
60
+ <div
61
+ className={cn(
62
+ "flex flex-col space-y-1.5 text-center sm:text-left",
63
+ className
64
+ )}
65
+ {...props}
66
+ />
67
+ )
68
+ DialogHeader.displayName = "DialogHeader"
69
+
70
+ const DialogFooter = ({
71
+ className,
72
+ ...props
73
+ }: React.HTMLAttributes<HTMLDivElement>) => (
74
+ <div
75
+ className={cn(
76
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
77
+ className
78
+ )}
79
+ {...props}
80
+ />
81
+ )
82
+ DialogFooter.displayName = "DialogFooter"
83
+
84
+ const DialogTitle = React.forwardRef<
85
+ React.ElementRef<typeof DialogPrimitive.Title>,
86
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
87
+ >(({ className, ...props }, ref) => (
88
+ <DialogPrimitive.Title
89
+ ref={ref}
90
+ className={cn(
91
+ "text-lg font-semibold leading-none tracking-tight",
92
+ className
93
+ )}
94
+ {...props}
95
+ />
96
+ ))
97
+ DialogTitle.displayName = DialogPrimitive.Title.displayName
98
+
99
+ const DialogDescription = React.forwardRef<
100
+ React.ElementRef<typeof DialogPrimitive.Description>,
101
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
102
+ >(({ className, ...props }, ref) => (
103
+ <DialogPrimitive.Description
104
+ ref={ref}
105
+ className={cn("text-sm text-muted-foreground", className)}
106
+ {...props}
107
+ />
108
+ ))
109
+ DialogDescription.displayName = DialogPrimitive.Description.displayName
110
+
111
+ export {
112
+ Dialog,
113
+ DialogPortal,
114
+ DialogOverlay,
115
+ DialogClose,
116
+ DialogTrigger,
117
+ DialogContent,
118
+ DialogHeader,
119
+ DialogFooter,
120
+ DialogTitle,
121
+ DialogDescription,
122
+ }
src/components/ui/dropdown-menu.tsx ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5
+ import { Check, ChevronRight, Circle } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ const DropdownMenu = DropdownMenuPrimitive.Root
10
+
11
+ const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12
+
13
+ const DropdownMenuGroup = DropdownMenuPrimitive.Group
14
+
15
+ const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16
+
17
+ const DropdownMenuSub = DropdownMenuPrimitive.Sub
18
+
19
+ const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20
+
21
+ const DropdownMenuSubTrigger = React.forwardRef<
22
+ React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
23
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
24
+ inset?: boolean
25
+ }
26
+ >(({ className, inset, children, ...props }, ref) => (
27
+ <DropdownMenuPrimitive.SubTrigger
28
+ ref={ref}
29
+ className={cn(
30
+ "flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
31
+ inset && "pl-8",
32
+ className
33
+ )}
34
+ {...props}
35
+ >
36
+ {children}
37
+ <ChevronRight className="ml-auto" />
38
+ </DropdownMenuPrimitive.SubTrigger>
39
+ ))
40
+ DropdownMenuSubTrigger.displayName =
41
+ DropdownMenuPrimitive.SubTrigger.displayName
42
+
43
+ const DropdownMenuSubContent = React.forwardRef<
44
+ React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
45
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
46
+ >(({ className, ...props }, ref) => (
47
+ <DropdownMenuPrimitive.SubContent
48
+ ref={ref}
49
+ className={cn(
50
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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",
51
+ className
52
+ )}
53
+ {...props}
54
+ />
55
+ ))
56
+ DropdownMenuSubContent.displayName =
57
+ DropdownMenuPrimitive.SubContent.displayName
58
+
59
+ const DropdownMenuContent = React.forwardRef<
60
+ React.ElementRef<typeof DropdownMenuPrimitive.Content>,
61
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
62
+ >(({ className, sideOffset = 4, ...props }, ref) => (
63
+ <DropdownMenuPrimitive.Portal>
64
+ <DropdownMenuPrimitive.Content
65
+ ref={ref}
66
+ sideOffset={sideOffset}
67
+ className={cn(
68
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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",
69
+ className
70
+ )}
71
+ {...props}
72
+ />
73
+ </DropdownMenuPrimitive.Portal>
74
+ ))
75
+ DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
76
+
77
+ const DropdownMenuItem = React.forwardRef<
78
+ React.ElementRef<typeof DropdownMenuPrimitive.Item>,
79
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
80
+ inset?: boolean
81
+ }
82
+ >(({ className, inset, ...props }, ref) => (
83
+ <DropdownMenuPrimitive.Item
84
+ ref={ref}
85
+ className={cn(
86
+ "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
87
+ inset && "pl-8",
88
+ className
89
+ )}
90
+ {...props}
91
+ />
92
+ ))
93
+ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
94
+
95
+ const DropdownMenuCheckboxItem = React.forwardRef<
96
+ React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
97
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
98
+ >(({ className, children, checked, ...props }, ref) => (
99
+ <DropdownMenuPrimitive.CheckboxItem
100
+ ref={ref}
101
+ className={cn(
102
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
103
+ className
104
+ )}
105
+ checked={checked}
106
+ {...props}
107
+ >
108
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
109
+ <DropdownMenuPrimitive.ItemIndicator>
110
+ <Check className="h-4 w-4" />
111
+ </DropdownMenuPrimitive.ItemIndicator>
112
+ </span>
113
+ {children}
114
+ </DropdownMenuPrimitive.CheckboxItem>
115
+ ))
116
+ DropdownMenuCheckboxItem.displayName =
117
+ DropdownMenuPrimitive.CheckboxItem.displayName
118
+
119
+ const DropdownMenuRadioItem = React.forwardRef<
120
+ React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
121
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
122
+ >(({ className, children, ...props }, ref) => (
123
+ <DropdownMenuPrimitive.RadioItem
124
+ ref={ref}
125
+ className={cn(
126
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
127
+ className
128
+ )}
129
+ {...props}
130
+ >
131
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
132
+ <DropdownMenuPrimitive.ItemIndicator>
133
+ <Circle className="h-2 w-2 fill-current" />
134
+ </DropdownMenuPrimitive.ItemIndicator>
135
+ </span>
136
+ {children}
137
+ </DropdownMenuPrimitive.RadioItem>
138
+ ))
139
+ DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
140
+
141
+ const DropdownMenuLabel = React.forwardRef<
142
+ React.ElementRef<typeof DropdownMenuPrimitive.Label>,
143
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
144
+ inset?: boolean
145
+ }
146
+ >(({ className, inset, ...props }, ref) => (
147
+ <DropdownMenuPrimitive.Label
148
+ ref={ref}
149
+ className={cn(
150
+ "px-2 py-1.5 text-sm font-semibold",
151
+ inset && "pl-8",
152
+ className
153
+ )}
154
+ {...props}
155
+ />
156
+ ))
157
+ DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
158
+
159
+ const DropdownMenuSeparator = React.forwardRef<
160
+ React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
161
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
162
+ >(({ className, ...props }, ref) => (
163
+ <DropdownMenuPrimitive.Separator
164
+ ref={ref}
165
+ className={cn("-mx-1 my-1 h-px bg-muted", className)}
166
+ {...props}
167
+ />
168
+ ))
169
+ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
170
+
171
+ const DropdownMenuShortcut = ({
172
+ className,
173
+ ...props
174
+ }: React.HTMLAttributes<HTMLSpanElement>) => {
175
+ return (
176
+ <span
177
+ className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
178
+ {...props}
179
+ />
180
+ )
181
+ }
182
+ DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
183
+
184
+ export {
185
+ DropdownMenu,
186
+ DropdownMenuTrigger,
187
+ DropdownMenuContent,
188
+ DropdownMenuItem,
189
+ DropdownMenuCheckboxItem,
190
+ DropdownMenuRadioItem,
191
+ DropdownMenuLabel,
192
+ DropdownMenuSeparator,
193
+ DropdownMenuShortcut,
194
+ DropdownMenuGroup,
195
+ DropdownMenuPortal,
196
+ DropdownMenuSub,
197
+ DropdownMenuSubContent,
198
+ DropdownMenuSubTrigger,
199
+ DropdownMenuRadioGroup,
200
+ }
src/components/ui/form.tsx ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ type ControllerProps,
11
+ type FieldPath,
12
+ type FieldValues,
13
+ } from "react-hook-form"
14
+
15
+ import { cn } from "@/lib/utils"
16
+ import { Label } from "@/components/ui/label"
17
+
18
+ const Form = FormProvider
19
+
20
+ type FormFieldContextValue<
21
+ TFieldValues extends FieldValues = FieldValues,
22
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
23
+ > = {
24
+ name: TName
25
+ }
26
+
27
+ const FormFieldContext = React.createContext<FormFieldContextValue>(
28
+ {} as FormFieldContextValue
29
+ )
30
+
31
+ const FormField = <
32
+ TFieldValues extends FieldValues = FieldValues,
33
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
34
+ >({
35
+ ...props
36
+ }: ControllerProps<TFieldValues, TName>) => {
37
+ return (
38
+ <FormFieldContext.Provider value={{ name: props.name }}>
39
+ <Controller {...props} />
40
+ </FormFieldContext.Provider>
41
+ )
42
+ }
43
+
44
+ const useFormField = () => {
45
+ const fieldContext = React.useContext(FormFieldContext)
46
+ const itemContext = React.useContext(FormItemContext)
47
+ const { getFieldState, formState } = useFormContext()
48
+
49
+ const fieldState = getFieldState(fieldContext.name, formState)
50
+
51
+ if (!fieldContext) {
52
+ throw new Error("useFormField should be used within <FormField>")
53
+ }
54
+
55
+ const { id } = itemContext
56
+
57
+ return {
58
+ id,
59
+ name: fieldContext.name,
60
+ formItemId: `${id}-form-item`,
61
+ formDescriptionId: `${id}-form-item-description`,
62
+ formMessageId: `${id}-form-item-message`,
63
+ ...fieldState,
64
+ }
65
+ }
66
+
67
+ type FormItemContextValue = {
68
+ id: string
69
+ }
70
+
71
+ const FormItemContext = React.createContext<FormItemContextValue>(
72
+ {} as FormItemContextValue
73
+ )
74
+
75
+ const FormItem = React.forwardRef<
76
+ HTMLDivElement,
77
+ React.HTMLAttributes<HTMLDivElement>
78
+ >(({ className, ...props }, ref) => {
79
+ const id = React.useId()
80
+
81
+ return (
82
+ <FormItemContext.Provider value={{ id }}>
83
+ <div ref={ref} className={cn("space-y-2", className)} {...props} />
84
+ </FormItemContext.Provider>
85
+ )
86
+ })
87
+ FormItem.displayName = "FormItem"
88
+
89
+ const FormLabel = React.forwardRef<
90
+ React.ElementRef<typeof LabelPrimitive.Root>,
91
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
92
+ >(({ className, ...props }, ref) => {
93
+ const { error, formItemId } = useFormField()
94
+
95
+ return (
96
+ <Label
97
+ ref={ref}
98
+ className={cn(error && "text-destructive", className)}
99
+ htmlFor={formItemId}
100
+ {...props}
101
+ />
102
+ )
103
+ })
104
+ FormLabel.displayName = "FormLabel"
105
+
106
+ const FormControl = React.forwardRef<
107
+ React.ElementRef<typeof Slot>,
108
+ React.ComponentPropsWithoutRef<typeof Slot>
109
+ >(({ ...props }, ref) => {
110
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
111
+
112
+ return (
113
+ <Slot
114
+ ref={ref}
115
+ id={formItemId}
116
+ aria-describedby={
117
+ !error
118
+ ? `${formDescriptionId}`
119
+ : `${formDescriptionId} ${formMessageId}`
120
+ }
121
+ aria-invalid={!!error}
122
+ {...props}
123
+ />
124
+ )
125
+ })
126
+ FormControl.displayName = "FormControl"
127
+
128
+ const FormDescription = React.forwardRef<
129
+ HTMLParagraphElement,
130
+ React.HTMLAttributes<HTMLParagraphElement>
131
+ >(({ className, ...props }, ref) => {
132
+ const { formDescriptionId } = useFormField()
133
+
134
+ return (
135
+ <p
136
+ ref={ref}
137
+ id={formDescriptionId}
138
+ className={cn("text-sm text-muted-foreground", className)}
139
+ {...props}
140
+ />
141
+ )
142
+ })
143
+ FormDescription.displayName = "FormDescription"
144
+
145
+ const FormMessage = React.forwardRef<
146
+ HTMLParagraphElement,
147
+ React.HTMLAttributes<HTMLParagraphElement>
148
+ >(({ className, children, ...props }, ref) => {
149
+ const { error, formMessageId } = useFormField()
150
+ const body = error ? String(error?.message ?? "") : children
151
+
152
+ if (!body) {
153
+ return null
154
+ }
155
+
156
+ return (
157
+ <p
158
+ ref={ref}
159
+ id={formMessageId}
160
+ className={cn("text-sm font-medium text-destructive", className)}
161
+ {...props}
162
+ >
163
+ {body}
164
+ </p>
165
+ )
166
+ })
167
+ FormMessage.displayName = "FormMessage"
168
+
169
+ export {
170
+ useFormField,
171
+ Form,
172
+ FormItem,
173
+ FormLabel,
174
+ FormControl,
175
+ FormDescription,
176
+ FormMessage,
177
+ FormField,
178
+ }
src/components/ui/input.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
6
+ ({ className, type, ...props }, ref) => {
7
+ return (
8
+ <input
9
+ type={type}
10
+ className={cn(
11
+ "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
12
+ className
13
+ )}
14
+ ref={ref}
15
+ {...props}
16
+ />
17
+ )
18
+ }
19
+ )
20
+ Input.displayName = "Input"
21
+
22
+ export { Input }
src/components/ui/label.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as LabelPrimitive from "@radix-ui/react-label"
5
+ import { cva, type VariantProps } from "class-variance-authority"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ const labelVariants = cva(
10
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11
+ )
12
+
13
+ const Label = React.forwardRef<
14
+ React.ElementRef<typeof LabelPrimitive.Root>,
15
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
16
+ VariantProps<typeof labelVariants>
17
+ >(({ className, ...props }, ref) => (
18
+ <LabelPrimitive.Root
19
+ ref={ref}
20
+ className={cn(labelVariants(), className)}
21
+ {...props}
22
+ />
23
+ ))
24
+ Label.displayName = LabelPrimitive.Root.displayName
25
+
26
+ export { Label }
src/components/ui/menubar.tsx ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as MenubarPrimitive from "@radix-ui/react-menubar"
5
+ import { Check, ChevronRight, Circle } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ function MenubarMenu({
10
+ ...props
11
+ }: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
12
+ return <MenubarPrimitive.Menu {...props} />
13
+ }
14
+
15
+ function MenubarGroup({
16
+ ...props
17
+ }: React.ComponentProps<typeof MenubarPrimitive.Group>) {
18
+ return <MenubarPrimitive.Group {...props} />
19
+ }
20
+
21
+ function MenubarPortal({
22
+ ...props
23
+ }: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
24
+ return <MenubarPrimitive.Portal {...props} />
25
+ }
26
+
27
+ function MenubarRadioGroup({
28
+ ...props
29
+ }: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
30
+ return <MenubarPrimitive.RadioGroup {...props} />
31
+ }
32
+
33
+ function MenubarSub({
34
+ ...props
35
+ }: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
36
+ return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
37
+ }
38
+
39
+ const Menubar = React.forwardRef<
40
+ React.ElementRef<typeof MenubarPrimitive.Root>,
41
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
42
+ >(({ className, ...props }, ref) => (
43
+ <MenubarPrimitive.Root
44
+ ref={ref}
45
+ className={cn(
46
+ "flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
47
+ className
48
+ )}
49
+ {...props}
50
+ />
51
+ ))
52
+ Menubar.displayName = MenubarPrimitive.Root.displayName
53
+
54
+ const MenubarTrigger = React.forwardRef<
55
+ React.ElementRef<typeof MenubarPrimitive.Trigger>,
56
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
57
+ >(({ className, ...props }, ref) => (
58
+ <MenubarPrimitive.Trigger
59
+ ref={ref}
60
+ className={cn(
61
+ "flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
62
+ className
63
+ )}
64
+ {...props}
65
+ />
66
+ ))
67
+ MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
68
+
69
+ const MenubarSubTrigger = React.forwardRef<
70
+ React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
71
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
72
+ inset?: boolean
73
+ }
74
+ >(({ className, inset, children, ...props }, ref) => (
75
+ <MenubarPrimitive.SubTrigger
76
+ ref={ref}
77
+ className={cn(
78
+ "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
79
+ inset && "pl-8",
80
+ className
81
+ )}
82
+ {...props}
83
+ >
84
+ {children}
85
+ <ChevronRight className="ml-auto h-4 w-4" />
86
+ </MenubarPrimitive.SubTrigger>
87
+ ))
88
+ MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
89
+
90
+ const MenubarSubContent = React.forwardRef<
91
+ React.ElementRef<typeof MenubarPrimitive.SubContent>,
92
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
93
+ >(({ className, ...props }, ref) => (
94
+ <MenubarPrimitive.SubContent
95
+ ref={ref}
96
+ className={cn(
97
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 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",
98
+ className
99
+ )}
100
+ {...props}
101
+ />
102
+ ))
103
+ MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
104
+
105
+ const MenubarContent = React.forwardRef<
106
+ React.ElementRef<typeof MenubarPrimitive.Content>,
107
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
108
+ >(
109
+ (
110
+ { className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
111
+ ref
112
+ ) => (
113
+ <MenubarPrimitive.Portal>
114
+ <MenubarPrimitive.Content
115
+ ref={ref}
116
+ align={align}
117
+ alignOffset={alignOffset}
118
+ sideOffset={sideOffset}
119
+ className={cn(
120
+ "z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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",
121
+ className
122
+ )}
123
+ {...props}
124
+ />
125
+ </MenubarPrimitive.Portal>
126
+ )
127
+ )
128
+ MenubarContent.displayName = MenubarPrimitive.Content.displayName
129
+
130
+ const MenubarItem = React.forwardRef<
131
+ React.ElementRef<typeof MenubarPrimitive.Item>,
132
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
133
+ inset?: boolean
134
+ }
135
+ >(({ className, inset, ...props }, ref) => (
136
+ <MenubarPrimitive.Item
137
+ ref={ref}
138
+ className={cn(
139
+ "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
140
+ inset && "pl-8",
141
+ className
142
+ )}
143
+ {...props}
144
+ />
145
+ ))
146
+ MenubarItem.displayName = MenubarPrimitive.Item.displayName
147
+
148
+ const MenubarCheckboxItem = React.forwardRef<
149
+ React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
150
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
151
+ >(({ className, children, checked, ...props }, ref) => (
152
+ <MenubarPrimitive.CheckboxItem
153
+ ref={ref}
154
+ className={cn(
155
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
156
+ className
157
+ )}
158
+ checked={checked}
159
+ {...props}
160
+ >
161
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
162
+ <MenubarPrimitive.ItemIndicator>
163
+ <Check className="h-4 w-4" />
164
+ </MenubarPrimitive.ItemIndicator>
165
+ </span>
166
+ {children}
167
+ </MenubarPrimitive.CheckboxItem>
168
+ ))
169
+ MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
170
+
171
+ const MenubarRadioItem = React.forwardRef<
172
+ React.ElementRef<typeof MenubarPrimitive.RadioItem>,
173
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
174
+ >(({ className, children, ...props }, ref) => (
175
+ <MenubarPrimitive.RadioItem
176
+ ref={ref}
177
+ className={cn(
178
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
179
+ className
180
+ )}
181
+ {...props}
182
+ >
183
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
184
+ <MenubarPrimitive.ItemIndicator>
185
+ <Circle className="h-2 w-2 fill-current" />
186
+ </MenubarPrimitive.ItemIndicator>
187
+ </span>
188
+ {children}
189
+ </MenubarPrimitive.RadioItem>
190
+ ))
191
+ MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
192
+
193
+ const MenubarLabel = React.forwardRef<
194
+ React.ElementRef<typeof MenubarPrimitive.Label>,
195
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
196
+ inset?: boolean
197
+ }
198
+ >(({ className, inset, ...props }, ref) => (
199
+ <MenubarPrimitive.Label
200
+ ref={ref}
201
+ className={cn(
202
+ "px-2 py-1.5 text-sm font-semibold",
203
+ inset && "pl-8",
204
+ className
205
+ )}
206
+ {...props}
207
+ />
208
+ ))
209
+ MenubarLabel.displayName = MenubarPrimitive.Label.displayName
210
+
211
+ const MenubarSeparator = React.forwardRef<
212
+ React.ElementRef<typeof MenubarPrimitive.Separator>,
213
+ React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
214
+ >(({ className, ...props }, ref) => (
215
+ <MenubarPrimitive.Separator
216
+ ref={ref}
217
+ className={cn("-mx-1 my-1 h-px bg-muted", className)}
218
+ {...props}
219
+ />
220
+ ))
221
+ MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
222
+
223
+ const MenubarShortcut = ({
224
+ className,
225
+ ...props
226
+ }: React.HTMLAttributes<HTMLSpanElement>) => {
227
+ return (
228
+ <span
229
+ className={cn(
230
+ "ml-auto text-xs tracking-widest text-muted-foreground",
231
+ className
232
+ )}
233
+ {...props}
234
+ />
235
+ )
236
+ }
237
+ MenubarShortcut.displayname = "MenubarShortcut"
238
+
239
+ export {
240
+ Menubar,
241
+ MenubarMenu,
242
+ MenubarTrigger,
243
+ MenubarContent,
244
+ MenubarItem,
245
+ MenubarSeparator,
246
+ MenubarLabel,
247
+ MenubarCheckboxItem,
248
+ MenubarRadioGroup,
249
+ MenubarRadioItem,
250
+ MenubarPortal,
251
+ MenubarSubContent,
252
+ MenubarSubTrigger,
253
+ MenubarGroup,
254
+ MenubarSub,
255
+ MenubarShortcut,
256
+ }
src/components/ui/popover.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ const Popover = PopoverPrimitive.Root
9
+
10
+ const PopoverTrigger = PopoverPrimitive.Trigger
11
+
12
+ const PopoverContent = React.forwardRef<
13
+ React.ElementRef<typeof PopoverPrimitive.Content>,
14
+ React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
15
+ >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16
+ <PopoverPrimitive.Portal>
17
+ <PopoverPrimitive.Content
18
+ ref={ref}
19
+ align={align}
20
+ sideOffset={sideOffset}
21
+ className={cn(
22
+ "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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",
23
+ className
24
+ )}
25
+ {...props}
26
+ />
27
+ </PopoverPrimitive.Portal>
28
+ ))
29
+ PopoverContent.displayName = PopoverPrimitive.Content.displayName
30
+
31
+ export { Popover, PopoverTrigger, PopoverContent }
src/components/ui/progress.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ const Progress = React.forwardRef<
9
+ React.ElementRef<typeof ProgressPrimitive.Root>,
10
+ React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
11
+ >(({ className, value, ...props }, ref) => (
12
+ <ProgressPrimitive.Root
13
+ ref={ref}
14
+ className={cn(
15
+ "relative h-4 w-full overflow-hidden rounded-full bg-secondary",
16
+ className
17
+ )}
18
+ {...props}
19
+ >
20
+ <ProgressPrimitive.Indicator
21
+ className="h-full w-full flex-1 bg-primary transition-all"
22
+ style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
23
+ />
24
+ </ProgressPrimitive.Root>
25
+ ))
26
+ Progress.displayName = ProgressPrimitive.Root.displayName
27
+
28
+ export { Progress }