Spaces:
Runtime error
Runtime error
Commit
·
b24a0b1
0
Parent(s):
Initial commit
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitignore +63 -0
- package.json +69 -0
- postcss.config.js +6 -0
- src/ai/dev.ts +5 -0
- src/ai/flows/auto-select-image-model.ts +209 -0
- src/ai/flows/enhance-user-prompt.ts +50 -0
- src/ai/flows/generate-image-google.ts +57 -0
- src/ai/flows/generate-image-openai.ts +75 -0
- src/ai/flows/generate-image-qwen.ts +104 -0
- src/ai/flows/generate-image.ts +61 -0
- src/ai/genkit.ts +7 -0
- src/app/favicon.ico +0 -0
- src/app/globals.css +72 -0
- src/app/layout.tsx +45 -0
- src/app/page.tsx +167 -0
- src/components/header.tsx +22 -0
- src/components/history-sidebar.tsx +63 -0
- src/components/icons.tsx +53 -0
- src/components/image-card.tsx +94 -0
- src/components/image-gallery.tsx +55 -0
- src/components/model-card.tsx +47 -0
- src/components/prompt-form.tsx +242 -0
- src/components/settings-panel.tsx +217 -0
- src/components/theme-toggle.tsx +40 -0
- src/components/ui/accordion.tsx +58 -0
- src/components/ui/alert-dialog.tsx +141 -0
- src/components/ui/alert.tsx +59 -0
- src/components/ui/avatar.tsx +50 -0
- src/components/ui/badge.tsx +36 -0
- src/components/ui/button.tsx +56 -0
- src/components/ui/calendar.tsx +68 -0
- src/components/ui/card.tsx +79 -0
- src/components/ui/carousel.tsx +262 -0
- src/components/ui/chart.tsx +365 -0
- src/components/ui/checkbox.tsx +30 -0
- src/components/ui/collapsible.tsx +11 -0
- src/components/ui/dialog.tsx +122 -0
- src/components/ui/dropdown-menu.tsx +200 -0
- src/components/ui/form.tsx +178 -0
- src/components/ui/input.tsx +22 -0
- src/components/ui/label.tsx +26 -0
- src/components/ui/menubar.tsx +256 -0
- src/components/ui/popover.tsx +31 -0
- src/components/ui/progress.tsx +28 -0
- src/components/ui/radio-group.tsx +44 -0
- src/components/ui/scroll-area.tsx +48 -0
- src/components/ui/select.tsx +160 -0
- src/components/ui/separator.tsx +31 -0
- src/components/ui/sheet.tsx +140 -0
- src/components/ui/sidebar.tsx +763 -0
.gitignore
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Production build outputs
|
| 2 |
+
.next/
|
| 3 |
+
out/
|
| 4 |
+
dist/
|
| 5 |
+
build/
|
| 6 |
+
|
| 7 |
+
# Dependencies
|
| 8 |
+
node_modules/
|
| 9 |
+
|
| 10 |
+
# Environment variables (IMPORTANT: Never commit API keys!)
|
| 11 |
+
.env
|
| 12 |
+
.env.local
|
| 13 |
+
.env.development
|
| 14 |
+
.env.production
|
| 15 |
+
.env.test
|
| 16 |
+
.env*.local
|
| 17 |
+
|
| 18 |
+
# Next.js
|
| 19 |
+
next-env.d.ts
|
| 20 |
+
|
| 21 |
+
# Logs
|
| 22 |
+
npm-debug.log*
|
| 23 |
+
yarn-debug.log*
|
| 24 |
+
yarn-error.log*
|
| 25 |
+
pnpm-debug.log*
|
| 26 |
+
.pnpm-debug.log*
|
| 27 |
+
|
| 28 |
+
# OS / Editor junk
|
| 29 |
+
.DS_Store
|
| 30 |
+
Thumbs.db
|
| 31 |
+
*.swp
|
| 32 |
+
*.swo
|
| 33 |
+
*~
|
| 34 |
+
|
| 35 |
+
# TypeScript build info
|
| 36 |
+
tsconfig.tsbuildinfo
|
| 37 |
+
*.tsbuildinfo
|
| 38 |
+
|
| 39 |
+
# Temporary files
|
| 40 |
+
*.temp
|
| 41 |
+
*.tmp
|
| 42 |
+
|
| 43 |
+
# IDE / Editor settings
|
| 44 |
+
.vscode/
|
| 45 |
+
.idea/
|
| 46 |
+
*.sublime-project
|
| 47 |
+
*.sublime-workspace
|
| 48 |
+
|
| 49 |
+
# Local caches
|
| 50 |
+
.cache/
|
| 51 |
+
.turbo/
|
| 52 |
+
|
| 53 |
+
# Debug
|
| 54 |
+
.npm/
|
| 55 |
+
|
| 56 |
+
# Vercel
|
| 57 |
+
.vercel
|
| 58 |
+
|
| 59 |
+
# Package managers
|
| 60 |
+
package-lock.json
|
| 61 |
+
*.lock
|
| 62 |
+
!pnpm-lock.yaml
|
| 63 |
+
|
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",
|
| 7 |
+
"genkit:dev": "genkit start -- tsx src/ai/dev.ts",
|
| 8 |
+
"genkit:watch": "genkit start -- tsx --watch src/ai/dev.ts",
|
| 9 |
+
"build": "NODE_ENV=production next build",
|
| 10 |
+
"start": "next start",
|
| 11 |
+
"lint": "next lint",
|
| 12 |
+
"typecheck": "tsc --noEmit"
|
| 13 |
+
},
|
| 14 |
+
"dependencies": {
|
| 15 |
+
"@genkit-ai/google-genai": "^1.20.0",
|
| 16 |
+
"@genkit-ai/next": "^1.20.0",
|
| 17 |
+
"@hookform/resolvers": "^4.1.3",
|
| 18 |
+
"@radix-ui/react-accordion": "^1.2.3",
|
| 19 |
+
"@radix-ui/react-alert-dialog": "^1.1.6",
|
| 20 |
+
"@radix-ui/react-avatar": "^1.1.3",
|
| 21 |
+
"@radix-ui/react-checkbox": "^1.1.4",
|
| 22 |
+
"@radix-ui/react-collapsible": "^1.1.11",
|
| 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.2",
|
| 33 |
+
"@radix-ui/react-slider": "^1.2.3",
|
| 34 |
+
"@radix-ui/react-slot": "^1.2.3",
|
| 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 |
+
"class-variance-authority": "^0.7.1",
|
| 40 |
+
"clsx": "^2.1.1",
|
| 41 |
+
"date-fns": "^3.6.0",
|
| 42 |
+
"dotenv": "^16.5.0",
|
| 43 |
+
"embla-carousel-react": "^8.6.0",
|
| 44 |
+
"firebase": "^11.9.1",
|
| 45 |
+
"genkit": "^1.20.0",
|
| 46 |
+
"lucide-react": "^0.475.0",
|
| 47 |
+
"next": "^15.3.6",
|
| 48 |
+
"next-themes": "0.4.0",
|
| 49 |
+
"patch-package": "^8.0.0",
|
| 50 |
+
"react": "^19.2.1",
|
| 51 |
+
"react-day-picker": "^9.11.3",
|
| 52 |
+
"react-dom": "^19.2.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",
|
| 61 |
+
"@types/react": "^19.2.1",
|
| 62 |
+
"@types/react-dom": "^19.2.1",
|
| 63 |
+
"autoprefixer": "^10.4.22",
|
| 64 |
+
"genkit-cli": "^1.20.0",
|
| 65 |
+
"postcss": "^8",
|
| 66 |
+
"tailwindcss": "^3.4.1",
|
| 67 |
+
"typescript": "^5"
|
| 68 |
+
}
|
| 69 |
+
}
|
postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module.exports = {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
src/ai/dev.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { config } from 'dotenv';
|
| 2 |
+
config();
|
| 3 |
+
|
| 4 |
+
import '@/ai/flows/enhance-user-prompt.ts';
|
| 5 |
+
import '@/ai/flows/generate-image.ts';
|
src/ai/flows/auto-select-image-model.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use server';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @fileOverview Smart routing flow for automatically selecting the best image generation provider.
|
| 5 |
+
*
|
| 6 |
+
* This flow:
|
| 7 |
+
* 1. Checks which API keys are valid/present
|
| 8 |
+
* 2. If smart routing is enabled and multiple providers are available, uses AI to select the best one
|
| 9 |
+
* 3. Falls back to available providers when some are unavailable
|
| 10 |
+
* 4. Returns the selected provider along with available options
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import { ai } from '@/ai/genkit';
|
| 14 |
+
import { z } from 'genkit';
|
| 15 |
+
import {
|
| 16 |
+
type ImageProvider,
|
| 17 |
+
PROVIDER_CONFIGS
|
| 18 |
+
} from '@/lib/api-config';
|
| 19 |
+
import { getAvailableProviders } from '@/lib/api-validation';
|
| 20 |
+
|
| 21 |
+
// Shared provider enum for consistent typing
|
| 22 |
+
const ProviderEnum = z.enum(['openai', 'google', 'qwen']);
|
| 23 |
+
|
| 24 |
+
// Client API keys schema
|
| 25 |
+
const ClientApiKeysSchema = z.object({
|
| 26 |
+
openai: z.string().optional(),
|
| 27 |
+
google: z.string().optional(),
|
| 28 |
+
qwen: z.string().optional(),
|
| 29 |
+
}).optional();
|
| 30 |
+
|
| 31 |
+
// Input schema for the smart router
|
| 32 |
+
const SmartRouterInputSchema = z.object({
|
| 33 |
+
prompt: z.string().describe('The user prompt for image generation.'),
|
| 34 |
+
smartRoutingEnabled: z.boolean().describe('Whether AI-based smart routing is enabled.'),
|
| 35 |
+
preferredProvider: ProviderEnum.optional().describe('User-preferred provider when smart routing is disabled.'),
|
| 36 |
+
clientApiKeys: ClientApiKeysSchema.describe('Optional API keys provided by user from client-side.'),
|
| 37 |
+
});
|
| 38 |
+
export type SmartRouterInput = z.infer<typeof SmartRouterInputSchema>;
|
| 39 |
+
|
| 40 |
+
// Output schema for the smart router
|
| 41 |
+
const SmartRouterOutputSchema = z.object({
|
| 42 |
+
selectedProvider: ProviderEnum.describe('The selected image generation provider.'),
|
| 43 |
+
availableProviders: z.array(ProviderEnum).describe('List of available providers.'),
|
| 44 |
+
reason: z.string().describe('Reason for the selection.'),
|
| 45 |
+
});
|
| 46 |
+
export type SmartRouterOutput = z.infer<typeof SmartRouterOutputSchema>;
|
| 47 |
+
|
| 48 |
+
// AI prompt for selecting the best provider based on the user's prompt
|
| 49 |
+
const selectProviderPrompt = ai.definePrompt({
|
| 50 |
+
name: 'selectProviderPrompt',
|
| 51 |
+
input: {
|
| 52 |
+
schema: z.object({
|
| 53 |
+
prompt: z.string(),
|
| 54 |
+
availableProviders: z.array(z.string()),
|
| 55 |
+
}),
|
| 56 |
+
},
|
| 57 |
+
output: {
|
| 58 |
+
schema: z.object({
|
| 59 |
+
selectedProvider: ProviderEnum,
|
| 60 |
+
reason: z.string(),
|
| 61 |
+
}),
|
| 62 |
+
},
|
| 63 |
+
prompt: `You are an AI assistant that selects the best image generation provider based on a user's prompt.
|
| 64 |
+
|
| 65 |
+
Available providers: {{{availableProviders}}}
|
| 66 |
+
|
| 67 |
+
Provider strengths:
|
| 68 |
+
- openai (DALL-E 3): Best for photorealistic images, product photography, professional headshots, realistic scenes, and detailed realistic artwork.
|
| 69 |
+
- google (Gemini): Best for general purpose, quick generation, simple images, icons, and versatile everyday needs.
|
| 70 |
+
- qwen (Qwen Image Plus): Best for artistic, creative, stylized, anime, illustration, digital art, fantasy, and visually striking artistic images.
|
| 71 |
+
|
| 72 |
+
Analyze the prompt and select the BEST provider from the AVAILABLE providers list.
|
| 73 |
+
If a provider is not in the available list, DO NOT select it.
|
| 74 |
+
|
| 75 |
+
User's prompt: {{{prompt}}}
|
| 76 |
+
|
| 77 |
+
Respond with:
|
| 78 |
+
1. selectedProvider: The name of the best provider (must be one from the available list)
|
| 79 |
+
2. reason: A brief explanation of why this provider is best for this prompt`,
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Check if a provider has a valid key either from client or env
|
| 84 |
+
*/
|
| 85 |
+
function hasValidKey(provider: ImageProvider, clientKeys?: { openai?: string; google?: string; qwen?: string }): boolean {
|
| 86 |
+
const clientKey = clientKeys?.[provider];
|
| 87 |
+
if (clientKey && clientKey.trim().length > 0) {
|
| 88 |
+
return true;
|
| 89 |
+
}
|
| 90 |
+
return false; // Server-side env check is done separately
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/**
|
| 94 |
+
* Smart router for selecting the best image generation provider.
|
| 95 |
+
*/
|
| 96 |
+
export async function smartSelectProvider(input: SmartRouterInput): Promise<SmartRouterOutput> {
|
| 97 |
+
// 1. Get available providers from environment
|
| 98 |
+
const envAvailableProviders = await getAvailableProviders();
|
| 99 |
+
|
| 100 |
+
// 2. Also check client-provided keys
|
| 101 |
+
const clientKeys = input.clientApiKeys;
|
| 102 |
+
const allProviders: ImageProvider[] = ['openai', 'google', 'qwen'];
|
| 103 |
+
|
| 104 |
+
// Combine: provider is available if it has env key OR valid client key
|
| 105 |
+
const availableProviders = allProviders.filter(provider =>
|
| 106 |
+
envAvailableProviders.includes(provider) || hasValidKey(provider, clientKeys)
|
| 107 |
+
);
|
| 108 |
+
|
| 109 |
+
// 2. Handle case where no providers are available
|
| 110 |
+
if (availableProviders.length === 0) {
|
| 111 |
+
return {
|
| 112 |
+
selectedProvider: 'google', // Default, but won't work
|
| 113 |
+
availableProviders: [],
|
| 114 |
+
reason: 'No API keys are configured. Please add at least one API key to your environment.',
|
| 115 |
+
};
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// Cast to the enum type for Zod compatibility
|
| 119 |
+
const typedAvailableProviders = availableProviders as ('openai' | 'google' | 'qwen')[];
|
| 120 |
+
|
| 121 |
+
// 3. If only one provider is available, use it
|
| 122 |
+
if (typedAvailableProviders.length === 1) {
|
| 123 |
+
const provider = typedAvailableProviders[0];
|
| 124 |
+
return {
|
| 125 |
+
selectedProvider: provider,
|
| 126 |
+
availableProviders: typedAvailableProviders,
|
| 127 |
+
reason: `Only ${PROVIDER_CONFIGS[provider].displayName} is available.`,
|
| 128 |
+
};
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
// 4. If smart routing is disabled and user has a preference
|
| 132 |
+
if (!input.smartRoutingEnabled && input.preferredProvider) {
|
| 133 |
+
// Check if preferred provider is available
|
| 134 |
+
if (typedAvailableProviders.includes(input.preferredProvider)) {
|
| 135 |
+
return {
|
| 136 |
+
selectedProvider: input.preferredProvider,
|
| 137 |
+
availableProviders: typedAvailableProviders,
|
| 138 |
+
reason: `User selected ${PROVIDER_CONFIGS[input.preferredProvider].displayName}.`,
|
| 139 |
+
};
|
| 140 |
+
} else {
|
| 141 |
+
// Preferred provider not available, fall back to first available
|
| 142 |
+
const fallback = typedAvailableProviders[0];
|
| 143 |
+
return {
|
| 144 |
+
selectedProvider: fallback,
|
| 145 |
+
availableProviders: typedAvailableProviders,
|
| 146 |
+
reason: `${PROVIDER_CONFIGS[input.preferredProvider].displayName} is not available. Falling back to ${PROVIDER_CONFIGS[fallback].displayName}.`,
|
| 147 |
+
};
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// 5. Smart routing enabled - use AI to select the best provider
|
| 152 |
+
try {
|
| 153 |
+
const { output } = await selectProviderPrompt({
|
| 154 |
+
prompt: input.prompt,
|
| 155 |
+
availableProviders: typedAvailableProviders,
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
if (output && typedAvailableProviders.includes(output.selectedProvider)) {
|
| 159 |
+
return {
|
| 160 |
+
selectedProvider: output.selectedProvider,
|
| 161 |
+
availableProviders: typedAvailableProviders,
|
| 162 |
+
reason: output.reason,
|
| 163 |
+
};
|
| 164 |
+
}
|
| 165 |
+
} catch (error) {
|
| 166 |
+
console.error('[SmartRouter] AI selection failed, using fallback:', error);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// 6. Fallback: Use keyword matching if AI fails
|
| 170 |
+
const prompt = input.prompt.toLowerCase();
|
| 171 |
+
|
| 172 |
+
// Check for photorealistic keywords -> OpenAI
|
| 173 |
+
const photorealisticKeywords = ['photo', 'realistic', 'professional', 'headshot', 'product', 'portrait'];
|
| 174 |
+
if (typedAvailableProviders.includes('openai') &&
|
| 175 |
+
photorealisticKeywords.some(kw => prompt.includes(kw))) {
|
| 176 |
+
return {
|
| 177 |
+
selectedProvider: 'openai',
|
| 178 |
+
availableProviders: typedAvailableProviders,
|
| 179 |
+
reason: 'Detected photorealistic content in prompt, using OpenAI DALL-E.',
|
| 180 |
+
};
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
// Check for artistic keywords -> Qwen
|
| 184 |
+
const artisticKeywords = ['artistic', 'fantasy', 'abstract', 'stylized', 'watercolor', 'painting', 'illustration', 'anime', 'digital art'];
|
| 185 |
+
if (typedAvailableProviders.includes('qwen') &&
|
| 186 |
+
artisticKeywords.some(kw => prompt.includes(kw))) {
|
| 187 |
+
return {
|
| 188 |
+
selectedProvider: 'qwen',
|
| 189 |
+
availableProviders: typedAvailableProviders,
|
| 190 |
+
reason: 'Detected artistic content in prompt, using Qwen Image Plus.',
|
| 191 |
+
};
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
// Default to Google for general purpose
|
| 195 |
+
if (typedAvailableProviders.includes('google')) {
|
| 196 |
+
return {
|
| 197 |
+
selectedProvider: 'google',
|
| 198 |
+
availableProviders: typedAvailableProviders,
|
| 199 |
+
reason: 'Using Google Gemini for general purpose image generation.',
|
| 200 |
+
};
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
// Final fallback - use first available
|
| 204 |
+
return {
|
| 205 |
+
selectedProvider: typedAvailableProviders[0],
|
| 206 |
+
availableProviders: typedAvailableProviders,
|
| 207 |
+
reason: `Using ${PROVIDER_CONFIGS[typedAvailableProviders[0]].displayName} as default.`,
|
| 208 |
+
};
|
| 209 |
+
}
|
src/ai/flows/enhance-user-prompt.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use server';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @fileOverview This file defines a Genkit flow for enhancing user-provided prompts using the Gemini AI model.
|
| 5 |
+
*
|
| 6 |
+
* The flow takes a user prompt as input and returns an enhanced version of the prompt.
|
| 7 |
+
* It exports:
|
| 8 |
+
* - enhanceUserPrompt: The main function to call to enhance a prompt.
|
| 9 |
+
* - EnhanceUserPromptInput: The input type for the enhanceUserPrompt function.
|
| 10 |
+
* - EnhanceUserPromptOutput: The output type for the enhanceUserPrompt function.
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import {ai} from '@/ai/genkit';
|
| 14 |
+
import {z} from 'genkit';
|
| 15 |
+
|
| 16 |
+
const EnhanceUserPromptInputSchema = z.object({
|
| 17 |
+
prompt: z.string().describe('The user-provided prompt to enhance.'),
|
| 18 |
+
});
|
| 19 |
+
export type EnhanceUserPromptInput = z.infer<typeof EnhanceUserPromptInputSchema>;
|
| 20 |
+
|
| 21 |
+
const EnhanceUserPromptOutputSchema = z.object({
|
| 22 |
+
enhancedPrompt: z.string().describe('The AI-enhanced version of the prompt.'),
|
| 23 |
+
});
|
| 24 |
+
export type EnhanceUserPromptOutput = z.infer<typeof EnhanceUserPromptOutputSchema>;
|
| 25 |
+
|
| 26 |
+
export async function enhanceUserPrompt(input: EnhanceUserPromptInput): Promise<EnhanceUserPromptOutput> {
|
| 27 |
+
return enhanceUserPromptFlow(input);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const enhanceUserPromptPrompt = ai.definePrompt({
|
| 31 |
+
name: 'enhanceUserPromptPrompt',
|
| 32 |
+
input: {schema: EnhanceUserPromptInputSchema},
|
| 33 |
+
output: {schema: EnhanceUserPromptOutputSchema},
|
| 34 |
+
prompt: `You are an AI prompt enhancer. You will take the user's prompt and enhance it to be more descriptive and specific, in order to get better results from image generation models.
|
| 35 |
+
|
| 36 |
+
User Prompt: {{{prompt}}}`,
|
| 37 |
+
model: 'googleai/gemini-2.5-pro',
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
const enhanceUserPromptFlow = ai.defineFlow(
|
| 41 |
+
{
|
| 42 |
+
name: 'enhanceUserPromptFlow',
|
| 43 |
+
inputSchema: EnhanceUserPromptInputSchema,
|
| 44 |
+
outputSchema: EnhanceUserPromptOutputSchema,
|
| 45 |
+
},
|
| 46 |
+
async input => {
|
| 47 |
+
const {output} = await enhanceUserPromptPrompt(input);
|
| 48 |
+
return output!;
|
| 49 |
+
}
|
| 50 |
+
);
|
src/ai/flows/generate-image-google.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use server';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @fileOverview Google Gemini image generation flow.
|
| 5 |
+
* Uses the Genkit AI framework with Google's Gemini model for image generation.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { ai } from '@/ai/genkit';
|
| 9 |
+
import { z } from 'genkit';
|
| 10 |
+
|
| 11 |
+
const GenerateImageGoogleInputSchema = z.object({
|
| 12 |
+
prompt: z.string().describe('The text prompt to generate an image from.'),
|
| 13 |
+
});
|
| 14 |
+
export type GenerateImageGoogleInput = z.infer<typeof GenerateImageGoogleInputSchema>;
|
| 15 |
+
|
| 16 |
+
const GenerateImageGoogleOutputSchema = z.object({
|
| 17 |
+
url: z.string().describe('The data URI of the generated image.'),
|
| 18 |
+
prompt: z.string().describe('The prompt used to generate the image.'),
|
| 19 |
+
provider: z.literal('google').describe('The provider used for generation.'),
|
| 20 |
+
});
|
| 21 |
+
export type GenerateImageGoogleOutput = z.infer<typeof GenerateImageGoogleOutputSchema>;
|
| 22 |
+
|
| 23 |
+
export async function generateImageGoogle(input: GenerateImageGoogleInput): Promise<GenerateImageGoogleOutput> {
|
| 24 |
+
return generateImageGoogleFlow(input);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const generateImageGoogleFlow = ai.defineFlow(
|
| 28 |
+
{
|
| 29 |
+
name: 'generateImageGoogleFlow',
|
| 30 |
+
inputSchema: GenerateImageGoogleInputSchema,
|
| 31 |
+
outputSchema: GenerateImageGoogleOutputSchema,
|
| 32 |
+
},
|
| 33 |
+
async (input) => {
|
| 34 |
+
const startTime = Date.now();
|
| 35 |
+
|
| 36 |
+
const { media } = await ai.generate({
|
| 37 |
+
model: 'googleai/gemini-2.0-flash-exp',
|
| 38 |
+
prompt: input.prompt,
|
| 39 |
+
config: {
|
| 40 |
+
responseModalities: ['IMAGE', 'TEXT'],
|
| 41 |
+
},
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
const endTime = Date.now();
|
| 45 |
+
console.log(`[Google] Image generation took ${endTime - startTime}ms`);
|
| 46 |
+
|
| 47 |
+
if (!media?.url) {
|
| 48 |
+
throw new Error('Google image generation failed - no image returned');
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
return {
|
| 52 |
+
url: media.url,
|
| 53 |
+
prompt: input.prompt,
|
| 54 |
+
provider: 'google' as const,
|
| 55 |
+
};
|
| 56 |
+
}
|
| 57 |
+
);
|
src/ai/flows/generate-image-openai.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use server';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @fileOverview OpenAI DALL-E image generation flow.
|
| 5 |
+
* Uses OpenAI's API for photorealistic image generation.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { z } from 'zod';
|
| 9 |
+
|
| 10 |
+
const GenerateImageOpenAIInputSchema = z.object({
|
| 11 |
+
prompt: z.string().describe('The text prompt to generate an image from.'),
|
| 12 |
+
apiKey: z.string().optional().describe('Optional API key provided by user.'),
|
| 13 |
+
});
|
| 14 |
+
export type GenerateImageOpenAIInput = z.infer<typeof GenerateImageOpenAIInputSchema>;
|
| 15 |
+
|
| 16 |
+
const GenerateImageOpenAIOutputSchema = z.object({
|
| 17 |
+
url: z.string().describe('The URL or data URI of the generated image.'),
|
| 18 |
+
prompt: z.string().describe('The prompt used to generate the image.'),
|
| 19 |
+
provider: z.literal('openai').describe('The provider used for generation.'),
|
| 20 |
+
});
|
| 21 |
+
export type GenerateImageOpenAIOutput = z.infer<typeof GenerateImageOpenAIOutputSchema>;
|
| 22 |
+
|
| 23 |
+
export async function generateImageOpenAI(input: GenerateImageOpenAIInput): Promise<GenerateImageOpenAIOutput> {
|
| 24 |
+
// Use provided API key or fall back to environment variable
|
| 25 |
+
const apiKey = input.apiKey || process.env.OPENAI_API_KEY;
|
| 26 |
+
|
| 27 |
+
if (!apiKey) {
|
| 28 |
+
throw new Error('OpenAI API key is not configured. Please add your API key in Settings.');
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const startTime = Date.now();
|
| 32 |
+
|
| 33 |
+
try {
|
| 34 |
+
const response = await fetch('https://api.openai.com/v1/images/generations', {
|
| 35 |
+
method: 'POST',
|
| 36 |
+
headers: {
|
| 37 |
+
'Content-Type': 'application/json',
|
| 38 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 39 |
+
},
|
| 40 |
+
body: JSON.stringify({
|
| 41 |
+
model: 'dall-e-3',
|
| 42 |
+
prompt: input.prompt,
|
| 43 |
+
n: 1,
|
| 44 |
+
size: '1024x1024',
|
| 45 |
+
response_format: 'b64_json',
|
| 46 |
+
}),
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
if (!response.ok) {
|
| 50 |
+
const error = await response.json().catch(() => ({}));
|
| 51 |
+
throw new Error(`OpenAI API error: ${error.error?.message || response.statusText}`);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const data = await response.json();
|
| 55 |
+
const endTime = Date.now();
|
| 56 |
+
console.log(`[OpenAI] Image generation took ${endTime - startTime}ms`);
|
| 57 |
+
|
| 58 |
+
if (!data.data?.[0]?.b64_json) {
|
| 59 |
+
throw new Error('OpenAI image generation failed - no image returned');
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// Convert base64 to data URI
|
| 63 |
+
const base64Image = data.data[0].b64_json;
|
| 64 |
+
const dataUri = `data:image/png;base64,${base64Image}`;
|
| 65 |
+
|
| 66 |
+
return {
|
| 67 |
+
url: dataUri,
|
| 68 |
+
prompt: input.prompt,
|
| 69 |
+
provider: 'openai' as const,
|
| 70 |
+
};
|
| 71 |
+
} catch (error) {
|
| 72 |
+
console.error('[OpenAI] Generation error:', error);
|
| 73 |
+
throw error;
|
| 74 |
+
}
|
| 75 |
+
}
|
src/ai/flows/generate-image-qwen.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use server';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @fileOverview Qwen Image Plus image generation flow.
|
| 5 |
+
* Uses Alibaba DashScope's OpenAI-compatible API for artistic image generation.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { z } from 'zod';
|
| 9 |
+
|
| 10 |
+
const GenerateImageQwenInputSchema = z.object({
|
| 11 |
+
prompt: z.string().describe('The text prompt to generate an image from.'),
|
| 12 |
+
apiKey: z.string().optional().describe('Optional API key provided by user.'),
|
| 13 |
+
});
|
| 14 |
+
export type GenerateImageQwenInput = z.infer<typeof GenerateImageQwenInputSchema>;
|
| 15 |
+
|
| 16 |
+
const GenerateImageQwenOutputSchema = z.object({
|
| 17 |
+
url: z.string().describe('The data URI of the generated image.'),
|
| 18 |
+
prompt: z.string().describe('The prompt used to generate the image.'),
|
| 19 |
+
provider: z.literal('qwen').describe('The provider used for generation.'),
|
| 20 |
+
});
|
| 21 |
+
export type GenerateImageQwenOutput = z.infer<typeof GenerateImageQwenOutputSchema>;
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* Qwen Image Plus image generation using DashScope's OpenAI-compatible API.
|
| 25 |
+
*/
|
| 26 |
+
export async function generateImageQwen(input: GenerateImageQwenInput): Promise<GenerateImageQwenOutput> {
|
| 27 |
+
// Use provided API key or fall back to environment variable
|
| 28 |
+
const apiKey = input.apiKey || process.env.DASHSCOPE_API_KEY;
|
| 29 |
+
|
| 30 |
+
if (!apiKey) {
|
| 31 |
+
throw new Error('DashScope API key is not configured. Please add your API key in Settings.');
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
const startTime = Date.now();
|
| 35 |
+
|
| 36 |
+
try {
|
| 37 |
+
const response = await fetch('https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions', {
|
| 38 |
+
method: 'POST',
|
| 39 |
+
headers: {
|
| 40 |
+
'Content-Type': 'application/json',
|
| 41 |
+
'Authorization': `Bearer ${apiKey}`,
|
| 42 |
+
},
|
| 43 |
+
body: JSON.stringify({
|
| 44 |
+
model: 'qwen-image-plus',
|
| 45 |
+
messages: [
|
| 46 |
+
{
|
| 47 |
+
role: 'user',
|
| 48 |
+
content: `Generate an image: ${input.prompt}`,
|
| 49 |
+
},
|
| 50 |
+
],
|
| 51 |
+
}),
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
if (!response.ok) {
|
| 55 |
+
const error = await response.json().catch(() => ({}));
|
| 56 |
+
throw new Error(`Qwen API error: ${error.error?.message || response.statusText}`);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const data = await response.json();
|
| 60 |
+
const endTime = Date.now();
|
| 61 |
+
console.log(`[Qwen] Image generation took ${endTime - startTime}ms`);
|
| 62 |
+
|
| 63 |
+
// Extract image from the response
|
| 64 |
+
// Qwen returns the image in the message content
|
| 65 |
+
const content = data.choices?.[0]?.message?.content;
|
| 66 |
+
|
| 67 |
+
if (!content) {
|
| 68 |
+
throw new Error('Qwen image generation failed - no content returned');
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// Check if response contains an image URL or base64 data
|
| 72 |
+
let imageUrl: string;
|
| 73 |
+
|
| 74 |
+
// Try to extract image URL from markdown format  or direct URL
|
| 75 |
+
const urlMatch = content.match(/!\[.*?\]\((.*?)\)|https?:\/\/[^\s\)]+\.(png|jpg|jpeg|gif|webp)/i);
|
| 76 |
+
if (urlMatch) {
|
| 77 |
+
imageUrl = urlMatch[1] || urlMatch[0];
|
| 78 |
+
} else if (content.startsWith('data:image')) {
|
| 79 |
+
// Already a data URI
|
| 80 |
+
imageUrl = content;
|
| 81 |
+
} else if (content.match(/^[A-Za-z0-9+/=]+$/)) {
|
| 82 |
+
// Looks like base64
|
| 83 |
+
imageUrl = `data:image/png;base64,${content}`;
|
| 84 |
+
} else {
|
| 85 |
+
// Try to find any base64 image data in the content
|
| 86 |
+
const base64Match = content.match(/data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/);
|
| 87 |
+
if (base64Match) {
|
| 88 |
+
imageUrl = base64Match[0];
|
| 89 |
+
} else {
|
| 90 |
+
console.error('[Qwen] Unexpected response format:', content.substring(0, 200));
|
| 91 |
+
throw new Error('Qwen image generation failed - could not extract image from response');
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
return {
|
| 96 |
+
url: imageUrl,
|
| 97 |
+
prompt: input.prompt,
|
| 98 |
+
provider: 'qwen' as const,
|
| 99 |
+
};
|
| 100 |
+
} catch (error) {
|
| 101 |
+
console.error('[Qwen] Generation error:', error);
|
| 102 |
+
throw error;
|
| 103 |
+
}
|
| 104 |
+
}
|
src/ai/flows/generate-image.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use server';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @fileOverview Unified image generation flow that routes to the appropriate provider.
|
| 5 |
+
*
|
| 6 |
+
* This is the main entry point for image generation. It accepts a provider parameter
|
| 7 |
+
* and routes the request to the appropriate provider-specific flow.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import { type ImageProvider } from '@/lib/api-config';
|
| 11 |
+
import { generateImageGoogle } from './generate-image-google';
|
| 12 |
+
import { generateImageOpenAI } from './generate-image-openai';
|
| 13 |
+
import { generateImageQwen } from './generate-image-qwen';
|
| 14 |
+
|
| 15 |
+
export interface ApiKeysInput {
|
| 16 |
+
openai?: string;
|
| 17 |
+
google?: string;
|
| 18 |
+
qwen?: string;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export interface GenerateImageInput {
|
| 22 |
+
prompt: string;
|
| 23 |
+
provider?: ImageProvider;
|
| 24 |
+
apiKeys?: ApiKeysInput;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export interface GenerateImageOutput {
|
| 28 |
+
url: string;
|
| 29 |
+
prompt: string;
|
| 30 |
+
provider: ImageProvider;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Generate an image using the specified provider.
|
| 35 |
+
* Defaults to 'google' if no provider is specified.
|
| 36 |
+
*/
|
| 37 |
+
export async function generateImage(input: GenerateImageInput): Promise<GenerateImageOutput> {
|
| 38 |
+
const provider = input.provider || 'google';
|
| 39 |
+
|
| 40 |
+
console.log(`[GenerateImage] Using provider: ${provider}`);
|
| 41 |
+
|
| 42 |
+
switch (provider) {
|
| 43 |
+
case 'openai':
|
| 44 |
+
return generateImageOpenAI({
|
| 45 |
+
prompt: input.prompt,
|
| 46 |
+
apiKey: input.apiKeys?.openai,
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
case 'qwen':
|
| 50 |
+
return generateImageQwen({
|
| 51 |
+
prompt: input.prompt,
|
| 52 |
+
apiKey: input.apiKeys?.qwen,
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
case 'google':
|
| 56 |
+
default:
|
| 57 |
+
// Google uses Genkit which reads from process.env directly
|
| 58 |
+
// Client-provided keys for Google would require a different approach
|
| 59 |
+
return generateImageGoogle({ prompt: input.prompt });
|
| 60 |
+
}
|
| 61 |
+
}
|
src/ai/genkit.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {genkit} from 'genkit';
|
| 2 |
+
import {googleAI} from '@genkit-ai/google-genai';
|
| 3 |
+
|
| 4 |
+
export const ai = genkit({
|
| 5 |
+
plugins: [googleAI()],
|
| 6 |
+
model: 'googleai/gemini-2.5-flash',
|
| 7 |
+
});
|
src/app/favicon.ico
ADDED
|
|
src/app/globals.css
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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% 94.1%;
|
| 12 |
+
--foreground: 240 10% 3.9%;
|
| 13 |
+
--card: 0 0% 100%;
|
| 14 |
+
--card-foreground: 240 10% 3.9%;
|
| 15 |
+
--popover: 0 0% 100%;
|
| 16 |
+
--popover-foreground: 240 10% 3.9%;
|
| 17 |
+
--primary: 275 100% 25.5%;
|
| 18 |
+
--primary-foreground: 0 0% 98%;
|
| 19 |
+
--secondary: 0 0% 96.1%;
|
| 20 |
+
--secondary-foreground: 240 10% 3.9%;
|
| 21 |
+
--muted: 0 0% 96.1%;
|
| 22 |
+
--muted-foreground: 240 3.8% 46.1%;
|
| 23 |
+
--accent: 274 100% 50%;
|
| 24 |
+
--accent-foreground: 0 0% 98%;
|
| 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: 275 100% 25.5%;
|
| 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 |
+
}
|
| 37 |
+
.dark {
|
| 38 |
+
--background: 240 6% 10%;
|
| 39 |
+
--foreground: 0 0% 98%;
|
| 40 |
+
--card: 240 6% 12%;
|
| 41 |
+
--card-foreground: 0 0% 98%;
|
| 42 |
+
--popover: 240 6% 10%;
|
| 43 |
+
--popover-foreground: 0 0% 98%;
|
| 44 |
+
--primary: 275 100% 70%;
|
| 45 |
+
--primary-foreground: 275 100% 15%;
|
| 46 |
+
--secondary: 0 0% 14.9%;
|
| 47 |
+
--secondary-foreground: 0 0% 98%;
|
| 48 |
+
--muted: 0 0% 14.9%;
|
| 49 |
+
--muted-foreground: 0 0% 63.9%;
|
| 50 |
+
--accent: 274 100% 65%;
|
| 51 |
+
--accent-foreground: 0 0% 98%;
|
| 52 |
+
--destructive: 0 62.8% 30.6%;
|
| 53 |
+
--destructive-foreground: 0 0% 98%;
|
| 54 |
+
--border: 0 0% 14.9%;
|
| 55 |
+
--input: 0 0% 14.9%;
|
| 56 |
+
--ring: 274 100% 65%;
|
| 57 |
+
--chart-1: 220 70% 50%;
|
| 58 |
+
--chart-2: 160 60% 45%;
|
| 59 |
+
--chart-3: 30 80% 55%;
|
| 60 |
+
--chart-4: 280 65% 60%;
|
| 61 |
+
--chart-5: 340 75% 55%;
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
@layer base {
|
| 66 |
+
* {
|
| 67 |
+
@apply border-border;
|
| 68 |
+
}
|
| 69 |
+
body {
|
| 70 |
+
@apply bg-background text-foreground;
|
| 71 |
+
}
|
| 72 |
+
}
|
src/app/layout.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Inter } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
import { ThemeProvider } from "@/providers/theme-provider";
|
| 5 |
+
import { ApiKeysProvider } from "@/contexts/api-keys-context";
|
| 6 |
+
import { Toaster } from "@/components/ui/toaster";
|
| 7 |
+
|
| 8 |
+
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
| 9 |
+
|
| 10 |
+
export const metadata: Metadata = {
|
| 11 |
+
title: "ImageForge AI",
|
| 12 |
+
description: "Generate amazing images with the power of multiple AI models.",
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
export default function RootLayout({
|
| 16 |
+
children,
|
| 17 |
+
}: Readonly<{
|
| 18 |
+
children: React.ReactNode;
|
| 19 |
+
}>) {
|
| 20 |
+
return (
|
| 21 |
+
<html lang="en" suppressHydrationWarning>
|
| 22 |
+
<head>
|
| 23 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 24 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
| 25 |
+
<link
|
| 26 |
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
|
| 27 |
+
rel="stylesheet"
|
| 28 |
+
/>
|
| 29 |
+
</head>
|
| 30 |
+
<body className={`${inter.variable} font-body antialiased`}>
|
| 31 |
+
<ThemeProvider
|
| 32 |
+
attribute="class"
|
| 33 |
+
defaultTheme="system"
|
| 34 |
+
enableSystem
|
| 35 |
+
disableTransitionOnChange
|
| 36 |
+
>
|
| 37 |
+
<ApiKeysProvider>
|
| 38 |
+
{children}
|
| 39 |
+
</ApiKeysProvider>
|
| 40 |
+
<Toaster />
|
| 41 |
+
</ThemeProvider>
|
| 42 |
+
</body>
|
| 43 |
+
</html>
|
| 44 |
+
);
|
| 45 |
+
}
|
src/app/page.tsx
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import * as React from 'react';
|
| 4 |
+
import { AppHeader } from '@/components/header';
|
| 5 |
+
import { HistorySidebar } from '@/components/history-sidebar';
|
| 6 |
+
import { ImageGallery, type GeneratedImage } from '@/components/image-gallery';
|
| 7 |
+
import { PromptForm } from '@/components/prompt-form';
|
| 8 |
+
import {
|
| 9 |
+
SidebarInset,
|
| 10 |
+
SidebarProvider,
|
| 11 |
+
} from '@/components/ui/sidebar';
|
| 12 |
+
import { useToast } from '@/hooks/use-toast';
|
| 13 |
+
import { generateImage } from '@/ai/flows/generate-image';
|
| 14 |
+
import { enhanceUserPrompt } from '@/ai/flows/enhance-user-prompt';
|
| 15 |
+
import { smartSelectProvider } from '@/ai/flows/auto-select-image-model';
|
| 16 |
+
import { PROVIDER_CONFIGS, type ImageProvider } from '@/lib/api-config';
|
| 17 |
+
import { useApiKeys } from '@/contexts/api-keys-context';
|
| 18 |
+
|
| 19 |
+
export default function DashboardPage() {
|
| 20 |
+
const { toast } = useToast();
|
| 21 |
+
const { apiKeys, setIsOpen: setSettingsOpen } = useApiKeys();
|
| 22 |
+
const [isGenerating, setIsGenerating] = React.useState(false);
|
| 23 |
+
const [numImagesToGenerate, setNumImagesToGenerate] = React.useState(1);
|
| 24 |
+
const [generatedImages, setGeneratedImages] = React.useState<
|
| 25 |
+
GeneratedImage[]
|
| 26 |
+
>([]);
|
| 27 |
+
const [history, setHistory] = React.useState<GeneratedImage[]>([]);
|
| 28 |
+
|
| 29 |
+
const handleGenerate = async (values: any) => {
|
| 30 |
+
setIsGenerating(true);
|
| 31 |
+
setGeneratedImages([]);
|
| 32 |
+
setNumImagesToGenerate(values.numImages);
|
| 33 |
+
const startTime = Date.now();
|
| 34 |
+
|
| 35 |
+
try {
|
| 36 |
+
// 1. Smart route to select the best provider
|
| 37 |
+
const routerResult = await smartSelectProvider({
|
| 38 |
+
prompt: values.prompt,
|
| 39 |
+
smartRoutingEnabled: values.smartRouting,
|
| 40 |
+
preferredProvider: values.smartRouting ? undefined : values.model,
|
| 41 |
+
clientApiKeys: {
|
| 42 |
+
openai: apiKeys.openai || undefined,
|
| 43 |
+
google: apiKeys.google || undefined,
|
| 44 |
+
qwen: apiKeys.qwen || undefined,
|
| 45 |
+
},
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
console.log('[Page] Router result:', routerResult);
|
| 49 |
+
|
| 50 |
+
// 2. Check if any providers are available
|
| 51 |
+
if (routerResult.availableProviders.length === 0) {
|
| 52 |
+
toast({
|
| 53 |
+
variant: 'destructive',
|
| 54 |
+
title: 'No API Keys Available',
|
| 55 |
+
description: 'Please configure at least one API key in Settings to generate images.',
|
| 56 |
+
action: (
|
| 57 |
+
<button
|
| 58 |
+
onClick={() => setSettingsOpen(true)}
|
| 59 |
+
className="rounded bg-primary px-3 py-1 text-sm text-primary-foreground hover:bg-primary/90"
|
| 60 |
+
>
|
| 61 |
+
Open Settings
|
| 62 |
+
</button>
|
| 63 |
+
),
|
| 64 |
+
});
|
| 65 |
+
setIsGenerating(false);
|
| 66 |
+
return;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// 3. Show which provider is being used
|
| 70 |
+
const providerName = PROVIDER_CONFIGS[routerResult.selectedProvider].displayName;
|
| 71 |
+
toast({
|
| 72 |
+
title: `Using ${providerName}`,
|
| 73 |
+
description: routerResult.reason,
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
// 4. Enhance prompt
|
| 77 |
+
let generationPrompt = values.prompt;
|
| 78 |
+
try {
|
| 79 |
+
const enhanced = await enhanceUserPrompt({ prompt: values.prompt });
|
| 80 |
+
generationPrompt = enhanced.enhancedPrompt;
|
| 81 |
+
} catch (enhanceError) {
|
| 82 |
+
console.warn('[Page] Prompt enhancement failed, using original prompt:', enhanceError);
|
| 83 |
+
// Continue with original prompt if enhancement fails
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// 5. Generate images with selected provider, passing client API keys
|
| 87 |
+
const selectedProvider = routerResult.selectedProvider;
|
| 88 |
+
|
| 89 |
+
const generationPromises = Array.from(
|
| 90 |
+
{ length: values.numImages },
|
| 91 |
+
() => generateImage({
|
| 92 |
+
prompt: generationPrompt,
|
| 93 |
+
provider: selectedProvider,
|
| 94 |
+
apiKeys: {
|
| 95 |
+
openai: apiKeys.openai || undefined,
|
| 96 |
+
google: apiKeys.google || undefined,
|
| 97 |
+
qwen: apiKeys.qwen || undefined,
|
| 98 |
+
},
|
| 99 |
+
})
|
| 100 |
+
);
|
| 101 |
+
|
| 102 |
+
const results = await Promise.all(generationPromises);
|
| 103 |
+
|
| 104 |
+
const newImages: GeneratedImage[] = results.map((result, i) => ({
|
| 105 |
+
id: `gen_${Date.now()}_${i}`,
|
| 106 |
+
url: result.url,
|
| 107 |
+
prompt: generationPrompt,
|
| 108 |
+
model: PROVIDER_CONFIGS[result.provider].displayName,
|
| 109 |
+
createdAt: new Date().toISOString(),
|
| 110 |
+
generationTime: (Date.now() - startTime) / 1000,
|
| 111 |
+
isFavorite: false,
|
| 112 |
+
}));
|
| 113 |
+
|
| 114 |
+
setGeneratedImages(newImages);
|
| 115 |
+
setHistory(prev => [...newImages, ...prev]);
|
| 116 |
+
|
| 117 |
+
toast({
|
| 118 |
+
title: 'Generation Complete!',
|
| 119 |
+
description: `${values.numImages} image(s) generated using ${providerName}.`,
|
| 120 |
+
});
|
| 121 |
+
} catch (error) {
|
| 122 |
+
console.error('[Page] Generation error:', error);
|
| 123 |
+
toast({
|
| 124 |
+
variant: 'destructive',
|
| 125 |
+
title: 'Generation Failed',
|
| 126 |
+
description:
|
| 127 |
+
error instanceof Error
|
| 128 |
+
? error.message
|
| 129 |
+
: 'An error occurred while generating images. Please try again.',
|
| 130 |
+
});
|
| 131 |
+
} finally {
|
| 132 |
+
setIsGenerating(false);
|
| 133 |
+
}
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
return (
|
| 137 |
+
<SidebarProvider>
|
| 138 |
+
<div className="flex min-h-screen">
|
| 139 |
+
<HistorySidebar history={history} />
|
| 140 |
+
<SidebarInset className="flex-1">
|
| 141 |
+
<AppHeader />
|
| 142 |
+
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-6">
|
| 143 |
+
<div className="grid h-full items-start gap-4 lg:grid-cols-5">
|
| 144 |
+
<div className="flex h-full flex-col gap-4 lg:col-span-2">
|
| 145 |
+
<PromptForm
|
| 146 |
+
onSubmit={handleGenerate}
|
| 147 |
+
isGenerating={isGenerating}
|
| 148 |
+
/>
|
| 149 |
+
</div>
|
| 150 |
+
<div className="h-full lg:col-span-3">
|
| 151 |
+
<ImageGallery
|
| 152 |
+
images={generatedImages}
|
| 153 |
+
isGenerating={isGenerating}
|
| 154 |
+
numImages={
|
| 155 |
+
isGenerating && generatedImages.length === 0
|
| 156 |
+
? numImagesToGenerate
|
| 157 |
+
: generatedImages.length
|
| 158 |
+
}
|
| 159 |
+
/>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
</main>
|
| 163 |
+
</SidebarInset>
|
| 164 |
+
</div>
|
| 165 |
+
</SidebarProvider>
|
| 166 |
+
);
|
| 167 |
+
}
|
src/components/header.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { SidebarTrigger } from "@/components/ui/sidebar";
|
| 2 |
+
import { ThemeToggle } from "./theme-toggle";
|
| 3 |
+
import { UserNav } from "./user-nav";
|
| 4 |
+
import { Icons } from "./icons";
|
| 5 |
+
import { SettingsPanel } from "./settings-panel";
|
| 6 |
+
|
| 7 |
+
export function AppHeader() {
|
| 8 |
+
return (
|
| 9 |
+
<header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b bg-background px-4 sm:static sm:h-auto sm:border-0 sm:bg-transparent sm:px-6">
|
| 10 |
+
<SidebarTrigger className="md:hidden" />
|
| 11 |
+
<div className="flex items-center gap-2">
|
| 12 |
+
<Icons.logo className="h-6 w-6 text-primary" />
|
| 13 |
+
<h1 className="text-xl font-bold tracking-tight">ImageForge AI</h1>
|
| 14 |
+
</div>
|
| 15 |
+
<div className="ml-auto flex items-center gap-2">
|
| 16 |
+
<SettingsPanel />
|
| 17 |
+
<ThemeToggle />
|
| 18 |
+
<UserNav />
|
| 19 |
+
</div>
|
| 20 |
+
</header>
|
| 21 |
+
);
|
| 22 |
+
}
|
src/components/history-sidebar.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { History } from "lucide-react"
|
| 2 |
+
import Image from "next/image"
|
| 3 |
+
import {
|
| 4 |
+
Sidebar,
|
| 5 |
+
SidebarContent,
|
| 6 |
+
SidebarHeader,
|
| 7 |
+
SidebarMenu,
|
| 8 |
+
SidebarMenuItem,
|
| 9 |
+
SidebarMenuButton,
|
| 10 |
+
SidebarFooter,
|
| 11 |
+
SidebarSeparator,
|
| 12 |
+
} from "@/components/ui/sidebar"
|
| 13 |
+
import { Button } from "./ui/button"
|
| 14 |
+
import { GeneratedImage } from "./image-gallery"
|
| 15 |
+
|
| 16 |
+
interface HistorySidebarProps {
|
| 17 |
+
history: GeneratedImage[];
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function HistorySidebar({ history }: HistorySidebarProps) {
|
| 21 |
+
return (
|
| 22 |
+
<Sidebar>
|
| 23 |
+
<SidebarHeader>
|
| 24 |
+
<div className="flex items-center gap-2">
|
| 25 |
+
<History className="h-5 w-5 text-muted-foreground" />
|
| 26 |
+
<h2 className="text-lg font-semibold">History</h2>
|
| 27 |
+
</div>
|
| 28 |
+
</SidebarHeader>
|
| 29 |
+
<SidebarSeparator />
|
| 30 |
+
<SidebarContent>
|
| 31 |
+
<SidebarMenu>
|
| 32 |
+
{history.map((item) => (
|
| 33 |
+
<SidebarMenuItem key={item.id}>
|
| 34 |
+
<SidebarMenuButton size="lg" className="h-auto justify-start p-2">
|
| 35 |
+
<Image
|
| 36 |
+
src={item.url}
|
| 37 |
+
alt={item.prompt}
|
| 38 |
+
width={48}
|
| 39 |
+
height={48}
|
| 40 |
+
className="rounded-md object-cover"
|
| 41 |
+
data-ai-hint={item.data_ai_hint}
|
| 42 |
+
/>
|
| 43 |
+
<div className="flex flex-col items-start text-left overflow-hidden">
|
| 44 |
+
<span className="truncate text-sm font-medium leading-tight w-full">
|
| 45 |
+
{item.prompt}
|
| 46 |
+
</span>
|
| 47 |
+
<span className="text-xs text-muted-foreground">
|
| 48 |
+
{item.model}
|
| 49 |
+
</span>
|
| 50 |
+
</div>
|
| 51 |
+
</SidebarMenuButton>
|
| 52 |
+
</SidebarMenuItem>
|
| 53 |
+
))}
|
| 54 |
+
</SidebarMenu>
|
| 55 |
+
</SidebarContent>
|
| 56 |
+
<SidebarFooter>
|
| 57 |
+
<Button variant="outline" className="w-full">
|
| 58 |
+
Clear History
|
| 59 |
+
</Button>
|
| 60 |
+
</SidebarFooter>
|
| 61 |
+
</Sidebar>
|
| 62 |
+
)
|
| 63 |
+
}
|
src/components/icons.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type LucideProps } from "lucide-react";
|
| 2 |
+
|
| 3 |
+
export const Icons = {
|
| 4 |
+
logo: (props: LucideProps) => (
|
| 5 |
+
<svg
|
| 6 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 7 |
+
width="24"
|
| 8 |
+
height="24"
|
| 9 |
+
viewBox="0 0 24 24"
|
| 10 |
+
fill="none"
|
| 11 |
+
stroke="currentColor"
|
| 12 |
+
strokeWidth="2"
|
| 13 |
+
strokeLinecap="round"
|
| 14 |
+
strokeLinejoin="round"
|
| 15 |
+
{...props}
|
| 16 |
+
>
|
| 17 |
+
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
| 18 |
+
</svg>
|
| 19 |
+
),
|
| 20 |
+
openai: (props: LucideProps) => (
|
| 21 |
+
<svg
|
| 22 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 23 |
+
viewBox="0 0 24 24"
|
| 24 |
+
fill="currentColor"
|
| 25 |
+
{...props}
|
| 26 |
+
>
|
| 27 |
+
<path d="M22.28 10.41a2.35 2.35 0 00-1.2-2.32l-6.2-3.48a2.35 2.35 0 00-2.38 0l-6.2 3.48a2.35 2.35 0 00-1.2 2.32v6.8a2.35 2.35 0 001.2 2.32l6.2 3.48a2.35 2.35 0 002.38 0l6.2-3.48a2.35 2.35 0 001.2-2.32v-6.8zM9.91 9.22a.94.94 0 01-1.33 1.33l-2-2a.94.94 0 111.33-1.33zM9.91 16.11a.94.94 0 11-1.33 1.33l-2-2a.94.94 0 111.33-1.33zM14.09 9.22a.94.94 0 11-1.33 1.33l-2-2a.94.94 0 111.33-1.33zM12.76 17.44a.94.94 0 11-1.33-1.33l2-2a.94.94 0 111.33 1.33zM16.5 12.75a.94.94 0 010-1.88h2a.94.94 0 010 1.88z" />
|
| 28 |
+
</svg>
|
| 29 |
+
),
|
| 30 |
+
qwen: (props: LucideProps) => (
|
| 31 |
+
<svg
|
| 32 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 33 |
+
viewBox="0 0 24 24"
|
| 34 |
+
fill="currentColor"
|
| 35 |
+
{...props}
|
| 36 |
+
>
|
| 37 |
+
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
|
| 38 |
+
</svg>
|
| 39 |
+
),
|
| 40 |
+
google: (props: LucideProps) => (
|
| 41 |
+
<svg
|
| 42 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 43 |
+
viewBox="0 0 24 24"
|
| 44 |
+
fill="currentColor"
|
| 45 |
+
{...props}
|
| 46 |
+
>
|
| 47 |
+
<path d="M21.35 12.14a9.14 9.14 0 00-.14-1.64H12v3.29h5.27a4.52 4.52 0 01-1.94 2.86v2.21h2.84a8.81 8.81 0 002.7-6.72z" />
|
| 48 |
+
<path d="M12 22a9.41 9.41 0 006.59-2.41l-2.84-2.21a5.61 5.61 0 01-3.75 1.36 5.67 5.67 0 01-5.35-3.86H2.94v2.28A9.79 9.79 0 0012 22z" />
|
| 49 |
+
<path d="M6.65 14.86a5.64 5.64 0 010-5.72V6.86H3.81a9.92 9.92 0 000 10.28z" />
|
| 50 |
+
<path d="M12 5.16a5.27 5.27 0 013.73 1.45l2.43-2.43A9.45 9.45 0 0012 2a9.79 9.79 0 00-9.06 4.86l2.91 2.28A5.67 5.67 0 0112 5.16z" />
|
| 51 |
+
</svg>
|
| 52 |
+
),
|
| 53 |
+
};
|
src/components/image-card.tsx
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import {
|
| 4 |
+
Copy,
|
| 5 |
+
Download,
|
| 6 |
+
Heart,
|
| 7 |
+
MoreVertical,
|
| 8 |
+
RefreshCw,
|
| 9 |
+
} from "lucide-react"
|
| 10 |
+
import Image from "next/image"
|
| 11 |
+
|
| 12 |
+
import { Button } from "@/components/ui/button"
|
| 13 |
+
import { Card, CardContent } from "@/components/ui/card"
|
| 14 |
+
import {
|
| 15 |
+
DropdownMenu,
|
| 16 |
+
DropdownMenuContent,
|
| 17 |
+
DropdownMenuItem,
|
| 18 |
+
DropdownMenuTrigger,
|
| 19 |
+
} from "@/components/ui/dropdown-menu"
|
| 20 |
+
import { type GeneratedImage } from "./image-gallery"
|
| 21 |
+
import { cn } from "@/lib/utils"
|
| 22 |
+
import { useState } from "react"
|
| 23 |
+
|
| 24 |
+
interface ImageCardProps {
|
| 25 |
+
image: GeneratedImage
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export function ImageCard({ image }: ImageCardProps) {
|
| 29 |
+
const [isFavorite, setIsFavorite] = useState(image.isFavorite);
|
| 30 |
+
|
| 31 |
+
const toggleFavorite = () => {
|
| 32 |
+
setIsFavorite(!isFavorite);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<Card className="group relative overflow-hidden rounded-lg">
|
| 37 |
+
<CardContent className="p-0">
|
| 38 |
+
<Image
|
| 39 |
+
src={image.url}
|
| 40 |
+
alt={image.prompt}
|
| 41 |
+
width={512}
|
| 42 |
+
height={512}
|
| 43 |
+
className="aspect-square w-full object-cover transition-transform group-hover:scale-105"
|
| 44 |
+
data-ai-hint={image.data_ai_hint}
|
| 45 |
+
/>
|
| 46 |
+
<div className="absolute inset-0 bg-black/20 opacity-0 transition-opacity group-hover:opacity-100" />
|
| 47 |
+
<div className="absolute top-2 right-2 flex scale-90 flex-col gap-2 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100">
|
| 48 |
+
<Button
|
| 49 |
+
size="icon"
|
| 50 |
+
variant="secondary"
|
| 51 |
+
className="h-8 w-8 rounded-full"
|
| 52 |
+
onClick={toggleFavorite}
|
| 53 |
+
>
|
| 54 |
+
<Heart className={cn("h-4 w-4", isFavorite && "fill-red-500 text-red-500")} />
|
| 55 |
+
</Button>
|
| 56 |
+
<Button
|
| 57 |
+
size="icon"
|
| 58 |
+
variant="secondary"
|
| 59 |
+
className="h-8 w-8 rounded-full"
|
| 60 |
+
>
|
| 61 |
+
<Download className="h-4 w-4" />
|
| 62 |
+
</Button>
|
| 63 |
+
<DropdownMenu>
|
| 64 |
+
<DropdownMenuTrigger asChild>
|
| 65 |
+
<Button
|
| 66 |
+
size="icon"
|
| 67 |
+
variant="secondary"
|
| 68 |
+
className="h-8 w-8 rounded-full"
|
| 69 |
+
>
|
| 70 |
+
<MoreVertical className="h-4 w-4" />
|
| 71 |
+
</Button>
|
| 72 |
+
</DropdownMenuTrigger>
|
| 73 |
+
<DropdownMenuContent align="end">
|
| 74 |
+
<DropdownMenuItem>
|
| 75 |
+
<Copy className="mr-2 h-4 w-4" />
|
| 76 |
+
Copy URL
|
| 77 |
+
</DropdownMenuItem>
|
| 78 |
+
<DropdownMenuItem>
|
| 79 |
+
<RefreshCw className="mr-2 h-4 w-4" />
|
| 80 |
+
Regenerate
|
| 81 |
+
</DropdownMenuItem>
|
| 82 |
+
</DropdownMenuContent>
|
| 83 |
+
</DropdownMenu>
|
| 84 |
+
</div>
|
| 85 |
+
<div className="absolute bottom-0 left-0 w-full bg-gradient-to-t from-black/80 to-transparent p-3 text-white">
|
| 86 |
+
<p className="truncate text-xs text-gray-300">
|
| 87 |
+
{image.model} · {image.generationTime.toFixed(1)}s
|
| 88 |
+
</p>
|
| 89 |
+
<p className="truncate text-sm font-medium">{image.prompt}</p>
|
| 90 |
+
</div>
|
| 91 |
+
</CardContent>
|
| 92 |
+
</Card>
|
| 93 |
+
)
|
| 94 |
+
}
|
src/components/image-gallery.tsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ImageOff } from "lucide-react"
|
| 2 |
+
import { Card, CardContent } from "@/components/ui/card"
|
| 3 |
+
import { Skeleton } from "@/components/ui/skeleton"
|
| 4 |
+
import { ImageCard } from "./image-card"
|
| 5 |
+
|
| 6 |
+
export interface GeneratedImage {
|
| 7 |
+
id: string
|
| 8 |
+
url: string
|
| 9 |
+
prompt: string
|
| 10 |
+
model: string
|
| 11 |
+
createdAt: string
|
| 12 |
+
generationTime: number
|
| 13 |
+
isFavorite: boolean
|
| 14 |
+
data_ai_hint?: string
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
interface ImageGalleryProps {
|
| 18 |
+
images: GeneratedImage[]
|
| 19 |
+
isGenerating: boolean
|
| 20 |
+
numImages: number
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function ImageGallery({
|
| 24 |
+
images,
|
| 25 |
+
isGenerating,
|
| 26 |
+
numImages,
|
| 27 |
+
}: ImageGalleryProps) {
|
| 28 |
+
return (
|
| 29 |
+
<Card className="flex h-full flex-col">
|
| 30 |
+
<CardContent className="flex-1 p-4">
|
| 31 |
+
{isGenerating && images.length === 0 ? (
|
| 32 |
+
<div className="grid h-full grid-cols-1 gap-4 md:grid-cols-2">
|
| 33 |
+
{Array.from({ length: numImages }).map((_, i) => (
|
| 34 |
+
<Skeleton key={i} className="aspect-square rounded-lg" />
|
| 35 |
+
))}
|
| 36 |
+
</div>
|
| 37 |
+
) : images.length > 0 ? (
|
| 38 |
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
| 39 |
+
{images.map((image) => (
|
| 40 |
+
<ImageCard key={image.id} image={image} />
|
| 41 |
+
))}
|
| 42 |
+
</div>
|
| 43 |
+
) : (
|
| 44 |
+
<div className="flex h-full flex-col items-center justify-center gap-4 text-center">
|
| 45 |
+
<ImageOff className="h-16 w-16 text-muted-foreground" />
|
| 46 |
+
<h3 className="text-xl font-semibold">No Images Yet</h3>
|
| 47 |
+
<p className="text-muted-foreground">
|
| 48 |
+
Your generated images will appear here.
|
| 49 |
+
</p>
|
| 50 |
+
</div>
|
| 51 |
+
)}
|
| 52 |
+
</CardContent>
|
| 53 |
+
</Card>
|
| 54 |
+
)
|
| 55 |
+
}
|
src/components/model-card.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { CheckCircle } from "lucide-react"
|
| 4 |
+
import { Card, CardContent } from "@/components/ui/card"
|
| 5 |
+
import { cn } from "@/lib/utils"
|
| 6 |
+
import { Icons } from "./icons"
|
| 7 |
+
|
| 8 |
+
interface ModelCardProps {
|
| 9 |
+
model: "openai" | "qwen" | "googleNanoBanana"
|
| 10 |
+
useCase: string
|
| 11 |
+
isSelected: boolean
|
| 12 |
+
onClick: () => void
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function ModelCard({
|
| 16 |
+
model,
|
| 17 |
+
useCase,
|
| 18 |
+
isSelected,
|
| 19 |
+
onClick,
|
| 20 |
+
}: ModelCardProps) {
|
| 21 |
+
const modelDetails = {
|
| 22 |
+
openai: { name: "OpenAI", icon: Icons.openai },
|
| 23 |
+
qwen: { name: "Qwen", icon: Icons.qwen },
|
| 24 |
+
googleNanoBanana: { name: "Google", icon: Icons.google },
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const { name, icon: Icon } = modelDetails[model]
|
| 28 |
+
|
| 29 |
+
return (
|
| 30 |
+
<Card
|
| 31 |
+
className={cn(
|
| 32 |
+
"cursor-pointer transition-all hover:shadow-md",
|
| 33 |
+
isSelected && "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
| 34 |
+
)}
|
| 35 |
+
onClick={onClick}
|
| 36 |
+
>
|
| 37 |
+
<CardContent className="relative flex flex-col items-center justify-center gap-2 p-3">
|
| 38 |
+
{isSelected && (
|
| 39 |
+
<CheckCircle className="absolute right-1 top-1 h-4 w-4 text-primary" />
|
| 40 |
+
)}
|
| 41 |
+
<Icon className="h-6 w-6" />
|
| 42 |
+
<p className="text-sm font-medium">{name}</p>
|
| 43 |
+
<p className="text-xs text-muted-foreground">{useCase}</p>
|
| 44 |
+
</CardContent>
|
| 45 |
+
</Card>
|
| 46 |
+
)
|
| 47 |
+
}
|
src/components/prompt-form.tsx
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { zodResolver } from "@hookform/resolvers/zod"
|
| 4 |
+
import { Sparkles, Wand2 } from "lucide-react"
|
| 5 |
+
import { useForm } from "react-hook-form"
|
| 6 |
+
import * as z from "zod"
|
| 7 |
+
|
| 8 |
+
import { Button } from "@/components/ui/button"
|
| 9 |
+
import {
|
| 10 |
+
Form,
|
| 11 |
+
FormControl,
|
| 12 |
+
FormDescription,
|
| 13 |
+
FormField,
|
| 14 |
+
FormItem,
|
| 15 |
+
FormLabel,
|
| 16 |
+
FormMessage,
|
| 17 |
+
} from "@/components/ui/form"
|
| 18 |
+
import { Label } from "@/components/ui/label"
|
| 19 |
+
import {
|
| 20 |
+
Select,
|
| 21 |
+
SelectContent,
|
| 22 |
+
SelectItem,
|
| 23 |
+
SelectTrigger,
|
| 24 |
+
SelectValue,
|
| 25 |
+
} from "@/components/ui/select"
|
| 26 |
+
import { Separator } from "@/components/ui/separator"
|
| 27 |
+
import { Switch } from "@/components/ui/switch"
|
| 28 |
+
import { Textarea } from "@/components/ui/textarea"
|
| 29 |
+
import { cn } from "@/lib/utils"
|
| 30 |
+
import { ModelCard } from "./model-card"
|
| 31 |
+
|
| 32 |
+
const formSchema = z.object({
|
| 33 |
+
prompt: z.string().min(1, "Prompt cannot be empty.").max(2000),
|
| 34 |
+
smartRouting: z.boolean(),
|
| 35 |
+
model: z.enum(["openai", "qwen", "googleNanoBanana"]),
|
| 36 |
+
aspectRatio: z.enum(["1:1", "16:9", "9:16"]),
|
| 37 |
+
numImages: z.number().min(1).max(4),
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
type PromptFormValues = z.infer<typeof formSchema>
|
| 41 |
+
|
| 42 |
+
interface PromptFormProps {
|
| 43 |
+
onSubmit: (values: PromptFormValues) => void
|
| 44 |
+
isGenerating: boolean
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export function PromptForm({ onSubmit, isGenerating }: PromptFormProps) {
|
| 48 |
+
const form = useForm<PromptFormValues>({
|
| 49 |
+
resolver: zodResolver(formSchema),
|
| 50 |
+
defaultValues: {
|
| 51 |
+
prompt: "",
|
| 52 |
+
smartRouting: true,
|
| 53 |
+
model: "openai",
|
| 54 |
+
aspectRatio: "1:1",
|
| 55 |
+
numImages: 1,
|
| 56 |
+
},
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
const watchPrompt = form.watch("prompt")
|
| 60 |
+
const watchSmartRouting = form.watch("smartRouting")
|
| 61 |
+
|
| 62 |
+
const handleEnhancePrompt = () => {
|
| 63 |
+
const currentPrompt = form.getValues("prompt")
|
| 64 |
+
if (currentPrompt) {
|
| 65 |
+
// Simulate API call to enhance prompt
|
| 66 |
+
const enhancedPrompt = `${currentPrompt}, cinematic lighting, hyper-detailed, 8k resolution`
|
| 67 |
+
form.setValue("prompt", enhancedPrompt, { shouldValidate: true })
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
return (
|
| 72 |
+
<Form {...form}>
|
| 73 |
+
<form
|
| 74 |
+
onSubmit={form.handleSubmit(onSubmit)}
|
| 75 |
+
className="flex h-full flex-col space-y-4"
|
| 76 |
+
>
|
| 77 |
+
<div className="flex flex-1 flex-col gap-4 rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
|
| 78 |
+
<FormField
|
| 79 |
+
control={form.control}
|
| 80 |
+
name="prompt"
|
| 81 |
+
render={({ field }) => (
|
| 82 |
+
<FormItem className="relative flex-grow">
|
| 83 |
+
<FormLabel className="sr-only">Prompt</FormLabel>
|
| 84 |
+
<FormControl>
|
| 85 |
+
<Textarea
|
| 86 |
+
placeholder="Describe the image you want to create..."
|
| 87 |
+
className="h-full resize-none border-0 p-0 shadow-none focus-visible:ring-0"
|
| 88 |
+
{...field}
|
| 89 |
+
/>
|
| 90 |
+
</FormControl>
|
| 91 |
+
<FormMessage className="absolute -bottom-5 left-0" />
|
| 92 |
+
<div className="absolute bottom-2 right-2 flex items-center gap-2">
|
| 93 |
+
<span className="text-xs text-muted-foreground">
|
| 94 |
+
{watchPrompt.length}/2000
|
| 95 |
+
</span>
|
| 96 |
+
<Button
|
| 97 |
+
type="button"
|
| 98 |
+
variant="ghost"
|
| 99 |
+
size="icon"
|
| 100 |
+
onClick={handleEnhancePrompt}
|
| 101 |
+
disabled={!watchPrompt}
|
| 102 |
+
aria-label="Enhance prompt"
|
| 103 |
+
>
|
| 104 |
+
<Sparkles className="h-4 w-4" />
|
| 105 |
+
</Button>
|
| 106 |
+
</div>
|
| 107 |
+
</FormItem>
|
| 108 |
+
)}
|
| 109 |
+
/>
|
| 110 |
+
|
| 111 |
+
<Separator />
|
| 112 |
+
|
| 113 |
+
<FormField
|
| 114 |
+
control={form.control}
|
| 115 |
+
name="smartRouting"
|
| 116 |
+
render={({ field }) => (
|
| 117 |
+
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
| 118 |
+
<div className="space-y-0.5">
|
| 119 |
+
<FormLabel>AI Smart Routing</FormLabel>
|
| 120 |
+
<FormDescription>
|
| 121 |
+
Automatically select the best model for your prompt.
|
| 122 |
+
</FormDescription>
|
| 123 |
+
</div>
|
| 124 |
+
<FormControl>
|
| 125 |
+
<Switch
|
| 126 |
+
checked={field.value}
|
| 127 |
+
onCheckedChange={field.onChange}
|
| 128 |
+
/>
|
| 129 |
+
</FormControl>
|
| 130 |
+
</FormItem>
|
| 131 |
+
)}
|
| 132 |
+
/>
|
| 133 |
+
|
| 134 |
+
{!watchSmartRouting && (
|
| 135 |
+
<FormField
|
| 136 |
+
control={form.control}
|
| 137 |
+
name="model"
|
| 138 |
+
render={({ field }) => (
|
| 139 |
+
<FormItem>
|
| 140 |
+
<FormLabel>Model</FormLabel>
|
| 141 |
+
<FormControl>
|
| 142 |
+
<div className="grid grid-cols-3 gap-2">
|
| 143 |
+
<ModelCard
|
| 144 |
+
model="openai"
|
| 145 |
+
useCase="Photorealistic"
|
| 146 |
+
isSelected={field.value === "openai"}
|
| 147 |
+
onClick={() => field.onChange("openai")}
|
| 148 |
+
/>
|
| 149 |
+
<ModelCard
|
| 150 |
+
model="qwen"
|
| 151 |
+
useCase="Artistic"
|
| 152 |
+
isSelected={field.value === "qwen"}
|
| 153 |
+
onClick={() => field.onChange("qwen")}
|
| 154 |
+
/>
|
| 155 |
+
<ModelCard
|
| 156 |
+
model="googleNanoBanana"
|
| 157 |
+
useCase="General Purpose"
|
| 158 |
+
isSelected={field.value === "googleNanoBanana"}
|
| 159 |
+
onClick={() =>
|
| 160 |
+
field.onChange("googleNanoBanana")
|
| 161 |
+
}
|
| 162 |
+
/>
|
| 163 |
+
</div>
|
| 164 |
+
</FormControl>
|
| 165 |
+
</FormItem>
|
| 166 |
+
)}
|
| 167 |
+
/>
|
| 168 |
+
)}
|
| 169 |
+
|
| 170 |
+
<div className="grid grid-cols-2 gap-4">
|
| 171 |
+
<FormField
|
| 172 |
+
control={form.control}
|
| 173 |
+
name="aspectRatio"
|
| 174 |
+
render={({ field }) => (
|
| 175 |
+
<FormItem>
|
| 176 |
+
<FormLabel>Aspect Ratio</FormLabel>
|
| 177 |
+
<Select
|
| 178 |
+
onValueChange={field.onChange}
|
| 179 |
+
defaultValue={field.value}
|
| 180 |
+
>
|
| 181 |
+
<FormControl>
|
| 182 |
+
<SelectTrigger>
|
| 183 |
+
<SelectValue placeholder="Select ratio" />
|
| 184 |
+
</SelectTrigger>
|
| 185 |
+
</FormControl>
|
| 186 |
+
<SelectContent>
|
| 187 |
+
<SelectItem value="1:1">Square (1:1)</SelectItem>
|
| 188 |
+
<SelectItem value="16:9">Landscape (16:9)</SelectItem>
|
| 189 |
+
<SelectItem value="9:16">Portrait (9:16)</SelectItem>
|
| 190 |
+
</SelectContent>
|
| 191 |
+
</Select>
|
| 192 |
+
</FormItem>
|
| 193 |
+
)}
|
| 194 |
+
/>
|
| 195 |
+
<FormField
|
| 196 |
+
control={form.control}
|
| 197 |
+
name="numImages"
|
| 198 |
+
render={({ field }) => (
|
| 199 |
+
<FormItem>
|
| 200 |
+
<FormLabel>Number of images</FormLabel>
|
| 201 |
+
<Select
|
| 202 |
+
onValueChange={(value) =>
|
| 203 |
+
field.onChange(parseInt(value, 10))
|
| 204 |
+
}
|
| 205 |
+
defaultValue={String(field.value)}
|
| 206 |
+
>
|
| 207 |
+
<FormControl>
|
| 208 |
+
<SelectTrigger>
|
| 209 |
+
<SelectValue placeholder="Select count" />
|
| 210 |
+
</SelectTrigger>
|
| 211 |
+
</FormControl>
|
| 212 |
+
<SelectContent>
|
| 213 |
+
<SelectItem value="1">1</SelectItem>
|
| 214 |
+
<SelectItem value="2">2</SelectItem>
|
| 215 |
+
<SelectItem value="4">4</SelectItem>
|
| 216 |
+
</SelectContent>
|
| 217 |
+
</Select>
|
| 218 |
+
</FormItem>
|
| 219 |
+
)}
|
| 220 |
+
/>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
|
| 224 |
+
<Button
|
| 225 |
+
type="submit"
|
| 226 |
+
size="lg"
|
| 227 |
+
disabled={!watchPrompt || isGenerating}
|
| 228 |
+
className="w-full"
|
| 229 |
+
>
|
| 230 |
+
{isGenerating ? (
|
| 231 |
+
"Generating..."
|
| 232 |
+
) : (
|
| 233 |
+
<>
|
| 234 |
+
<Wand2 className="mr-2 h-4 w-4" />
|
| 235 |
+
Generate Image
|
| 236 |
+
</>
|
| 237 |
+
)}
|
| 238 |
+
</Button>
|
| 239 |
+
</form>
|
| 240 |
+
</Form>
|
| 241 |
+
)
|
| 242 |
+
}
|
src/components/settings-panel.tsx
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import * as React from 'react';
|
| 4 |
+
import { Settings, Eye, EyeOff, Check, Trash2, Key } from 'lucide-react';
|
| 5 |
+
import { Button } from '@/components/ui/button';
|
| 6 |
+
import { Input } from '@/components/ui/input';
|
| 7 |
+
import { Label } from '@/components/ui/label';
|
| 8 |
+
import {
|
| 9 |
+
Sheet,
|
| 10 |
+
SheetContent,
|
| 11 |
+
SheetDescription,
|
| 12 |
+
SheetHeader,
|
| 13 |
+
SheetTitle,
|
| 14 |
+
SheetTrigger,
|
| 15 |
+
} from '@/components/ui/sheet';
|
| 16 |
+
import { useApiKeys } from '@/contexts/api-keys-context';
|
| 17 |
+
import { PROVIDER_CONFIGS, type ImageProvider } from '@/lib/api-config';
|
| 18 |
+
import { Icons } from './icons';
|
| 19 |
+
import { cn } from '@/lib/utils';
|
| 20 |
+
|
| 21 |
+
interface ApiKeyInputProps {
|
| 22 |
+
provider: ImageProvider;
|
| 23 |
+
label: string;
|
| 24 |
+
placeholder: string;
|
| 25 |
+
icon: React.ComponentType<{ className?: string }>;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function ApiKeyInput({ provider, label, placeholder, icon: Icon }: ApiKeyInputProps) {
|
| 29 |
+
const { getApiKey, setApiKey, clearApiKey, hasKey } = useApiKeys();
|
| 30 |
+
const [showKey, setShowKey] = React.useState(false);
|
| 31 |
+
const [localValue, setLocalValue] = React.useState('');
|
| 32 |
+
const [isSaved, setIsSaved] = React.useState(false);
|
| 33 |
+
|
| 34 |
+
// Sync local value with context on mount and when context changes
|
| 35 |
+
React.useEffect(() => {
|
| 36 |
+
const contextValue = getApiKey(provider);
|
| 37 |
+
setLocalValue(contextValue);
|
| 38 |
+
setIsSaved(contextValue.length > 0);
|
| 39 |
+
}, [getApiKey, provider]);
|
| 40 |
+
|
| 41 |
+
const handleSave = () => {
|
| 42 |
+
setApiKey(provider, localValue);
|
| 43 |
+
setIsSaved(true);
|
| 44 |
+
setTimeout(() => setIsSaved(false), 2000);
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const handleClear = () => {
|
| 48 |
+
setLocalValue('');
|
| 49 |
+
clearApiKey(provider);
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
| 53 |
+
if (e.key === 'Enter') {
|
| 54 |
+
handleSave();
|
| 55 |
+
}
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
const isConfigured = hasKey(provider);
|
| 59 |
+
|
| 60 |
+
return (
|
| 61 |
+
<div className="space-y-2">
|
| 62 |
+
<div className="flex items-center gap-2">
|
| 63 |
+
<Icon className="h-4 w-4 text-muted-foreground" />
|
| 64 |
+
<Label htmlFor={`api-key-${provider}`} className="text-sm font-medium">
|
| 65 |
+
{label}
|
| 66 |
+
</Label>
|
| 67 |
+
{isConfigured && (
|
| 68 |
+
<span className="ml-auto flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
| 69 |
+
<Check className="h-3 w-3" />
|
| 70 |
+
Configured
|
| 71 |
+
</span>
|
| 72 |
+
)}
|
| 73 |
+
</div>
|
| 74 |
+
<div className="flex gap-2">
|
| 75 |
+
<div className="relative flex-1">
|
| 76 |
+
<Input
|
| 77 |
+
id={`api-key-${provider}`}
|
| 78 |
+
type={showKey ? 'text' : 'password'}
|
| 79 |
+
value={localValue}
|
| 80 |
+
onChange={(e) => setLocalValue(e.target.value)}
|
| 81 |
+
onKeyDown={handleKeyDown}
|
| 82 |
+
placeholder={placeholder}
|
| 83 |
+
className="pr-10 font-mono text-sm"
|
| 84 |
+
/>
|
| 85 |
+
<Button
|
| 86 |
+
type="button"
|
| 87 |
+
variant="ghost"
|
| 88 |
+
size="icon"
|
| 89 |
+
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2"
|
| 90 |
+
onClick={() => setShowKey(!showKey)}
|
| 91 |
+
>
|
| 92 |
+
{showKey ? (
|
| 93 |
+
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
| 94 |
+
) : (
|
| 95 |
+
<Eye className="h-4 w-4 text-muted-foreground" />
|
| 96 |
+
)}
|
| 97 |
+
</Button>
|
| 98 |
+
</div>
|
| 99 |
+
<Button
|
| 100 |
+
type="button"
|
| 101 |
+
variant="outline"
|
| 102 |
+
size="icon"
|
| 103 |
+
onClick={handleSave}
|
| 104 |
+
disabled={localValue === getApiKey(provider)}
|
| 105 |
+
className={cn(isSaved && 'bg-green-100 dark:bg-green-900')}
|
| 106 |
+
>
|
| 107 |
+
<Check className={cn('h-4 w-4', isSaved && 'text-green-600')} />
|
| 108 |
+
</Button>
|
| 109 |
+
<Button
|
| 110 |
+
type="button"
|
| 111 |
+
variant="outline"
|
| 112 |
+
size="icon"
|
| 113 |
+
onClick={handleClear}
|
| 114 |
+
disabled={!localValue}
|
| 115 |
+
>
|
| 116 |
+
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
| 117 |
+
</Button>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
export function SettingsPanel() {
|
| 124 |
+
const { isOpen, setIsOpen, hasKey } = useApiKeys();
|
| 125 |
+
|
| 126 |
+
const configuredCount = [
|
| 127 |
+
hasKey('openai'),
|
| 128 |
+
hasKey('google'),
|
| 129 |
+
hasKey('qwen'),
|
| 130 |
+
].filter(Boolean).length;
|
| 131 |
+
|
| 132 |
+
return (
|
| 133 |
+
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
| 134 |
+
<SheetTrigger asChild>
|
| 135 |
+
<Button variant="ghost" size="icon" className="relative">
|
| 136 |
+
<Settings className="h-5 w-5" />
|
| 137 |
+
{configuredCount > 0 && (
|
| 138 |
+
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
|
| 139 |
+
{configuredCount}
|
| 140 |
+
</span>
|
| 141 |
+
)}
|
| 142 |
+
<span className="sr-only">Settings</span>
|
| 143 |
+
</Button>
|
| 144 |
+
</SheetTrigger>
|
| 145 |
+
<SheetContent side="left" className="w-[400px] sm:w-[450px]">
|
| 146 |
+
<SheetHeader>
|
| 147 |
+
<SheetTitle className="flex items-center gap-2">
|
| 148 |
+
<Key className="h-5 w-5" />
|
| 149 |
+
API Keys
|
| 150 |
+
</SheetTitle>
|
| 151 |
+
<SheetDescription>
|
| 152 |
+
Configure your API keys for image generation. Keys are stored locally in your browser and never sent to our servers.
|
| 153 |
+
</SheetDescription>
|
| 154 |
+
</SheetHeader>
|
| 155 |
+
<div className="mt-6 space-y-6">
|
| 156 |
+
<ApiKeyInput
|
| 157 |
+
provider="openai"
|
| 158 |
+
label="OpenAI API Key"
|
| 159 |
+
placeholder="sk-..."
|
| 160 |
+
icon={Icons.openai}
|
| 161 |
+
/>
|
| 162 |
+
<ApiKeyInput
|
| 163 |
+
provider="google"
|
| 164 |
+
label="Google Gemini API Key"
|
| 165 |
+
placeholder="AIza..."
|
| 166 |
+
icon={Icons.google}
|
| 167 |
+
/>
|
| 168 |
+
<ApiKeyInput
|
| 169 |
+
provider="qwen"
|
| 170 |
+
label="Qwen (DashScope) API Key"
|
| 171 |
+
placeholder="sk-..."
|
| 172 |
+
icon={Icons.qwen}
|
| 173 |
+
/>
|
| 174 |
+
|
| 175 |
+
<div className="rounded-lg border bg-muted/50 p-4">
|
| 176 |
+
<h4 className="text-sm font-medium">How to get API keys:</h4>
|
| 177 |
+
<ul className="mt-2 space-y-1 text-xs text-muted-foreground">
|
| 178 |
+
<li>
|
| 179 |
+
<strong>OpenAI:</strong>{' '}
|
| 180 |
+
<a
|
| 181 |
+
href="https://platform.openai.com/api-keys"
|
| 182 |
+
target="_blank"
|
| 183 |
+
rel="noopener noreferrer"
|
| 184 |
+
className="text-primary hover:underline"
|
| 185 |
+
>
|
| 186 |
+
platform.openai.com/api-keys
|
| 187 |
+
</a>
|
| 188 |
+
</li>
|
| 189 |
+
<li>
|
| 190 |
+
<strong>Google:</strong>{' '}
|
| 191 |
+
<a
|
| 192 |
+
href="https://aistudio.google.com/apikey"
|
| 193 |
+
target="_blank"
|
| 194 |
+
rel="noopener noreferrer"
|
| 195 |
+
className="text-primary hover:underline"
|
| 196 |
+
>
|
| 197 |
+
aistudio.google.com/apikey
|
| 198 |
+
</a>
|
| 199 |
+
</li>
|
| 200 |
+
<li>
|
| 201 |
+
<strong>Qwen:</strong>{' '}
|
| 202 |
+
<a
|
| 203 |
+
href="https://dashscope.console.aliyun.com/"
|
| 204 |
+
target="_blank"
|
| 205 |
+
rel="noopener noreferrer"
|
| 206 |
+
className="text-primary hover:underline"
|
| 207 |
+
>
|
| 208 |
+
dashscope.console.aliyun.com
|
| 209 |
+
</a>
|
| 210 |
+
</li>
|
| 211 |
+
</ul>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
</SheetContent>
|
| 215 |
+
</Sheet>
|
| 216 |
+
);
|
| 217 |
+
}
|
src/components/theme-toggle.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { useTheme } from "next-themes"
|
| 5 |
+
import { Moon, Sun } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
import {
|
| 9 |
+
DropdownMenu,
|
| 10 |
+
DropdownMenuContent,
|
| 11 |
+
DropdownMenuItem,
|
| 12 |
+
DropdownMenuTrigger,
|
| 13 |
+
} from "@/components/ui/dropdown-menu"
|
| 14 |
+
|
| 15 |
+
export function ThemeToggle() {
|
| 16 |
+
const { setTheme } = useTheme()
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<DropdownMenu>
|
| 20 |
+
<DropdownMenuTrigger asChild>
|
| 21 |
+
<Button variant="ghost" size="icon">
|
| 22 |
+
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
| 23 |
+
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
| 24 |
+
<span className="sr-only">Toggle theme</span>
|
| 25 |
+
</Button>
|
| 26 |
+
</DropdownMenuTrigger>
|
| 27 |
+
<DropdownMenuContent align="end">
|
| 28 |
+
<DropdownMenuItem onClick={() => setTheme("light")}>
|
| 29 |
+
Light
|
| 30 |
+
</DropdownMenuItem>
|
| 31 |
+
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
| 32 |
+
Dark
|
| 33 |
+
</DropdownMenuItem>
|
| 34 |
+
<DropdownMenuItem onClick={() => setTheme("system")}>
|
| 35 |
+
System
|
| 36 |
+
</DropdownMenuItem>
|
| 37 |
+
</DropdownMenuContent>
|
| 38 |
+
</DropdownMenu>
|
| 39 |
+
)
|
| 40 |
+
}
|
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,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
Chevron: ({ orientation, className, ...props }) => {
|
| 58 |
+
const Icon = orientation === "left" ? ChevronLeft : ChevronRight;
|
| 59 |
+
return <Icon className={cn("h-4 w-4", className)} {...props} />;
|
| 60 |
+
},
|
| 61 |
+
}}
|
| 62 |
+
{...props}
|
| 63 |
+
/>
|
| 64 |
+
)
|
| 65 |
+
}
|
| 66 |
+
Calendar.displayName = "Calendar"
|
| 67 |
+
|
| 68 |
+
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/carousel.tsx
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import useEmblaCarousel, {
|
| 5 |
+
type UseEmblaCarouselType,
|
| 6 |
+
} from "embla-carousel-react"
|
| 7 |
+
import { ArrowLeft, ArrowRight } from "lucide-react"
|
| 8 |
+
|
| 9 |
+
import { cn } from "@/lib/utils"
|
| 10 |
+
import { Button } from "@/components/ui/button"
|
| 11 |
+
|
| 12 |
+
type CarouselApi = UseEmblaCarouselType[1]
|
| 13 |
+
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
| 14 |
+
type CarouselOptions = UseCarouselParameters[0]
|
| 15 |
+
type CarouselPlugin = UseCarouselParameters[1]
|
| 16 |
+
|
| 17 |
+
type CarouselProps = {
|
| 18 |
+
opts?: CarouselOptions
|
| 19 |
+
plugins?: CarouselPlugin
|
| 20 |
+
orientation?: "horizontal" | "vertical"
|
| 21 |
+
setApi?: (api: CarouselApi) => void
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
type CarouselContextProps = {
|
| 25 |
+
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
| 26 |
+
api: ReturnType<typeof useEmblaCarousel>[1]
|
| 27 |
+
scrollPrev: () => void
|
| 28 |
+
scrollNext: () => void
|
| 29 |
+
canScrollPrev: boolean
|
| 30 |
+
canScrollNext: boolean
|
| 31 |
+
} & CarouselProps
|
| 32 |
+
|
| 33 |
+
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
| 34 |
+
|
| 35 |
+
function useCarousel() {
|
| 36 |
+
const context = React.useContext(CarouselContext)
|
| 37 |
+
|
| 38 |
+
if (!context) {
|
| 39 |
+
throw new Error("useCarousel must be used within a <Carousel />")
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
return context
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const Carousel = React.forwardRef<
|
| 46 |
+
HTMLDivElement,
|
| 47 |
+
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
| 48 |
+
>(
|
| 49 |
+
(
|
| 50 |
+
{
|
| 51 |
+
orientation = "horizontal",
|
| 52 |
+
opts,
|
| 53 |
+
setApi,
|
| 54 |
+
plugins,
|
| 55 |
+
className,
|
| 56 |
+
children,
|
| 57 |
+
...props
|
| 58 |
+
},
|
| 59 |
+
ref
|
| 60 |
+
) => {
|
| 61 |
+
const [carouselRef, api] = useEmblaCarousel(
|
| 62 |
+
{
|
| 63 |
+
...opts,
|
| 64 |
+
axis: orientation === "horizontal" ? "x" : "y",
|
| 65 |
+
},
|
| 66 |
+
plugins
|
| 67 |
+
)
|
| 68 |
+
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
| 69 |
+
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
| 70 |
+
|
| 71 |
+
const onSelect = React.useCallback((api: CarouselApi) => {
|
| 72 |
+
if (!api) {
|
| 73 |
+
return
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
setCanScrollPrev(api.canScrollPrev())
|
| 77 |
+
setCanScrollNext(api.canScrollNext())
|
| 78 |
+
}, [])
|
| 79 |
+
|
| 80 |
+
const scrollPrev = React.useCallback(() => {
|
| 81 |
+
api?.scrollPrev()
|
| 82 |
+
}, [api])
|
| 83 |
+
|
| 84 |
+
const scrollNext = React.useCallback(() => {
|
| 85 |
+
api?.scrollNext()
|
| 86 |
+
}, [api])
|
| 87 |
+
|
| 88 |
+
const handleKeyDown = React.useCallback(
|
| 89 |
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
| 90 |
+
if (event.key === "ArrowLeft") {
|
| 91 |
+
event.preventDefault()
|
| 92 |
+
scrollPrev()
|
| 93 |
+
} else if (event.key === "ArrowRight") {
|
| 94 |
+
event.preventDefault()
|
| 95 |
+
scrollNext()
|
| 96 |
+
}
|
| 97 |
+
},
|
| 98 |
+
[scrollPrev, scrollNext]
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
React.useEffect(() => {
|
| 102 |
+
if (!api || !setApi) {
|
| 103 |
+
return
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
setApi(api)
|
| 107 |
+
}, [api, setApi])
|
| 108 |
+
|
| 109 |
+
React.useEffect(() => {
|
| 110 |
+
if (!api) {
|
| 111 |
+
return
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
onSelect(api)
|
| 115 |
+
api.on("reInit", onSelect)
|
| 116 |
+
api.on("select", onSelect)
|
| 117 |
+
|
| 118 |
+
return () => {
|
| 119 |
+
api?.off("select", onSelect)
|
| 120 |
+
}
|
| 121 |
+
}, [api, onSelect])
|
| 122 |
+
|
| 123 |
+
return (
|
| 124 |
+
<CarouselContext.Provider
|
| 125 |
+
value={{
|
| 126 |
+
carouselRef,
|
| 127 |
+
api: api,
|
| 128 |
+
opts,
|
| 129 |
+
orientation:
|
| 130 |
+
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
| 131 |
+
scrollPrev,
|
| 132 |
+
scrollNext,
|
| 133 |
+
canScrollPrev,
|
| 134 |
+
canScrollNext,
|
| 135 |
+
}}
|
| 136 |
+
>
|
| 137 |
+
<div
|
| 138 |
+
ref={ref}
|
| 139 |
+
onKeyDownCapture={handleKeyDown}
|
| 140 |
+
className={cn("relative", className)}
|
| 141 |
+
role="region"
|
| 142 |
+
aria-roledescription="carousel"
|
| 143 |
+
{...props}
|
| 144 |
+
>
|
| 145 |
+
{children}
|
| 146 |
+
</div>
|
| 147 |
+
</CarouselContext.Provider>
|
| 148 |
+
)
|
| 149 |
+
}
|
| 150 |
+
)
|
| 151 |
+
Carousel.displayName = "Carousel"
|
| 152 |
+
|
| 153 |
+
const CarouselContent = React.forwardRef<
|
| 154 |
+
HTMLDivElement,
|
| 155 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 156 |
+
>(({ className, ...props }, ref) => {
|
| 157 |
+
const { carouselRef, orientation } = useCarousel()
|
| 158 |
+
|
| 159 |
+
return (
|
| 160 |
+
<div ref={carouselRef} className="overflow-hidden">
|
| 161 |
+
<div
|
| 162 |
+
ref={ref}
|
| 163 |
+
className={cn(
|
| 164 |
+
"flex",
|
| 165 |
+
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
| 166 |
+
className
|
| 167 |
+
)}
|
| 168 |
+
{...props}
|
| 169 |
+
/>
|
| 170 |
+
</div>
|
| 171 |
+
)
|
| 172 |
+
})
|
| 173 |
+
CarouselContent.displayName = "CarouselContent"
|
| 174 |
+
|
| 175 |
+
const CarouselItem = React.forwardRef<
|
| 176 |
+
HTMLDivElement,
|
| 177 |
+
React.HTMLAttributes<HTMLDivElement>
|
| 178 |
+
>(({ className, ...props }, ref) => {
|
| 179 |
+
const { orientation } = useCarousel()
|
| 180 |
+
|
| 181 |
+
return (
|
| 182 |
+
<div
|
| 183 |
+
ref={ref}
|
| 184 |
+
role="group"
|
| 185 |
+
aria-roledescription="slide"
|
| 186 |
+
className={cn(
|
| 187 |
+
"min-w-0 shrink-0 grow-0 basis-full",
|
| 188 |
+
orientation === "horizontal" ? "pl-4" : "pt-4",
|
| 189 |
+
className
|
| 190 |
+
)}
|
| 191 |
+
{...props}
|
| 192 |
+
/>
|
| 193 |
+
)
|
| 194 |
+
})
|
| 195 |
+
CarouselItem.displayName = "CarouselItem"
|
| 196 |
+
|
| 197 |
+
const CarouselPrevious = React.forwardRef<
|
| 198 |
+
HTMLButtonElement,
|
| 199 |
+
React.ComponentProps<typeof Button>
|
| 200 |
+
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
| 201 |
+
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
| 202 |
+
|
| 203 |
+
return (
|
| 204 |
+
<Button
|
| 205 |
+
ref={ref}
|
| 206 |
+
variant={variant}
|
| 207 |
+
size={size}
|
| 208 |
+
className={cn(
|
| 209 |
+
"absolute h-8 w-8 rounded-full",
|
| 210 |
+
orientation === "horizontal"
|
| 211 |
+
? "-left-12 top-1/2 -translate-y-1/2"
|
| 212 |
+
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
| 213 |
+
className
|
| 214 |
+
)}
|
| 215 |
+
disabled={!canScrollPrev}
|
| 216 |
+
onClick={scrollPrev}
|
| 217 |
+
{...props}
|
| 218 |
+
>
|
| 219 |
+
<ArrowLeft className="h-4 w-4" />
|
| 220 |
+
<span className="sr-only">Previous slide</span>
|
| 221 |
+
</Button>
|
| 222 |
+
)
|
| 223 |
+
})
|
| 224 |
+
CarouselPrevious.displayName = "CarouselPrevious"
|
| 225 |
+
|
| 226 |
+
const CarouselNext = React.forwardRef<
|
| 227 |
+
HTMLButtonElement,
|
| 228 |
+
React.ComponentProps<typeof Button>
|
| 229 |
+
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
| 230 |
+
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
| 231 |
+
|
| 232 |
+
return (
|
| 233 |
+
<Button
|
| 234 |
+
ref={ref}
|
| 235 |
+
variant={variant}
|
| 236 |
+
size={size}
|
| 237 |
+
className={cn(
|
| 238 |
+
"absolute h-8 w-8 rounded-full",
|
| 239 |
+
orientation === "horizontal"
|
| 240 |
+
? "-right-12 top-1/2 -translate-y-1/2"
|
| 241 |
+
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
| 242 |
+
className
|
| 243 |
+
)}
|
| 244 |
+
disabled={!canScrollNext}
|
| 245 |
+
onClick={scrollNext}
|
| 246 |
+
{...props}
|
| 247 |
+
>
|
| 248 |
+
<ArrowRight className="h-4 w-4" />
|
| 249 |
+
<span className="sr-only">Next slide</span>
|
| 250 |
+
</Button>
|
| 251 |
+
)
|
| 252 |
+
})
|
| 253 |
+
CarouselNext.displayName = "CarouselNext"
|
| 254 |
+
|
| 255 |
+
export {
|
| 256 |
+
type CarouselApi,
|
| 257 |
+
Carousel,
|
| 258 |
+
CarouselContent,
|
| 259 |
+
CarouselItem,
|
| 260 |
+
CarouselPrevious,
|
| 261 |
+
CarouselNext,
|
| 262 |
+
}
|
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/collapsible.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
| 4 |
+
|
| 5 |
+
const Collapsible = CollapsiblePrimitive.Root
|
| 6 |
+
|
| 7 |
+
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
| 8 |
+
|
| 9 |
+
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
| 10 |
+
|
| 11 |
+
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
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 }
|
src/components/ui/radio-group.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
| 5 |
+
import { Circle } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
|
| 9 |
+
const RadioGroup = React.forwardRef<
|
| 10 |
+
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
| 11 |
+
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
| 12 |
+
>(({ className, ...props }, ref) => {
|
| 13 |
+
return (
|
| 14 |
+
<RadioGroupPrimitive.Root
|
| 15 |
+
className={cn("grid gap-2", className)}
|
| 16 |
+
{...props}
|
| 17 |
+
ref={ref}
|
| 18 |
+
/>
|
| 19 |
+
)
|
| 20 |
+
})
|
| 21 |
+
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
| 22 |
+
|
| 23 |
+
const RadioGroupItem = React.forwardRef<
|
| 24 |
+
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
| 25 |
+
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
| 26 |
+
>(({ className, ...props }, ref) => {
|
| 27 |
+
return (
|
| 28 |
+
<RadioGroupPrimitive.Item
|
| 29 |
+
ref={ref}
|
| 30 |
+
className={cn(
|
| 31 |
+
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
| 32 |
+
className
|
| 33 |
+
)}
|
| 34 |
+
{...props}
|
| 35 |
+
>
|
| 36 |
+
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
| 37 |
+
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
| 38 |
+
</RadioGroupPrimitive.Indicator>
|
| 39 |
+
</RadioGroupPrimitive.Item>
|
| 40 |
+
)
|
| 41 |
+
})
|
| 42 |
+
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
| 43 |
+
|
| 44 |
+
export { RadioGroup, RadioGroupItem }
|
src/components/ui/scroll-area.tsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
const ScrollArea = React.forwardRef<
|
| 9 |
+
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
| 10 |
+
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
| 11 |
+
>(({ className, children, ...props }, ref) => (
|
| 12 |
+
<ScrollAreaPrimitive.Root
|
| 13 |
+
ref={ref}
|
| 14 |
+
className={cn("relative overflow-hidden", className)}
|
| 15 |
+
{...props}
|
| 16 |
+
>
|
| 17 |
+
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
| 18 |
+
{children}
|
| 19 |
+
</ScrollAreaPrimitive.Viewport>
|
| 20 |
+
<ScrollBar />
|
| 21 |
+
<ScrollAreaPrimitive.Corner />
|
| 22 |
+
</ScrollAreaPrimitive.Root>
|
| 23 |
+
))
|
| 24 |
+
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
| 25 |
+
|
| 26 |
+
const ScrollBar = React.forwardRef<
|
| 27 |
+
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
| 28 |
+
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
| 29 |
+
>(({ className, orientation = "vertical", ...props }, ref) => (
|
| 30 |
+
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
| 31 |
+
ref={ref}
|
| 32 |
+
orientation={orientation}
|
| 33 |
+
className={cn(
|
| 34 |
+
"flex touch-none select-none transition-colors",
|
| 35 |
+
orientation === "vertical" &&
|
| 36 |
+
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
| 37 |
+
orientation === "horizontal" &&
|
| 38 |
+
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
| 39 |
+
className
|
| 40 |
+
)}
|
| 41 |
+
{...props}
|
| 42 |
+
>
|
| 43 |
+
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
| 44 |
+
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
| 45 |
+
))
|
| 46 |
+
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
| 47 |
+
|
| 48 |
+
export { ScrollArea, ScrollBar }
|
src/components/ui/select.tsx
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as SelectPrimitive from "@radix-ui/react-select"
|
| 5 |
+
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
|
| 9 |
+
const Select = SelectPrimitive.Root
|
| 10 |
+
|
| 11 |
+
const SelectGroup = SelectPrimitive.Group
|
| 12 |
+
|
| 13 |
+
const SelectValue = SelectPrimitive.Value
|
| 14 |
+
|
| 15 |
+
const SelectTrigger = React.forwardRef<
|
| 16 |
+
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
| 17 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
| 18 |
+
>(({ className, children, ...props }, ref) => (
|
| 19 |
+
<SelectPrimitive.Trigger
|
| 20 |
+
ref={ref}
|
| 21 |
+
className={cn(
|
| 22 |
+
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
| 23 |
+
className
|
| 24 |
+
)}
|
| 25 |
+
{...props}
|
| 26 |
+
>
|
| 27 |
+
{children}
|
| 28 |
+
<SelectPrimitive.Icon asChild>
|
| 29 |
+
<ChevronDown className="h-4 w-4 opacity-50" />
|
| 30 |
+
</SelectPrimitive.Icon>
|
| 31 |
+
</SelectPrimitive.Trigger>
|
| 32 |
+
))
|
| 33 |
+
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
| 34 |
+
|
| 35 |
+
const SelectScrollUpButton = React.forwardRef<
|
| 36 |
+
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
| 37 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
| 38 |
+
>(({ className, ...props }, ref) => (
|
| 39 |
+
<SelectPrimitive.ScrollUpButton
|
| 40 |
+
ref={ref}
|
| 41 |
+
className={cn(
|
| 42 |
+
"flex cursor-default items-center justify-center py-1",
|
| 43 |
+
className
|
| 44 |
+
)}
|
| 45 |
+
{...props}
|
| 46 |
+
>
|
| 47 |
+
<ChevronUp className="h-4 w-4" />
|
| 48 |
+
</SelectPrimitive.ScrollUpButton>
|
| 49 |
+
))
|
| 50 |
+
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
| 51 |
+
|
| 52 |
+
const SelectScrollDownButton = React.forwardRef<
|
| 53 |
+
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
| 54 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
| 55 |
+
>(({ className, ...props }, ref) => (
|
| 56 |
+
<SelectPrimitive.ScrollDownButton
|
| 57 |
+
ref={ref}
|
| 58 |
+
className={cn(
|
| 59 |
+
"flex cursor-default items-center justify-center py-1",
|
| 60 |
+
className
|
| 61 |
+
)}
|
| 62 |
+
{...props}
|
| 63 |
+
>
|
| 64 |
+
<ChevronDown className="h-4 w-4" />
|
| 65 |
+
</SelectPrimitive.ScrollDownButton>
|
| 66 |
+
))
|
| 67 |
+
SelectScrollDownButton.displayName =
|
| 68 |
+
SelectPrimitive.ScrollDownButton.displayName
|
| 69 |
+
|
| 70 |
+
const SelectContent = React.forwardRef<
|
| 71 |
+
React.ElementRef<typeof SelectPrimitive.Content>,
|
| 72 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
| 73 |
+
>(({ className, children, position = "popper", ...props }, ref) => (
|
| 74 |
+
<SelectPrimitive.Portal>
|
| 75 |
+
<SelectPrimitive.Content
|
| 76 |
+
ref={ref}
|
| 77 |
+
className={cn(
|
| 78 |
+
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover 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",
|
| 79 |
+
position === "popper" &&
|
| 80 |
+
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
| 81 |
+
className
|
| 82 |
+
)}
|
| 83 |
+
position={position}
|
| 84 |
+
{...props}
|
| 85 |
+
>
|
| 86 |
+
<SelectScrollUpButton />
|
| 87 |
+
<SelectPrimitive.Viewport
|
| 88 |
+
className={cn(
|
| 89 |
+
"p-1",
|
| 90 |
+
position === "popper" &&
|
| 91 |
+
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
| 92 |
+
)}
|
| 93 |
+
>
|
| 94 |
+
{children}
|
| 95 |
+
</SelectPrimitive.Viewport>
|
| 96 |
+
<SelectScrollDownButton />
|
| 97 |
+
</SelectPrimitive.Content>
|
| 98 |
+
</SelectPrimitive.Portal>
|
| 99 |
+
))
|
| 100 |
+
SelectContent.displayName = SelectPrimitive.Content.displayName
|
| 101 |
+
|
| 102 |
+
const SelectLabel = React.forwardRef<
|
| 103 |
+
React.ElementRef<typeof SelectPrimitive.Label>,
|
| 104 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
| 105 |
+
>(({ className, ...props }, ref) => (
|
| 106 |
+
<SelectPrimitive.Label
|
| 107 |
+
ref={ref}
|
| 108 |
+
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
| 109 |
+
{...props}
|
| 110 |
+
/>
|
| 111 |
+
))
|
| 112 |
+
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
| 113 |
+
|
| 114 |
+
const SelectItem = React.forwardRef<
|
| 115 |
+
React.ElementRef<typeof SelectPrimitive.Item>,
|
| 116 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
| 117 |
+
>(({ className, children, ...props }, ref) => (
|
| 118 |
+
<SelectPrimitive.Item
|
| 119 |
+
ref={ref}
|
| 120 |
+
className={cn(
|
| 121 |
+
"relative flex w-full 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",
|
| 122 |
+
className
|
| 123 |
+
)}
|
| 124 |
+
{...props}
|
| 125 |
+
>
|
| 126 |
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
| 127 |
+
<SelectPrimitive.ItemIndicator>
|
| 128 |
+
<Check className="h-4 w-4" />
|
| 129 |
+
</SelectPrimitive.ItemIndicator>
|
| 130 |
+
</span>
|
| 131 |
+
|
| 132 |
+
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
| 133 |
+
</SelectPrimitive.Item>
|
| 134 |
+
))
|
| 135 |
+
SelectItem.displayName = SelectPrimitive.Item.displayName
|
| 136 |
+
|
| 137 |
+
const SelectSeparator = React.forwardRef<
|
| 138 |
+
React.ElementRef<typeof SelectPrimitive.Separator>,
|
| 139 |
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
| 140 |
+
>(({ className, ...props }, ref) => (
|
| 141 |
+
<SelectPrimitive.Separator
|
| 142 |
+
ref={ref}
|
| 143 |
+
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
| 144 |
+
{...props}
|
| 145 |
+
/>
|
| 146 |
+
))
|
| 147 |
+
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
| 148 |
+
|
| 149 |
+
export {
|
| 150 |
+
Select,
|
| 151 |
+
SelectGroup,
|
| 152 |
+
SelectValue,
|
| 153 |
+
SelectTrigger,
|
| 154 |
+
SelectContent,
|
| 155 |
+
SelectLabel,
|
| 156 |
+
SelectItem,
|
| 157 |
+
SelectSeparator,
|
| 158 |
+
SelectScrollUpButton,
|
| 159 |
+
SelectScrollDownButton,
|
| 160 |
+
}
|
src/components/ui/separator.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
const Separator = React.forwardRef<
|
| 9 |
+
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
| 10 |
+
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
| 11 |
+
>(
|
| 12 |
+
(
|
| 13 |
+
{ className, orientation = "horizontal", decorative = true, ...props },
|
| 14 |
+
ref
|
| 15 |
+
) => (
|
| 16 |
+
<SeparatorPrimitive.Root
|
| 17 |
+
ref={ref}
|
| 18 |
+
decorative={decorative}
|
| 19 |
+
orientation={orientation}
|
| 20 |
+
className={cn(
|
| 21 |
+
"shrink-0 bg-border",
|
| 22 |
+
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
| 23 |
+
className
|
| 24 |
+
)}
|
| 25 |
+
{...props}
|
| 26 |
+
/>
|
| 27 |
+
)
|
| 28 |
+
)
|
| 29 |
+
Separator.displayName = SeparatorPrimitive.Root.displayName
|
| 30 |
+
|
| 31 |
+
export { Separator }
|
src/components/ui/sheet.tsx
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
| 5 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 6 |
+
import { X } from "lucide-react"
|
| 7 |
+
|
| 8 |
+
import { cn } from "@/lib/utils"
|
| 9 |
+
|
| 10 |
+
const Sheet = SheetPrimitive.Root
|
| 11 |
+
|
| 12 |
+
const SheetTrigger = SheetPrimitive.Trigger
|
| 13 |
+
|
| 14 |
+
const SheetClose = SheetPrimitive.Close
|
| 15 |
+
|
| 16 |
+
const SheetPortal = SheetPrimitive.Portal
|
| 17 |
+
|
| 18 |
+
const SheetOverlay = React.forwardRef<
|
| 19 |
+
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
| 20 |
+
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
| 21 |
+
>(({ className, ...props }, ref) => (
|
| 22 |
+
<SheetPrimitive.Overlay
|
| 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 |
+
ref={ref}
|
| 29 |
+
/>
|
| 30 |
+
))
|
| 31 |
+
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
| 32 |
+
|
| 33 |
+
const sheetVariants = cva(
|
| 34 |
+
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
| 35 |
+
{
|
| 36 |
+
variants: {
|
| 37 |
+
side: {
|
| 38 |
+
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
| 39 |
+
bottom:
|
| 40 |
+
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
| 41 |
+
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
| 42 |
+
right:
|
| 43 |
+
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
| 44 |
+
},
|
| 45 |
+
},
|
| 46 |
+
defaultVariants: {
|
| 47 |
+
side: "right",
|
| 48 |
+
},
|
| 49 |
+
}
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
interface SheetContentProps
|
| 53 |
+
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
| 54 |
+
VariantProps<typeof sheetVariants> {}
|
| 55 |
+
|
| 56 |
+
const SheetContent = React.forwardRef<
|
| 57 |
+
React.ElementRef<typeof SheetPrimitive.Content>,
|
| 58 |
+
SheetContentProps
|
| 59 |
+
>(({ side = "right", className, children, ...props }, ref) => (
|
| 60 |
+
<SheetPortal>
|
| 61 |
+
<SheetOverlay />
|
| 62 |
+
<SheetPrimitive.Content
|
| 63 |
+
ref={ref}
|
| 64 |
+
className={cn(sheetVariants({ side }), className)}
|
| 65 |
+
{...props}
|
| 66 |
+
>
|
| 67 |
+
{children}
|
| 68 |
+
<SheetPrimitive.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-secondary">
|
| 69 |
+
<X className="h-4 w-4" />
|
| 70 |
+
<span className="sr-only">Close</span>
|
| 71 |
+
</SheetPrimitive.Close>
|
| 72 |
+
</SheetPrimitive.Content>
|
| 73 |
+
</SheetPortal>
|
| 74 |
+
))
|
| 75 |
+
SheetContent.displayName = SheetPrimitive.Content.displayName
|
| 76 |
+
|
| 77 |
+
const SheetHeader = ({
|
| 78 |
+
className,
|
| 79 |
+
...props
|
| 80 |
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
| 81 |
+
<div
|
| 82 |
+
className={cn(
|
| 83 |
+
"flex flex-col space-y-2 text-center sm:text-left",
|
| 84 |
+
className
|
| 85 |
+
)}
|
| 86 |
+
{...props}
|
| 87 |
+
/>
|
| 88 |
+
)
|
| 89 |
+
SheetHeader.displayName = "SheetHeader"
|
| 90 |
+
|
| 91 |
+
const SheetFooter = ({
|
| 92 |
+
className,
|
| 93 |
+
...props
|
| 94 |
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
| 95 |
+
<div
|
| 96 |
+
className={cn(
|
| 97 |
+
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
| 98 |
+
className
|
| 99 |
+
)}
|
| 100 |
+
{...props}
|
| 101 |
+
/>
|
| 102 |
+
)
|
| 103 |
+
SheetFooter.displayName = "SheetFooter"
|
| 104 |
+
|
| 105 |
+
const SheetTitle = React.forwardRef<
|
| 106 |
+
React.ElementRef<typeof SheetPrimitive.Title>,
|
| 107 |
+
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
| 108 |
+
>(({ className, ...props }, ref) => (
|
| 109 |
+
<SheetPrimitive.Title
|
| 110 |
+
ref={ref}
|
| 111 |
+
className={cn("text-lg font-semibold text-foreground", className)}
|
| 112 |
+
{...props}
|
| 113 |
+
/>
|
| 114 |
+
))
|
| 115 |
+
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
| 116 |
+
|
| 117 |
+
const SheetDescription = React.forwardRef<
|
| 118 |
+
React.ElementRef<typeof SheetPrimitive.Description>,
|
| 119 |
+
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
| 120 |
+
>(({ className, ...props }, ref) => (
|
| 121 |
+
<SheetPrimitive.Description
|
| 122 |
+
ref={ref}
|
| 123 |
+
className={cn("text-sm text-muted-foreground", className)}
|
| 124 |
+
{...props}
|
| 125 |
+
/>
|
| 126 |
+
))
|
| 127 |
+
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
| 128 |
+
|
| 129 |
+
export {
|
| 130 |
+
Sheet,
|
| 131 |
+
SheetPortal,
|
| 132 |
+
SheetOverlay,
|
| 133 |
+
SheetTrigger,
|
| 134 |
+
SheetClose,
|
| 135 |
+
SheetContent,
|
| 136 |
+
SheetHeader,
|
| 137 |
+
SheetFooter,
|
| 138 |
+
SheetTitle,
|
| 139 |
+
SheetDescription,
|
| 140 |
+
}
|
src/components/ui/sidebar.tsx
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Slot } from "@radix-ui/react-slot"
|
| 5 |
+
import { VariantProps, cva } from "class-variance-authority"
|
| 6 |
+
import { PanelLeft } from "lucide-react"
|
| 7 |
+
|
| 8 |
+
import { useIsMobile } from "@/hooks/use-mobile"
|
| 9 |
+
import { cn } from "@/lib/utils"
|
| 10 |
+
import { Button } from "@/components/ui/button"
|
| 11 |
+
import { Input } from "@/components/ui/input"
|
| 12 |
+
import { Separator } from "@/components/ui/separator"
|
| 13 |
+
import { Sheet, SheetContent } from "@/components/ui/sheet"
|
| 14 |
+
import { Skeleton } from "@/components/ui/skeleton"
|
| 15 |
+
import {
|
| 16 |
+
Tooltip,
|
| 17 |
+
TooltipContent,
|
| 18 |
+
TooltipProvider,
|
| 19 |
+
TooltipTrigger,
|
| 20 |
+
} from "@/components/ui/tooltip"
|
| 21 |
+
|
| 22 |
+
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
| 23 |
+
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
| 24 |
+
const SIDEBAR_WIDTH = "16rem"
|
| 25 |
+
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
| 26 |
+
const SIDEBAR_WIDTH_ICON = "3rem"
|
| 27 |
+
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
| 28 |
+
|
| 29 |
+
type SidebarContext = {
|
| 30 |
+
state: "expanded" | "collapsed"
|
| 31 |
+
open: boolean
|
| 32 |
+
setOpen: (open: boolean) => void
|
| 33 |
+
openMobile: boolean
|
| 34 |
+
setOpenMobile: (open: boolean) => void
|
| 35 |
+
isMobile: boolean
|
| 36 |
+
toggleSidebar: () => void
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const SidebarContext = React.createContext<SidebarContext | null>(null)
|
| 40 |
+
|
| 41 |
+
function useSidebar() {
|
| 42 |
+
const context = React.useContext(SidebarContext)
|
| 43 |
+
if (!context) {
|
| 44 |
+
throw new Error("useSidebar must be used within a SidebarProvider.")
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
return context
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const SidebarProvider = React.forwardRef<
|
| 51 |
+
HTMLDivElement,
|
| 52 |
+
React.ComponentProps<"div"> & {
|
| 53 |
+
defaultOpen?: boolean
|
| 54 |
+
open?: boolean
|
| 55 |
+
onOpenChange?: (open: boolean) => void
|
| 56 |
+
}
|
| 57 |
+
>(
|
| 58 |
+
(
|
| 59 |
+
{
|
| 60 |
+
defaultOpen = true,
|
| 61 |
+
open: openProp,
|
| 62 |
+
onOpenChange: setOpenProp,
|
| 63 |
+
className,
|
| 64 |
+
style,
|
| 65 |
+
children,
|
| 66 |
+
...props
|
| 67 |
+
},
|
| 68 |
+
ref
|
| 69 |
+
) => {
|
| 70 |
+
const isMobile = useIsMobile()
|
| 71 |
+
const [openMobile, setOpenMobile] = React.useState(false)
|
| 72 |
+
|
| 73 |
+
// This is the internal state of the sidebar.
|
| 74 |
+
// We use openProp and setOpenProp for control from outside the component.
|
| 75 |
+
const [_open, _setOpen] = React.useState(defaultOpen)
|
| 76 |
+
const open = openProp ?? _open
|
| 77 |
+
const setOpen = React.useCallback(
|
| 78 |
+
(value: boolean | ((value: boolean) => boolean)) => {
|
| 79 |
+
const openState = typeof value === "function" ? value(open) : value
|
| 80 |
+
if (setOpenProp) {
|
| 81 |
+
setOpenProp(openState)
|
| 82 |
+
} else {
|
| 83 |
+
_setOpen(openState)
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// This sets the cookie to keep the sidebar state.
|
| 87 |
+
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
| 88 |
+
},
|
| 89 |
+
[setOpenProp, open]
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
// Helper to toggle the sidebar.
|
| 93 |
+
const toggleSidebar = React.useCallback(() => {
|
| 94 |
+
return isMobile
|
| 95 |
+
? setOpenMobile((open) => !open)
|
| 96 |
+
: setOpen((open) => !open)
|
| 97 |
+
}, [isMobile, setOpen, setOpenMobile])
|
| 98 |
+
|
| 99 |
+
// Adds a keyboard shortcut to toggle the sidebar.
|
| 100 |
+
React.useEffect(() => {
|
| 101 |
+
const handleKeyDown = (event: KeyboardEvent) => {
|
| 102 |
+
if (
|
| 103 |
+
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
| 104 |
+
(event.metaKey || event.ctrlKey)
|
| 105 |
+
) {
|
| 106 |
+
event.preventDefault()
|
| 107 |
+
toggleSidebar()
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
window.addEventListener("keydown", handleKeyDown)
|
| 112 |
+
return () => window.removeEventListener("keydown", handleKeyDown)
|
| 113 |
+
}, [toggleSidebar])
|
| 114 |
+
|
| 115 |
+
// We add a state so that we can do data-state="expanded" or "collapsed".
|
| 116 |
+
// This makes it easier to style the sidebar with Tailwind classes.
|
| 117 |
+
const state = open ? "expanded" : "collapsed"
|
| 118 |
+
|
| 119 |
+
const contextValue = React.useMemo<SidebarContext>(
|
| 120 |
+
() => ({
|
| 121 |
+
state,
|
| 122 |
+
open,
|
| 123 |
+
setOpen,
|
| 124 |
+
isMobile,
|
| 125 |
+
openMobile,
|
| 126 |
+
setOpenMobile,
|
| 127 |
+
toggleSidebar,
|
| 128 |
+
}),
|
| 129 |
+
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
return (
|
| 133 |
+
<SidebarContext.Provider value={contextValue}>
|
| 134 |
+
<TooltipProvider delayDuration={0}>
|
| 135 |
+
<div
|
| 136 |
+
style={
|
| 137 |
+
{
|
| 138 |
+
"--sidebar-width": SIDEBAR_WIDTH,
|
| 139 |
+
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
| 140 |
+
...style,
|
| 141 |
+
} as React.CSSProperties
|
| 142 |
+
}
|
| 143 |
+
className={cn(
|
| 144 |
+
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
| 145 |
+
className
|
| 146 |
+
)}
|
| 147 |
+
ref={ref}
|
| 148 |
+
{...props}
|
| 149 |
+
>
|
| 150 |
+
{children}
|
| 151 |
+
</div>
|
| 152 |
+
</TooltipProvider>
|
| 153 |
+
</SidebarContext.Provider>
|
| 154 |
+
)
|
| 155 |
+
}
|
| 156 |
+
)
|
| 157 |
+
SidebarProvider.displayName = "SidebarProvider"
|
| 158 |
+
|
| 159 |
+
const Sidebar = React.forwardRef<
|
| 160 |
+
HTMLDivElement,
|
| 161 |
+
React.ComponentProps<"div"> & {
|
| 162 |
+
side?: "left" | "right"
|
| 163 |
+
variant?: "sidebar" | "floating" | "inset"
|
| 164 |
+
collapsible?: "offcanvas" | "icon" | "none"
|
| 165 |
+
}
|
| 166 |
+
>(
|
| 167 |
+
(
|
| 168 |
+
{
|
| 169 |
+
side = "left",
|
| 170 |
+
variant = "sidebar",
|
| 171 |
+
collapsible = "offcanvas",
|
| 172 |
+
className,
|
| 173 |
+
children,
|
| 174 |
+
...props
|
| 175 |
+
},
|
| 176 |
+
ref
|
| 177 |
+
) => {
|
| 178 |
+
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
| 179 |
+
|
| 180 |
+
if (collapsible === "none") {
|
| 181 |
+
return (
|
| 182 |
+
<div
|
| 183 |
+
className={cn(
|
| 184 |
+
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
| 185 |
+
className
|
| 186 |
+
)}
|
| 187 |
+
ref={ref}
|
| 188 |
+
{...props}
|
| 189 |
+
>
|
| 190 |
+
{children}
|
| 191 |
+
</div>
|
| 192 |
+
)
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
if (isMobile) {
|
| 196 |
+
return (
|
| 197 |
+
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
| 198 |
+
<SheetContent
|
| 199 |
+
data-sidebar="sidebar"
|
| 200 |
+
data-mobile="true"
|
| 201 |
+
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
| 202 |
+
style={
|
| 203 |
+
{
|
| 204 |
+
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
| 205 |
+
} as React.CSSProperties
|
| 206 |
+
}
|
| 207 |
+
side={side}
|
| 208 |
+
>
|
| 209 |
+
<div className="flex h-full w-full flex-col">{children}</div>
|
| 210 |
+
</SheetContent>
|
| 211 |
+
</Sheet>
|
| 212 |
+
)
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
return (
|
| 216 |
+
<div
|
| 217 |
+
ref={ref}
|
| 218 |
+
className="group peer hidden md:block text-sidebar-foreground"
|
| 219 |
+
data-state={state}
|
| 220 |
+
data-collapsible={state === "collapsed" ? collapsible : ""}
|
| 221 |
+
data-variant={variant}
|
| 222 |
+
data-side={side}
|
| 223 |
+
>
|
| 224 |
+
{/* This is what handles the sidebar gap on desktop */}
|
| 225 |
+
<div
|
| 226 |
+
className={cn(
|
| 227 |
+
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
|
| 228 |
+
"group-data-[collapsible=offcanvas]:w-0",
|
| 229 |
+
"group-data-[side=right]:rotate-180",
|
| 230 |
+
variant === "floating" || variant === "inset"
|
| 231 |
+
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
| 232 |
+
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
| 233 |
+
)}
|
| 234 |
+
/>
|
| 235 |
+
<div
|
| 236 |
+
className={cn(
|
| 237 |
+
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
|
| 238 |
+
side === "left"
|
| 239 |
+
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
| 240 |
+
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
| 241 |
+
// Adjust the padding for floating and inset variants.
|
| 242 |
+
variant === "floating" || variant === "inset"
|
| 243 |
+
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
| 244 |
+
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
| 245 |
+
className
|
| 246 |
+
)}
|
| 247 |
+
{...props}
|
| 248 |
+
>
|
| 249 |
+
<div
|
| 250 |
+
data-sidebar="sidebar"
|
| 251 |
+
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
| 252 |
+
>
|
| 253 |
+
{children}
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
)
|
| 258 |
+
}
|
| 259 |
+
)
|
| 260 |
+
Sidebar.displayName = "Sidebar"
|
| 261 |
+
|
| 262 |
+
const SidebarTrigger = React.forwardRef<
|
| 263 |
+
React.ElementRef<typeof Button>,
|
| 264 |
+
React.ComponentProps<typeof Button>
|
| 265 |
+
>(({ className, onClick, ...props }, ref) => {
|
| 266 |
+
const { toggleSidebar } = useSidebar()
|
| 267 |
+
|
| 268 |
+
return (
|
| 269 |
+
<Button
|
| 270 |
+
ref={ref}
|
| 271 |
+
data-sidebar="trigger"
|
| 272 |
+
variant="ghost"
|
| 273 |
+
size="icon"
|
| 274 |
+
className={cn("h-7 w-7", className)}
|
| 275 |
+
onClick={(event) => {
|
| 276 |
+
onClick?.(event)
|
| 277 |
+
toggleSidebar()
|
| 278 |
+
}}
|
| 279 |
+
{...props}
|
| 280 |
+
>
|
| 281 |
+
<PanelLeft />
|
| 282 |
+
<span className="sr-only">Toggle Sidebar</span>
|
| 283 |
+
</Button>
|
| 284 |
+
)
|
| 285 |
+
})
|
| 286 |
+
SidebarTrigger.displayName = "SidebarTrigger"
|
| 287 |
+
|
| 288 |
+
const SidebarRail = React.forwardRef<
|
| 289 |
+
HTMLButtonElement,
|
| 290 |
+
React.ComponentProps<"button">
|
| 291 |
+
>(({ className, ...props }, ref) => {
|
| 292 |
+
const { toggleSidebar } = useSidebar()
|
| 293 |
+
|
| 294 |
+
return (
|
| 295 |
+
<button
|
| 296 |
+
ref={ref}
|
| 297 |
+
data-sidebar="rail"
|
| 298 |
+
aria-label="Toggle Sidebar"
|
| 299 |
+
tabIndex={-1}
|
| 300 |
+
onClick={toggleSidebar}
|
| 301 |
+
title="Toggle Sidebar"
|
| 302 |
+
className={cn(
|
| 303 |
+
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
| 304 |
+
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
| 305 |
+
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
| 306 |
+
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
| 307 |
+
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
| 308 |
+
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
| 309 |
+
className
|
| 310 |
+
)}
|
| 311 |
+
{...props}
|
| 312 |
+
/>
|
| 313 |
+
)
|
| 314 |
+
})
|
| 315 |
+
SidebarRail.displayName = "SidebarRail"
|
| 316 |
+
|
| 317 |
+
const SidebarInset = React.forwardRef<
|
| 318 |
+
HTMLDivElement,
|
| 319 |
+
React.ComponentProps<"main">
|
| 320 |
+
>(({ className, ...props }, ref) => {
|
| 321 |
+
return (
|
| 322 |
+
<main
|
| 323 |
+
ref={ref}
|
| 324 |
+
className={cn(
|
| 325 |
+
"relative flex min-h-svh flex-1 flex-col bg-background",
|
| 326 |
+
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
| 327 |
+
className
|
| 328 |
+
)}
|
| 329 |
+
{...props}
|
| 330 |
+
/>
|
| 331 |
+
)
|
| 332 |
+
})
|
| 333 |
+
SidebarInset.displayName = "SidebarInset"
|
| 334 |
+
|
| 335 |
+
const SidebarInput = React.forwardRef<
|
| 336 |
+
React.ElementRef<typeof Input>,
|
| 337 |
+
React.ComponentProps<typeof Input>
|
| 338 |
+
>(({ className, ...props }, ref) => {
|
| 339 |
+
return (
|
| 340 |
+
<Input
|
| 341 |
+
ref={ref}
|
| 342 |
+
data-sidebar="input"
|
| 343 |
+
className={cn(
|
| 344 |
+
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
| 345 |
+
className
|
| 346 |
+
)}
|
| 347 |
+
{...props}
|
| 348 |
+
/>
|
| 349 |
+
)
|
| 350 |
+
})
|
| 351 |
+
SidebarInput.displayName = "SidebarInput"
|
| 352 |
+
|
| 353 |
+
const SidebarHeader = React.forwardRef<
|
| 354 |
+
HTMLDivElement,
|
| 355 |
+
React.ComponentProps<"div">
|
| 356 |
+
>(({ className, ...props }, ref) => {
|
| 357 |
+
return (
|
| 358 |
+
<div
|
| 359 |
+
ref={ref}
|
| 360 |
+
data-sidebar="header"
|
| 361 |
+
className={cn("flex flex-col gap-2 p-2", className)}
|
| 362 |
+
{...props}
|
| 363 |
+
/>
|
| 364 |
+
)
|
| 365 |
+
})
|
| 366 |
+
SidebarHeader.displayName = "SidebarHeader"
|
| 367 |
+
|
| 368 |
+
const SidebarFooter = React.forwardRef<
|
| 369 |
+
HTMLDivElement,
|
| 370 |
+
React.ComponentProps<"div">
|
| 371 |
+
>(({ className, ...props }, ref) => {
|
| 372 |
+
return (
|
| 373 |
+
<div
|
| 374 |
+
ref={ref}
|
| 375 |
+
data-sidebar="footer"
|
| 376 |
+
className={cn("flex flex-col gap-2 p-2", className)}
|
| 377 |
+
{...props}
|
| 378 |
+
/>
|
| 379 |
+
)
|
| 380 |
+
})
|
| 381 |
+
SidebarFooter.displayName = "SidebarFooter"
|
| 382 |
+
|
| 383 |
+
const SidebarSeparator = React.forwardRef<
|
| 384 |
+
React.ElementRef<typeof Separator>,
|
| 385 |
+
React.ComponentProps<typeof Separator>
|
| 386 |
+
>(({ className, ...props }, ref) => {
|
| 387 |
+
return (
|
| 388 |
+
<Separator
|
| 389 |
+
ref={ref}
|
| 390 |
+
data-sidebar="separator"
|
| 391 |
+
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
| 392 |
+
{...props}
|
| 393 |
+
/>
|
| 394 |
+
)
|
| 395 |
+
})
|
| 396 |
+
SidebarSeparator.displayName = "SidebarSeparator"
|
| 397 |
+
|
| 398 |
+
const SidebarContent = React.forwardRef<
|
| 399 |
+
HTMLDivElement,
|
| 400 |
+
React.ComponentProps<"div">
|
| 401 |
+
>(({ className, ...props }, ref) => {
|
| 402 |
+
return (
|
| 403 |
+
<div
|
| 404 |
+
ref={ref}
|
| 405 |
+
data-sidebar="content"
|
| 406 |
+
className={cn(
|
| 407 |
+
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
| 408 |
+
className
|
| 409 |
+
)}
|
| 410 |
+
{...props}
|
| 411 |
+
/>
|
| 412 |
+
)
|
| 413 |
+
})
|
| 414 |
+
SidebarContent.displayName = "SidebarContent"
|
| 415 |
+
|
| 416 |
+
const SidebarGroup = React.forwardRef<
|
| 417 |
+
HTMLDivElement,
|
| 418 |
+
React.ComponentProps<"div">
|
| 419 |
+
>(({ className, ...props }, ref) => {
|
| 420 |
+
return (
|
| 421 |
+
<div
|
| 422 |
+
ref={ref}
|
| 423 |
+
data-sidebar="group"
|
| 424 |
+
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
| 425 |
+
{...props}
|
| 426 |
+
/>
|
| 427 |
+
)
|
| 428 |
+
})
|
| 429 |
+
SidebarGroup.displayName = "SidebarGroup"
|
| 430 |
+
|
| 431 |
+
const SidebarGroupLabel = React.forwardRef<
|
| 432 |
+
HTMLDivElement,
|
| 433 |
+
React.ComponentProps<"div"> & { asChild?: boolean }
|
| 434 |
+
>(({ className, asChild = false, ...props }, ref) => {
|
| 435 |
+
const Comp = asChild ? Slot : "div"
|
| 436 |
+
|
| 437 |
+
return (
|
| 438 |
+
<Comp
|
| 439 |
+
ref={ref}
|
| 440 |
+
data-sidebar="group-label"
|
| 441 |
+
className={cn(
|
| 442 |
+
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
| 443 |
+
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
| 444 |
+
className
|
| 445 |
+
)}
|
| 446 |
+
{...props}
|
| 447 |
+
/>
|
| 448 |
+
)
|
| 449 |
+
})
|
| 450 |
+
SidebarGroupLabel.displayName = "SidebarGroupLabel"
|
| 451 |
+
|
| 452 |
+
const SidebarGroupAction = React.forwardRef<
|
| 453 |
+
HTMLButtonElement,
|
| 454 |
+
React.ComponentProps<"button"> & { asChild?: boolean }
|
| 455 |
+
>(({ className, asChild = false, ...props }, ref) => {
|
| 456 |
+
const Comp = asChild ? Slot : "button"
|
| 457 |
+
|
| 458 |
+
return (
|
| 459 |
+
<Comp
|
| 460 |
+
ref={ref}
|
| 461 |
+
data-sidebar="group-action"
|
| 462 |
+
className={cn(
|
| 463 |
+
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
| 464 |
+
// Increases the hit area of the button on mobile.
|
| 465 |
+
"after:absolute after:-inset-2 after:md:hidden",
|
| 466 |
+
"group-data-[collapsible=icon]:hidden",
|
| 467 |
+
className
|
| 468 |
+
)}
|
| 469 |
+
{...props}
|
| 470 |
+
/>
|
| 471 |
+
)
|
| 472 |
+
})
|
| 473 |
+
SidebarGroupAction.displayName = "SidebarGroupAction"
|
| 474 |
+
|
| 475 |
+
const SidebarGroupContent = React.forwardRef<
|
| 476 |
+
HTMLDivElement,
|
| 477 |
+
React.ComponentProps<"div">
|
| 478 |
+
>(({ className, ...props }, ref) => (
|
| 479 |
+
<div
|
| 480 |
+
ref={ref}
|
| 481 |
+
data-sidebar="group-content"
|
| 482 |
+
className={cn("w-full text-sm", className)}
|
| 483 |
+
{...props}
|
| 484 |
+
/>
|
| 485 |
+
))
|
| 486 |
+
SidebarGroupContent.displayName = "SidebarGroupContent"
|
| 487 |
+
|
| 488 |
+
const SidebarMenu = React.forwardRef<
|
| 489 |
+
HTMLUListElement,
|
| 490 |
+
React.ComponentProps<"ul">
|
| 491 |
+
>(({ className, ...props }, ref) => (
|
| 492 |
+
<ul
|
| 493 |
+
ref={ref}
|
| 494 |
+
data-sidebar="menu"
|
| 495 |
+
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
| 496 |
+
{...props}
|
| 497 |
+
/>
|
| 498 |
+
))
|
| 499 |
+
SidebarMenu.displayName = "SidebarMenu"
|
| 500 |
+
|
| 501 |
+
const SidebarMenuItem = React.forwardRef<
|
| 502 |
+
HTMLLIElement,
|
| 503 |
+
React.ComponentProps<"li">
|
| 504 |
+
>(({ className, ...props }, ref) => (
|
| 505 |
+
<li
|
| 506 |
+
ref={ref}
|
| 507 |
+
data-sidebar="menu-item"
|
| 508 |
+
className={cn("group/menu-item relative", className)}
|
| 509 |
+
{...props}
|
| 510 |
+
/>
|
| 511 |
+
))
|
| 512 |
+
SidebarMenuItem.displayName = "SidebarMenuItem"
|
| 513 |
+
|
| 514 |
+
const sidebarMenuButtonVariants = cva(
|
| 515 |
+
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
| 516 |
+
{
|
| 517 |
+
variants: {
|
| 518 |
+
variant: {
|
| 519 |
+
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
| 520 |
+
outline:
|
| 521 |
+
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
| 522 |
+
},
|
| 523 |
+
size: {
|
| 524 |
+
default: "h-8 text-sm",
|
| 525 |
+
sm: "h-7 text-xs",
|
| 526 |
+
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
| 527 |
+
},
|
| 528 |
+
},
|
| 529 |
+
defaultVariants: {
|
| 530 |
+
variant: "default",
|
| 531 |
+
size: "default",
|
| 532 |
+
},
|
| 533 |
+
}
|
| 534 |
+
)
|
| 535 |
+
|
| 536 |
+
const SidebarMenuButton = React.forwardRef<
|
| 537 |
+
HTMLButtonElement,
|
| 538 |
+
React.ComponentProps<"button"> & {
|
| 539 |
+
asChild?: boolean
|
| 540 |
+
isActive?: boolean
|
| 541 |
+
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
| 542 |
+
} & VariantProps<typeof sidebarMenuButtonVariants>
|
| 543 |
+
>(
|
| 544 |
+
(
|
| 545 |
+
{
|
| 546 |
+
asChild = false,
|
| 547 |
+
isActive = false,
|
| 548 |
+
variant = "default",
|
| 549 |
+
size = "default",
|
| 550 |
+
tooltip,
|
| 551 |
+
className,
|
| 552 |
+
...props
|
| 553 |
+
},
|
| 554 |
+
ref
|
| 555 |
+
) => {
|
| 556 |
+
const Comp = asChild ? Slot : "button"
|
| 557 |
+
const { isMobile, state } = useSidebar()
|
| 558 |
+
|
| 559 |
+
const button = (
|
| 560 |
+
<Comp
|
| 561 |
+
ref={ref}
|
| 562 |
+
data-sidebar="menu-button"
|
| 563 |
+
data-size={size}
|
| 564 |
+
data-active={isActive}
|
| 565 |
+
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
| 566 |
+
{...props}
|
| 567 |
+
/>
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
if (!tooltip) {
|
| 571 |
+
return button
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
if (typeof tooltip === "string") {
|
| 575 |
+
tooltip = {
|
| 576 |
+
children: tooltip,
|
| 577 |
+
}
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
return (
|
| 581 |
+
<Tooltip>
|
| 582 |
+
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
| 583 |
+
<TooltipContent
|
| 584 |
+
side="right"
|
| 585 |
+
align="center"
|
| 586 |
+
hidden={state !== "collapsed" || isMobile}
|
| 587 |
+
{...tooltip}
|
| 588 |
+
/>
|
| 589 |
+
</Tooltip>
|
| 590 |
+
)
|
| 591 |
+
}
|
| 592 |
+
)
|
| 593 |
+
SidebarMenuButton.displayName = "SidebarMenuButton"
|
| 594 |
+
|
| 595 |
+
const SidebarMenuAction = React.forwardRef<
|
| 596 |
+
HTMLButtonElement,
|
| 597 |
+
React.ComponentProps<"button"> & {
|
| 598 |
+
asChild?: boolean
|
| 599 |
+
showOnHover?: boolean
|
| 600 |
+
}
|
| 601 |
+
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
| 602 |
+
const Comp = asChild ? Slot : "button"
|
| 603 |
+
|
| 604 |
+
return (
|
| 605 |
+
<Comp
|
| 606 |
+
ref={ref}
|
| 607 |
+
data-sidebar="menu-action"
|
| 608 |
+
className={cn(
|
| 609 |
+
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
| 610 |
+
// Increases the hit area of the button on mobile.
|
| 611 |
+
"after:absolute after:-inset-2 after:md:hidden",
|
| 612 |
+
"peer-data-[size=sm]/menu-button:top-1",
|
| 613 |
+
"peer-data-[size=default]/menu-button:top-1.5",
|
| 614 |
+
"peer-data-[size=lg]/menu-button:top-2.5",
|
| 615 |
+
"group-data-[collapsible=icon]:hidden",
|
| 616 |
+
showOnHover &&
|
| 617 |
+
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
| 618 |
+
className
|
| 619 |
+
)}
|
| 620 |
+
{...props}
|
| 621 |
+
/>
|
| 622 |
+
)
|
| 623 |
+
})
|
| 624 |
+
SidebarMenuAction.displayName = "SidebarMenuAction"
|
| 625 |
+
|
| 626 |
+
const SidebarMenuBadge = React.forwardRef<
|
| 627 |
+
HTMLDivElement,
|
| 628 |
+
React.ComponentProps<"div">
|
| 629 |
+
>(({ className, ...props }, ref) => (
|
| 630 |
+
<div
|
| 631 |
+
ref={ref}
|
| 632 |
+
data-sidebar="menu-badge"
|
| 633 |
+
className={cn(
|
| 634 |
+
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
|
| 635 |
+
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
| 636 |
+
"peer-data-[size=sm]/menu-button:top-1",
|
| 637 |
+
"peer-data-[size=default]/menu-button:top-1.5",
|
| 638 |
+
"peer-data-[size=lg]/menu-button:top-2.5",
|
| 639 |
+
"group-data-[collapsible=icon]:hidden",
|
| 640 |
+
className
|
| 641 |
+
)}
|
| 642 |
+
{...props}
|
| 643 |
+
/>
|
| 644 |
+
))
|
| 645 |
+
SidebarMenuBadge.displayName = "SidebarMenuBadge"
|
| 646 |
+
|
| 647 |
+
const SidebarMenuSkeleton = React.forwardRef<
|
| 648 |
+
HTMLDivElement,
|
| 649 |
+
React.ComponentProps<"div"> & {
|
| 650 |
+
showIcon?: boolean
|
| 651 |
+
}
|
| 652 |
+
>(({ className, showIcon = false, ...props }, ref) => {
|
| 653 |
+
// Random width between 50 to 90%.
|
| 654 |
+
const width = React.useMemo(() => {
|
| 655 |
+
return `${Math.floor(Math.random() * 40) + 50}%`
|
| 656 |
+
}, [])
|
| 657 |
+
|
| 658 |
+
return (
|
| 659 |
+
<div
|
| 660 |
+
ref={ref}
|
| 661 |
+
data-sidebar="menu-skeleton"
|
| 662 |
+
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
|
| 663 |
+
{...props}
|
| 664 |
+
>
|
| 665 |
+
{showIcon && (
|
| 666 |
+
<Skeleton
|
| 667 |
+
className="size-4 rounded-md"
|
| 668 |
+
data-sidebar="menu-skeleton-icon"
|
| 669 |
+
/>
|
| 670 |
+
)}
|
| 671 |
+
<Skeleton
|
| 672 |
+
className="h-4 flex-1 max-w-[--skeleton-width]"
|
| 673 |
+
data-sidebar="menu-skeleton-text"
|
| 674 |
+
style={
|
| 675 |
+
{
|
| 676 |
+
"--skeleton-width": width,
|
| 677 |
+
} as React.CSSProperties
|
| 678 |
+
}
|
| 679 |
+
/>
|
| 680 |
+
</div>
|
| 681 |
+
)
|
| 682 |
+
})
|
| 683 |
+
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
|
| 684 |
+
|
| 685 |
+
const SidebarMenuSub = React.forwardRef<
|
| 686 |
+
HTMLUListElement,
|
| 687 |
+
React.ComponentProps<"ul">
|
| 688 |
+
>(({ className, ...props }, ref) => (
|
| 689 |
+
<ul
|
| 690 |
+
ref={ref}
|
| 691 |
+
data-sidebar="menu-sub"
|
| 692 |
+
className={cn(
|
| 693 |
+
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
| 694 |
+
"group-data-[collapsible=icon]:hidden",
|
| 695 |
+
className
|
| 696 |
+
)}
|
| 697 |
+
{...props}
|
| 698 |
+
/>
|
| 699 |
+
))
|
| 700 |
+
SidebarMenuSub.displayName = "SidebarMenuSub"
|
| 701 |
+
|
| 702 |
+
const SidebarMenuSubItem = React.forwardRef<
|
| 703 |
+
HTMLLIElement,
|
| 704 |
+
React.ComponentProps<"li">
|
| 705 |
+
>(({ ...props }, ref) => <li ref={ref} {...props} />)
|
| 706 |
+
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
|
| 707 |
+
|
| 708 |
+
const SidebarMenuSubButton = React.forwardRef<
|
| 709 |
+
HTMLAnchorElement,
|
| 710 |
+
React.ComponentProps<"a"> & {
|
| 711 |
+
asChild?: boolean
|
| 712 |
+
size?: "sm" | "md"
|
| 713 |
+
isActive?: boolean
|
| 714 |
+
}
|
| 715 |
+
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
| 716 |
+
const Comp = asChild ? Slot : "a"
|
| 717 |
+
|
| 718 |
+
return (
|
| 719 |
+
<Comp
|
| 720 |
+
ref={ref}
|
| 721 |
+
data-sidebar="menu-sub-button"
|
| 722 |
+
data-size={size}
|
| 723 |
+
data-active={isActive}
|
| 724 |
+
className={cn(
|
| 725 |
+
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
| 726 |
+
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
| 727 |
+
size === "sm" && "text-xs",
|
| 728 |
+
size === "md" && "text-sm",
|
| 729 |
+
"group-data-[collapsible=icon]:hidden",
|
| 730 |
+
className
|
| 731 |
+
)}
|
| 732 |
+
{...props}
|
| 733 |
+
/>
|
| 734 |
+
)
|
| 735 |
+
})
|
| 736 |
+
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
|
| 737 |
+
|
| 738 |
+
export {
|
| 739 |
+
Sidebar,
|
| 740 |
+
SidebarContent,
|
| 741 |
+
SidebarFooter,
|
| 742 |
+
SidebarGroup,
|
| 743 |
+
SidebarGroupAction,
|
| 744 |
+
SidebarGroupContent,
|
| 745 |
+
SidebarGroupLabel,
|
| 746 |
+
SidebarHeader,
|
| 747 |
+
SidebarInput,
|
| 748 |
+
SidebarInset,
|
| 749 |
+
SidebarMenu,
|
| 750 |
+
SidebarMenuAction,
|
| 751 |
+
SidebarMenuBadge,
|
| 752 |
+
SidebarMenuButton,
|
| 753 |
+
SidebarMenuItem,
|
| 754 |
+
SidebarMenuSkeleton,
|
| 755 |
+
SidebarMenuSub,
|
| 756 |
+
SidebarMenuSubButton,
|
| 757 |
+
SidebarMenuSubItem,
|
| 758 |
+
SidebarProvider,
|
| 759 |
+
SidebarRail,
|
| 760 |
+
SidebarSeparator,
|
| 761 |
+
SidebarTrigger,
|
| 762 |
+
useSidebar,
|
| 763 |
+
}
|