feat: add UI build to build process

- Created separate build script to handle both CLI and UI building
- Added automatic UI dependency installation
- Copy built UI artifacts to dist directory

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
musistudio
2025-07-30 11:15:05 +08:00
parent 31db041084
commit 112d7ef8f9
57 changed files with 13581 additions and 365 deletions

View File

@@ -0,0 +1,147 @@
import { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode, Dispatch, SetStateAction } from 'react';
import { api } from '@/lib/api';
export interface Transformer {
path: string;
options: {
[key: string]: string;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
export interface ProviderTransformer {
use: (string | (string | Record<string, unknown> | { max_tokens: number })[])[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any; // for model specific transformers
}
export interface Provider {
name: string;
api_base_url: string;
api_key: string;
models: string[];
transformer?: ProviderTransformer;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
export interface RouterConfig {
default: string;
background: string;
think: string;
longContext: string;
webSearch: string;
}
export interface Config {
LOG: boolean;
CLAUDE_PATH: string;
HOST: string;
PORT: number;
APIKEY: string;
transformers: Transformer[];
Providers: Provider[];
Router: RouterConfig;
}
interface ConfigContextType {
config: Config | null;
setConfig: Dispatch<SetStateAction<Config | null>>;
error: Error | null;
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
// eslint-disable-next-line react-refresh/only-export-components
export function useConfig() {
const context = useContext(ConfigContext);
if (context === undefined) {
throw new Error('useConfig must be used within a ConfigProvider');
}
return context;
}
interface ConfigProviderProps {
children: ReactNode;
}
export function ConfigProvider({ children }: ConfigProviderProps) {
const [config, setConfig] = useState<Config | null>(null);
const [error, setError] = useState<Error | null>(null);
const [hasFetched, setHasFetched] = useState<boolean>(false);
const [apiKey, setApiKey] = useState<string | null>(localStorage.getItem('apiKey'));
// Listen for localStorage changes
useEffect(() => {
const handleStorageChange = () => {
setApiKey(localStorage.getItem('apiKey'));
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, []);
useEffect(() => {
const fetchConfig = async () => {
// Reset fetch state when API key changes
setHasFetched(false);
setConfig(null);
setError(null);
};
fetchConfig();
}, [apiKey]);
useEffect(() => {
const fetchConfig = async () => {
// Prevent duplicate API calls in React StrictMode
// Skip if we've already fetched
if (hasFetched) {
return;
}
setHasFetched(true);
try {
// Try to fetch config regardless of API key presence
const data = await api.getConfig();
setConfig(data);
} catch (err) {
console.error('Failed to fetch config:', err);
// If we get a 401, the API client will redirect to login
// Otherwise, set an empty config or error
if ((err as Error).message !== 'Unauthorized') {
// Set default empty config when fetch fails
setConfig({
LOG: false,
CLAUDE_PATH: '',
HOST: '127.0.0.1',
PORT: 3456,
APIKEY: '',
transformers: [],
Providers: [],
Router: {
default: '',
background: '',
think: '',
longContext: '',
webSearch: ''
}
});
setError(err as Error);
}
}
};
fetchConfig();
}, [hasFetched, apiKey]);
return (
<ConfigContext.Provider value={{ config, setConfig, error }}>
{children}
</ConfigContext.Provider>
);
}

129
ui/src/components/Login.tsx Normal file
View File

@@ -0,0 +1,129 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { api } from '@/lib/api';
export function Login() {
const { t } = useTranslation();
const navigate = useNavigate();
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
// Check if user is already authenticated
useEffect(() => {
const checkAuth = async () => {
const apiKey = localStorage.getItem('apiKey');
if (apiKey) {
setIsLoading(true);
// Verify the API key is still valid
try {
await api.getConfig();
navigate('/dashboard');
} catch (err) {
// If verification fails, remove the API key
localStorage.removeItem('apiKey');
} finally {
setIsLoading(false);
}
}
};
checkAuth();
// Listen for unauthorized events
const handleUnauthorized = () => {
navigate('/login');
};
window.addEventListener('unauthorized', handleUnauthorized);
return () => {
window.removeEventListener('unauthorized', handleUnauthorized);
};
}, [navigate]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Set the API key
api.setApiKey(apiKey);
// Dispatch storage event to notify other components of the change
window.dispatchEvent(new StorageEvent('storage', {
key: 'apiKey',
newValue: apiKey,
url: window.location.href
}));
// Test the API key by fetching config (skip if apiKey is empty)
if (apiKey) {
await api.getConfig();
}
// Navigate to dashboard
// The ConfigProvider will handle fetching the config
navigate('/dashboard');
} catch (err) {
// Clear the API key on failure
api.setApiKey('');
setError(t('login.invalidApiKey'));
}
};
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl">{t('login.title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</div>
<p className="text-center text-sm text-gray-500">{t('login.validating')}</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl">{t('login.title')}</CardTitle>
<CardDescription>
{t('login.description')}
</CardDescription>
</CardHeader>
<form onSubmit={handleLogin}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="apiKey">{t('login.apiKey')}</Label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t('login.apiKeyPlaceholder')}
/>
</div>
{error && <div className="text-sm text-red-500">{error}</div>}
</CardContent>
<CardFooter>
<Button className="w-full" type="submit">
{t('login.signIn')}
</Button>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { type Provider } from "./ConfigProvider";
interface ProviderListProps {
providers: Provider[];
onEdit: (index: number) => void;
onRemove: (index: number) => void;
}
export function ProviderList({ providers, onEdit, onRemove }: ProviderListProps) {
return (
<div className="space-y-3">
{providers.map((provider, index) => (
<div key={index} className="flex items-start justify-between rounded-md border bg-white p-4 transition-all hover:shadow-md animate-slide-in hover:scale-[1.01]">
<div className="flex-1 space-y-1.5">
<p className="text-md font-semibold text-gray-800">{provider.name}</p>
<p className="text-sm text-gray-500">{provider.api_base_url}</p>
<div className="flex flex-wrap gap-2 pt-2">
{provider.models.map((model) => (
<Badge key={model} variant="outline" className="font-normal transition-all-ease hover:scale-105">{model}</Badge>
))}
</div>
</div>
<div className="ml-4 flex flex-shrink-0 items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => onEdit(index)} className="transition-all-ease hover:scale-110">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="destructive" size="icon" onClick={() => onRemove(index)} className="transition-all duration-200 hover:scale-110">
<Trash2 className="h-4 w-4 text-current transition-colors duration-200" />
</Button>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,775 @@
import { useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useConfig } from "./ConfigProvider";
import { ProviderList } from "./ProviderList";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { X, Trash2, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Combobox } from "@/components/ui/combobox";
import { ComboInput } from "@/components/ui/combo-input";
export function Providers() {
const { t } = useTranslation();
const { config, setConfig } = useConfig();
const [editingProviderIndex, setEditingProviderIndex] = useState<number | null>(null);
const [deletingProviderIndex, setDeletingProviderIndex] = useState<number | null>(null);
const [hasFetchedModels, setHasFetchedModels] = useState<Record<number, boolean>>({});
const [providerParamInputs, setProviderParamInputs] = useState<Record<string, {name: string, value: string}>>({});
const [modelParamInputs, setModelParamInputs] = useState<Record<string, {name: string, value: string}>>({});
const comboInputRef = useRef<HTMLInputElement>(null);
if (!config) {
return null;
}
const handleAddProvider = () => {
const newProviders = [...config.Providers, { name: "", api_base_url: "", api_key: "", models: [] }];
setConfig({ ...config, Providers: newProviders });
setEditingProviderIndex(newProviders.length - 1);
};
const handleSaveProvider = () => {
setEditingProviderIndex(null);
};
const handleCancelAddProvider = () => {
// If we're adding a new provider, remove it regardless of content
if (editingProviderIndex !== null && editingProviderIndex === config.Providers.length - 1) {
const newProviders = [...config.Providers];
newProviders.pop();
setConfig({ ...config, Providers: newProviders });
}
// Reset fetched models state for this provider
if (editingProviderIndex !== null) {
setHasFetchedModels(prev => {
const newState = { ...prev };
delete newState[editingProviderIndex];
return newState;
});
}
setEditingProviderIndex(null);
};
const handleRemoveProvider = (index: number) => {
const newProviders = [...config.Providers];
newProviders.splice(index, 1);
setConfig({ ...config, Providers: newProviders });
setDeletingProviderIndex(null);
};
const handleProviderChange = (index: number, field: string, value: string) => {
const newProviders = [...config.Providers];
newProviders[index][field] = value;
setConfig({ ...config, Providers: newProviders });
};
const handleProviderTransformerChange = (index: number, transformerPath: string) => {
if (!transformerPath) return; // Don't add empty transformers
const newProviders = [...config.Providers];
if (!newProviders[index].transformer) {
newProviders[index].transformer = { use: [] };
}
// Add transformer to the use array
newProviders[index].transformer!.use = [...newProviders[index].transformer!.use, transformerPath];
setConfig({ ...config, Providers: newProviders });
};
const removeProviderTransformerAtIndex = (index: number, transformerIndex: number) => {
const newProviders = [...config.Providers];
if (newProviders[index].transformer) {
const newUseArray = [...newProviders[index].transformer!.use];
newUseArray.splice(transformerIndex, 1);
newProviders[index].transformer!.use = newUseArray;
// If use array is now empty and no other properties, remove transformer entirely
if (newUseArray.length === 0 && Object.keys(newProviders[index].transformer!).length === 1) {
delete newProviders[index].transformer;
}
}
setConfig({ ...config, Providers: newProviders });
};
const handleModelTransformerChange = (providerIndex: number, model: string, transformerPath: string) => {
if (!transformerPath) return; // Don't add empty transformers
const newProviders = [...config.Providers];
if (!newProviders[providerIndex].transformer) {
newProviders[providerIndex].transformer = { use: [] };
}
// Initialize model transformer if it doesn't exist
if (!newProviders[providerIndex].transformer![model]) {
newProviders[providerIndex].transformer![model] = { use: [] };
}
// Add transformer to the use array
newProviders[providerIndex].transformer![model].use = [...newProviders[providerIndex].transformer![model].use, transformerPath];
setConfig({ ...config, Providers: newProviders });
};
const removeModelTransformerAtIndex = (providerIndex: number, model: string, transformerIndex: number) => {
const newProviders = [...config.Providers];
if (newProviders[providerIndex].transformer && newProviders[providerIndex].transformer![model]) {
const newUseArray = [...newProviders[providerIndex].transformer![model].use];
newUseArray.splice(transformerIndex, 1);
newProviders[providerIndex].transformer![model].use = newUseArray;
// If use array is now empty and no other properties, remove model transformer entirely
if (newUseArray.length === 0 && Object.keys(newProviders[providerIndex].transformer![model]).length === 1) {
delete newProviders[providerIndex].transformer![model];
}
}
setConfig({ ...config, Providers: newProviders });
};
const addProviderTransformerParameter = (providerIndex: number, transformerIndex: number, paramName: string, paramValue: string) => {
const newProviders = [...config.Providers];
if (!newProviders[providerIndex].transformer) {
newProviders[providerIndex].transformer = { use: [] };
}
// Add parameter to the specified transformer in use array
if (newProviders[providerIndex].transformer!.use && newProviders[providerIndex].transformer!.use.length > transformerIndex) {
const targetTransformer = newProviders[providerIndex].transformer!.use[transformerIndex];
// If it's already an array with parameters, update it
if (Array.isArray(targetTransformer)) {
const transformerArray = [...targetTransformer];
// Check if the second element is an object (parameters object)
if (transformerArray.length > 1 && typeof transformerArray[1] === 'object' && transformerArray[1] !== null) {
// Update the existing parameters object
const existingParams = transformerArray[1] as Record<string, unknown>;
const paramsObj: Record<string, unknown> = { ...existingParams, [paramName]: paramValue };
transformerArray[1] = paramsObj;
} else if (transformerArray.length > 1) {
// If there are other elements, add the parameters object
const paramsObj = { [paramName]: paramValue };
transformerArray.splice(1, transformerArray.length - 1, paramsObj);
} else {
// Add a new parameters object
const paramsObj = { [paramName]: paramValue };
transformerArray.push(paramsObj);
}
newProviders[providerIndex].transformer!.use[transformerIndex] = transformerArray as any;
} else {
// Convert to array format with parameters
const paramsObj = { [paramName]: paramValue };
newProviders[providerIndex].transformer!.use[transformerIndex] = [targetTransformer as string, paramsObj] as any;
}
}
setConfig({ ...config, Providers: newProviders });
};
const removeProviderTransformerParameterAtIndex = (providerIndex: number, transformerIndex: number, paramName: string) => {
const newProviders = [...config.Providers];
if (!newProviders[providerIndex].transformer?.use || newProviders[providerIndex].transformer.use.length <= transformerIndex) {
return;
}
const targetTransformer = newProviders[providerIndex].transformer.use[transformerIndex];
if (Array.isArray(targetTransformer) && targetTransformer.length > 1) {
const transformerArray = [...targetTransformer];
// Check if the second element is an object (parameters object)
if (typeof transformerArray[1] === 'object' && transformerArray[1] !== null) {
const paramsObj = { ...(transformerArray[1] as Record<string, unknown>) };
delete paramsObj[paramName];
// If the parameters object is now empty, remove it
if (Object.keys(paramsObj).length === 0) {
transformerArray.splice(1, 1);
} else {
transformerArray[1] = paramsObj;
}
newProviders[providerIndex].transformer!.use[transformerIndex] = transformerArray;
setConfig({ ...config, Providers: newProviders });
}
}
};
const addModelTransformerParameter = (providerIndex: number, model: string, transformerIndex: number, paramName: string, paramValue: string) => {
const newProviders = [...config.Providers];
if (!newProviders[providerIndex].transformer) {
newProviders[providerIndex].transformer = { use: [] };
}
if (!newProviders[providerIndex].transformer![model]) {
newProviders[providerIndex].transformer![model] = { use: [] };
}
// Add parameter to the specified transformer in use array
if (newProviders[providerIndex].transformer![model].use && newProviders[providerIndex].transformer![model].use.length > transformerIndex) {
const targetTransformer = newProviders[providerIndex].transformer![model].use[transformerIndex];
// If it's already an array with parameters, update it
if (Array.isArray(targetTransformer)) {
const transformerArray = [...targetTransformer];
// Check if the second element is an object (parameters object)
if (transformerArray.length > 1 && typeof transformerArray[1] === 'object' && transformerArray[1] !== null) {
// Update the existing parameters object
const existingParams = transformerArray[1] as Record<string, unknown>;
const paramsObj: Record<string, unknown> = { ...existingParams, [paramName]: paramValue };
transformerArray[1] = paramsObj;
} else if (transformerArray.length > 1) {
// If there are other elements, add the parameters object
const paramsObj = { [paramName]: paramValue };
transformerArray.splice(1, transformerArray.length - 1, paramsObj);
} else {
// Add a new parameters object
const paramsObj = { [paramName]: paramValue };
transformerArray.push(paramsObj);
}
newProviders[providerIndex].transformer![model].use[transformerIndex] = transformerArray as any;
} else {
// Convert to array format with parameters
const paramsObj = { [paramName]: paramValue };
newProviders[providerIndex].transformer![model].use[transformerIndex] = [targetTransformer as string, paramsObj] as any;
}
}
setConfig({ ...config, Providers: newProviders });
};
const removeModelTransformerParameterAtIndex = (providerIndex: number, model: string, transformerIndex: number, paramName: string) => {
const newProviders = [...config.Providers];
if (!newProviders[providerIndex].transformer?.[model]?.use || newProviders[providerIndex].transformer[model].use.length <= transformerIndex) {
return;
}
const targetTransformer = newProviders[providerIndex].transformer[model].use[transformerIndex];
if (Array.isArray(targetTransformer) && targetTransformer.length > 1) {
const transformerArray = [...targetTransformer];
// Check if the second element is an object (parameters object)
if (typeof transformerArray[1] === 'object' && transformerArray[1] !== null) {
const paramsObj = { ...(transformerArray[1] as Record<string, unknown>) };
delete paramsObj[paramName];
// If the parameters object is now empty, remove it
if (Object.keys(paramsObj).length === 0) {
transformerArray.splice(1, 1);
} else {
transformerArray[1] = paramsObj;
}
newProviders[providerIndex].transformer![model].use[transformerIndex] = transformerArray;
setConfig({ ...config, Providers: newProviders });
}
}
};
const handleAddModel = (index: number, model: string) => {
if (!model.trim()) return;
const newProviders = [...config.Providers];
const models = [...newProviders[index].models];
// Check if model already exists
if (!models.includes(model.trim())) {
models.push(model.trim());
newProviders[index].models = models;
setConfig({ ...config, Providers: newProviders });
}
};
const handleRemoveModel = (providerIndex: number, modelIndex: number) => {
const newProviders = [...config.Providers];
const models = [...newProviders[providerIndex].models];
models.splice(modelIndex, 1);
newProviders[providerIndex].models = models;
setConfig({ ...config, Providers: newProviders });
};
const editingProvider = editingProviderIndex !== null ? config.Providers[editingProviderIndex] : null;
return (
<Card className="flex h-full flex-col rounded-lg border shadow-sm">
<CardHeader className="flex flex-row items-center justify-between border-b p-4">
<CardTitle className="text-lg">{t("providers.title")} <span className="text-sm font-normal text-gray-500">({config.Providers.length})</span></CardTitle>
<Button onClick={handleAddProvider}>{t("providers.add")}</Button>
</CardHeader>
<CardContent className="flex-grow overflow-y-auto p-4">
<ProviderList
providers={config.Providers}
onEdit={setEditingProviderIndex}
onRemove={setDeletingProviderIndex}
/>
</CardContent>
{/* Edit Dialog */}
<Dialog open={editingProviderIndex !== null} onOpenChange={(open) => {
if (!open) {
handleCancelAddProvider();
}
}}>
<DialogContent className="max-h-[80vh] flex flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("providers.edit")}</DialogTitle>
</DialogHeader>
{editingProvider && editingProviderIndex !== null && (
<div className="space-y-4 p-4 overflow-y-auto flex-grow">
<div className="space-y-2">
<Label htmlFor="name">{t("providers.name")}</Label>
<Input id="name" value={editingProvider.name} onChange={(e) => handleProviderChange(editingProviderIndex, 'name', e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="api_base_url">{t("providers.api_base_url")}</Label>
<Input id="api_base_url" value={editingProvider.api_base_url} onChange={(e) => handleProviderChange(editingProviderIndex, 'api_base_url', e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="api_key">{t("providers.api_key")}</Label>
<Input id="api_key" type="password" value={editingProvider.api_key} onChange={(e) => handleProviderChange(editingProviderIndex, 'api_key', e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="models">{t("providers.models")}</Label>
<div className="space-y-2">
<div className="flex gap-2">
<div className="flex-1">
{hasFetchedModels[editingProviderIndex] ? (
<ComboInput
ref={comboInputRef}
options={editingProvider.models.map(model => ({ label: model, value: model }))}
value=""
onChange={(_) => {
// 只更新输入值,不添加模型
}}
onEnter={(value) => {
if (editingProviderIndex !== null) {
handleAddModel(editingProviderIndex, value);
}
}}
inputPlaceholder={t("providers.models_placeholder")}
/>
) : (
<Input
id="models"
placeholder={t("providers.models_placeholder")}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.currentTarget.value.trim() && editingProviderIndex !== null) {
handleAddModel(editingProviderIndex, e.currentTarget.value);
e.currentTarget.value = '';
}
}}
/>
)}
</div>
<Button
onClick={() => {
if (hasFetchedModels[editingProviderIndex] && comboInputRef.current) {
// 使用ComboInput的逻辑
const comboInput = comboInputRef.current as any;
const currentValue = comboInput.getCurrentValue();
if (currentValue && currentValue.trim() && editingProviderIndex !== null) {
handleAddModel(editingProviderIndex, currentValue.trim());
// 清空ComboInput
comboInput.clearInput();
}
} else {
// 使用普通Input的逻辑
const input = document.getElementById('models') as HTMLInputElement;
if (input && input.value.trim() && editingProviderIndex !== null) {
handleAddModel(editingProviderIndex, input.value);
input.value = '';
}
}
}}
>
{t("providers.add_model")}
</Button>
{/* <Button
onClick={() => editingProvider && fetchAvailableModels(editingProvider)}
disabled={isFetchingModels}
variant="outline"
>
{isFetchingModels ? t("providers.fetching_models") : t("providers.fetch_available_models")}
</Button> */}
</div>
<div className="flex flex-wrap gap-2 pt-2">
{editingProvider.models.map((model, modelIndex) => (
<Badge key={modelIndex} variant="outline" className="font-normal flex items-center gap-1">
{model}
<button
type="button"
className="ml-1 rounded-full hover:bg-gray-200"
onClick={() => editingProviderIndex !== null && handleRemoveModel(editingProviderIndex, modelIndex)}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
</div>
</div>
{/* Provider Transformer Selection */}
<div className="space-y-2">
<Label>{t("providers.provider_transformer")}</Label>
{/* Add new transformer */}
<div className="flex gap-2">
<Combobox
options={config.transformers.map(t => ({
label: t.path.split('/').pop() || t.path,
value: t.path
}))}
value=""
onChange={(value) => {
if (editingProviderIndex !== null) {
handleProviderTransformerChange(editingProviderIndex, value);
}
}}
placeholder={t("providers.select_transformer")}
emptyPlaceholder={t("providers.no_transformers")}
/>
</div>
{/* Display existing transformers */}
{editingProvider.transformer?.use && editingProvider.transformer.use.length > 0 && (
<div className="space-y-2 mt-2">
<div className="text-sm font-medium text-gray-700">{t("providers.selected_transformers")}</div>
{editingProvider.transformer.use.map((transformer: any, transformerIndex: number) => (
<div key={transformerIndex} className="border rounded-md p-3">
<div className="flex gap-2 items-center mb-2">
<div className="flex-1 bg-gray-50 rounded p-2 text-sm">
{typeof transformer === 'string' ? transformer : Array.isArray(transformer) ? String(transformer[0]) : String(transformer)}
</div>
<Button
variant="outline"
size="icon"
onClick={() => {
if (editingProviderIndex !== null) {
removeProviderTransformerAtIndex(editingProviderIndex, transformerIndex);
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Transformer-specific Parameters */}
<div className="mt-2 pl-4 border-l-2 border-gray-200">
<Label className="text-sm">{t("providers.transformer_parameters")}</Label>
<div className="space-y-2 mt-1">
<div className="flex gap-2">
<Input
placeholder={t("providers.parameter_name")}
value={providerParamInputs[`provider-${editingProviderIndex}-transformer-${transformerIndex}`]?.name || ""}
onChange={(e) => {
const key = `provider-${editingProviderIndex}-transformer-${transformerIndex}`;
setProviderParamInputs(prev => ({
...prev,
[key]: {
...prev[key] || {name: "", value: ""},
name: e.target.value
}
}));
}}
/>
<Input
placeholder={t("providers.parameter_value")}
value={providerParamInputs[`provider-${editingProviderIndex}-transformer-${transformerIndex}`]?.value || ""}
onChange={(e) => {
const key = `provider-${editingProviderIndex}-transformer-${transformerIndex}`;
setProviderParamInputs(prev => ({
...prev,
[key]: {
...prev[key] || {name: "", value: ""},
value: e.target.value
}
}));
}}
/>
<Button
size="sm"
onClick={() => {
if (editingProviderIndex !== null) {
const key = `provider-${editingProviderIndex}-transformer-${transformerIndex}`;
const paramInput = providerParamInputs[key];
if (paramInput && paramInput.name && paramInput.value) {
addProviderTransformerParameter(editingProviderIndex, transformerIndex, paramInput.name, paramInput.value);
setProviderParamInputs(prev => ({
...prev,
[key]: {name: "", value: ""}
}));
}
}
}}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Display existing parameters for this transformer */}
{(() => {
// Get parameters for this specific transformer
if (!editingProvider.transformer?.use || editingProvider.transformer.use.length <= transformerIndex) {
return null;
}
const targetTransformer = editingProvider.transformer.use[transformerIndex];
let params = {};
if (Array.isArray(targetTransformer) && targetTransformer.length > 1) {
// Check if the second element is an object (parameters object)
if (typeof targetTransformer[1] === 'object' && targetTransformer[1] !== null) {
params = targetTransformer[1] as Record<string, unknown>;
}
}
return Object.keys(params).length > 0 ? (
<div className="space-y-1">
{Object.entries(params).map(([key, value]) => (
<div key={key} className="flex items-center justify-between bg-gray-50 rounded p-2">
<div className="text-sm">
<span className="font-medium">{key}:</span> {String(value)}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => {
if (editingProviderIndex !== null) {
// We need a function to remove parameters from a specific transformer
removeProviderTransformerParameterAtIndex(editingProviderIndex, transformerIndex, key);
}
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
) : null;
})()}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Model-specific Transformers */}
{editingProvider.models.length > 0 && (
<div className="space-y-2">
<Label>{t("providers.model_transformers")}</Label>
<div className="space-y-3">
{editingProvider.models.map((model, modelIndex) => (
<div key={modelIndex} className="border rounded-md p-3">
<div className="font-medium text-sm mb-2">{model}</div>
{/* Add new transformer */}
<div className="flex gap-2">
<div className="flex-1 flex gap-2">
<Combobox
options={config.transformers.map(t => ({
label: t.path.split('/').pop() || t.path,
value: t.path
}))}
value=""
onChange={(value) => {
if (editingProviderIndex !== null) {
handleModelTransformerChange(editingProviderIndex, model, value);
}
}}
placeholder={t("providers.select_transformer")}
emptyPlaceholder={t("providers.no_transformers")}
/>
</div>
</div>
{/* Display existing transformers */}
{editingProvider.transformer?.[model]?.use && editingProvider.transformer[model].use.length > 0 && (
<div className="space-y-2 mt-2">
<div className="text-sm font-medium text-gray-700">{t("providers.selected_transformers")}</div>
{editingProvider.transformer[model].use.map((transformer: any, transformerIndex: number) => (
<div key={transformerIndex} className="border rounded-md p-3">
<div className="flex gap-2 items-center mb-2">
<div className="flex-1 bg-gray-50 rounded p-2 text-sm">
{typeof transformer === 'string' ? transformer : Array.isArray(transformer) ? String(transformer[0]) : String(transformer)}
</div>
<Button
variant="outline"
size="icon"
onClick={() => {
if (editingProviderIndex !== null) {
removeModelTransformerAtIndex(editingProviderIndex, model, transformerIndex);
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Transformer-specific Parameters */}
<div className="mt-2 pl-4 border-l-2 border-gray-200">
<Label className="text-sm">{t("providers.transformer_parameters")}</Label>
<div className="space-y-2 mt-1">
<div className="flex gap-2">
<Input
placeholder={t("providers.parameter_name")}
value={modelParamInputs[`model-${editingProviderIndex}-${model}-transformer-${transformerIndex}`]?.name || ""}
onChange={(e) => {
const key = `model-${editingProviderIndex}-${model}-transformer-${transformerIndex}`;
setModelParamInputs(prev => ({
...prev,
[key]: {
...prev[key] || {name: "", value: ""},
name: e.target.value
}
}));
}}
/>
<Input
placeholder={t("providers.parameter_value")}
value={modelParamInputs[`model-${editingProviderIndex}-${model}-transformer-${transformerIndex}`]?.value || ""}
onChange={(e) => {
const key = `model-${editingProviderIndex}-${model}-transformer-${transformerIndex}`;
setModelParamInputs(prev => ({
...prev,
[key]: {
...prev[key] || {name: "", value: ""},
value: e.target.value
}
}));
}}
/>
<Button
size="sm"
onClick={() => {
if (editingProviderIndex !== null) {
const key = `model-${editingProviderIndex}-${model}-transformer-${transformerIndex}`;
const paramInput = modelParamInputs[key];
if (paramInput && paramInput.name && paramInput.value) {
addModelTransformerParameter(editingProviderIndex, model, transformerIndex, paramInput.name, paramInput.value);
setModelParamInputs(prev => ({
...prev,
[key]: {name: "", value: ""}
}));
}
}
}}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Display existing parameters for this transformer */}
{(() => {
// Get parameters for this specific transformer
if (!editingProvider.transformer?.[model]?.use || editingProvider.transformer[model].use.length <= transformerIndex) {
return null;
}
const targetTransformer = editingProvider.transformer[model].use[transformerIndex];
let params = {};
if (Array.isArray(targetTransformer) && targetTransformer.length > 1) {
// Check if the second element is an object (parameters object)
if (typeof targetTransformer[1] === 'object' && targetTransformer[1] !== null) {
params = targetTransformer[1] as Record<string, unknown>;
}
}
return Object.keys(params).length > 0 ? (
<div className="space-y-1">
{Object.entries(params).map(([key, value]) => (
<div key={key} className="flex items-center justify-between bg-gray-50 rounded p-2">
<div className="text-sm">
<span className="font-medium">{key}:</span> {String(value)}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => {
if (editingProviderIndex !== null) {
// We need a function to remove parameters from a specific transformer
removeModelTransformerParameterAtIndex(editingProviderIndex, model, transformerIndex, key);
}
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
) : null;
})()}
</div>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
<div className="space-y-3 mt-auto">
<div className="flex justify-end gap-2">
{/* <Button
variant="outline"
onClick={() => editingProvider && testConnectivity(editingProvider)}
disabled={isTestingConnectivity || !editingProvider}
>
<Wifi className="mr-2 h-4 w-4" />
{isTestingConnectivity ? t("providers.testing") : t("providers.test_connectivity")}
</Button> */}
<Button onClick={handleSaveProvider}>{t("app.save")}</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deletingProviderIndex !== null} onOpenChange={() => setDeletingProviderIndex(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("providers.delete")}</DialogTitle>
<DialogDescription>
{t("providers.delete_provider_confirm")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingProviderIndex(null)}>{t("providers.cancel")}</Button>
<Button variant="destructive" onClick={() => deletingProviderIndex !== null && handleRemoveProvider(deletingProviderIndex)}>{t("providers.delete")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -0,0 +1,91 @@
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { useConfig } from "./ConfigProvider";
import { Combobox } from "./ui/combobox";
export function Router() {
const { t } = useTranslation();
const { config, setConfig } = useConfig();
if (!config) {
return null;
}
const handleRouterChange = (field: string, value: string) => {
const newRouter = { ...config.Router, [field]: value };
setConfig({ ...config, Router: newRouter });
};
const modelOptions = config.Providers.flatMap((provider) =>
provider.models.map((model) => ({
value: `${provider.name},${model}`,
label: `${provider.name}, ${model}`,
}))
);
return (
<Card className="flex h-full flex-col rounded-lg border shadow-sm">
<CardHeader className="border-b p-4">
<CardTitle className="text-lg">{t("router.title")}</CardTitle>
</CardHeader>
<CardContent className="flex-grow space-y-5 overflow-y-auto p-4">
<div className="space-y-2">
<Label>{t("router.default")}</Label>
<Combobox
options={modelOptions}
value={config.Router.default}
onChange={(value) => handleRouterChange("default", value)}
placeholder={t("router.selectModel")}
searchPlaceholder={t("router.searchModel")}
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
<div className="space-y-2">
<Label>{t("router.background")}</Label>
<Combobox
options={modelOptions}
value={config.Router.background}
onChange={(value) => handleRouterChange("background", value)}
placeholder={t("router.selectModel")}
searchPlaceholder={t("router.searchModel")}
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
<div className="space-y-2">
<Label>{t("router.think")}</Label>
<Combobox
options={modelOptions}
value={config.Router.think}
onChange={(value) => handleRouterChange("think", value)}
placeholder={t("router.selectModel")}
searchPlaceholder={t("router.searchModel")}
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
<div className="space-y-2">
<Label>{t("router.longContext")}</Label>
<Combobox
options={modelOptions}
value={config.Router.longContext}
onChange={(value) => handleRouterChange("longContext", value)}
placeholder={t("router.selectModel")}
searchPlaceholder={t("router.searchModel")}
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
<div className="space-y-2">
<Label>{t("router.webSearch")}</Label>
<Combobox
options={modelOptions}
value={config.Router.webSearch}
onChange={(value) => handleRouterChange("webSearch", value)}
placeholder={t("router.selectModel")}
searchPlaceholder={t("router.searchModel")}
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,71 @@
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useConfig } from "./ConfigProvider";
interface SettingsDialogProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
}
export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) {
const { t } = useTranslation();
const { config, setConfig } = useConfig();
if (!config) {
return null;
}
const handleLogChange = (checked: boolean) => {
setConfig({ ...config, LOG: checked });
};
const handlePathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setConfig({ ...config, CLAUDE_PATH: e.target.value });
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("toplevel.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center space-x-2">
<Switch id="log" checked={config.LOG} onCheckedChange={handleLogChange} />
<Label htmlFor="log" className="transition-all-ease hover:scale-[1.02] cursor-pointer">{t("toplevel.log")}</Label>
</div>
<div className="space-y-2">
<Label htmlFor="claude-path" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.claude_path")}</Label>
<Input id="claude-path" value={config.CLAUDE_PATH} onChange={handlePathChange} className="transition-all-ease focus:scale-[1.01]" />
</div>
<div className="space-y-2">
<Label htmlFor="host" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.host")}</Label>
<Input id="host" value={config.HOST} onChange={(e) => setConfig({ ...config, HOST: e.target.value })} className="transition-all-ease focus:scale-[1.01]" />
</div>
<div className="space-y-2">
<Label htmlFor="port" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.port")}</Label>
<Input id="port" type="number" value={config.PORT} onChange={(e) => setConfig({ ...config, PORT: parseInt(e.target.value, 10) })} className="transition-all-ease focus:scale-[1.01]" />
</div>
<div className="space-y-2">
<Label htmlFor="apikey" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.apikey")}</Label>
<Input id="apikey" type="password" value={config.APIKEY} onChange={(e) => setConfig({ ...config, APIKEY: e.target.value })} className="transition-all-ease focus:scale-[1.01]" />
</div>
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)} className="transition-all-ease hover:scale-[1.02] active:scale-[0.98]">{t("app.save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,32 @@
import { Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { type Transformer } from "./ConfigProvider";
interface TransformerListProps {
transformers: Transformer[];
onEdit: (index: number) => void;
onRemove: (index: number) => void;
}
export function TransformerList({ transformers, onEdit, onRemove }: TransformerListProps) {
return (
<div className="space-y-3">
{transformers.map((transformer, index) => (
<div key={index} className="flex items-start justify-between rounded-md border bg-white p-4 transition-all hover:shadow-md animate-slide-in hover:scale-[1.01]">
<div className="flex-1 space-y-1.5">
<p className="text-md font-semibold text-gray-800">{transformer.path}</p>
<p className="text-sm text-gray-500">{transformer.options.project}</p>
</div>
<div className="ml-4 flex flex-shrink-0 items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => onEdit(index)} className="transition-all-ease hover:scale-110">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="destructive" size="icon" onClick={() => onRemove(index)} className="transition-all duration-200 hover:scale-110">
<Trash2 className="h-4 w-4 text-current transition-colors duration-200" />
</Button>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,220 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Plus, Trash2 } from "lucide-react";
import { useConfig } from "./ConfigProvider";
import { TransformerList } from "./TransformerList";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function Transformers() {
const { t } = useTranslation();
const { config, setConfig } = useConfig();
const [editingTransformerIndex, setEditingTransformerIndex] = useState<number | null>(null);
const [deletingTransformerIndex, setDeletingTransformerIndex] = useState<number | null>(null);
const [newTransformer, setNewTransformer] = useState<{ path: string; options: { [key: string]: string } } | null>(null);
if (!config) {
return null;
}
const handleAddTransformer = () => {
const newTransformer = { path: "", options: {} };
setNewTransformer(newTransformer);
setEditingTransformerIndex(config.transformers.length); // Use the length as index for the new item
};
const handleRemoveTransformer = (index: number) => {
const newTransformers = [...config.transformers];
newTransformers.splice(index, 1);
setConfig({ ...config, transformers: newTransformers });
setDeletingTransformerIndex(null);
};
const handleTransformerChange = (index: number, field: string, value: string, optionKey?: string) => {
if (index < config.transformers.length) {
// Editing an existing transformer
const newTransformers = [...config.transformers];
if (optionKey !== undefined) {
newTransformers[index].options[optionKey] = value;
} else {
(newTransformers[index] as Record<string, unknown>)[field] = value;
}
setConfig({ ...config, transformers: newTransformers });
} else {
// Editing the new transformer
if (newTransformer) {
const updatedTransformer = { ...newTransformer };
if (optionKey !== undefined) {
updatedTransformer.options[optionKey] = value;
} else {
(updatedTransformer as Record<string, unknown>)[field] = value;
}
setNewTransformer(updatedTransformer);
}
}
};
const editingTransformer = editingTransformerIndex !== null ?
(editingTransformerIndex < config.transformers.length ?
config.transformers[editingTransformerIndex] :
newTransformer) :
null;
const handleSaveTransformer = () => {
if (newTransformer && editingTransformerIndex === config.transformers.length) {
// Saving a new transformer
const newTransformers = [...config.transformers, newTransformer];
setConfig({ ...config, transformers: newTransformers });
}
// Close the dialog
setEditingTransformerIndex(null);
setNewTransformer(null);
};
const handleCancelTransformer = () => {
// Close the dialog without saving
setEditingTransformerIndex(null);
setNewTransformer(null);
};
return (
<Card className="flex h-full flex-col rounded-lg border shadow-sm">
<CardHeader className="flex flex-row items-center justify-between border-b p-4">
<CardTitle className="text-lg">{t("transformers.title")} <span className="text-sm font-normal text-gray-500">({config.transformers.length})</span></CardTitle>
<Button onClick={handleAddTransformer}>{t("transformers.add")}</Button>
</CardHeader>
<CardContent className="flex-grow overflow-y-auto p-4">
<TransformerList
transformers={config.transformers}
onEdit={setEditingTransformerIndex}
onRemove={setDeletingTransformerIndex}
/>
</CardContent>
{/* Edit Dialog */}
<Dialog open={editingTransformerIndex !== null} onOpenChange={handleCancelTransformer}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("transformers.edit")}</DialogTitle>
</DialogHeader>
{editingTransformer && editingTransformerIndex !== null && (
<div className="space-y-4 py-4 px-6 max-h-96 overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="transformer-path">{t("transformers.path")}</Label>
<Input
id="transformer-path"
value={editingTransformer.path}
onChange={(e) => handleTransformerChange(editingTransformerIndex, "path", e.target.value)}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t("transformers.parameters")}</Label>
<Button
variant="outline"
size="sm"
onClick={() => {
const newKey = `param${Object.keys(editingTransformer.options).length + 1}`;
if (editingTransformerIndex !== null) {
const newOptions = { ...editingTransformer.options, [newKey]: "" };
if (editingTransformerIndex < config.transformers.length) {
const newTransformers = [...config.transformers];
newTransformers[editingTransformerIndex].options = newOptions;
setConfig({ ...config, transformers: newTransformers });
} else if (newTransformer) {
setNewTransformer({ ...newTransformer, options: newOptions });
}
}
}}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{Object.entries(editingTransformer.options).map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<Input
value={key}
onChange={(e) => {
const newOptions = { ...editingTransformer.options };
delete newOptions[key];
newOptions[e.target.value] = value;
if (editingTransformerIndex !== null) {
if (editingTransformerIndex < config.transformers.length) {
const newTransformers = [...config.transformers];
newTransformers[editingTransformerIndex].options = newOptions;
setConfig({ ...config, transformers: newTransformers });
} else if (newTransformer) {
setNewTransformer({ ...newTransformer, options: newOptions });
}
}
}}
className="flex-1"
/>
<Input
value={value}
onChange={(e) => {
if (editingTransformerIndex !== null) {
handleTransformerChange(editingTransformerIndex, "options", e.target.value, key);
}
}}
className="flex-1"
/>
<Button
variant="outline"
size="icon"
onClick={() => {
if (editingTransformerIndex !== null) {
const newOptions = { ...editingTransformer.options };
delete newOptions[key];
if (editingTransformerIndex < config.transformers.length) {
const newTransformers = [...config.transformers];
newTransformers[editingTransformerIndex].options = newOptions;
setConfig({ ...config, transformers: newTransformers });
} else if (newTransformer) {
setNewTransformer({ ...newTransformer, options: newOptions });
}
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={handleCancelTransformer}>{t("app.cancel")}</Button>
<Button onClick={handleSaveTransformer}>{t("app.save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deletingTransformerIndex !== null} onOpenChange={() => setDeletingTransformerIndex(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("transformers.delete")}</DialogTitle>
<DialogDescription>
{t("transformers.delete_transformer_confirm")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingTransformerIndex(null)}>{t("app.cancel")}</Button>
<Button variant="destructive" onClick={() => deletingTransformerIndex !== null && handleRemoveTransformer(deletingTransformerIndex)}>{t("app.delete")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -0,0 +1,38 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"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",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
// eslint-disable-next-line react-refresh/only-export-components
export { Badge, badgeVariants }

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"border border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
// eslint-disable-next-line react-refresh/only-export-components
export { Button, buttonVariants }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import { Check, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface ComboInputProps {
options: { label: string; value: string }[];
value?: string;
onChange: (value: string) => void;
onEnter?: (value: string) => void;
searchPlaceholder?: string;
emptyPlaceholder?: string;
inputPlaceholder?: string;
}
export const ComboInput = React.forwardRef<HTMLInputElement, ComboInputProps>(({
options,
value,
onChange,
onEnter,
searchPlaceholder = "Search...",
emptyPlaceholder = "No options found.",
inputPlaceholder = "Type or select...",
}, ref) => {
const [open, setOpen] = React.useState(false)
const [inputValue, setInputValue] = React.useState(value || "")
const internalInputRef = React.useRef<HTMLInputElement>(null)
// Forward ref to the internal input
React.useImperativeHandle(ref, () => internalInputRef.current as HTMLInputElement)
React.useEffect(() => {
setInputValue(value || "")
}, [value])
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
setInputValue(newValue)
onChange(newValue)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && inputValue.trim() && onEnter) {
onEnter(inputValue.trim())
setInputValue("")
}
}
const handleSelect = (selectedValue: string) => {
setInputValue(selectedValue)
onChange(selectedValue)
if (onEnter) {
onEnter(selectedValue)
setInputValue("")
}
setOpen(false)
}
// Function to get current value for external access
const getCurrentValue = () => inputValue
// Expose methods through the ref
React.useImperativeHandle(ref, () => ({
...internalInputRef.current!,
value: inputValue,
getCurrentValue,
clearInput: () => {
setInputValue("")
onChange("")
}
}))
return (
<div className="relative">
<Input
ref={internalInputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={inputPlaceholder}
className="pr-10"
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
>
<ChevronsUpDown className="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0 animate-fade-in">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
className="transition-all-ease hover:bg-accent hover:text-accent-foreground"
>
<Check
className={cn(
"mr-2 h-4 w-4 transition-opacity",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
})

View File

@@ -0,0 +1,87 @@
"use client"
import * as React from "react"
import { Check, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface ComboboxProps {
options: { label: string; value: string }[];
value?: string;
onChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyPlaceholder?: string;
}
export function Combobox({
options,
value,
onChange,
placeholder = "Select an option...",
searchPlaceholder = "Search...",
emptyPlaceholder = "No options found.",
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)
const selectedOption = options.find((option) => option.value === value)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between transition-all-ease hover:scale-[1.02] active:scale-[0.98]"
>
{selectedOption ? selectedOption.label : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0 animate-fade-in">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
onChange(currentValue === value ? "" : currentValue)
setOpen(false)
}}
className="transition-all-ease hover:bg-accent hover:text-accent-foreground"
>
<Check
className={cn(
"mr-2 h-4 w-4 transition-opacity",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,181 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,125 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<(
React.ElementRef<typeof DialogPrimitive.Overlay>
), (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
)>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"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",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<(
React.ElementRef<typeof DialogPrimitive.Content>
), (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
)>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"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-300 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 animate-scale-in",
className
)}
{...props}
>
{children}
<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 transition-all-ease hover:scale-110">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<(
React.ElementRef<typeof DialogPrimitive.Title>
), (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
)>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<(
React.ElementRef<typeof DialogPrimitive.Description>
), (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
)>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"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",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,114 @@
"use client"
import * as React from "react"
import { Check, ChevronsUpDown, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Badge } from "@/components/ui/badge"
interface MultiComboboxProps {
options: { label: string; value: string }[];
value?: string[];
onChange: (value: string[]) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyPlaceholder?: string;
}
export function MultiCombobox({
options,
value = [],
onChange,
placeholder = "Select options...",
searchPlaceholder = "Search...",
emptyPlaceholder = "No options found.",
}: MultiComboboxProps) {
const [open, setOpen] = React.useState(false)
const handleSelect = (currentValue: string) => {
if (value.includes(currentValue)) {
onChange(value.filter(v => v !== currentValue))
} else {
onChange([...value, currentValue])
}
}
const removeValue = (val: string, e: React.MouseEvent) => {
e.stopPropagation()
onChange(value.filter(v => v !== val))
}
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1">
{value.map((val) => {
const option = options.find(opt => opt.value === val)
return (
<Badge key={val} variant="outline" className="font-normal">
{option?.label || val}
<button
onClick={(e) => removeValue(val, e)}
className="ml-1 rounded-full hover:bg-gray-200"
>
<X className="h-3 w-3" />
</button>
</Badge>
)
})}
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between transition-all-ease hover:scale-[1.02] active:scale-[0.98]"
>
{value.length > 0 ? `${value.length} selected` : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0 animate-fade-in">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
className="transition-all-ease hover:bg-accent hover:text-accent-foreground"
>
<Check
className={cn(
"mr-2 h-4 w-4 transition-opacity",
value.includes(option.value) ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden animate-fade-in",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-all-ease focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 transition-all-ease"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,59 @@
import { useEffect } from 'react';
import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react';
interface ToastProps {
message: string;
type: 'success' | 'error' | 'warning';
onClose: () => void;
}
export function Toast({ message, type, onClose }: ToastProps) {
useEffect(() => {
const timer = setTimeout(() => {
onClose();
}, 3000);
return () => clearTimeout(timer);
}, [onClose]);
const getIcon = () => {
switch (type) {
case 'success':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <XCircle className="h-5 w-5 text-red-500" />;
case 'warning':
return <AlertCircle className="h-5 w-5 text-yellow-500" />;
default:
return null;
}
};
const getBackgroundColor = () => {
switch (type) {
case 'success':
return 'bg-green-100 border-green-200';
case 'error':
return 'bg-red-100 border-red-200';
case 'warning':
return 'bg-yellow-100 border-yellow-200';
default:
return 'bg-gray-100 border-gray-200';
}
};
return (
<div className={`fixed top-4 right-4 z-50 flex items-center justify-between p-4 rounded-lg border shadow-lg ${getBackgroundColor()} transition-all duration-300 ease-in-out`}>
<div className="flex items-center space-x-2">
{getIcon()}
<span className="text-sm font-medium">{message}</span>
</div>
<button
onClick={onClose}
className="ml-4 text-gray-500 hover:text-gray-700 focus:outline-none"
>
<X className="h-4 w-4" />
</button>
</div>
);
}