mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-20 02:10:15 +01:00
📦 feat: Move Shared Components to @librechat/client (#8685)
* feat: init @librechat/client * feat: Add common types and interfaces for accessibility, agents, artifacts, assistants, and tools * feat: Add jotai as a peer dependency * fix build client package * feat: cleanup unused types from common/index.ts - Remove 104 unused type exports from packages/client/src/common/index.ts - Keep only 7 actually used exports (93% reduction) - Add cleanup script with enhanced import pattern detection - Support both named imports and namespace imports (* as t) - Create automatic backups and comprehensive documentation - Maintain type safety with build verification - No breaking changes to existing code Kept exports: - TShowToast, Option, OptionWithIcon, DropdownValueSetter - MentionOption, NotificationSeverity, MenuItemProps Scripts: cleanup-common-types-safe.js, README-CLEANUP.md * fix: cleanup * fix: package; refactor: tsconfig * feat: add back `recoil` * fix: move dependencies to peerDependencies in client package * feat: add @librechat/client as a dependency in package.json and package-lock.json * feat: update client package configuration and dependencies - Added new dependencies for Rollup plugins and updated existing ones in package.json and package-lock.json. - Introduced a new Rollup configuration file for building the client package. - Refactored build scripts to include a dedicated build command for the client. - Updated TypeScript configuration for improved module resolution and type declaration output. - Integrated a Toast component from the client package into the main App component. * feat: enhance Rollup configuration for client package - Updated terser plugin settings to preserve directives like 'use client'. - Added custom warning handler to ignore "use client" directive warnings during the build process. * chore: rename package/client build script command * feat: update client package dependencies and Rollup configuration - Added rollup-plugin-postcss to package.json and updated package-lock.json. - Enhanced Rollup configuration to include postcss plugin for CSS handling. - Updated index.ts to export all components from the components directory for better modularity. * feat: add client package directory to update configuration - Included the 'client' package directory in the update.js configuration to ensure it is recognized during updates. * feat: export Toast component in client package - Added export for the Toast component in index.ts to enhance modularity and accessibility of components. * feat: /client transition to @librechat/client * chore: fixed formatting issues * fix: update peer dependencies in @librechat/client to prevent bundling them * fix: correct useSprings implementation in SplitText component * fix: circular dependencies in DataTable * fix: add remaining peer dependencies and match actual versions previously used in `client/package.json` * fix: correct frontend:ci script to include client package build * chore: enhance unused package detection for @librechat/client and improve dependency extraction * fix: add missing peer dependency for @radix-ui/react-collapsible * chore: include "packages/client" in unused i18next keys detection * test: update AgentFooter tests to use document.querySelector for spinner checks test: mock window.matchMedia in setupTests.js for consistent test environment * feat: add react-hook-form dependency and update FormInput component to use its types * chore: linting * refactor: remove unused defaultSelectedValues prop from MCPSelect and MultiSelect components * chore: linting * feat: update GitHub Actions workflow to publish @librechat/client * chore: update GitHub Actions workflow to install and build data-provider and client dependencies * chore: add missing @testing-library/react dependency to client package * chore: update tsconfig.json to exclude additional test files * chore: fix build issues, resolve latest LC changes * chore: move MCP components outside of `~/components/ui` * feat: implement dynamic theme system with environment variable support and Tailwind CSS integration * chore: remove unnecessary logging of sttExternal and ttsExternal in Speech component * chore: squashed cleanup commits chore: move @tanstack/react-virtual to dependencies and remove recoil from package.json chore: move dependencies to peerDependencies in package.json feat: update package.json and rollup.config.js to include jotai and enhance bundling configuration feat: update package.json and rollup.config.js to include jotai and enhance bundling configuration refactor: reorganize exports in index.ts for improved clarity refactor: remove unused types and interfaces from common files refactor: update peer dependencies and improve component typings - Removed duplicate peer dependencies from package.json and organized them. - Updated rollup.config.js to disable TypeScript checking during the build process. - Modified AnimatedTabs component to use React.ReactNode for label and content types, and added TypeScript workarounds for compatibility. - Enhanced Label and Separator components to accept an optional className prop and improved prop spreading. - Updated Slider component to include an optional className prop and refined prop handling for better type safety. refactor: clean up client workflow and update package dependencies refactor: update package dependencies and improve PostCSS and Rollup configurations chore: bump version to 0.1.2 in package.json chore: bump client version to 0.1.2 in package-lock.json chore: bump client version to 0.1.3 and update dependencies chore: bump client version to 0.1.4 and update @react-spring dependencies chore: update package version to 0.1.5 and adjust peer dependencies - Bump version in package.json from 0.1.4 to 0.1.5. - Update peer dependency for @tanstack/react-query to allow version 5.0.0. - Add @tanstack/react-table and @tanstack/react-virtual as dependencies. - Update various dependencies to their latest compatible versions. - Simplify postcss.config.js by removing unnecessary options. - Clean up rollup.config.js by removing ignored PostCSS warnings. - Update CheckboxButton component to cast icon as React JSX element. - Adjust Combobox component's class names for better styling. - Change DropdownPopup component to use React's namespace import. - Modify InputOTP component to use 'any' type for OTPInputContext. - Ensure displayLabel and value in ModelParameters are converted to strings. - Update MultiSearch component's placeholder to ensure it's a string. - Cast selectIcon in MultiSelect as React JSX element for consistency. - Update OGDialogTemplate to cast selectText as React JSX element. - Initialize animationRef in PixelCard with undefined for clarity. - Add TypeScript ignore comments in Select and SelectDropDown components for Radix UI type conflicts. - Ensure title in SelectDropDown is a string and adjust rendering of options. - Update useLocalize hook to cast options as any for compatibility. refactor: code structure; chore: translations cleanup chore: remove unused imports and clean up code in NewChat component refactor: enhance Menu component to support custom render functions for menu items style: update itemClassName in ToolsDropdown for improved UI consistency fix: merge conflicts chore: update @radix-ui/react-accordion to version 1.2.11 * refactor: remove unnecessary TypeScript type assertions in AnimatedTabs, Label, Separator, and Slider components * feat: enhance theme system with localStorage persistence and new theme atoms * chore: bump version of @librechat/client to 0.1.7 * chore: fix ci/cd warnings/errors related to linting and unused localization keys * chore: update dependencies for class-variance-authority, clsx, and match-sorter * chore: bump @librechat/client to v0.1.8 * feat: add utility colors for theme customization and remove unused tailwindConfig * v0.1.9 --------- Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
This commit is contained in:
parent
97e1cdd224
commit
79197454f8
569 changed files with 7010 additions and 1848 deletions
|
|
@ -1,51 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { ChevronDownIcon } from '@radix-ui/react-icons';
|
||||
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<AccordionPrimitive.Item ref={ref} className={cn('border-b', className)} {...props} />
|
||||
));
|
||||
AccordionItem.displayName = 'AccordionItem';
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className = '', children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className = '', children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pb-4 pt-0', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
|
||||
import { cn } from '../../utils';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
type AlertPortalProps = AlertDialogPrimitive.AlertDialogPortalProps & { className?: string };
|
||||
|
||||
const AlertDialogPortal = ({ className = '', children, ...props }: AlertPortalProps) => (
|
||||
<AlertDialogPrimitive.Portal className={cn(className)} {...(props as AlertPortalProps)}>
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
|
||||
{children}
|
||||
</div>
|
||||
</AlertDialogPrimitive.Portal>
|
||||
);
|
||||
AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-gray-500/90 transition-opacity animate-in fade-in dark:bg-gray-800/90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed z-50 grid w-full max-w-lg scale-100 gap-4 bg-white p-6 opacity-100 animate-in fade-in-90 slide-in-from-bottom-10 sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0 md:w-full',
|
||||
'dark:bg-gray-900',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className = '', ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({ className = '', ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-gray-900', 'dark:text-gray-50', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-gray-500', 'dark:text-gray-400', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-gray-900 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-100 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 inline-flex h-10 items-center justify-center rounded-md border border-gray-200 bg-transparent px-4 py-2 text-sm font-semibold text-gray-900 transition-colors hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900 sm:mt-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const AnimatedSearchInput = ({
|
||||
value,
|
||||
onChange,
|
||||
isSearching: searching,
|
||||
placeholder,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
isSearching?: boolean;
|
||||
placeholder: string;
|
||||
}) => {
|
||||
const isSearching = searching === true;
|
||||
const hasValue = value != null && value.length > 0;
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="relative rounded-lg transition-all duration-500 ease-in-out">
|
||||
<div className="relative">
|
||||
{/* Icon on the left */}
|
||||
<div className="absolute left-3 top-1/2 z-50 -translate-y-1/2">
|
||||
<Search
|
||||
className={cn(
|
||||
`
|
||||
h-4 w-4 transition-all duration-500 ease-in-out`,
|
||||
isSearching && hasValue ? 'text-blue-400' : 'text-gray-400',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Input field */}
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className={`
|
||||
peer relative z-20 w-full rounded-lg bg-surface-secondary px-10
|
||||
py-2 outline-none ring-0 backdrop-blur-sm transition-all
|
||||
duration-500 ease-in-out placeholder:text-gray-400
|
||||
focus:outline-none focus:ring-0
|
||||
`}
|
||||
/>
|
||||
|
||||
{/* Gradient overlay */}
|
||||
<div
|
||||
className={`
|
||||
pointer-events-none absolute inset-0 z-20 rounded-lg
|
||||
bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-blue-500/20
|
||||
transition-all duration-500 ease-in-out
|
||||
${isSearching && hasValue ? 'opacity-100 blur-sm' : 'opacity-0 blur-none'}
|
||||
`}
|
||||
/>
|
||||
|
||||
{/* Animated loading indicator */}
|
||||
<div
|
||||
className={`
|
||||
absolute right-3 top-1/2 z-20 -translate-y-1/2
|
||||
transition-all duration-500 ease-in-out
|
||||
${isSearching && hasValue ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}
|
||||
`}
|
||||
>
|
||||
<div className="relative h-2 w-2">
|
||||
<div className="absolute inset-0 animate-ping rounded-full bg-blue-500/60" />
|
||||
<div className="absolute inset-0 rounded-full bg-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outer glow effect */}
|
||||
<div
|
||||
className={`
|
||||
absolute -inset-8 -z-10
|
||||
transition-all duration-700 ease-in-out
|
||||
${isSearching && hasValue ? 'scale-105 opacity-100' : 'scale-100 opacity-0'}
|
||||
`}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<div
|
||||
className={`
|
||||
bg-gradient-radial absolute inset-0 from-blue-500/10 to-transparent
|
||||
transition-opacity duration-700 ease-in-out
|
||||
${isSearching && hasValue ? 'animate-pulse-slow opacity-100' : 'opacity-0'}
|
||||
`}
|
||||
/>
|
||||
<div
|
||||
className={`
|
||||
absolute inset-0 bg-gradient-to-r from-purple-500/5 via-blue-500/5 to-purple-500/5
|
||||
blur-xl transition-all duration-700 ease-in-out
|
||||
${isSearching && hasValue ? 'animate-gradient-x opacity-100' : 'opacity-0'}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
absolute inset-0 -z-20 scale-100 bg-gradient-to-r from-blue-500/10
|
||||
via-purple-500/10 to-blue-500/10 opacity-0 blur-xl
|
||||
transition-all duration-500 ease-in-out
|
||||
peer-focus:scale-105 peer-focus:opacity-100
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedSearchInput;
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
/* AnimatedTabs.css */
|
||||
.animated-tab-panel {
|
||||
transition-property: opacity, translate;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 300ms;
|
||||
animation-duration: 300ms;
|
||||
}
|
||||
|
||||
/* Sliding underline animation for tabs */
|
||||
.animated-tab-list {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.animated-tab-list::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background-color: currentColor; /* Inherit color from active tab */
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
left: var(--tab-left, 0);
|
||||
width: var(--tab-width, 0);
|
||||
}
|
||||
|
||||
.animated-tab {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.animated-tab[data-state="active"] {
|
||||
border-bottom-color: transparent !important;
|
||||
}
|
||||
|
||||
.animated-tab-panel[data-enter] {
|
||||
opacity: 1 !important;
|
||||
translate: 0 !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animated-tab-panel {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.animated-tab-panel:not([data-open]) {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
.animated-panels:has(> [data-was-open]) > .animated-tab-panel {
|
||||
opacity: 0;
|
||||
translate: -100%;
|
||||
}
|
||||
|
||||
.animated-panels [data-was-open] ~ .animated-tab-panel,
|
||||
.animated-panels [data-open] ~ .animated-tab-panel {
|
||||
translate: 100%;
|
||||
}
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import { ReactNode, forwardRef, useEffect, useRef } from 'react';
|
||||
import type { ElementRef } from 'react';
|
||||
import { cn } from '~/utils';
|
||||
import './AnimatedTabs.css';
|
||||
|
||||
export interface TabItem {
|
||||
id?: string;
|
||||
label: ReactNode;
|
||||
content: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface AnimatedTabsProps {
|
||||
tabs: TabItem[];
|
||||
className?: string;
|
||||
tabListClassName?: string;
|
||||
tabClassName?: string;
|
||||
tabPanelClassName?: string;
|
||||
tabListProps?: Ariakit.TabListProps;
|
||||
containerClassName?: string;
|
||||
defaultSelectedId?: string;
|
||||
}
|
||||
|
||||
function usePrevious<T>(value: T) {
|
||||
const ref = useRef<T>();
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
const Tab = forwardRef<ElementRef<typeof Ariakit.Tab>, Ariakit.TabProps>(function Tab(props, ref) {
|
||||
const tabRef = useRef<HTMLButtonElement | null>(null);
|
||||
useEffect(() => {
|
||||
const tabElement = tabRef.current;
|
||||
if (!tabElement) return;
|
||||
|
||||
const updateState = () => {
|
||||
const isSelected = tabElement.getAttribute('aria-selected') === 'true';
|
||||
tabElement.setAttribute('data-state', isSelected ? 'active' : 'inactive');
|
||||
};
|
||||
|
||||
updateState();
|
||||
|
||||
const observer = new MutationObserver(updateState);
|
||||
observer.observe(tabElement, { attributes: true, attributeFilter: ['aria-selected'] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Ariakit.Tab
|
||||
ref={(node) => {
|
||||
// Forward the ref to both our local ref and the provided ref
|
||||
tabRef.current = node;
|
||||
if (typeof ref === 'function') ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
}}
|
||||
{...props}
|
||||
className={`animated-tab aria-selected:text-token-text-primary flex select-none items-center justify-center gap-2 whitespace-nowrap border-none text-sm font-medium outline-none transition-colors aria-disabled:opacity-50 ${props.className || ''}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const TabPanel = forwardRef<ElementRef<typeof Ariakit.TabPanel>, Ariakit.TabPanelProps>(
|
||||
function TabPanel(props, ref) {
|
||||
const tab = Ariakit.useTabContext();
|
||||
const previousTabId = usePrevious(Ariakit.useStoreState(tab, 'selectedId'));
|
||||
const wasOpen = props.tabId && previousTabId === props.tabId;
|
||||
|
||||
return (
|
||||
<Ariakit.TabPanel
|
||||
ref={ref}
|
||||
{...props}
|
||||
data-was-open={wasOpen || undefined}
|
||||
className={`animated-tab-panel max-w-full ${props.className || ''}`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export function AnimatedTabs({
|
||||
tabs,
|
||||
className = '',
|
||||
tabListClassName = '',
|
||||
tabClassName = '',
|
||||
tabPanelClassName = '',
|
||||
containerClassName = '',
|
||||
tabListProps = {},
|
||||
defaultSelectedId,
|
||||
}: AnimatedTabsProps) {
|
||||
const tabIds = tabs.map((tab, index) => tab.id || `tab-${index}`);
|
||||
const firstTabId = defaultSelectedId || tabIds[0];
|
||||
const tabListRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const tabList = tabListRef.current;
|
||||
if (!tabList) return;
|
||||
|
||||
// Function to update the underline position
|
||||
const updateUnderline = () => {
|
||||
const activeTab = tabList.querySelector('[data-state="active"]') as HTMLElement;
|
||||
if (!activeTab) return;
|
||||
|
||||
tabList.style.setProperty('--tab-left', `${activeTab.offsetLeft}px`);
|
||||
tabList.style.setProperty('--tab-width', `${activeTab.offsetWidth}px`);
|
||||
};
|
||||
|
||||
updateUnderline();
|
||||
|
||||
const observer = new MutationObserver(updateUnderline);
|
||||
observer.observe(tabList, { attributes: true, subtree: true, attributeFilter: ['data-state'] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [tabs]);
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<Ariakit.TabProvider defaultSelectedId={firstTabId}>
|
||||
<Ariakit.TabList
|
||||
ref={tabListRef}
|
||||
aria-label="Tabs"
|
||||
className={`animated-tab-list flex py-1 ${tabListClassName}`}
|
||||
{...tabListProps}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<Tab
|
||||
key={tabIds[index]}
|
||||
id={tabIds[index]}
|
||||
disabled={tab.disabled}
|
||||
className={tabClassName}
|
||||
data-state={tabIds[index] === firstTabId ? 'active' : 'inactive'}
|
||||
>
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
</Ariakit.TabList>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'animated-panels relative flex w-full flex-col items-center overflow-hidden p-0',
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<TabPanel
|
||||
key={`panel-${tabIds[index]}`}
|
||||
id={`panel-${tabIds[index]}`}
|
||||
tabId={tabIds[index]}
|
||||
className={tabPanelClassName}
|
||||
>
|
||||
{tab.content}
|
||||
</TabPanel>
|
||||
))}
|
||||
</div>
|
||||
</Ariakit.TabProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import type React from 'react';
|
||||
|
||||
import { X, Plus } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface BadgeProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
icon?: LucideIcon;
|
||||
label: string;
|
||||
id?: string;
|
||||
isActive?: boolean;
|
||||
isEditing?: boolean;
|
||||
isDragging?: boolean;
|
||||
isAvailable: boolean;
|
||||
isInChat?: boolean;
|
||||
onBadgeAction?: () => void;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export default function Badge({
|
||||
icon: Icon,
|
||||
label,
|
||||
id,
|
||||
isActive = false,
|
||||
isEditing = false,
|
||||
isDragging = false,
|
||||
isAvailable = true,
|
||||
isInChat = false,
|
||||
onBadgeAction,
|
||||
onToggle,
|
||||
className,
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
const isMoveable = isEditing && isAvailable;
|
||||
const isDisabled = id === '1' && isInChat;
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEditing && onToggle) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'group relative inline-flex items-center gap-1.5 rounded-full px-4 py-1.5',
|
||||
'border border-border-medium text-sm font-medium transition-shadow md:w-full',
|
||||
'size-9 p-2 md:p-3',
|
||||
isActive
|
||||
? 'bg-surface-active shadow-md'
|
||||
: 'bg-surface-chat shadow-sm hover:bg-surface-hover hover:shadow-md',
|
||||
'active:scale-95 active:shadow-inner',
|
||||
isMoveable && 'cursor-move',
|
||||
isDisabled && 'cursor-not-allowed opacity-50 hover:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
animate={{
|
||||
scale: isDragging ? 1.1 : 1,
|
||||
boxShadow: isDragging ? '0 10px 25px rgba(0,0,0,0.1)' : undefined,
|
||||
}}
|
||||
whileTap={{ scale: isDragging ? 1.1 : isDisabled ? 1 : 0.97 }}
|
||||
transition={{ type: 'tween', duration: 0.1, ease: 'easeOut' }}
|
||||
{...props}
|
||||
>
|
||||
{Icon && <Icon className={cn('relative h-5 w-5 md:h-4 md:w-4', !label && 'mx-auto')} />}
|
||||
<span className="relative hidden md:inline">{label}</span>
|
||||
|
||||
{isEditing && !isDragging && (
|
||||
<motion.button
|
||||
className="absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full bg-surface-secondary-alt text-text-primary shadow-sm md:h-5 md:w-5"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onBadgeAction?.();
|
||||
}}
|
||||
>
|
||||
{isAvailable ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<'nav'> & {
|
||||
separator?: React.ReactNode;
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
||||
Breadcrumb.displayName = 'Breadcrumb';
|
||||
|
||||
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<'ol'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-muted-foreground flex flex-wrap items-center gap-1.5 break-words text-sm sm:gap-2.5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
BreadcrumbList.displayName = 'BreadcrumbList';
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<'li'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn('inline-flex items-center gap-1.5', className)} {...props} />
|
||||
),
|
||||
);
|
||||
BreadcrumbItem.displayName = 'BreadcrumbItem';
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<'a'> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'a';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn('hover:text-foreground transition-colors', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
BreadcrumbLink.displayName = 'BreadcrumbLink';
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<'span'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('text-foreground font-normal', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
BreadcrumbPage.displayName = 'BreadcrumbPage';
|
||||
|
||||
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<'li'>) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
|
||||
|
||||
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg 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',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-surface-destructive text-destructive-foreground hover:bg-surface-destructive-hover',
|
||||
outline:
|
||||
'text-text-primary border border-border-light bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-surface-hover hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
// hardcoded text color because of WCAG contrast issues (text-white)
|
||||
submit: 'bg-surface-submit text-white hover:bg-surface-submit-hover',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-lg px-3',
|
||||
lg: 'h-11 rounded-lg px-8',
|
||||
icon: 'size-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';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '../../utils';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'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',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center')}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import { Checkbox, useStoreState, useCheckboxStore } from '@ariakit/react';
|
||||
import { cn } from '~/utils';
|
||||
import * as React from 'react';
|
||||
|
||||
const CheckboxButton = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
{
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
className?: string;
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
isCheckedClassName?: string;
|
||||
setValue?: (values: {
|
||||
e?: React.ChangeEvent<HTMLInputElement>;
|
||||
value: boolean | string;
|
||||
}) => void;
|
||||
}
|
||||
>(({ icon, label, setValue, className, checked, defaultChecked, isCheckedClassName }, ref) => {
|
||||
const checkbox = useCheckboxStore();
|
||||
const isChecked = useStoreState(checkbox, (state) => state?.value);
|
||||
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation();
|
||||
if (typeof isChecked !== 'boolean') {
|
||||
return;
|
||||
}
|
||||
setValue?.({ e, value: !isChecked });
|
||||
};
|
||||
|
||||
// Sync with controlled checked prop
|
||||
useEffect(() => {
|
||||
if (checked !== undefined) {
|
||||
checkbox.setValue(checked);
|
||||
}
|
||||
}, [checked, checkbox]);
|
||||
|
||||
// Set initial value from defaultChecked
|
||||
useEffect(() => {
|
||||
if (defaultChecked !== undefined && checked === undefined) {
|
||||
checkbox.setValue(defaultChecked);
|
||||
}
|
||||
}, [defaultChecked, checked, checkbox]);
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
ref={ref}
|
||||
store={checkbox}
|
||||
onChange={onChange}
|
||||
className={cn(
|
||||
// Base styling from MultiSelect's selectClassName
|
||||
'group relative inline-flex items-center justify-center gap-1.5',
|
||||
'rounded-full border border-border-medium text-sm font-medium',
|
||||
'size-9 p-2 transition-all md:w-full md:p-3',
|
||||
'bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner',
|
||||
|
||||
// Checked state styling
|
||||
isChecked && isCheckedClassName && isCheckedClassName,
|
||||
|
||||
// Additional custom classes
|
||||
className,
|
||||
)}
|
||||
render={<button type="button" aria-label={label} />}
|
||||
>
|
||||
{/* Icon if provided */}
|
||||
{icon && <span className="icon-md text-text-primary">{icon}</span>}
|
||||
|
||||
{/* Show the label on larger screens */}
|
||||
<span className="hidden truncate md:block">{label}</span>
|
||||
</Checkbox>
|
||||
);
|
||||
});
|
||||
|
||||
CheckboxButton.displayName = 'CheckboxButton';
|
||||
|
||||
export default CheckboxButton;
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
import { startTransition } from 'react';
|
||||
import { Search as SearchIcon } from 'lucide-react';
|
||||
import * as RadixSelect from '@radix-ui/react-select';
|
||||
import { CheckIcon, ChevronDownIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxProvider,
|
||||
ComboboxCancel,
|
||||
} from '@ariakit/react';
|
||||
import type { OptionWithIcon } from '~/common';
|
||||
import { SelectTrigger, SelectValue, SelectScrollDownButton } from './Select';
|
||||
import useCombobox from '~/hooks/Input/useCombobox';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function ComboboxComponent({
|
||||
selectedValue,
|
||||
displayValue,
|
||||
items,
|
||||
setValue,
|
||||
ariaLabel,
|
||||
searchPlaceholder,
|
||||
selectPlaceholder,
|
||||
isCollapsed,
|
||||
SelectIcon,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
displayValue?: string;
|
||||
selectedValue: string;
|
||||
searchPlaceholder?: string;
|
||||
selectPlaceholder?: string;
|
||||
items: OptionWithIcon[] | string[];
|
||||
setValue: (value: string) => void;
|
||||
isCollapsed: boolean;
|
||||
SelectIcon?: React.ReactNode;
|
||||
}) {
|
||||
const options: OptionWithIcon[] = (items ?? []).map((option: string | OptionWithIcon) => {
|
||||
if (typeof option === 'string') {
|
||||
return { label: option, value: option };
|
||||
}
|
||||
return option;
|
||||
});
|
||||
|
||||
const { open, setOpen, setSearchValue, matches } = useCombobox({
|
||||
value: selectedValue,
|
||||
options,
|
||||
});
|
||||
|
||||
return (
|
||||
<RadixSelect.Root
|
||||
value={selectedValue}
|
||||
onValueChange={setValue}
|
||||
open={open}
|
||||
/** Hacky fix for radix-ui Android issue: https://github.com/radix-ui/primitives/issues/1658 */
|
||||
onOpenChange={() => {
|
||||
if (open === true) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
setOpen(!open);
|
||||
}, 75);
|
||||
}}
|
||||
>
|
||||
<ComboboxProvider
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
resetValueOnHide
|
||||
includesBaseElement={false}
|
||||
setValue={(value) => {
|
||||
startTransition(() => {
|
||||
setSearchValue(value);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'flex items-center gap-2 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0',
|
||||
isCollapsed
|
||||
? 'flex h-9 w-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto [&>svg]:hidden'
|
||||
: '',
|
||||
'bg-white text-black hover:bg-gray-50 focus-visible:ring-2 focus-visible:ring-gray-500 dark:bg-gray-850 dark:text-white ',
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder={selectPlaceholder}>
|
||||
<div className="assistant-item flex items-center justify-center overflow-hidden rounded-full">
|
||||
{SelectIcon ? SelectIcon : <ChevronDownIcon />}
|
||||
</div>
|
||||
<span
|
||||
className={cn('ml-2', isCollapsed ? 'hidden' : '')}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
{selectedValue
|
||||
? displayValue ?? selectedValue
|
||||
: selectPlaceholder && selectPlaceholder}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<RadixSelect.Portal>
|
||||
<RadixSelect.Content
|
||||
role="dialog"
|
||||
aria-label={ariaLabel + 's'}
|
||||
position="popper"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground relative z-50 max-h-[52vh] min-w-[8rem] overflow-hidden rounded-md border border-gray-200 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 dark:border-gray-600',
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
'bg-white dark:bg-gray-700',
|
||||
)}
|
||||
>
|
||||
<RadixSelect.Viewport className="mb-5 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
|
||||
<div className="group sticky left-0 top-0 z-10 flex h-12 items-center gap-2 bg-white px-2 px-3 py-2 text-black duration-300 dark:bg-gray-700 dark:text-white">
|
||||
<SearchIcon className="h-4 w-4 text-gray-500 transition-colors duration-300 dark:group-focus-within:text-gray-300 dark:group-hover:text-gray-300" />
|
||||
<Combobox
|
||||
autoSelect
|
||||
placeholder={searchPlaceholder}
|
||||
className="flex-1 rounded-md border-none bg-transparent px-2.5 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-700/10 dark:focus:ring-gray-200/10"
|
||||
// Ariakit's Combobox manually triggers a blur event on virtually
|
||||
// blurred items, making them work as if they had actual DOM
|
||||
// focus. These blur events might happen after the corresponding
|
||||
// focus events in the capture phase, leading Radix Select to
|
||||
// close the popover. This happens because Radix Select relies on
|
||||
// the order of these captured events to discern if the focus was
|
||||
// outside the element. Since we don't have access to the
|
||||
// onInteractOutside prop in the Radix SelectContent component to
|
||||
// stop this behavior, we can turn off Ariakit's behavior here.
|
||||
onBlurCapture={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
<ComboboxCancel
|
||||
hideWhenEmpty={true}
|
||||
className="relative flex h-5 w-5 items-center justify-end text-gray-500 transition-colors duration-300 dark:group-focus-within:text-gray-300 dark:group-hover:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<ComboboxList className="overflow-y-auto p-1 py-2">
|
||||
{matches.map(({ label, value, icon }) => (
|
||||
<RadixSelect.Item key={value} value={`${value ?? ''}`} asChild>
|
||||
<ComboboxItem
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'rounded-lg hover:bg-gray-100/50 hover:bg-gray-50 dark:text-white dark:hover:bg-gray-600',
|
||||
)}
|
||||
/** Hacky fix for radix-ui Android issue: https://github.com/radix-ui/primitives/issues/1658 */
|
||||
onTouchEnd={() => {
|
||||
setValue(`${value ?? ''}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<RadixSelect.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</RadixSelect.ItemIndicator>
|
||||
</span>
|
||||
<RadixSelect.ItemText>
|
||||
<div className="[&_svg]:text-foreground flex items-center justify-center gap-3 dark:text-white [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0">
|
||||
<div className="assistant-item overflow-hidden rounded-full ">
|
||||
{icon && icon}
|
||||
</div>
|
||||
{label}
|
||||
</div>
|
||||
</RadixSelect.ItemText>
|
||||
</ComboboxItem>
|
||||
</RadixSelect.Item>
|
||||
))}
|
||||
</ComboboxList>
|
||||
</RadixSelect.Viewport>
|
||||
<SelectScrollDownButton className="absolute bottom-0 left-0 right-0" />
|
||||
</RadixSelect.Content>
|
||||
</RadixSelect.Portal>
|
||||
</ComboboxProvider>
|
||||
</RadixSelect.Root>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
import { Search, ChevronDown } from 'lucide-react';
|
||||
import { useMemo, useState, useRef, memo, useEffect } from 'react';
|
||||
import { SelectRenderer } from '@ariakit/react-core/select/select-renderer';
|
||||
import type { OptionWithIcon } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface ControlComboboxProps {
|
||||
selectedValue: string;
|
||||
displayValue?: string;
|
||||
items: OptionWithIcon[];
|
||||
setValue: (value: string) => void;
|
||||
ariaLabel: string;
|
||||
searchPlaceholder?: string;
|
||||
selectPlaceholder?: string;
|
||||
isCollapsed: boolean;
|
||||
SelectIcon?: React.ReactNode;
|
||||
containerClassName?: string;
|
||||
iconClassName?: string;
|
||||
showCarat?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
iconSide?: 'left' | 'right';
|
||||
selectId?: string;
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
|
||||
function ControlCombobox({
|
||||
selectedValue,
|
||||
displayValue,
|
||||
items,
|
||||
setValue,
|
||||
ariaLabel,
|
||||
searchPlaceholder,
|
||||
selectPlaceholder,
|
||||
containerClassName,
|
||||
isCollapsed,
|
||||
SelectIcon,
|
||||
showCarat,
|
||||
className,
|
||||
disabled,
|
||||
iconClassName,
|
||||
iconSide = 'left',
|
||||
selectId,
|
||||
}: ControlComboboxProps) {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [buttonWidth, setButtonWidth] = useState<number | null>(null);
|
||||
|
||||
const getItem = (option: OptionWithIcon) => ({
|
||||
id: `item-${option.value}`,
|
||||
value: option.value as string | undefined,
|
||||
label: option.label,
|
||||
icon: option.icon,
|
||||
});
|
||||
|
||||
const combobox = Ariakit.useComboboxStore({
|
||||
defaultItems: items.map(getItem),
|
||||
resetValueOnHide: true,
|
||||
value: searchValue,
|
||||
setValue: setSearchValue,
|
||||
});
|
||||
|
||||
const select = Ariakit.useSelectStore({
|
||||
combobox,
|
||||
defaultItems: items.map(getItem),
|
||||
value: selectedValue,
|
||||
setValue,
|
||||
});
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const filteredItems = matchSorter(items, searchValue, {
|
||||
keys: ['value', 'label'],
|
||||
baseSort: (a, b) => (a.index < b.index ? -1 : 1),
|
||||
});
|
||||
return filteredItems.map(getItem);
|
||||
}, [searchValue, items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (buttonRef.current && !isCollapsed) {
|
||||
setButtonWidth(buttonRef.current.offsetWidth);
|
||||
}
|
||||
}, [isCollapsed]);
|
||||
|
||||
const selectIconClassName = cn(
|
||||
'flex h-5 w-5 items-center justify-center overflow-hidden rounded-full',
|
||||
iconClassName,
|
||||
);
|
||||
const optionIconClassName = cn(
|
||||
'mr-2 flex h-5 w-5 items-center justify-center overflow-hidden rounded-full',
|
||||
iconClassName,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full items-center justify-center px-1', containerClassName)}>
|
||||
<Ariakit.SelectLabel store={select} className="sr-only">
|
||||
{ariaLabel}
|
||||
</Ariakit.SelectLabel>
|
||||
<Ariakit.Select
|
||||
ref={buttonRef}
|
||||
store={select}
|
||||
id={selectId}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 rounded-full bg-surface-secondary',
|
||||
'text-text-primary hover:bg-surface-tertiary',
|
||||
'border border-border-light',
|
||||
isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-xl px-3 py-2 text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{SelectIcon != null && iconSide === 'left' && (
|
||||
<div className={selectIconClassName}>{SelectIcon}</div>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-grow truncate text-left">
|
||||
{displayValue != null
|
||||
? displayValue || selectPlaceholder
|
||||
: selectedValue || selectPlaceholder}
|
||||
</span>
|
||||
{SelectIcon != null && iconSide === 'right' && (
|
||||
<div className={selectIconClassName}>{SelectIcon}</div>
|
||||
)}
|
||||
{showCarat && <ChevronDown className="h-4 w-4 text-text-secondary" />}
|
||||
</>
|
||||
)}
|
||||
</Ariakit.Select>
|
||||
<Ariakit.SelectPopover
|
||||
store={select}
|
||||
gutter={4}
|
||||
portal
|
||||
className={cn(
|
||||
'animate-popover z-50 overflow-hidden rounded-xl border border-border-light bg-surface-secondary shadow-lg',
|
||||
)}
|
||||
style={{ width: isCollapsed ? '300px' : (buttonWidth ?? '300px') }}
|
||||
>
|
||||
<div className="py-1.5">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-primary" />
|
||||
<Ariakit.Combobox
|
||||
store={combobox}
|
||||
autoSelect
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full rounded-md bg-surface-secondary py-2 pl-9 pr-3 text-sm text-text-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-auto">
|
||||
<Ariakit.ComboboxList store={combobox}>
|
||||
<SelectRenderer store={select} items={matches} itemSize={ROW_HEIGHT} overscan={5}>
|
||||
{({ value, icon, label, ...item }) => (
|
||||
<Ariakit.ComboboxItem
|
||||
key={item.id}
|
||||
{...item}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center px-3 text-sm',
|
||||
'text-text-primary hover:bg-surface-tertiary',
|
||||
'data-[active-item]:bg-surface-tertiary',
|
||||
)}
|
||||
render={<Ariakit.SelectItem value={value} />}
|
||||
>
|
||||
{icon != null && iconSide === 'left' && (
|
||||
<div className={optionIconClassName}>{icon}</div>
|
||||
)}
|
||||
<span className="flex-grow truncate text-left">{label}</span>
|
||||
{icon != null && iconSide === 'right' && (
|
||||
<div className={optionIconClassName}>{icon}</div>
|
||||
)}
|
||||
</Ariakit.ComboboxItem>
|
||||
)}
|
||||
</SelectRenderer>
|
||||
</Ariakit.ComboboxList>
|
||||
</div>
|
||||
</Ariakit.SelectPopover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ControlCombobox);
|
||||
|
|
@ -1,481 +0,0 @@
|
|||
import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import {
|
||||
Row,
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
VisibilityState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel,
|
||||
} from '@tanstack/react-table';
|
||||
import type { Table as TTable } from '@tanstack/react-table';
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
Checkbox,
|
||||
TableRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
AnimatedSearchInput,
|
||||
Skeleton,
|
||||
} from './';
|
||||
import { TrashIcon, Spinner } from '~/components/svg';
|
||||
import { useLocalize, useMediaQuery } from '~/hooks';
|
||||
import { LocalizeFunction } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||
meta?: {
|
||||
size?: string | number;
|
||||
mobileSize?: string | number;
|
||||
minWidth?: string | number;
|
||||
};
|
||||
};
|
||||
|
||||
const SelectionCheckbox = memo(
|
||||
({
|
||||
checked,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
ariaLabel: string;
|
||||
}) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="flex h-full w-[30px] items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
SelectionCheckbox.displayName = 'SelectionCheckbox';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: TableColumn<TData, TValue>[];
|
||||
data: TData[];
|
||||
onDelete?: (selectedRows: TData[]) => Promise<void>;
|
||||
filterColumn?: string;
|
||||
defaultSort?: SortingState;
|
||||
columnVisibilityMap?: Record<string, string>;
|
||||
className?: string;
|
||||
pageSize?: number;
|
||||
isFetchingNextPage?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
fetchNextPage?: (options?: unknown) => Promise<unknown>;
|
||||
enableRowSelection?: boolean;
|
||||
showCheckboxes?: boolean;
|
||||
onFilterChange?: (value: string) => void;
|
||||
filterValue?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const TableRowComponent = <TData, TValue>({
|
||||
row,
|
||||
isSmallScreen,
|
||||
onSelectionChange,
|
||||
index,
|
||||
isSearching,
|
||||
}: {
|
||||
row: Row<TData>;
|
||||
isSmallScreen: boolean;
|
||||
onSelectionChange?: (rowId: string, selected: boolean) => void;
|
||||
index: number;
|
||||
isSearching: boolean;
|
||||
}) => {
|
||||
const handleSelection = useCallback(
|
||||
(value: boolean) => {
|
||||
row.toggleSelected(value);
|
||||
onSelectionChange?.(row.id, value);
|
||||
},
|
||||
[row, onSelectionChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||
className="motion-safe:animate-fadeIn border-b border-border-light transition-all duration-300 ease-out hover:bg-surface-secondary"
|
||||
style={{
|
||||
animationDelay: `${index * 20}ms`,
|
||||
transform: `translateY(${isSearching ? '4px' : '0'})`,
|
||||
opacity: isSearching ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
if (cell.column.id === 'select') {
|
||||
return (
|
||||
<TableCell key={cell.id} className="px-2 py-1 transition-all duration-300">
|
||||
<SelectionCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={handleSelection}
|
||||
ariaLabel="Select row"
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="w-0 max-w-0 px-2 py-1 align-middle text-xs transition-all duration-300 sm:px-4 sm:py-2 sm:text-sm"
|
||||
style={getColumnStyle(
|
||||
cell.column.columnDef as TableColumn<TData, TValue>,
|
||||
isSmallScreen,
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden text-ellipsis">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoizedTableRow = memo(TableRowComponent) as typeof TableRowComponent;
|
||||
|
||||
function getColumnStyle<TData, TValue>(
|
||||
column: TableColumn<TData, TValue>,
|
||||
isSmallScreen: boolean,
|
||||
): React.CSSProperties {
|
||||
return {
|
||||
width: isSmallScreen ? column.meta?.mobileSize : column.meta?.size,
|
||||
minWidth: column.meta?.minWidth,
|
||||
maxWidth: column.meta?.size,
|
||||
};
|
||||
}
|
||||
|
||||
const DeleteButton = memo(
|
||||
({
|
||||
onDelete,
|
||||
isDeleting,
|
||||
disabled,
|
||||
isSmallScreen,
|
||||
localize,
|
||||
}: {
|
||||
onDelete?: () => Promise<void>;
|
||||
isDeleting: boolean;
|
||||
disabled: boolean;
|
||||
isSmallScreen: boolean;
|
||||
localize: LocalizeFunction;
|
||||
}) => {
|
||||
if (!onDelete) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDelete}
|
||||
disabled={disabled}
|
||||
className={cn('min-w-[40px] transition-all duration-200', isSmallScreen && 'px-2 py-1')}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<>
|
||||
<TrashIcon className="size-3.5 text-red-400 sm:size-4" />
|
||||
{!isSmallScreen && <span className="ml-2">{localize('com_ui_delete')}</span>}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
onDelete,
|
||||
filterColumn,
|
||||
defaultSort = [],
|
||||
className = '',
|
||||
isFetchingNextPage = false,
|
||||
hasNextPage = false,
|
||||
fetchNextPage,
|
||||
enableRowSelection = true,
|
||||
showCheckboxes = true,
|
||||
onFilterChange,
|
||||
filterValue,
|
||||
isLoading,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const localize = useLocalize();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const search = useRecoilValue(store.search);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [searchTerm, setSearchTerm] = useState(filterValue ?? '');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
if (!enableRowSelection || !showCheckboxes) {
|
||||
return columns;
|
||||
}
|
||||
const selectColumn = {
|
||||
id: 'select',
|
||||
header: ({ table }: { table: TTable<TData> }) => (
|
||||
<div className="flex h-full w-[30px] items-center justify-center">
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(Boolean(value))}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }: { row: Row<TData> }) => (
|
||||
<SelectionCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={(value) => row.toggleSelected(value)}
|
||||
ariaLabel="Select row"
|
||||
/>
|
||||
),
|
||||
meta: { size: '50px' },
|
||||
};
|
||||
return [selectColumn, ...columns];
|
||||
}, [columns, enableRowSelection, showCheckboxes]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: tableColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
enableRowSelection,
|
||||
enableMultiRowSelection: true,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
});
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
estimateSize: useCallback(() => 48, []),
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
|
||||
const paddingBottom =
|
||||
virtualRows.length > 0 ? totalSize - virtualRows[virtualRows.length - 1].end : 0;
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = tableContainerRef.current;
|
||||
if (!scrollElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleScroll = async () => {
|
||||
if (!hasNextPage || isFetchingNextPage) {
|
||||
return;
|
||||
}
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||||
if (scrollHeight - scrollTop <= clientHeight * 1.5) {
|
||||
try {
|
||||
// Safely fetch next page without breaking if lastPage is undefined
|
||||
await fetchNextPage?.();
|
||||
} catch (error) {
|
||||
console.error('Unable to fetch next page:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => scrollElement.removeEventListener('scroll', handleScroll);
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsSearching(true);
|
||||
const timeout = setTimeout(() => {
|
||||
onFilterChange?.(searchTerm);
|
||||
setIsSearching(false);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [searchTerm, onFilterChange]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!onDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const itemsToDelete = table.getFilteredSelectedRowModel().rows.map((r) => r.original);
|
||||
await onDelete(itemsToDelete);
|
||||
setRowSelection({});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [onDelete, table]);
|
||||
|
||||
const getRandomWidth = () => Math.floor(Math.random() * (410 - 170 + 1)) + 170;
|
||||
|
||||
const skeletons = Array.from({ length: 13 }, (_, index) => {
|
||||
const randomWidth = getRandomWidth();
|
||||
const firstDataColumnIndex = tableColumns[0]?.id === 'select' ? 1 : 0;
|
||||
|
||||
return (
|
||||
<TableRow key={index} className="motion-safe:animate-fadeIn border-b border-border-light">
|
||||
{tableColumns.map((column, columnIndex) => {
|
||||
const style = getColumnStyle(column as TableColumn<TData, TValue>, isSmallScreen);
|
||||
const isFirstDataColumn = columnIndex === firstDataColumnIndex;
|
||||
|
||||
return (
|
||||
<TableCell key={column.id} className="px-2 py-1 sm:px-4 sm:py-2" style={style}>
|
||||
<Skeleton
|
||||
className="h-6"
|
||||
style={isFirstDataColumn ? { width: `${randomWidth}px` } : { width: '100%' }}
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col gap-4', className)}>
|
||||
{/* Table controls */}
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-4">
|
||||
{enableRowSelection && showCheckboxes && (
|
||||
<DeleteButton
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
|
||||
isSmallScreen={isSmallScreen}
|
||||
localize={localize}
|
||||
/>
|
||||
)}
|
||||
{filterColumn !== undefined && table.getColumn(filterColumn) && search.enabled && (
|
||||
<div className="relative flex-1">
|
||||
<AnimatedSearchInput
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
isSearching={isSearching}
|
||||
placeholder={`${localize('com_ui_search')}...`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Virtualized table */}
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className={cn(
|
||||
'relative h-[calc(100vh-20rem)] max-w-full overflow-x-auto overflow-y-auto rounded-md border border-black/10 dark:border-white/10',
|
||||
'transition-all duration-300 ease-out',
|
||||
isSearching && 'bg-surface-secondary/50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Table className="w-full min-w-[300px] table-fixed border-separate border-spacing-0">
|
||||
<TableHeader className="sticky top-0 z-50 bg-surface-secondary">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="border-b border-border-light">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className="whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary sm:px-4"
|
||||
style={getColumnStyle(
|
||||
header.column.columnDef as TableColumn<TData, TValue>,
|
||||
isSmallScreen,
|
||||
)}
|
||||
onClick={
|
||||
header.column.getCanSort()
|
||||
? header.column.getToggleSortingHandler()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{paddingTop > 0 && (
|
||||
<tr>
|
||||
<td style={{ height: `${paddingTop}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{isLoading && skeletons}
|
||||
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
return (
|
||||
<MemoizedTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
isSmallScreen={isSmallScreen}
|
||||
index={virtualRow.index}
|
||||
isSearching={isSearching}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{!virtualRows.length && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="p-4 text-center">
|
||||
{localize('com_ui_no_data')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{paddingBottom > 0 && (
|
||||
<tr>
|
||||
<td style={{ height: `${paddingBottom}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{(isFetchingNextPage || hasNextPage) && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="p-4">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
hasNextPage && <div className="h-6" />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons';
|
||||
import { Column } from '@tanstack/react-table';
|
||||
|
||||
import { cn } from '~/utils';
|
||||
import { Button } from './Button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './DropdownMenu';
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className = '',
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="data-[state=open]:bg-accent -ml-3 h-8">
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDownIcon className="ml-2 h-4 w-4" />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUpIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="z-[1001]">
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
|
||||
Asc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
|
||||
Desc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { useDelayedRender } from '~/hooks';
|
||||
|
||||
const DelayedRender = ({ delay, children }) => useDelayedRender(delay)(() => children);
|
||||
|
||||
export default DelayedRender;
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Button } from '../ui/Button';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
import { useMediaQuery } from '~/hooks';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
type DialogPortalProps = DialogPrimitive.DialogPortalProps & { className?: string };
|
||||
|
||||
const DialogPortal = ({ className = '', children, ...props }: DialogPortalProps) => (
|
||||
<DialogPrimitive.Portal className={cn(className)} {...(props as DialogPortalProps)}>
|
||||
<div className="fixed inset-0 z-[999] flex items-start justify-center sm:items-center">
|
||||
{children}
|
||||
</div>
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
DialogPortal.displayName = DialogPrimitive.Portal.displayName;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-[999] bg-gray-600/65 transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in dark:bg-black/80',
|
||||
className ?? '',
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
disableScroll?: boolean;
|
||||
};
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
DialogContentProps
|
||||
>(
|
||||
(
|
||||
{ className, children = true, showCloseButton = true, disableScroll = false, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed z-[999] grid w-full gap-4 rounded-b-lg bg-white pb-6 animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:rounded-lg',
|
||||
'dark:bg-gray-700',
|
||||
isSmallScreen
|
||||
? 'fixed left-1/2 top-1/2 z-[999] m-auto grid w-11/12 -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-white pb-6'
|
||||
: '',
|
||||
disableScroll ? 'overflow-hidden' : '',
|
||||
className ?? '',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-6 top-[1.6rem] rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-gray-100 dark:focus:ring-white dark:focus:ring-offset-gray-700 dark:data-[state=open]:bg-gray-800">
|
||||
<X className="h-5 w-5 text-black dark:text-white" />
|
||||
<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-2 border-b border-black/10 p-6 pb-4 text-left dark:border-white/10',
|
||||
className ?? '',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-row justify-between space-x-2 px-6 py-4', 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 text-gray-900', 'dark:text-gray-50', 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-gray-500', 'dark:text-gray-400', className ?? '')}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
const DialogClose = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 inline-flex h-10 items-center justify-center rounded-lg border border-gray-200 bg-transparent px-4 py-2 text-sm font-semibold text-gray-900 transition-colors hover:bg-gray-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800 sm:mt-0',
|
||||
className ?? '',
|
||||
/* Important: for accessibility */
|
||||
'focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogClose.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogButton = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentPropsWithoutRef<typeof Button>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'mt-2 inline-flex h-10 items-center justify-center rounded-lg border border-gray-200 bg-transparent px-4 py-2 text-sm font-semibold text-gray-900 transition-colors hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900 sm:mt-0',
|
||||
className ?? '',
|
||||
/* Important: for accessibility */
|
||||
'focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogButton.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogClose,
|
||||
DialogButton,
|
||||
};
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import 'test/matchMedia.mock';
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import DialogTemplate from './DialogTemplate';
|
||||
import { Dialog } from '@radix-ui/react-dialog';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
describe('DialogTemplate', () => {
|
||||
let mockSelectHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSelectHandler = jest.fn();
|
||||
});
|
||||
|
||||
it('renders correctly with all props', () => {
|
||||
const { getByText } = render(
|
||||
<RecoilRoot>
|
||||
<Dialog
|
||||
open
|
||||
data-testid="test-dialog"
|
||||
onOpenChange={() => {
|
||||
return;
|
||||
}}
|
||||
>
|
||||
<DialogTemplate
|
||||
title="Test Dialog"
|
||||
description="Test Description"
|
||||
main={<div>Main Content</div>}
|
||||
buttons={<button>Button</button>}
|
||||
leftButtons={<button>Left Button</button>}
|
||||
selection={{ selectHandler: mockSelectHandler, selectText: 'Select' }}
|
||||
/>
|
||||
</Dialog>
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(getByText('Test Dialog')).toBeInTheDocument();
|
||||
expect(getByText('Test Description')).toBeInTheDocument();
|
||||
expect(getByText('Main Content')).toBeInTheDocument();
|
||||
expect(getByText('Button')).toBeInTheDocument();
|
||||
expect(getByText('Left Button')).toBeInTheDocument();
|
||||
expect(getByText('Cancel')).toBeInTheDocument();
|
||||
expect(getByText('Select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correctly without optional props', () => {
|
||||
const { queryByText } = render(
|
||||
<RecoilRoot>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={() => {
|
||||
return;
|
||||
}}
|
||||
></Dialog>
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(queryByText('Test Dialog')).toBeNull();
|
||||
expect(queryByText('Test Description')).not.toBeInTheDocument();
|
||||
expect(queryByText('Main Content')).not.toBeInTheDocument();
|
||||
expect(queryByText('Button')).not.toBeInTheDocument();
|
||||
expect(queryByText('Left Button')).not.toBeInTheDocument();
|
||||
expect(queryByText('Cancel')).not.toBeInTheDocument();
|
||||
expect(queryByText('Select')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls selectHandler when the select button is clicked', () => {
|
||||
const { getByText } = render(
|
||||
<RecoilRoot>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={() => {
|
||||
return;
|
||||
}}
|
||||
>
|
||||
<DialogTemplate
|
||||
title="Test Dialog"
|
||||
selection={{ selectHandler: mockSelectHandler, selectText: 'Select' }}
|
||||
/>
|
||||
</Dialog>
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByText('Select'));
|
||||
|
||||
expect(mockSelectHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
import { forwardRef, ReactNode, Ref } from 'react';
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './';
|
||||
import { cn } from '~/utils/';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type SelectionProps = {
|
||||
selectHandler?: () => void;
|
||||
selectClasses?: string;
|
||||
selectText?: string;
|
||||
};
|
||||
|
||||
type DialogTemplateProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
main?: ReactNode;
|
||||
buttons?: ReactNode;
|
||||
leftButtons?: ReactNode;
|
||||
selection?: SelectionProps;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
footerClassName?: string;
|
||||
showCloseButton?: boolean;
|
||||
showCancelButton?: boolean;
|
||||
};
|
||||
|
||||
const DialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDivElement>) => {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
main,
|
||||
buttons,
|
||||
leftButtons,
|
||||
selection,
|
||||
className,
|
||||
headerClassName,
|
||||
footerClassName,
|
||||
showCloseButton,
|
||||
showCancelButton = true,
|
||||
} = props;
|
||||
const { selectHandler, selectClasses, selectText } = selection || {};
|
||||
const Cancel = localize('com_ui_cancel');
|
||||
|
||||
const defaultSelect =
|
||||
'bg-gray-800 text-white transition-colors hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-gray-200';
|
||||
return (
|
||||
<DialogContent
|
||||
showCloseButton={showCloseButton}
|
||||
ref={ref}
|
||||
className={cn('shadow-2xl dark:bg-gray-700', className || '')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogHeader className={cn(headerClassName ?? '')}>
|
||||
<DialogTitle className="text-lg font-medium leading-6 text-gray-800 dark:text-gray-200">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
{description && (
|
||||
<DialogDescription className="text-gray-600 dark:text-gray-300">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<div className="px-6">{main ? main : null}</div>
|
||||
<DialogFooter className={footerClassName}>
|
||||
<div>{leftButtons ? leftButtons : null}</div>
|
||||
<div className="flex h-auto gap-3">
|
||||
{showCancelButton && (
|
||||
<DialogClose className="border-gray-100 hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-600">
|
||||
{Cancel}
|
||||
</DialogClose>
|
||||
)}
|
||||
{buttons ? buttons : null}
|
||||
{selection ? (
|
||||
<DialogClose
|
||||
onClick={selectHandler}
|
||||
className={`${
|
||||
selectClasses || defaultSelect
|
||||
} inline-flex h-10 items-center justify-center rounded-lg border-none px-4 py-2 text-sm`}
|
||||
>
|
||||
{selectText}
|
||||
</DialogClose>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
});
|
||||
|
||||
export default DialogTemplate;
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
import React from 'react';
|
||||
import * as Select from '@ariakit/react/select';
|
||||
import type { Option } from '~/common';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
interface DropdownProps {
|
||||
value?: string;
|
||||
label?: string;
|
||||
onChange: (value: string) => void;
|
||||
options: (string | Option | { divider: true })[];
|
||||
className?: string;
|
||||
sizeClasses?: string;
|
||||
testId?: string;
|
||||
icon?: React.ReactNode;
|
||||
iconOnly?: boolean;
|
||||
renderValue?: (option: Option) => React.ReactNode;
|
||||
ariaLabel?: string;
|
||||
portal?: boolean;
|
||||
}
|
||||
|
||||
const isDivider = (item: string | Option | { divider: true }): item is { divider: true } =>
|
||||
typeof item === 'object' && 'divider' in item;
|
||||
|
||||
const isOption = (item: string | Option | { divider: true }): item is Option =>
|
||||
typeof item === 'object' && 'value' in item && 'label' in item;
|
||||
|
||||
const Dropdown: React.FC<DropdownProps> = ({
|
||||
value: selectedValue,
|
||||
label = '',
|
||||
onChange,
|
||||
options,
|
||||
className = '',
|
||||
sizeClasses,
|
||||
testId = 'dropdown-menu',
|
||||
icon,
|
||||
iconOnly = false,
|
||||
renderValue,
|
||||
ariaLabel,
|
||||
portal = true,
|
||||
}) => {
|
||||
const handleChange = (value: string) => {
|
||||
onChange(value);
|
||||
};
|
||||
|
||||
const selectProps = Select.useSelectStore({
|
||||
value: selectedValue,
|
||||
setValue: handleChange,
|
||||
});
|
||||
|
||||
const getOptionObject = (val: string | undefined): Option | undefined => {
|
||||
if (val == null || val === '') {
|
||||
return undefined;
|
||||
}
|
||||
return options
|
||||
.filter((o) => !isDivider(o))
|
||||
.map((o) => (typeof o === 'string' ? { value: o, label: o } : o))
|
||||
.find((o) => isOption(o) && o.value === val) as Option | undefined;
|
||||
};
|
||||
|
||||
const getOptionLabel = (currentValue: string | undefined) => {
|
||||
if (currentValue == null || currentValue === '') {
|
||||
return '';
|
||||
}
|
||||
const option = getOptionObject(currentValue);
|
||||
return option ? option.label : currentValue;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Select.Select
|
||||
store={selectProps}
|
||||
className={cn(
|
||||
'focus:ring-offset-ring-offset relative inline-flex items-center justify-between rounded-xl border border-input bg-background px-3 py-2 text-sm text-text-primary transition-all duration-200 ease-in-out hover:bg-accent hover:text-accent-foreground focus:ring-ring-primary',
|
||||
iconOnly ? 'h-full w-10' : 'w-fit gap-2',
|
||||
className,
|
||||
)}
|
||||
data-testid={testId}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{icon}
|
||||
{!iconOnly && (
|
||||
<span className="block truncate">
|
||||
{label}
|
||||
{(() => {
|
||||
const matchedOption = getOptionObject(selectedValue);
|
||||
if (matchedOption && renderValue) {
|
||||
return renderValue(matchedOption);
|
||||
}
|
||||
return getOptionLabel(selectedValue);
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!iconOnly && <Select.SelectArrow />}
|
||||
</Select.Select>
|
||||
<Select.SelectPopover
|
||||
portal={portal}
|
||||
store={selectProps}
|
||||
className={cn('popover-ui', sizeClasses, className, 'max-h-[80vh] overflow-y-auto')}
|
||||
>
|
||||
{options.map((item, index) => {
|
||||
if (isDivider(item)) {
|
||||
return <div key={`divider-${index}`} className="my-1 border-t border-border-heavy" />;
|
||||
}
|
||||
|
||||
const option = typeof item === 'string' ? { value: item, label: item } : item;
|
||||
if (!isOption(option)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select.SelectItem
|
||||
key={`option-${index}`}
|
||||
value={String(option.value)}
|
||||
className="select-item"
|
||||
data-theme={option.value}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{option.icon != null && <span>{option.icon as React.ReactNode}</span>}
|
||||
<span className="block truncate">{option.label}</span>
|
||||
{selectedValue === option.value && (
|
||||
<span className="ml-auto pl-2">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md block group-hover:hidden"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Select.SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select.SelectPopover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className = '', inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-gray-100 data-[state=open]:bg-gray-100 dark:focus:bg-gray-900 dark:data-[state=open]:bg-gray-900',
|
||||
inset ? 'pl-8' : '',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-100 bg-white p-1 text-gray-700 shadow-md animate-in slide-in-from-left-1 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className = '', sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-100 bg-white p-1 text-gray-700 shadow-md animate-in 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 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className = '', inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-900',
|
||||
inset ? 'pl-8' : '',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className = '', children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-900',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className = '', children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
className,
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className = '', inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold text-gray-900 dark:text-gray-300',
|
||||
inset ? 'pl-8' : '',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-border-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className = '',
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span className={cn('ml-auto text-xs tracking-widest text-gray-500', className)} {...props} />
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
import React, { FC } from 'react';
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import { AnchorPropsWithSelection } from '@headlessui/react/dist/internal/floating';
|
||||
import type { Option } from '~/common';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
interface DropdownProps {
|
||||
value?: string | Option;
|
||||
label?: string;
|
||||
onChange: (value: string | Option) => void;
|
||||
options: (string | Option)[];
|
||||
className?: string;
|
||||
anchor?: AnchorPropsWithSelection;
|
||||
sizeClasses?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* Mainly used for the Speech Voice Selection Dropdown
|
||||
*/
|
||||
|
||||
const Dropdown: FC<DropdownProps> = ({
|
||||
value,
|
||||
label = '',
|
||||
onChange,
|
||||
options,
|
||||
className = '',
|
||||
anchor,
|
||||
sizeClasses,
|
||||
testId = 'dropdown-menu',
|
||||
}) => {
|
||||
const getValue = (option?: string | Option) =>
|
||||
typeof option === 'string' ? option : option?.value;
|
||||
|
||||
const getDisplay = (option?: string | Option) =>
|
||||
typeof option === 'string' ? option : option?.label ?? option?.value;
|
||||
|
||||
const isEqual = (a: string | Option, b: string | Option): boolean => getValue(a) === getValue(b);
|
||||
|
||||
const selectedOption = options.find((option) => isEqual(option, value ?? '')) ?? value;
|
||||
|
||||
const handleChange = (newValue: string | Option) => {
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Listbox value={selectedOption} onChange={handleChange}>
|
||||
<div className={cn('relative', className)}>
|
||||
<ListboxButton
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-between rounded-md border-gray-50 bg-white py-2 pl-3 pr-8 text-black transition-all duration-100 ease-in-out hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 dark:focus:ring-white dark:focus:ring-offset-gray-700',
|
||||
'w-auto',
|
||||
className,
|
||||
)}
|
||||
aria-label="Select an option"
|
||||
>
|
||||
<span className="block truncate">
|
||||
{label}
|
||||
{getDisplay(selectedOption)}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
className="h-4 w-5 rotate-0 transform text-black transition-transform duration-300 ease-in-out dark:text-gray-50"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
leave="transition ease-in duration-50"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
className={cn(
|
||||
'absolute z-50 mt-1 flex flex-col items-start gap-1 overflow-auto rounded-lg border border-gray-300 bg-white p-1.5 text-gray-700 shadow-lg transition-opacity focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
||||
sizeClasses,
|
||||
className,
|
||||
)}
|
||||
anchor={anchor}
|
||||
aria-label="List of options"
|
||||
>
|
||||
{options.map((item, index) => (
|
||||
<ListboxOption
|
||||
key={index}
|
||||
value={item}
|
||||
className={cn(
|
||||
'relative cursor-pointer select-none rounded border-gray-300 bg-white py-2.5 pl-3 pr-3 text-sm text-gray-700 hover:bg-gray-100 dark:border-gray-300 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600',
|
||||
)}
|
||||
style={{ width: '100%' }}
|
||||
data-theme={getValue(item)}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="block truncate">{getDisplay(item)}</span>
|
||||
{selected && (
|
||||
<span className="ml-auto pl-2">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md block group-hover:hidden"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import React from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import type * as t from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface DropdownProps {
|
||||
keyPrefix?: string;
|
||||
trigger: React.ReactNode;
|
||||
items: t.MenuItemProps[];
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
itemClassName?: string;
|
||||
sameWidth?: boolean;
|
||||
anchor?: { x: string; y: string };
|
||||
gutter?: number;
|
||||
modal?: boolean;
|
||||
portal?: boolean;
|
||||
preserveTabOrder?: boolean;
|
||||
focusLoop?: boolean;
|
||||
menuId: string;
|
||||
mountByState?: boolean;
|
||||
unmountOnHide?: boolean;
|
||||
finalFocus?: React.RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
type MenuProps = Omit<
|
||||
DropdownProps,
|
||||
'trigger' | 'isOpen' | 'setIsOpen' | 'focusLoop' | 'mountByState'
|
||||
>;
|
||||
|
||||
const DropdownPopup: React.FC<DropdownProps> = ({
|
||||
trigger,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
focusLoop,
|
||||
mountByState,
|
||||
...props
|
||||
}) => {
|
||||
const menu = Ariakit.useMenuStore({ open: isOpen, setOpen: setIsOpen, focusLoop });
|
||||
if (mountByState) {
|
||||
return (
|
||||
<Ariakit.MenuProvider store={menu}>
|
||||
{trigger}
|
||||
{isOpen && <Menu {...props} />}
|
||||
</Ariakit.MenuProvider>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Ariakit.MenuProvider store={menu}>
|
||||
{trigger}
|
||||
<Menu {...props} />
|
||||
</Ariakit.MenuProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const Menu: React.FC<MenuProps> = ({
|
||||
items,
|
||||
menuId,
|
||||
keyPrefix,
|
||||
className,
|
||||
iconClassName,
|
||||
itemClassName,
|
||||
modal,
|
||||
portal,
|
||||
sameWidth,
|
||||
gutter = 8,
|
||||
finalFocus,
|
||||
unmountOnHide,
|
||||
preserveTabOrder,
|
||||
}) => {
|
||||
const menu = Ariakit.useMenuContext();
|
||||
return (
|
||||
<Ariakit.Menu
|
||||
id={menuId}
|
||||
modal={modal}
|
||||
gutter={gutter}
|
||||
portal={portal}
|
||||
sameWidth={sameWidth}
|
||||
finalFocus={finalFocus}
|
||||
unmountOnHide={unmountOnHide}
|
||||
preserveTabOrder={preserveTabOrder}
|
||||
className={cn('popover-ui z-50', className)}
|
||||
>
|
||||
{items
|
||||
.filter((item) => item.show !== false)
|
||||
.map((item, index) => {
|
||||
if (item.separate === true) {
|
||||
return <Ariakit.MenuSeparator key={index} className="my-1 h-px bg-white/10" />;
|
||||
}
|
||||
return (
|
||||
<Ariakit.MenuItem
|
||||
key={`${keyPrefix ?? ''}${index}-${item.id ?? ''}`}
|
||||
id={item.id}
|
||||
className={cn(
|
||||
'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
|
||||
itemClassName,
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
render={item.render}
|
||||
ref={item.ref}
|
||||
hideOnClick={item.hideOnClick}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (item.onClick) {
|
||||
item.onClick(event);
|
||||
}
|
||||
if (item.hideOnClick === false) {
|
||||
return;
|
||||
}
|
||||
menu?.hide();
|
||||
}}
|
||||
>
|
||||
{item.icon != null && (
|
||||
<span className={cn('mr-2 size-4', iconClassName)} aria-hidden="true">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
{item.label}
|
||||
{item.kbd != null && (
|
||||
// eslint-disable-next-line i18next/no-literal-string
|
||||
<kbd className="ml-auto hidden font-sans text-xs text-black/50 group-hover:inline group-focus:inline dark:text-white/50">
|
||||
⌘{item.kbd}
|
||||
</kbd>
|
||||
)}
|
||||
</Ariakit.MenuItem>
|
||||
);
|
||||
})}
|
||||
</Ariakit.Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownPopup;
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
|
||||
type FileUploadProps = {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
const FileUpload = forwardRef<HTMLInputElement, FileUploadProps>(
|
||||
({ children, handleFileChange }, ref) => {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<input
|
||||
ref={ref}
|
||||
multiple
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FileUpload.displayName = 'FileUpload';
|
||||
|
||||
export default FileUpload;
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Label, Input } from '~/components/ui';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function FormInput({
|
||||
field,
|
||||
label,
|
||||
labelClass,
|
||||
inputClass,
|
||||
containerClass,
|
||||
labelAdjacent,
|
||||
placeholder = '',
|
||||
type = 'string',
|
||||
}: {
|
||||
field: any;
|
||||
label: string;
|
||||
labelClass?: string;
|
||||
inputClass?: string;
|
||||
placeholder?: string;
|
||||
containerClass?: string;
|
||||
type?: 'string' | 'number';
|
||||
labelAdjacent?: React.ReactNode;
|
||||
}) {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
|
||||
if (type !== 'number') {
|
||||
field.onChange(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === '') {
|
||||
field.onChange(value);
|
||||
} else if (!isNaN(Number(value))) {
|
||||
field.onChange(Number(value));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full flex-col items-center gap-2', containerClass)}>
|
||||
<div className="flex w-full items-center justify-start gap-2">
|
||||
<Label
|
||||
htmlFor={`${field.name}-input`}
|
||||
className={cn('text-left text-sm font-semibold text-text-primary', labelClass)}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
{labelAdjacent}
|
||||
</div>
|
||||
<Input
|
||||
id={`${field.name}-input`}
|
||||
value={field.value ?? ''}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'flex h-10 max-h-10 w-full resize-none border-none bg-surface-secondary px-3 py-2',
|
||||
inputClass,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
|
||||
|
||||
import { cn } from '../../utils';
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardPortal = HoverCardPrimitive.Portal;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> & { disabled?: boolean }
|
||||
>(({ className = '', align = 'center', sideOffset = 6, disabled = false, ...props }, ref) => {
|
||||
if (disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-64 origin-[--radix-hover-card-content-transform-origin] rounded-xl border border-border-light bg-surface-secondary p-4 text-text-primary 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',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent, HoverCardPortal };
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className ?? '',
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import React from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import type { OptionWithIcon } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type ComboboxProps = {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
options: OptionWithIcon[] | string[];
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur: () => void;
|
||||
};
|
||||
|
||||
export const InputCombobox: React.FC<ComboboxProps> = ({
|
||||
label,
|
||||
labelClassName,
|
||||
placeholder = 'Select an option',
|
||||
options,
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
}) => {
|
||||
const isOptionObject = (option: unknown): option is OptionWithIcon => {
|
||||
return option != null && typeof option === 'object' && 'value' in option;
|
||||
};
|
||||
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [inputValue, setInputValue] = React.useState(value);
|
||||
const [isKeyboardFocus, setIsKeyboardFocus] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setInputValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
setInputValue(newValue);
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Ariakit.ComboboxProvider value={inputValue} setValue={handleChange}>
|
||||
{label != null && (
|
||||
<Ariakit.ComboboxLabel
|
||||
className={cn('mb-2 block text-sm font-medium text-text-primary', labelClassName ?? '')}
|
||||
>
|
||||
{label}
|
||||
</Ariakit.ComboboxLabel>
|
||||
)}
|
||||
<div className={cn('relative', isKeyboardFocus ? 'rounded-md ring-2 ring-ring-primary' : '')}>
|
||||
<Ariakit.Combobox
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'h-10 w-full rounded-md border border-border-light bg-surface-primary px-3 py-2 text-sm',
|
||||
'placeholder-text-secondary hover:bg-surface-hover',
|
||||
'focus:outline-none',
|
||||
className,
|
||||
)}
|
||||
onChange={(event) => handleChange(event.target.value)}
|
||||
onBlur={() => {
|
||||
setIsKeyboardFocus(false);
|
||||
onBlur();
|
||||
}}
|
||||
onFocusVisible={() => {
|
||||
setIsKeyboardFocus(true);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
setIsKeyboardFocus(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Ariakit.ComboboxPopover
|
||||
gutter={4}
|
||||
sameWidth
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
className={cn(
|
||||
'z-50 max-h-60 w-full overflow-auto rounded-md bg-surface-primary p-1 shadow-lg',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
)}
|
||||
>
|
||||
{options.map((option: string | OptionWithIcon, index: number) => (
|
||||
<Ariakit.ComboboxItem
|
||||
key={index}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
|
||||
'cursor-pointer hover:bg-surface-tertiary hover:text-text-primary',
|
||||
'data-[active-item]:bg-surface-tertiary data-[active-item]:text-text-primary',
|
||||
)}
|
||||
value={isOptionObject(option) ? `${option.value ?? ''}` : option}
|
||||
>
|
||||
{isOptionObject(option) && option.icon != null && (
|
||||
<span className="mr-2 flex-shrink-0">{option.icon}</span>
|
||||
)}
|
||||
{isOptionObject(option) ? option.label : option}
|
||||
</Ariakit.ComboboxItem>
|
||||
))}
|
||||
</Ariakit.ComboboxPopover>
|
||||
</Ariakit.ComboboxProvider>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import * as React from 'react';
|
||||
|
||||
// import { NumericFormat } from 'react-number-format';
|
||||
|
||||
import RCInputNumber from 'rc-input-number';
|
||||
import * as InputNumberPrimitive from 'rc-input-number';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
// TODO help needed
|
||||
// React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
// React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
|
||||
const InputNumber = React.forwardRef<
|
||||
React.ElementRef<typeof RCInputNumber>,
|
||||
InputNumberPrimitive.InputNumberProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RCInputNumber
|
||||
className={cn(
|
||||
'flex max-h-5 w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-50',
|
||||
className ?? '',
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
InputNumber.displayName = 'Input';
|
||||
|
||||
// console.log(_InputNumber);
|
||||
|
||||
// const InputNumber = React.forwardRef(({ className, ...props }, ref) => {
|
||||
// return (
|
||||
// <NumericFormat
|
||||
// className={cn(
|
||||
// 'flex h-10 w-full rounded-md border border-gray-300 bg-transparent py-2 px-3 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-50 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
|
||||
// className
|
||||
// )}
|
||||
// ref={ref}
|
||||
// {...props}
|
||||
// />
|
||||
// );
|
||||
// });
|
||||
|
||||
export { InputNumber };
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||
import { Minus } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
'flex items-center gap-2 has-[:disabled]:opacity-50',
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn('disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
InputOTP.displayName = 'InputOTP';
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center', className)} {...props} />
|
||||
));
|
||||
InputOTPGroup.displayName = 'InputOTPGroup';
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-md relative flex h-11 w-11 items-center justify-center border-y border-r border-input shadow-sm transition-all first:rounded-l-xl first:border-l last:rounded-r-xl',
|
||||
isActive && 'z-10 ring-1 ring-ring',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
InputOTPSlot.displayName = 'InputOTPSlot';
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Minus />
|
||||
</div>
|
||||
));
|
||||
InputOTPSeparator.displayName = 'InputOTPSeparator';
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import { Input } from '~/components/ui/Input';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export type InputWithDropdownProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
options: string[];
|
||||
onSelect?: (value: string) => void;
|
||||
};
|
||||
|
||||
const InputWithDropdown = React.forwardRef<HTMLInputElement, InputWithDropdownProps>(
|
||||
({ className, options, onSelect, ...props }, ref) => {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [inputValue, setInputValue] = React.useState((props.value as string) || '');
|
||||
const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
setInputValue(value);
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
if (onSelect) {
|
||||
onSelect(value);
|
||||
}
|
||||
if (props.onChange) {
|
||||
props.onChange({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
if (props.onChange) {
|
||||
props.onChange(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
setHighlightedIndex((prevIndex) =>
|
||||
prevIndex < options.length - 1 ? prevIndex + 1 : prevIndex,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (isOpen && highlightedIndex !== -1) {
|
||||
handleSelect(options[highlightedIndex]);
|
||||
}
|
||||
setIsOpen(false);
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (inputRef.current && !inputRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={inputRef}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...props}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls="dropdown-list"
|
||||
className={cn('bg-surface-secondary', className ?? '')}
|
||||
ref={ref}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-tertiary hover:text-secondary absolute inset-y-0 right-0 flex items-center rounded-md px-2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring-primary"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label={isOpen ? 'Close dropdown' : 'Open dropdown'}
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<ul
|
||||
id="dropdown-list"
|
||||
role="listbox"
|
||||
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border-medium bg-surface-secondary shadow-lg focus:ring-1 focus:ring-inset focus:ring-ring-primary"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<li
|
||||
key={index}
|
||||
role="option"
|
||||
aria-selected={index === highlightedIndex}
|
||||
className={cn(
|
||||
'cursor-pointer rounded-md px-3 py-2',
|
||||
'focus:bg-surface-tertiary focus:outline-none focus:ring-1 focus:ring-inset focus:ring-ring-primary',
|
||||
index === highlightedIndex
|
||||
? 'text-primary bg-surface-active'
|
||||
: 'text-secondary hover:bg-surface-tertiary',
|
||||
)}
|
||||
onClick={() => handleSelect(option)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelect(option);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
{option}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
InputWithDropdown.displayName = 'InputWithDropdown';
|
||||
|
||||
export default InputWithDropdown;
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
|
||||
import { cn } from '../../utils';
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'block w-full break-all text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Input, Label, Button } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useMCPAuthValuesQuery } from '~/data-provider/Tools/queries';
|
||||
|
||||
export interface CustomUserVarConfig {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface CustomUserVarsSectionProps {
|
||||
serverName: string;
|
||||
fields: Record<string, CustomUserVarConfig>;
|
||||
onSave: (authData: Record<string, string>) => void;
|
||||
onRevoke: () => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
interface AuthFieldProps {
|
||||
name: string;
|
||||
config: CustomUserVarConfig;
|
||||
hasValue: boolean;
|
||||
control: any;
|
||||
errors: any;
|
||||
}
|
||||
|
||||
function AuthField({ name, config, hasValue, control, errors }: AuthFieldProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={name} className="text-sm font-medium">
|
||||
{config.title}
|
||||
</Label>
|
||||
{hasValue ? (
|
||||
<div className="flex min-w-fit items-center gap-2 whitespace-nowrap rounded-full border border-border-medium px-2 py-0.5 text-xs font-medium text-text-secondary">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
<span>{localize('com_ui_set')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-fit items-center gap-2 whitespace-nowrap rounded-full border border-border-medium px-2 py-0.5 text-xs font-medium text-text-secondary">
|
||||
<div className="h-1.5 w-1.5 rounded-full border border-border-medium" />
|
||||
<span>{localize('com_ui_unset')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id={name}
|
||||
type="text"
|
||||
{...field}
|
||||
placeholder={
|
||||
hasValue
|
||||
? localize('com_ui_mcp_update_var', { 0: config.title })
|
||||
: localize('com_ui_mcp_enter_var', { 0: config.title })
|
||||
}
|
||||
className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{config.description && (
|
||||
<p
|
||||
className="text-xs text-text-secondary [&_a]:text-blue-500 [&_a]:hover:text-blue-600 dark:[&_a]:text-blue-400 dark:[&_a]:hover:text-blue-300"
|
||||
dangerouslySetInnerHTML={{ __html: config.description }}
|
||||
/>
|
||||
)}
|
||||
{errors[name] && <p className="text-xs text-red-500">{errors[name]?.message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomUserVarsSection({
|
||||
serverName,
|
||||
fields,
|
||||
onSave,
|
||||
onRevoke,
|
||||
isSubmitting = false,
|
||||
}: CustomUserVarsSectionProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
// Fetch auth value flags for the server
|
||||
const { data: authValuesData } = useMCPAuthValuesQuery(serverName, {
|
||||
enabled: !!serverName,
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<Record<string, string>>({
|
||||
defaultValues: useMemo(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
Object.keys(fields).forEach((key) => {
|
||||
initial[key] = '';
|
||||
});
|
||||
return initial;
|
||||
}, [fields]),
|
||||
});
|
||||
|
||||
const onFormSubmit = (data: Record<string, string>) => {
|
||||
onSave(data);
|
||||
};
|
||||
|
||||
const handleRevokeClick = () => {
|
||||
onRevoke();
|
||||
// Reset form after revoke
|
||||
reset();
|
||||
};
|
||||
|
||||
// Don't render if no fields to configure
|
||||
if (!fields || Object.keys(fields).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
||||
{Object.entries(fields).map(([key, config]) => {
|
||||
const hasValue = authValuesData?.authValueFlags?.[key] || false;
|
||||
|
||||
return (
|
||||
<AuthField
|
||||
key={key}
|
||||
name={key}
|
||||
config={config}
|
||||
hasValue={hasValue}
|
||||
control={control}
|
||||
errors={errors}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</form>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
onClick={handleRevokeClick}
|
||||
className="bg-red-600 text-white hover:bg-red-700 dark:hover:bg-red-800"
|
||||
disabled={isSubmitting}
|
||||
size="sm"
|
||||
>
|
||||
{localize('com_ui_revoke')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit(onFormSubmit)}
|
||||
className="bg-green-500 text-white hover:bg-green-600"
|
||||
disabled={isSubmitting}
|
||||
size="sm"
|
||||
>
|
||||
{isSubmitting ? localize('com_ui_saving') : localize('com_ui_save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Loader2, KeyRound, PlugZap, AlertTriangle } from 'lucide-react';
|
||||
import { MCPServerStatus } from 'librechat-data-provider/dist/types/types/queries';
|
||||
import {
|
||||
OGDialog,
|
||||
OGDialogContent,
|
||||
OGDialogHeader,
|
||||
OGDialogTitle,
|
||||
OGDialogDescription,
|
||||
} from '~/components/ui/OriginalDialog';
|
||||
import CustomUserVarsSection from './CustomUserVarsSection';
|
||||
import ServerInitializationSection from './ServerInitializationSection';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export interface ConfigFieldDetail {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface MCPConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
fieldsSchema: Record<string, ConfigFieldDetail>;
|
||||
initialValues: Record<string, string>;
|
||||
onSave: (updatedValues: Record<string, string>) => void;
|
||||
isSubmitting?: boolean;
|
||||
onRevoke?: () => void;
|
||||
serverName: string;
|
||||
serverStatus?: MCPServerStatus;
|
||||
}
|
||||
|
||||
export default function MCPConfigDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
fieldsSchema,
|
||||
onSave,
|
||||
isSubmitting = false,
|
||||
onRevoke,
|
||||
serverName,
|
||||
serverStatus,
|
||||
}: MCPConfigDialogProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const hasFields = Object.keys(fieldsSchema).length > 0;
|
||||
const dialogTitle = hasFields
|
||||
? localize('com_ui_configure_mcp_variables_for', { 0: serverName })
|
||||
: `${serverName} MCP Server`;
|
||||
const dialogDescription = hasFields
|
||||
? localize('com_ui_mcp_dialog_desc')
|
||||
: `Manage connection and settings for the ${serverName} MCP server.`;
|
||||
|
||||
// Helper function to render status badge based on connection state
|
||||
const renderStatusBadge = () => {
|
||||
if (!serverStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
|
||||
if (connectionState === 'connecting') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-600 dark:bg-blue-950 dark:text-blue-400">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>{localize('com_ui_connecting')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (connectionState === 'disconnected') {
|
||||
if (requiresOAuth) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-600 dark:bg-amber-950 dark:text-amber-400">
|
||||
<KeyRound className="h-3 w-3" />
|
||||
<span>{localize('com_ui_oauth')}</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-orange-50 px-2 py-0.5 text-xs font-medium text-orange-600 dark:bg-orange-950 dark:text-orange-400">
|
||||
<PlugZap className="h-3 w-3" />
|
||||
<span>{localize('com_ui_offline')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (connectionState === 'error') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-600 dark:bg-red-950 dark:text-red-400">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<span>{localize('com_ui_error')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (connectionState === 'connected') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900 dark:text-green-300">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
<span>{localize('com_ui_active')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialogContent className="flex max-h-[90vh] w-full max-w-md flex-col">
|
||||
<OGDialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<OGDialogTitle>{dialogTitle}</OGDialogTitle>
|
||||
{renderStatusBadge()}
|
||||
</div>
|
||||
<OGDialogDescription>{dialogDescription}</OGDialogDescription>
|
||||
</OGDialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Custom User Variables Section */}
|
||||
<CustomUserVarsSection
|
||||
serverName={serverName}
|
||||
fields={fieldsSchema}
|
||||
onSave={onSave}
|
||||
onRevoke={onRevoke || (() => {})}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Server Initialization Section */}
|
||||
<ServerInitializationSection
|
||||
serverName={serverName}
|
||||
requiresOAuth={serverStatus?.requiresOAuth || false}
|
||||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
import React from 'react';
|
||||
import { SettingsIcon, AlertTriangle, Loader2, KeyRound, PlugZap, X } from 'lucide-react';
|
||||
import type { MCPServerStatus, TPlugin } from 'librechat-data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
let localize: ReturnType<typeof useLocalize>;
|
||||
|
||||
interface StatusIconProps {
|
||||
serverName: string;
|
||||
onConfigClick: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
interface InitializingStatusProps extends StatusIconProps {
|
||||
onCancel: (e: React.MouseEvent) => void;
|
||||
canCancel: boolean;
|
||||
}
|
||||
|
||||
interface MCPServerStatusIconProps {
|
||||
serverName: string;
|
||||
serverStatus?: MCPServerStatus;
|
||||
tool?: TPlugin;
|
||||
onConfigClick: (e: React.MouseEvent) => void;
|
||||
isInitializing: boolean;
|
||||
canCancel: boolean;
|
||||
onCancel: (e: React.MouseEvent) => void;
|
||||
hasCustomUserVars?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the appropriate status icon for an MCP server based on its state
|
||||
*/
|
||||
export default function MCPServerStatusIcon({
|
||||
serverName,
|
||||
serverStatus,
|
||||
tool,
|
||||
onConfigClick,
|
||||
isInitializing,
|
||||
canCancel,
|
||||
onCancel,
|
||||
hasCustomUserVars = false,
|
||||
}: MCPServerStatusIconProps) {
|
||||
localize = useLocalize();
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<InitializingStatusIcon
|
||||
serverName={serverName}
|
||||
onConfigClick={onConfigClick}
|
||||
onCancel={onCancel}
|
||||
canCancel={canCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!serverStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
|
||||
if (connectionState === 'connecting') {
|
||||
return <ConnectingStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
}
|
||||
|
||||
if (connectionState === 'disconnected') {
|
||||
if (requiresOAuth) {
|
||||
return <DisconnectedOAuthStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
}
|
||||
return <DisconnectedStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
}
|
||||
|
||||
if (connectionState === 'error') {
|
||||
return <ErrorStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
}
|
||||
|
||||
if (connectionState === 'connected') {
|
||||
// Only show config button if there are customUserVars to configure
|
||||
if (hasCustomUserVars) {
|
||||
const isAuthenticated = tool?.authenticated || requiresOAuth;
|
||||
return (
|
||||
<AuthenticatedStatusIcon
|
||||
serverName={serverName}
|
||||
onConfigClick={onConfigClick}
|
||||
isAuthenticated={isAuthenticated}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null; // No config button for connected servers without customUserVars
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function InitializingStatusIcon({ serverName, onCancel, canCancel }: InitializingStatusProps) {
|
||||
if (canCancel) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-red-100 dark:hover:bg-red-900/20"
|
||||
aria-label={localize('com_ui_cancel')}
|
||||
title={localize('com_ui_cancel')}
|
||||
>
|
||||
<div className="group relative h-4 w-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-500 group-hover:opacity-0" />
|
||||
<X className="absolute inset-0 h-4 w-4 text-red-500 opacity-0 group-hover:opacity-100" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded p-1">
|
||||
<Loader2
|
||||
className="h-4 w-4 animate-spin text-blue-500"
|
||||
aria-label={localize('com_nav_mcp_status_connecting', { 0: serverName })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectingStatusIcon({ serverName }: StatusIconProps) {
|
||||
return (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded p-1">
|
||||
<Loader2
|
||||
className="h-4 w-4 animate-spin text-blue-500"
|
||||
aria-label={localize('com_nav_mcp_status_connecting', { 0: serverName })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisconnectedOAuthStatusIcon({ serverName, onConfigClick }: StatusIconProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfigClick}
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
|
||||
>
|
||||
<KeyRound className="h-4 w-4 text-amber-500" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DisconnectedStatusIcon({ serverName, onConfigClick }: StatusIconProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfigClick}
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
|
||||
>
|
||||
<PlugZap className="h-4 w-4 text-orange-500" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorStatusIcon({ serverName, onConfigClick }: StatusIconProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfigClick}
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface AuthenticatedStatusProps extends StatusIconProps {
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
function AuthenticatedStatusIcon({
|
||||
serverName,
|
||||
onConfigClick,
|
||||
isAuthenticated,
|
||||
}: AuthenticatedStatusProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfigClick}
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
|
||||
>
|
||||
<SettingsIcon className={`h-4 w-4 ${isAuthenticated ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import { RefreshCw, Link } from 'lucide-react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useMCPServerInitialization } from '~/hooks/MCP/useMCPServerInitialization';
|
||||
import { Button } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface ServerInitializationSectionProps {
|
||||
serverName: string;
|
||||
requiresOAuth: boolean;
|
||||
}
|
||||
|
||||
export default function ServerInitializationSection({
|
||||
serverName,
|
||||
requiresOAuth,
|
||||
}: ServerInitializationSectionProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const [oauthUrl, setOauthUrl] = useState<string | null>(null);
|
||||
|
||||
// Use the shared initialization hook
|
||||
const { initializeServer, isLoading, connectionStatus, cancelOAuthFlow, isCancellable } =
|
||||
useMCPServerInitialization({
|
||||
onOAuthStarted: (name, url) => {
|
||||
// Store the OAuth URL locally for display
|
||||
setOauthUrl(url);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Clear OAuth URL on success
|
||||
setOauthUrl(null);
|
||||
},
|
||||
});
|
||||
|
||||
const serverStatus = connectionStatus[serverName];
|
||||
const isConnected = serverStatus?.connectionState === 'connected';
|
||||
const canCancel = isCancellable(serverName);
|
||||
|
||||
const handleInitializeClick = useCallback(() => {
|
||||
setOauthUrl(null);
|
||||
initializeServer(serverName);
|
||||
}, [initializeServer, serverName]);
|
||||
|
||||
const handleCancelClick = useCallback(() => {
|
||||
setOauthUrl(null);
|
||||
cancelOAuthFlow(serverName);
|
||||
}, [cancelOAuthFlow, serverName]);
|
||||
|
||||
// Show subtle reinitialize option if connected
|
||||
if (isConnected) {
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<button
|
||||
onClick={handleInitializeClick}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600 disabled:opacity-50 dark:text-gray-500 dark:hover:text-gray-400"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
{isLoading ? localize('com_ui_loading') : localize('com_ui_reinitialize')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[#991b1b] bg-[#2C1315] p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||
{requiresOAuth
|
||||
? localize('com_ui_mcp_not_authenticated', { 0: serverName })
|
||||
: localize('com_ui_mcp_not_initialized', { 0: serverName })}
|
||||
</span>
|
||||
</div>
|
||||
{/* Only show authenticate button when OAuth URL is not present */}
|
||||
{!oauthUrl && (
|
||||
<Button
|
||||
onClick={handleInitializeClick}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 dark:hover:bg-blue-800"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
{localize('com_ui_loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{requiresOAuth
|
||||
? localize('com_ui_authenticate')
|
||||
: localize('com_ui_mcp_initialize')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OAuth URL display */}
|
||||
{oauthUrl && (
|
||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/20">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-blue-500">
|
||||
<Link className="h-2.5 w-2.5 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||
{localize('com_ui_auth_url')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => window.open(oauthUrl, '_blank', 'noopener,noreferrer')}
|
||||
className="flex-1 bg-blue-600 text-white hover:bg-blue-700 dark:hover:bg-blue-800"
|
||||
>
|
||||
{localize('com_ui_continue_oauth')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCancelClick}
|
||||
disabled={!canCancel}
|
||||
className="bg-gray-200 text-gray-700 hover:bg-gray-300 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
title={!canCancel ? 'disabled' : undefined}
|
||||
>
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-blue-600 dark:text-blue-400">
|
||||
{localize('com_ui_oauth_flow_desc')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { TranslationKeys, useLocalize } from '~/hooks';
|
||||
import { Minus, Plus } from 'lucide-react';
|
||||
|
||||
interface ModelParametersProps {
|
||||
label?: string;
|
||||
ariaLabel?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
stepClick?: number;
|
||||
initialValue?: number;
|
||||
showButtons?: boolean;
|
||||
onChange?: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ModelParameters: React.FC<ModelParametersProps> = ({
|
||||
label = 'Value',
|
||||
ariaLabel = 'Value',
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
stepClick = 1,
|
||||
initialValue = 0,
|
||||
showButtons = true,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const rangeRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const id = `model-parameter-${ariaLabel.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
const displayLabel =
|
||||
label && label.startsWith('com_') ? localize(label as TranslationKeys) : label;
|
||||
|
||||
const getDecimalPlaces = (num: number) => {
|
||||
const match = ('' + num).match(/(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/);
|
||||
if (!match) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, (match[1] ? match[1].length : 0) - (match[2] ? +match[2] : 0));
|
||||
};
|
||||
|
||||
const decimalPlaces = getDecimalPlaces(step);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValue: number) => {
|
||||
const clampedValue = Math.min(Math.max(newValue, min), max);
|
||||
const finalValue = Object.is(clampedValue, -0) ? 0 : clampedValue;
|
||||
setValue(finalValue);
|
||||
onChange?.(finalValue);
|
||||
},
|
||||
[min, max, onChange],
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleChange(parseFloat(e.target.value));
|
||||
},
|
||||
[handleChange],
|
||||
);
|
||||
|
||||
const handleIncrement = useCallback(() => {
|
||||
handleChange(value + stepClick);
|
||||
}, [value, stepClick, handleChange]);
|
||||
|
||||
const handleDecrement = useCallback(() => {
|
||||
handleChange(value - stepClick);
|
||||
}, [value, stepClick, handleChange]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
handleIncrement();
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
handleDecrement();
|
||||
}
|
||||
},
|
||||
[handleIncrement, handleDecrement],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const rangeElement = rangeRef.current;
|
||||
if (rangeElement) {
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
rangeElement.style.backgroundSize = `${percentage}% 100%`;
|
||||
}
|
||||
}, [value, min, max]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={`text-sm font-medium ${disabled ? 'text-gray-400 dark:text-gray-400' : ''}`}
|
||||
>
|
||||
{displayLabel}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<output
|
||||
htmlFor={id}
|
||||
className={`select-none text-sm font-medium ${
|
||||
disabled ? 'text-gray-400 dark:text-gray-400' : ''
|
||||
}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{value.toFixed(decimalPlaces).replace('-0.00', '0.00')}
|
||||
</output>
|
||||
{showButtons && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDecrement}
|
||||
className={`rounded-md p-1 transition-colors ${
|
||||
disabled
|
||||
? 'cursor-not-allowed text-gray-400 dark:text-gray-400'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
aria-label={`Decrease ${label}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleIncrement}
|
||||
className={`rounded-md p-1 transition-colors ${
|
||||
disabled
|
||||
? 'cursor-not-allowed text-gray-400 dark:text-gray-400'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
aria-label={`Increase ${label}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
ref={rangeRef}
|
||||
type="range"
|
||||
id={id}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
className={`slider-thumb h-2 w-full appearance-none rounded-lg bg-gradient-to-r from-gray-500 to-gray-500 bg-no-repeat focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${
|
||||
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
|
||||
}`}
|
||||
tabIndex={0}
|
||||
style={{
|
||||
backgroundSize: '50% 100%',
|
||||
backgroundPosition: 'left',
|
||||
}}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
aria-valuetext={`${value.toFixed(decimalPlaces).replace('-0.00', '0.00')}`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{isHovering ? (
|
||||
<div className="trab mt-1 flex justify-between">
|
||||
<span className="text-xs text-gray-500">{min}</span>
|
||||
<span className="text-xs text-gray-500">{max}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1" style={{ height: '1rem' }}></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ModelParameters);
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
import { Search, X } from 'lucide-react';
|
||||
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/** This is a generic that can be added to Menu and Select components */
|
||||
export default function MultiSearch({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className = '',
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (filter: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onChangeHandler: React.ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(e) => onChange(e.target.value),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const clearSearch = () => {
|
||||
onChange('');
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'focus:to-surface-primary/50 group sticky left-0 top-0 z-10 flex h-12 items-center gap-2 bg-gradient-to-b from-surface-tertiary-alt from-65% to-transparent px-3 py-2 text-text-primary transition-colors duration-300 focus:bg-gradient-to-b focus:from-surface-primary',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Search
|
||||
className="h-4 w-4 text-text-secondary-alt transition-colors duration-300"
|
||||
aria-hidden={'true'}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={onChangeHandler}
|
||||
placeholder={placeholder ?? localize('com_ui_select_search_model')}
|
||||
aria-label="Search Model"
|
||||
className="flex-1 rounded-md border-none bg-transparent px-2.5 py-2 text-sm placeholder-text-secondary focus:outline-none focus:ring-1 focus:ring-ring-primary"
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex h-5 w-5 items-center justify-end rounded-md text-text-secondary-alt',
|
||||
value?.length ?? 0 ? 'cursor-pointer opacity-100' : 'hidden',
|
||||
)}
|
||||
aria-label={'Clear search'}
|
||||
onClick={clearSearch}
|
||||
tabIndex={0}
|
||||
>
|
||||
<X
|
||||
aria-hidden={'true'}
|
||||
className={cn(
|
||||
'text-text-secondary-alt',
|
||||
value?.length ?? 0 ? 'cursor-pointer opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function that will take a multiSearch input
|
||||
* @param node
|
||||
*/
|
||||
function defaultGetStringKey(node: unknown): string {
|
||||
if (typeof node === 'string') {
|
||||
// BUGFIX: Detect psedeo separators and make sure they don't appear in the list when filtering items
|
||||
// it makes sure (for the most part) that the model name starts and ends with dashes
|
||||
// The long-term fix here would be to enable seperators (model groupings) but there's no
|
||||
// feature mocks for such a thing yet
|
||||
if (node.startsWith('---') && node.endsWith('---')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return node.toUpperCase();
|
||||
}
|
||||
// This should be a noop, but it's here for redundancy
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for conditionally making a multi-element list component into a sortable component
|
||||
* Returns a RenderNode for search input when search functionality is available
|
||||
* @param availableOptions
|
||||
* @param placeholder
|
||||
* @param getTextKeyOverride
|
||||
* @param className - Additional classnames to add to the search container
|
||||
* @param disabled - If the search should be disabled
|
||||
* @returns
|
||||
*/
|
||||
export function useMultiSearch<OptionsType extends unknown[]>({
|
||||
availableOptions = [] as unknown as OptionsType,
|
||||
placeholder,
|
||||
getTextKeyOverride,
|
||||
className,
|
||||
disabled = false,
|
||||
}: {
|
||||
availableOptions?: OptionsType;
|
||||
placeholder?: string;
|
||||
getTextKeyOverride?: (node: OptionsType[0]) => string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}): [OptionsType, React.ReactNode] {
|
||||
const [filterValue, setFilterValue] = useState<string | null>(null);
|
||||
|
||||
// We conditionally show the search when there's more than 10 elements in the menu
|
||||
const shouldShowSearch = availableOptions.length > 10 && !disabled;
|
||||
|
||||
// Define the helper function used to enable search
|
||||
// If this is invalidly described, we will assume developer error - tf. avoid rendering
|
||||
const getTextKeyHelper = getTextKeyOverride || defaultGetStringKey;
|
||||
|
||||
// Iterate said options
|
||||
const filteredOptions = useMemo(() => {
|
||||
const currentFilter = filterValue ?? '';
|
||||
if (!shouldShowSearch || !currentFilter || !availableOptions.length) {
|
||||
// Don't render if available options aren't present, there's no filter active
|
||||
return availableOptions;
|
||||
}
|
||||
// Filter through the values, using a simple text-based search
|
||||
// nothing too fancy, but we can add a better search algo later if we need
|
||||
const upperFilterValue = currentFilter.toUpperCase();
|
||||
|
||||
return availableOptions.filter((value) =>
|
||||
getTextKeyHelper(value).includes(upperFilterValue),
|
||||
) as OptionsType;
|
||||
}, [availableOptions, getTextKeyHelper, filterValue, shouldShowSearch]);
|
||||
|
||||
const onSearchChange = useCallback(
|
||||
(nextFilterValue: string) => setFilterValue(nextFilterValue),
|
||||
[],
|
||||
);
|
||||
|
||||
const searchRender = shouldShowSearch ? (
|
||||
<MultiSearch
|
||||
value={filterValue}
|
||||
className={className}
|
||||
onChange={onSearchChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return [filteredOptions, searchRender];
|
||||
}
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
import React, { useRef } from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectArrow,
|
||||
SelectItem,
|
||||
SelectItemCheck,
|
||||
SelectLabel,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
} from '@ariakit/react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface MultiSelectProps<T extends string> {
|
||||
items: T[];
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
defaultSelectedValues?: T[];
|
||||
onSelectedValuesChange?: (values: T[]) => void;
|
||||
renderSelectedValues?: (values: T[], placeholder?: string) => React.ReactNode;
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
labelClassName?: string;
|
||||
selectClassName?: string;
|
||||
selectIcon?: React.ReactNode;
|
||||
popoverClassName?: string;
|
||||
selectItemsClassName?: string;
|
||||
selectedValues: T[];
|
||||
setSelectedValues: (values: T[]) => void;
|
||||
renderItemContent?: (
|
||||
value: T,
|
||||
defaultContent: React.ReactNode,
|
||||
isSelected: boolean,
|
||||
) => React.ReactNode;
|
||||
}
|
||||
|
||||
function defaultRender<T extends string>(values: T[], placeholder?: string) {
|
||||
if (values.length === 0) {
|
||||
return placeholder || 'Select...';
|
||||
}
|
||||
if (values.length === 1) {
|
||||
return values[0];
|
||||
}
|
||||
return `${values.length} items selected`;
|
||||
}
|
||||
|
||||
export default function MultiSelect<T extends string>({
|
||||
items,
|
||||
label,
|
||||
placeholder = 'Select...',
|
||||
defaultSelectedValues = [],
|
||||
onSelectedValuesChange,
|
||||
renderSelectedValues = defaultRender,
|
||||
className,
|
||||
selectIcon,
|
||||
itemClassName,
|
||||
labelClassName,
|
||||
selectClassName,
|
||||
popoverClassName,
|
||||
selectItemsClassName,
|
||||
selectedValues = [],
|
||||
setSelectedValues,
|
||||
renderItemContent,
|
||||
}: MultiSelectProps<T>) {
|
||||
const selectRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleValueChange = (values: T[]) => {
|
||||
setSelectedValues(values);
|
||||
if (onSelectedValuesChange) {
|
||||
onSelectedValuesChange(values);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<SelectProvider value={selectedValues} setValue={handleValueChange}>
|
||||
{label && (
|
||||
<SelectLabel className={cn('mb-1 block text-sm text-text-primary', labelClassName)}>
|
||||
{label}
|
||||
</SelectLabel>
|
||||
)}
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 rounded-xl px-3 py-2 text-sm',
|
||||
'bg-surface-tertiary text-text-primary shadow-sm hover:cursor-pointer hover:bg-surface-hover',
|
||||
'outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75',
|
||||
selectClassName,
|
||||
selectedValues.length > 0 && selectItemsClassName != null && selectItemsClassName,
|
||||
)}
|
||||
onChange={(e) => e.stopPropagation()}
|
||||
>
|
||||
{selectIcon && selectIcon}
|
||||
<span className="mr-auto hidden truncate md:block">
|
||||
{renderSelectedValues(selectedValues, placeholder)}
|
||||
</span>
|
||||
<SelectArrow className="ml-1 hidden stroke-1 text-base opacity-75 md:block" />
|
||||
</Select>
|
||||
<SelectPopover
|
||||
gutter={4}
|
||||
sameWidth
|
||||
modal
|
||||
unmountOnHide
|
||||
finalFocus={selectRef}
|
||||
className={cn(
|
||||
'animate-popover z-50 flex max-h-[300px]',
|
||||
'flex-col overflow-auto overscroll-contain rounded-xl',
|
||||
'bg-surface-secondary px-1.5 py-1 text-text-primary shadow-lg',
|
||||
'border border-border-light',
|
||||
'outline-none',
|
||||
popoverClassName,
|
||||
)}
|
||||
>
|
||||
{items.map((value) => {
|
||||
const defaultContent = (
|
||||
<>
|
||||
<SelectItemCheck className="mr-0.5 text-primary" />
|
||||
<span className="truncate">{value}</span>
|
||||
</>
|
||||
);
|
||||
const isCurrentItemSelected = selectedValues.includes(value);
|
||||
return (
|
||||
<SelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-2 py-1.5 hover:cursor-pointer',
|
||||
'scroll-m-1 outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
'w-full min-w-0 text-sm',
|
||||
itemClassName,
|
||||
)}
|
||||
>
|
||||
{renderItemContent
|
||||
? renderItemContent(value, defaultContent, isCurrentItemSelected)
|
||||
: defaultContent}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,231 +0,0 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
Label,
|
||||
ListboxOptions,
|
||||
ListboxOption,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import { Wrench, ArrowRight } from 'lucide-react';
|
||||
import { CheckMark } from '~/components/svg';
|
||||
import useOnClickOutside from '~/hooks/useOnClickOutside';
|
||||
import { useMultiSearch } from './MultiSearch';
|
||||
import { cn } from '~/utils/';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
|
||||
export type TMultiSelectDropDownProps = {
|
||||
title?: string;
|
||||
value: Array<{ icon?: string; name?: string; isButton?: boolean }>;
|
||||
disabled?: boolean;
|
||||
setSelected: (option: string) => void;
|
||||
availableValues: TPlugin[];
|
||||
showAbove?: boolean;
|
||||
showLabel?: boolean;
|
||||
containerClassName?: string;
|
||||
optionsClassName?: string;
|
||||
labelClassName?: string;
|
||||
isSelected: (value: string) => boolean;
|
||||
className?: string;
|
||||
searchPlaceholder?: string;
|
||||
optionValueKey?: string;
|
||||
};
|
||||
|
||||
function MultiSelectDropDown({
|
||||
title = 'Plugins',
|
||||
value,
|
||||
disabled,
|
||||
setSelected,
|
||||
availableValues,
|
||||
showAbove = false,
|
||||
showLabel = true,
|
||||
containerClassName,
|
||||
optionsClassName = '',
|
||||
labelClassName = '',
|
||||
isSelected,
|
||||
className,
|
||||
searchPlaceholder,
|
||||
optionValueKey = 'value',
|
||||
}: TMultiSelectDropDownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const menuRef = useRef(null);
|
||||
const excludeIds = ['select-plugin', 'plugins-label', 'selected-plugins'];
|
||||
useOnClickOutside(menuRef, () => setIsOpen(false), excludeIds);
|
||||
|
||||
const handleSelect: (value: string) => void = (option) => {
|
||||
setSelected(option);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
// input will appear near the top of the menu, allowing correct filtering of different model menu items. This will
|
||||
// reset once the component is unmounted (as per a normal search)
|
||||
const [filteredValues, searchRender] = useMultiSearch<TPlugin[]>({
|
||||
availableOptions: availableValues,
|
||||
placeholder: searchPlaceholder,
|
||||
getTextKeyOverride: (option) => (option.name || '').toUpperCase(),
|
||||
});
|
||||
|
||||
const hasSearchRender = Boolean(searchRender);
|
||||
const options = hasSearchRender ? filteredValues : availableValues;
|
||||
|
||||
const transitionProps = { className: 'top-full mt-3' };
|
||||
if (showAbove) {
|
||||
transitionProps.className = 'bottom-full mb-3';
|
||||
}
|
||||
const openProps = { open: isOpen };
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center gap-2', containerClassName ?? '')}>
|
||||
<div className="relative w-full">
|
||||
{/* the function typing is correct but there's still an issue here */}
|
||||
{/* @ts-ignore */}
|
||||
<Listbox value={value} onChange={handleSelect} disabled={disabled}>
|
||||
{() => (
|
||||
<>
|
||||
<ListboxButton
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default flex-col rounded-md border border-black/10 bg-white py-2 pl-3 pr-10 text-left focus:outline-none focus:ring-0 focus:ring-offset-0 dark:border-gray-600 dark:border-white/20 dark:bg-gray-800 sm:text-sm',
|
||||
className ?? '',
|
||||
)}
|
||||
id={excludeIds[0]}
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
{...openProps}
|
||||
>
|
||||
{' '}
|
||||
{showLabel && (
|
||||
<Label
|
||||
className={cn('block text-xs text-gray-700 dark:text-gray-500', labelClassName)}
|
||||
id={excludeIds[1]}
|
||||
data-headlessui-state=""
|
||||
>
|
||||
{title}
|
||||
</Label>
|
||||
)}
|
||||
<span className="inline-flex w-full truncate" id={excludeIds[2]}>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-6 items-center gap-1 truncate text-sm text-gray-800 dark:text-white',
|
||||
!showLabel ? 'text-xs' : '',
|
||||
)}
|
||||
>
|
||||
{!showLabel && title.length > 0 && (
|
||||
<span className="text-xs text-gray-700 dark:text-gray-500">{title}:</span>
|
||||
)}
|
||||
<span className="flex h-6 items-center gap-1 truncate">
|
||||
<div className="flex gap-1">
|
||||
{value.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative"
|
||||
style={{ width: '16px', height: '16px' }}
|
||||
>
|
||||
{v.icon ? (
|
||||
<img
|
||||
src={v.icon}
|
||||
alt={`${v} logo`}
|
||||
className="h-full w-full rounded-sm bg-white"
|
||||
/>
|
||||
) : (
|
||||
<Wrench className="h-full w-full rounded-sm bg-white" />
|
||||
)}
|
||||
<div className="absolute inset-0 rounded-sm ring-1 ring-inset ring-black/10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4 text-gray-400"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={showAbove ? { transform: 'scaleY(-1)' } : {}}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
show={isOpen}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
{...transitionProps}
|
||||
>
|
||||
<ListboxOptions
|
||||
ref={menuRef}
|
||||
className={cn(
|
||||
'absolute z-50 mt-2 max-h-60 w-full overflow-auto rounded bg-white text-base text-xs ring-1 ring-black/10 focus:outline-none dark:bg-gray-800 dark:ring-white/20 dark:last:border-0 md:w-[100%]',
|
||||
optionsClassName,
|
||||
)}
|
||||
>
|
||||
{searchRender}
|
||||
{options.map((option, i: number) => {
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
const selected = isSelected(option[optionValueKey]);
|
||||
return (
|
||||
<ListboxOption
|
||||
key={i}
|
||||
value={option[optionValueKey]}
|
||||
className="group relative flex h-[42px] cursor-pointer select-none items-center overflow-hidden border-b border-black/10 pl-3 pr-9 text-gray-800 last:border-0 hover:bg-gray-20 dark:border-white/20 dark:text-white dark:hover:bg-gray-700"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
{!option.isButton && (
|
||||
<span className="h-6 w-6 shrink-0">
|
||||
<div className="relative" style={{ width: '100%', height: '100%' }}>
|
||||
{option.icon ? (
|
||||
<img
|
||||
src={option.icon}
|
||||
alt={`${option.name} logo`}
|
||||
className="h-full w-full rounded-sm bg-white"
|
||||
/>
|
||||
) : (
|
||||
<Wrench className="h-full w-full rounded-sm bg-white" />
|
||||
)}
|
||||
<div className="absolute inset-0 rounded-sm ring-1 ring-inset ring-black/10"></div>
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-6 items-center gap-1 text-gray-800 dark:text-gray-200',
|
||||
selected ? 'font-semibold' : '',
|
||||
)}
|
||||
>
|
||||
{option.name}
|
||||
</span>
|
||||
{option.isButton && (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-800 dark:text-gray-200">
|
||||
<ArrowRight />
|
||||
</span>
|
||||
)}
|
||||
{selected && !option.isButton && (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-800 dark:text-gray-200">
|
||||
<CheckMark />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</ListboxOption>
|
||||
);
|
||||
})}
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MultiSelectDropDown;
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
import { Wrench } from 'lucide-react';
|
||||
import { Root, Trigger, Content, Portal } from '@radix-ui/react-popover';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import MenuItem from '~/components/Chat/Menus/UI/MenuItem';
|
||||
import { useMultiSearch } from './MultiSearch';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
type SelectDropDownProps = {
|
||||
title?: string;
|
||||
value: Array<{ icon?: string; name?: string; isButton?: boolean }>;
|
||||
disabled?: boolean;
|
||||
setSelected: (option: string) => void;
|
||||
availableValues: TPlugin[];
|
||||
showAbove?: boolean;
|
||||
showLabel?: boolean;
|
||||
containerClassName?: string;
|
||||
isSelected: (value: string) => boolean;
|
||||
className?: string;
|
||||
optionValueKey?: string;
|
||||
searchPlaceholder?: string;
|
||||
};
|
||||
|
||||
function MultiSelectPop({
|
||||
title: _title = 'Plugins',
|
||||
value,
|
||||
setSelected,
|
||||
availableValues,
|
||||
showAbove = false,
|
||||
showLabel = true,
|
||||
containerClassName,
|
||||
isSelected,
|
||||
optionValueKey = 'value',
|
||||
searchPlaceholder,
|
||||
}: SelectDropDownProps) {
|
||||
// const localize = useLocalize();
|
||||
|
||||
const title = _title;
|
||||
const excludeIds = ['select-plugin', 'plugins-label', 'selected-plugins'];
|
||||
|
||||
// Detemine if we should to convert this component into a searchable select
|
||||
const [filteredValues, searchRender] = useMultiSearch<TPlugin[]>({
|
||||
availableOptions: availableValues,
|
||||
placeholder: searchPlaceholder,
|
||||
getTextKeyOverride: (option) => (option.name || '').toUpperCase(),
|
||||
});
|
||||
const hasSearchRender = Boolean(searchRender);
|
||||
const options = hasSearchRender ? filteredValues : availableValues;
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<div className={cn('flex items-center justify-center gap-2', containerClassName ?? '')}>
|
||||
<div className="relative">
|
||||
<Trigger asChild>
|
||||
<button
|
||||
data-testid="select-dropdown-button"
|
||||
className={cn(
|
||||
'relative flex flex-col rounded-md border border-black/10 bg-white py-2 pl-3 pr-10 text-left focus:outline-none focus:ring-0 focus:ring-offset-0 dark:border-gray-700 dark:bg-gray-800 dark:bg-gray-800 sm:text-sm',
|
||||
'pointer-cursor font-normal',
|
||||
'hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700',
|
||||
)}
|
||||
>
|
||||
{' '}
|
||||
{showLabel && (
|
||||
<label className="block text-xs text-gray-700 dark:text-gray-500 ">{title}</label>
|
||||
)}
|
||||
<span className="inline-flex" id={excludeIds[2]}>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-6 items-center gap-1 text-sm text-gray-800 dark:text-white',
|
||||
!showLabel ? 'text-xs' : '',
|
||||
)}
|
||||
>
|
||||
{/* {!showLabel && title.length > 0 && (
|
||||
<span className="text-xs text-gray-700 dark:text-gray-500">{title}:</span>
|
||||
)} */}
|
||||
<span className="flex items-center gap-1 ">
|
||||
<div className="flex gap-1">
|
||||
{value.length === 0 && 'None selected'}
|
||||
{value.map((v, i) => (
|
||||
<div key={i} className="relative">
|
||||
{v.icon ? (
|
||||
<img src={v.icon} alt={`${v} logo`} className="icon-lg rounded-sm" />
|
||||
) : (
|
||||
<Wrench className="icon-lg rounded-sm bg-white" />
|
||||
)}
|
||||
<div className="absolute inset-0 rounded-sm ring-1 ring-inset ring-black/10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4 text-gray-400"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={showAbove ? { transform: 'scaleY(-1)' } : {}}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</Trigger>
|
||||
<Portal>
|
||||
<Content
|
||||
side="bottom"
|
||||
align="center"
|
||||
className={cn(
|
||||
'mt-2 max-h-[52vh] min-w-full overflow-hidden overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white',
|
||||
hasSearchRender && 'relative',
|
||||
)}
|
||||
>
|
||||
{searchRender}
|
||||
{options.map((option) => {
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
const selected = isSelected(option[optionValueKey]);
|
||||
return (
|
||||
<MenuItem
|
||||
key={`${option[optionValueKey]}`}
|
||||
title={option.name}
|
||||
value={option[optionValueKey]}
|
||||
selected={selected}
|
||||
onClick={() => setSelected(option.pluginKey)}
|
||||
icon={
|
||||
option.icon ? (
|
||||
<img
|
||||
src={option.icon}
|
||||
alt={`${option.name} logo`}
|
||||
className="icon-sm mr-1 rounded-sm bg-cover"
|
||||
/>
|
||||
) : (
|
||||
<Wrench className="icon-sm mr-1 rounded-sm bg-white bg-cover dark:bg-gray-800" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Content>
|
||||
</Portal>
|
||||
</div>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default MultiSelectPop;
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import { forwardRef, ReactNode, Ref } from 'react';
|
||||
import {
|
||||
OGDialogTitle,
|
||||
OGDialogClose,
|
||||
OGDialogFooter,
|
||||
OGDialogHeader,
|
||||
OGDialogContent,
|
||||
OGDialogDescription,
|
||||
} from './OriginalDialog';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Button } from './Button';
|
||||
import { Spinner } from '../svg';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
type SelectionProps = {
|
||||
selectHandler?: () => void;
|
||||
selectClasses?: string;
|
||||
selectText?: string | ReactNode;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
type DialogTemplateProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
main?: ReactNode;
|
||||
buttons?: ReactNode;
|
||||
leftButtons?: ReactNode;
|
||||
selection?: SelectionProps;
|
||||
className?: string;
|
||||
overlayClassName?: string;
|
||||
headerClassName?: string;
|
||||
mainClassName?: string;
|
||||
footerClassName?: string;
|
||||
showCloseButton?: boolean;
|
||||
showCancelButton?: boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDivElement>) => {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
title,
|
||||
main,
|
||||
buttons,
|
||||
selection,
|
||||
className,
|
||||
leftButtons,
|
||||
description = '',
|
||||
mainClassName,
|
||||
headerClassName,
|
||||
footerClassName,
|
||||
showCloseButton,
|
||||
overlayClassName,
|
||||
showCancelButton = true,
|
||||
} = props;
|
||||
const { selectHandler, selectClasses, selectText, isLoading } = selection || {};
|
||||
|
||||
const defaultSelect =
|
||||
'bg-gray-800 text-white transition-colors hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-gray-200';
|
||||
return (
|
||||
<OGDialogContent
|
||||
overlayClassName={overlayClassName}
|
||||
showCloseButton={showCloseButton}
|
||||
ref={ref}
|
||||
className={cn('w-11/12 border-none bg-background text-foreground', className ?? '')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<OGDialogHeader className={cn(headerClassName ?? '')}>
|
||||
<OGDialogTitle>{title}</OGDialogTitle>
|
||||
{description && (
|
||||
<OGDialogDescription className="items-center justify-center">
|
||||
{description}
|
||||
</OGDialogDescription>
|
||||
)}
|
||||
</OGDialogHeader>
|
||||
<div className={cn('px-0 py-2', mainClassName)}>{main != null ? main : null}</div>
|
||||
<OGDialogFooter className={footerClassName}>
|
||||
<div>
|
||||
{leftButtons != null ? (
|
||||
<div className="mt-3 flex h-auto gap-3 max-sm:w-full max-sm:flex-col sm:mt-0 sm:flex-row">
|
||||
{leftButtons}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex h-auto gap-3 max-sm:w-full max-sm:flex-col sm:flex-row">
|
||||
{showCancelButton && (
|
||||
<OGDialogClose asChild>
|
||||
<Button variant="outline">{localize('com_ui_cancel')}</Button>
|
||||
</OGDialogClose>
|
||||
)}
|
||||
{buttons != null ? buttons : null}
|
||||
{selection ? (
|
||||
<OGDialogClose
|
||||
onClick={selectHandler}
|
||||
disabled={isLoading}
|
||||
className={`${
|
||||
selectClasses ?? defaultSelect
|
||||
} flex h-10 items-center justify-center rounded-lg border-none px-4 py-2 text-sm disabled:opacity-80 max-sm:order-first max-sm:w-full sm:order-none`}
|
||||
>
|
||||
{isLoading === true ? <Spinner className="size-4 text-white" /> : selectText}
|
||||
</OGDialogClose>
|
||||
) : null}
|
||||
</div>
|
||||
</OGDialogFooter>
|
||||
</OGDialogContent>
|
||||
);
|
||||
});
|
||||
|
||||
export default OGDialogTemplate;
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface OGDialogProps extends DialogPrimitive.DialogProps {
|
||||
triggerRef?: React.RefObject<HTMLButtonElement | HTMLInputElement | null>;
|
||||
triggerRefs?: React.RefObject<HTMLButtonElement | HTMLInputElement | null>[];
|
||||
}
|
||||
|
||||
const Dialog = React.forwardRef<HTMLDivElement, OGDialogProps>(
|
||||
({ children, triggerRef, triggerRefs, onOpenChange, ...props }, _ref) => {
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open && triggerRef?.current) {
|
||||
setTimeout(() => {
|
||||
triggerRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
if (triggerRefs?.length) {
|
||||
triggerRefs.forEach((ref) => {
|
||||
if (ref?.current) {
|
||||
setTimeout(() => {
|
||||
ref.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
onOpenChange?.(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root {...props} onOpenChange={handleOpenChange}>
|
||||
{children}
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
export 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;
|
||||
|
||||
type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
disableScroll?: boolean;
|
||||
overlayClassName?: string;
|
||||
};
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
DialogContentProps
|
||||
>(({ className, overlayClassName, showCloseButton = true, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'max-w-11/12 fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl bg-background p-6 text-text-primary 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%]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-ring-primary 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">
|
||||
<X className="h-4 w-4" />
|
||||
{/* eslint-disable-next-line i18next/no-literal-string */}
|
||||
<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 as OGDialog,
|
||||
DialogPortal as OGDialogPortal,
|
||||
DialogOverlay as OGDialogOverlay,
|
||||
DialogClose as OGDialogClose,
|
||||
DialogTrigger as OGDialogTrigger,
|
||||
DialogContent as OGDialogContent,
|
||||
DialogHeader as OGDialogHeader,
|
||||
DialogFooter as OGDialogFooter,
|
||||
DialogTitle as OGDialogTitle,
|
||||
DialogDescription as OGDialogDescription,
|
||||
};
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
import { ButtonProps, buttonVariants } from './Button';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Pagination.displayName = 'Pagination';
|
||||
|
||||
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ul ref={ref} className={cn('flex flex-row items-center gap-1', className)} {...props} />
|
||||
),
|
||||
);
|
||||
PaginationContent.displayName = 'PaginationContent';
|
||||
|
||||
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<'li'>>(
|
||||
({ className, ...props }, ref) => <li ref={ref} className={cn('', className)} {...props} />,
|
||||
);
|
||||
PaginationItem.displayName = 'PaginationItem';
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
} & Pick<ButtonProps, 'size'> &
|
||||
React.ComponentProps<'a'>;
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive = false,
|
||||
size = 'icon',
|
||||
children,
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'outline' : 'ghost',
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children || <span className="sr-only">Page link</span>}
|
||||
</a>
|
||||
);
|
||||
PaginationLink.displayName = 'PaginationLink';
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn('gap-1 pl-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationPrevious.displayName = 'PaginationPrevious';
|
||||
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn('gap-1 pr-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationNext.displayName = 'PaginationNext';
|
||||
|
||||
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
PaginationEllipsis.displayName = 'PaginationEllipsis';
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
};
|
||||
|
|
@ -1,376 +0,0 @@
|
|||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
class Pixel {
|
||||
width: number;
|
||||
height: number;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
speed: number;
|
||||
size: number;
|
||||
sizeStep: number;
|
||||
minSize: number;
|
||||
maxSizeInteger: number;
|
||||
maxSize: number;
|
||||
delay: number;
|
||||
counter: number;
|
||||
counterStep: number;
|
||||
isIdle: boolean;
|
||||
isReverse: boolean;
|
||||
isShimmer: boolean;
|
||||
activationThreshold: number;
|
||||
|
||||
constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
color: string,
|
||||
speed: number,
|
||||
delay: number,
|
||||
activationThreshold: number,
|
||||
) {
|
||||
this.width = canvas.width;
|
||||
this.height = canvas.height;
|
||||
this.ctx = context;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.color = color;
|
||||
this.speed = this.random(0.1, 0.9) * speed;
|
||||
this.size = 0;
|
||||
this.sizeStep = Math.random() * 0.4;
|
||||
this.minSize = 0.5;
|
||||
this.maxSizeInteger = 2;
|
||||
this.maxSize = this.random(this.minSize, this.maxSizeInteger);
|
||||
this.delay = delay;
|
||||
this.counter = 0;
|
||||
this.counterStep = Math.random() * 4 + (this.width + this.height) * 0.01;
|
||||
this.isIdle = false;
|
||||
this.isReverse = false;
|
||||
this.isShimmer = false;
|
||||
this.activationThreshold = activationThreshold;
|
||||
}
|
||||
|
||||
private random(min: number, max: number) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
|
||||
private draw() {
|
||||
const offset = this.maxSizeInteger * 0.5 - this.size * 0.5;
|
||||
this.ctx.fillStyle = this.color;
|
||||
this.ctx.fillRect(this.x + offset, this.y + offset, this.size, this.size);
|
||||
}
|
||||
|
||||
appear() {
|
||||
this.isIdle = false;
|
||||
if (this.counter <= this.delay) {
|
||||
this.counter += this.counterStep;
|
||||
return;
|
||||
}
|
||||
if (this.size >= this.maxSize) {
|
||||
this.isShimmer = true;
|
||||
}
|
||||
if (this.isShimmer) {
|
||||
this.shimmer();
|
||||
} else {
|
||||
this.size += this.sizeStep;
|
||||
}
|
||||
this.draw();
|
||||
}
|
||||
|
||||
appearWithProgress(progress: number) {
|
||||
const diff = progress - this.activationThreshold;
|
||||
if (diff <= 0) {
|
||||
this.isIdle = true;
|
||||
return;
|
||||
}
|
||||
if (this.counter <= this.delay) {
|
||||
this.counter += this.counterStep;
|
||||
this.isIdle = false;
|
||||
return;
|
||||
}
|
||||
if (this.size >= this.maxSize) {
|
||||
this.isShimmer = true;
|
||||
}
|
||||
if (this.isShimmer) {
|
||||
this.shimmer();
|
||||
} else {
|
||||
this.size += this.sizeStep;
|
||||
}
|
||||
this.isIdle = false;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
disappear() {
|
||||
this.isShimmer = false;
|
||||
this.counter = 0;
|
||||
if (this.size <= 0) {
|
||||
this.isIdle = true;
|
||||
return;
|
||||
}
|
||||
this.size -= 0.1;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
private shimmer() {
|
||||
if (this.size >= this.maxSize) {
|
||||
this.isReverse = true;
|
||||
} else if (this.size <= this.minSize) {
|
||||
this.isReverse = false;
|
||||
}
|
||||
this.size += this.isReverse ? -this.speed : this.speed;
|
||||
}
|
||||
}
|
||||
|
||||
const getEffectiveSpeed = (value: number, reducedMotion: boolean) => {
|
||||
const parsed = parseInt(String(value), 10);
|
||||
const throttle = 0.001;
|
||||
if (parsed <= 0 || reducedMotion) {
|
||||
return 0;
|
||||
}
|
||||
if (parsed >= 100) {
|
||||
return 100 * throttle;
|
||||
}
|
||||
return parsed * throttle;
|
||||
};
|
||||
|
||||
const clamp = (n: number, min = 0, max = 1) => Math.min(Math.max(n, min), max);
|
||||
|
||||
const VARIANTS = {
|
||||
default: { gap: 5, speed: 35, colors: '#f8fafc,#f1f5f9,#cbd5e1', noFocus: false },
|
||||
blue: { gap: 10, speed: 25, colors: '#e0f2fe,#7dd3fc,#0ea5e9', noFocus: false },
|
||||
yellow: { gap: 3, speed: 20, colors: '#fef08a,#fde047,#eab308', noFocus: false },
|
||||
pink: { gap: 6, speed: 80, colors: '#fecdd3,#fda4af,#e11d48', noFocus: true },
|
||||
} as const;
|
||||
|
||||
interface PixelCardProps {
|
||||
variant?: keyof typeof VARIANTS;
|
||||
gap?: number;
|
||||
speed?: number;
|
||||
colors?: string;
|
||||
noFocus?: boolean;
|
||||
className?: string;
|
||||
progress?: number;
|
||||
randomness?: number;
|
||||
width?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export default function PixelCard({
|
||||
variant = 'default',
|
||||
gap,
|
||||
speed,
|
||||
colors,
|
||||
noFocus,
|
||||
className = '',
|
||||
progress,
|
||||
randomness = 0.3,
|
||||
width,
|
||||
height,
|
||||
}: PixelCardProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pixelsRef = useRef<Pixel[]>([]);
|
||||
const animationRef = useRef<number>();
|
||||
const timePrevRef = useRef(performance.now());
|
||||
const progressRef = useRef<number | undefined>(progress);
|
||||
const reducedMotion = useRef(
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
).current;
|
||||
|
||||
const cfg = VARIANTS[variant];
|
||||
const g = gap ?? cfg.gap;
|
||||
const s = speed ?? cfg.speed;
|
||||
const palette = colors ?? cfg.colors;
|
||||
const disableFocus = noFocus ?? cfg.noFocus;
|
||||
|
||||
const updateCanvasOpacity = useCallback(() => {
|
||||
if (!canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
if (progressRef.current === undefined) {
|
||||
canvasRef.current.style.opacity = '1';
|
||||
return;
|
||||
}
|
||||
const fadeStart = 0.9;
|
||||
const alpha =
|
||||
progressRef.current >= fadeStart ? 1 - (progressRef.current - fadeStart) / 0.1 : 1;
|
||||
canvasRef.current.style.opacity = String(clamp(alpha));
|
||||
}, []);
|
||||
|
||||
const animate = useCallback(
|
||||
(method: keyof Pixel) => {
|
||||
animationRef.current = requestAnimationFrame(() => animate(method));
|
||||
|
||||
const now = performance.now();
|
||||
const elapsed = now - timePrevRef.current;
|
||||
if (elapsed < 1000 / 60) {
|
||||
return;
|
||||
}
|
||||
timePrevRef.current = now - (elapsed % (1000 / 60));
|
||||
|
||||
const ctx = canvasRef.current?.getContext('2d');
|
||||
if (!ctx || !canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
|
||||
|
||||
let idle = true;
|
||||
for (const p of pixelsRef.current) {
|
||||
if (method === 'appearWithProgress') {
|
||||
progressRef.current !== undefined
|
||||
? p.appearWithProgress(progressRef.current)
|
||||
: (p.isIdle = true);
|
||||
} else {
|
||||
// @ts-ignore dynamic dispatch
|
||||
p[method]();
|
||||
}
|
||||
if (!p.isIdle) {
|
||||
idle = false;
|
||||
}
|
||||
}
|
||||
|
||||
updateCanvasOpacity();
|
||||
if (idle) {
|
||||
cancelAnimationFrame(animationRef.current!);
|
||||
}
|
||||
},
|
||||
[updateCanvasOpacity],
|
||||
);
|
||||
|
||||
const startAnim = useCallback(
|
||||
(m: keyof Pixel) => {
|
||||
cancelAnimationFrame(animationRef.current!);
|
||||
animationRef.current = requestAnimationFrame(() => animate(m));
|
||||
},
|
||||
[animate],
|
||||
);
|
||||
|
||||
const initPixels = useCallback(() => {
|
||||
if (!containerRef.current || !canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { width: cw, height: ch } = containerRef.current.getBoundingClientRect();
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
canvasRef.current.width = Math.floor(cw);
|
||||
canvasRef.current.height = Math.floor(ch);
|
||||
|
||||
const cols = palette.split(',');
|
||||
const px: Pixel[] = [];
|
||||
|
||||
const cx = cw / 2;
|
||||
const cy = ch / 2;
|
||||
const maxDist = Math.hypot(cx, cy);
|
||||
|
||||
for (let x = 0; x < cw; x += g) {
|
||||
for (let y = 0; y < ch; y += g) {
|
||||
const color = cols[Math.floor(Math.random() * cols.length)];
|
||||
const distNorm = Math.hypot(x - cx, y - cy) / maxDist;
|
||||
const threshold = clamp(distNorm * (1 - randomness) + Math.random() * randomness);
|
||||
const delay = reducedMotion ? 0 : distNorm * maxDist;
|
||||
if (!ctx) {
|
||||
continue;
|
||||
}
|
||||
px.push(
|
||||
new Pixel(
|
||||
canvasRef.current,
|
||||
ctx,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
getEffectiveSpeed(s, reducedMotion),
|
||||
delay,
|
||||
threshold,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
pixelsRef.current = px;
|
||||
|
||||
if (progressRef.current !== undefined) {
|
||||
startAnim('appearWithProgress');
|
||||
}
|
||||
}, [g, palette, s, randomness, reducedMotion, startAnim]);
|
||||
|
||||
useEffect(() => {
|
||||
progressRef.current = progress;
|
||||
if (progress !== undefined) {
|
||||
startAnim('appearWithProgress');
|
||||
}
|
||||
}, [progress, startAnim]);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress === undefined) {
|
||||
cancelAnimationFrame(animationRef.current!);
|
||||
}
|
||||
}, [progress]);
|
||||
|
||||
useEffect(() => {
|
||||
initPixels();
|
||||
const obs = new ResizeObserver(initPixels);
|
||||
containerRef.current && obs.observe(containerRef.current);
|
||||
return () => {
|
||||
obs.disconnect();
|
||||
cancelAnimationFrame(animationRef.current!);
|
||||
};
|
||||
}, [initPixels]);
|
||||
|
||||
const hoverIn = () => progressRef.current === undefined && startAnim('appear');
|
||||
const hoverOut = () => progressRef.current === undefined && startAnim('disappear');
|
||||
const focusIn: React.FocusEventHandler<HTMLDivElement> = (e) => {
|
||||
if (
|
||||
!disableFocus &&
|
||||
!e.currentTarget.contains(e.relatedTarget) &&
|
||||
progressRef.current === undefined
|
||||
) {
|
||||
startAnim('appear');
|
||||
}
|
||||
};
|
||||
const focusOut: React.FocusEventHandler<HTMLDivElement> = (e) => {
|
||||
if (
|
||||
!disableFocus &&
|
||||
!e.currentTarget.contains(e.relatedTarget) &&
|
||||
progressRef.current === undefined
|
||||
) {
|
||||
startAnim('disappear');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: width || '100%',
|
||||
height: height || '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative isolate grid select-none place-items-center overflow-hidden rounded-lg border border-border-light shadow-md transition-colors duration-200 ease-in-out',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transitionTimingFunction: 'cubic-bezier(0.5, 1, 0.89, 1)',
|
||||
}}
|
||||
onMouseEnter={hoverIn}
|
||||
onMouseLeave={hoverOut}
|
||||
onFocus={disableFocus ? undefined : focusIn}
|
||||
onBlur={disableFocus ? undefined : focusOut}
|
||||
tabIndex={disableFocus ? -1 : 0}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none absolute inset-0 block"
|
||||
width={width && width !== 'auto' ? parseInt(String(width)) : undefined}
|
||||
height={height && height !== 'auto' ? parseInt(String(height)) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function Prompt({ title, prompt }: { title: string; prompt: string }) {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div
|
||||
// onclick="selectPromptTemplate(0)"
|
||||
className="flex w-full flex-col gap-2 rounded-md bg-gray-50 p-4 text-left hover:bg-gray-200 dark:bg-white/5 "
|
||||
>
|
||||
<h2 className="m-auto flex items-center gap-3 text-lg font-normal md:flex-col md:gap-2">
|
||||
{title}
|
||||
</h2>
|
||||
<button>
|
||||
<p className="w-full rounded-md bg-gray-50 p-3 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-gray-800">
|
||||
{prompt}
|
||||
</p>
|
||||
</button>
|
||||
<span className="font-medium">{localize('com_ui_use_prompt')} →</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { cn } from '~/utils';
|
||||
export const QuestionMark = ({ className = '' }) => {
|
||||
return (
|
||||
<span>
|
||||
<div
|
||||
className={cn(
|
||||
'border-token-border-medium text-token-text-tertiary ml-2 flex h-3.5 w-3.5 cursor-default items-center justify-center rounded-full border text-[0.5rem] font-medium leading-none',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { GripVertical } from 'lucide-react';
|
||||
import * as ResizablePrimitive from 'react-resizable-panels';
|
||||
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className = '',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel;
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className = '',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean;
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-sm border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
|
||||
const ResizableHandleAlt = ({
|
||||
withHandle,
|
||||
className = '',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean;
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
'bg-border focus-visible:ring-ring group relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border invisible z-10 flex h-4 w-3 items-center justify-center rounded-sm border group-hover:visible group-active:visible">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle, ResizableHandleAlt };
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { CaretSortIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
|
||||
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className = '', children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-gray-200 border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 [&>span]:line-clamp-1',
|
||||
'rounded-lg hover:bg-gray-100/50 dark:hover:bg-gray-700',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<CaretSortIcon className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1 dark:text-white',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1 dark:text-white',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className = '', children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 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 dark:border-gray-600',
|
||||
position === 'popper'
|
||||
? 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1'
|
||||
: '',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper'
|
||||
? 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
: '',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className = '', children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'rounded-lg hover:bg-gray-100/50 dark:hover:bg-gray-700',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
|
|
@ -1,285 +0,0 @@
|
|||
import React, { useRef } from 'react';
|
||||
import {
|
||||
Label,
|
||||
Listbox,
|
||||
Transition,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from '@headlessui/react';
|
||||
import type { Option, OptionWithIcon, DropdownValueSetter } from '~/common';
|
||||
import CheckMark from '~/components/svg/CheckMark';
|
||||
import { useMultiSearch } from './MultiSearch';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
type SelectDropDownProps = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
value: string | null | Option | OptionWithIcon;
|
||||
setValue: DropdownValueSetter | ((value: string) => void);
|
||||
tabIndex?: number;
|
||||
availableValues?: string[] | Option[] | OptionWithIcon[];
|
||||
emptyTitle?: boolean;
|
||||
showAbove?: boolean;
|
||||
showLabel?: boolean;
|
||||
iconSide?: 'left' | 'right';
|
||||
optionIconSide?: 'left' | 'right';
|
||||
renderOption?: () => React.ReactNode;
|
||||
containerClassName?: string;
|
||||
currentValueClass?: string;
|
||||
optionsListClass?: string;
|
||||
optionsClass?: string;
|
||||
subContainerClassName?: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
searchClassName?: string;
|
||||
searchPlaceholder?: string;
|
||||
showOptionIcon?: boolean;
|
||||
};
|
||||
|
||||
function getOptionText(option: string | Option | OptionWithIcon): string {
|
||||
if (typeof option === 'string') {
|
||||
return option;
|
||||
}
|
||||
if ('label' in option) {
|
||||
return option.label ?? '';
|
||||
}
|
||||
if ('value' in option) {
|
||||
return (option.value ?? '') + '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function SelectDropDown({
|
||||
title: _title,
|
||||
value,
|
||||
disabled,
|
||||
setValue,
|
||||
availableValues,
|
||||
showAbove = false,
|
||||
showLabel = true,
|
||||
emptyTitle = false,
|
||||
iconSide = 'right',
|
||||
optionIconSide = 'left',
|
||||
placeholder,
|
||||
containerClassName,
|
||||
optionsListClass,
|
||||
optionsClass,
|
||||
currentValueClass,
|
||||
subContainerClassName,
|
||||
className,
|
||||
renderOption,
|
||||
searchClassName,
|
||||
searchPlaceholder,
|
||||
showOptionIcon = false,
|
||||
}: SelectDropDownProps) {
|
||||
const localize = useLocalize();
|
||||
const transitionProps = { className: 'top-full mt-3' };
|
||||
if (showAbove) {
|
||||
transitionProps.className = 'bottom-full mb-3';
|
||||
}
|
||||
|
||||
let title = _title;
|
||||
if (emptyTitle) {
|
||||
title = '';
|
||||
} else if (!(title ?? '')) {
|
||||
title = localize('com_ui_model');
|
||||
}
|
||||
const values = availableValues ?? [];
|
||||
|
||||
// Enable searchable select if enough items are provided.
|
||||
const [filteredValues, searchRender] = useMultiSearch<string[] | Option[]>({
|
||||
availableOptions: values,
|
||||
placeholder: searchPlaceholder,
|
||||
getTextKeyOverride: (option) => getOptionText(option).toUpperCase(),
|
||||
className: searchClassName,
|
||||
disabled,
|
||||
});
|
||||
const hasSearchRender = searchRender != null;
|
||||
const options = hasSearchRender ? filteredValues : values;
|
||||
const renderIcon = showOptionIcon && value != null && (value as OptionWithIcon).icon != null;
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center gap-2', containerClassName ?? '')}>
|
||||
<div className={cn('relative w-full', subContainerClassName ?? '')}>
|
||||
<Listbox value={value} onChange={setValue} disabled={disabled}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<ListboxButton
|
||||
ref={buttonRef}
|
||||
data-testid="select-dropdown-button"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (!open && buttonRef.current) {
|
||||
buttonRef.current.click();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default flex-col rounded-md border border-black/10 bg-white py-2 pl-3 pr-10 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:bg-white dark:border-gray-600 dark:bg-gray-700 sm:text-sm',
|
||||
className ?? '',
|
||||
)}
|
||||
>
|
||||
{showLabel && (
|
||||
<Label
|
||||
className="block text-xs text-gray-700 dark:text-gray-500"
|
||||
id="headlessui-listbox-label-:r1:"
|
||||
data-headlessui-state=""
|
||||
>
|
||||
{title}
|
||||
</Label>
|
||||
)}
|
||||
<span className="inline-flex w-full truncate">
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-6 items-center gap-1 truncate text-sm text-gray-800 dark:text-white',
|
||||
!showLabel ? 'text-xs' : '',
|
||||
currentValueClass ?? '',
|
||||
)}
|
||||
>
|
||||
{!showLabel && !emptyTitle && (
|
||||
<span className="text-xs text-gray-700 dark:text-gray-500">{title}:</span>
|
||||
)}
|
||||
{renderIcon && optionIconSide !== 'right' && (
|
||||
<span className="icon-md flex items-center">
|
||||
{(value as OptionWithIcon).icon}
|
||||
</span>
|
||||
)}
|
||||
{renderIcon && (
|
||||
<span className="icon-md absolute right-0 mr-8 flex items-center">
|
||||
{(value as OptionWithIcon).icon}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
if (!value) {
|
||||
return <span className="text-text-secondary">{placeholder}</span>;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return value.label ?? '';
|
||||
}
|
||||
return value;
|
||||
})()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4 text-gray-400"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={showAbove ? { transform: 'scaleY(-1)' } : {}}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
{...transitionProps}
|
||||
>
|
||||
<ListboxOptions
|
||||
className={cn(
|
||||
'absolute z-10 mt-2 max-h-60 w-full overflow-auto rounded border bg-white text-xs ring-black/10 dark:border-gray-600 dark:bg-gray-700 dark:ring-white/20 md:w-[100%]',
|
||||
optionsListClass ?? '',
|
||||
)}
|
||||
>
|
||||
{renderOption && (
|
||||
<ListboxOption
|
||||
key={'listbox-render-option'}
|
||||
value={null}
|
||||
className={cn(
|
||||
'group relative flex h-[42px] cursor-pointer select-none items-center overflow-hidden pl-3 pr-9 text-gray-800 hover:bg-gray-20 dark:text-white dark:hover:bg-gray-700',
|
||||
optionsClass ?? '',
|
||||
)}
|
||||
>
|
||||
{renderOption()}
|
||||
</ListboxOption>
|
||||
)}
|
||||
{searchRender}
|
||||
{options.map((option: string | Option, i: number) => {
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
const currentLabel =
|
||||
typeof option === 'string' ? option : (option.label ?? option.value ?? '');
|
||||
const currentValue = typeof option === 'string' ? option : (option.value ?? '');
|
||||
const currentIcon =
|
||||
typeof option === 'string'
|
||||
? null
|
||||
: ((option.icon as React.ReactNode) ?? null);
|
||||
let activeValue: string | number | null | Option = value;
|
||||
if (typeof activeValue !== 'string') {
|
||||
activeValue = activeValue?.value ?? '';
|
||||
}
|
||||
return (
|
||||
<ListboxOption
|
||||
key={i}
|
||||
value={option}
|
||||
className={({ active }) =>
|
||||
cn(
|
||||
'group relative flex h-[42px] cursor-pointer select-none items-center overflow-hidden pl-3 pr-9 text-gray-800 hover:bg-gray-20 dark:text-white dark:hover:bg-gray-600',
|
||||
active ? 'bg-surface-active text-text-primary' : '',
|
||||
optionsClass ?? '',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-6 items-center gap-1 text-gray-800 dark:text-gray-200',
|
||||
option === value ? 'font-semibold' : '',
|
||||
iconSide === 'left' ? 'ml-4' : '',
|
||||
)}
|
||||
>
|
||||
{currentIcon != null && (
|
||||
<span
|
||||
className={cn(
|
||||
'mr-1',
|
||||
optionIconSide === 'right' ? 'absolute right-0 pr-2' : '',
|
||||
)}
|
||||
>
|
||||
{currentIcon}
|
||||
</span>
|
||||
)}
|
||||
{currentLabel}
|
||||
</span>
|
||||
{currentValue === activeValue && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-y-0 flex items-center text-gray-800 dark:text-gray-200',
|
||||
iconSide === 'left' ? 'left-0 pl-2' : 'right-0 pr-3',
|
||||
)}
|
||||
>
|
||||
<CheckMark />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</ListboxOption>
|
||||
);
|
||||
})}
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectDropDown;
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Root, Trigger, Content, Portal } from '@radix-ui/react-popover';
|
||||
import MenuItem from '~/components/Chat/Menus/UI/MenuItem';
|
||||
import type { Option } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils/';
|
||||
import { useMultiSearch } from './MultiSearch';
|
||||
|
||||
type SelectDropDownProps = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
value: string | null | Option;
|
||||
disabled?: boolean;
|
||||
setValue: (value: string) => void;
|
||||
availableValues: string[] | Option[];
|
||||
emptyTitle?: boolean;
|
||||
showAbove?: boolean;
|
||||
showLabel?: boolean;
|
||||
iconSide?: 'left' | 'right';
|
||||
renderOption?: () => React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
};
|
||||
|
||||
function SelectDropDownPop({
|
||||
title: _title,
|
||||
value,
|
||||
availableValues,
|
||||
setValue,
|
||||
showAbove = false,
|
||||
showLabel = true,
|
||||
emptyTitle = false,
|
||||
footer,
|
||||
}: SelectDropDownProps) {
|
||||
const localize = useLocalize();
|
||||
const transitionProps = { className: 'top-full mt-3' };
|
||||
if (showAbove) {
|
||||
transitionProps.className = 'bottom-full mb-3';
|
||||
}
|
||||
|
||||
let title = _title;
|
||||
|
||||
if (emptyTitle) {
|
||||
title = '';
|
||||
} else if (!title) {
|
||||
title = localize('com_ui_model');
|
||||
}
|
||||
|
||||
// Detemine if we should to convert this component into a searchable select. If we have enough elements, a search
|
||||
// input will appear near the top of the menu, allowing correct filtering of different model menu items. This will
|
||||
// reset once the component is unmounted (as per a normal search)
|
||||
const [filteredValues, searchRender] = useMultiSearch<string[] | Option[]>({
|
||||
availableOptions: availableValues,
|
||||
});
|
||||
const hasSearchRender = Boolean(searchRender);
|
||||
const options = hasSearchRender ? filteredValues : availableValues;
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<div className={'flex items-center justify-center gap-2'}>
|
||||
<div className={'relative w-full'}>
|
||||
<Trigger asChild>
|
||||
<button
|
||||
data-testid="select-dropdown-button"
|
||||
className={cn(
|
||||
'pointer-cursor relative flex flex-col rounded-lg border border-black/10 bg-white py-2 pl-3 pr-10 text-left focus:ring-0 focus:ring-offset-0 dark:border-gray-700 dark:bg-gray-800 sm:text-sm',
|
||||
'hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700',
|
||||
'min-w-[200px] max-w-[215px] sm:min-w-full sm:max-w-full',
|
||||
)}
|
||||
aria-label={`Select ${title}`}
|
||||
aria-haspopup="false"
|
||||
>
|
||||
{' '}
|
||||
{showLabel && (
|
||||
<label className="block text-xs text-gray-700 dark:text-gray-500">{title}</label>
|
||||
)}
|
||||
<span className="inline-flex w-full">
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-6 items-center gap-1 text-sm text-text-primary',
|
||||
!showLabel ? 'text-xs' : '',
|
||||
'min-w-[75px] font-normal',
|
||||
)}
|
||||
>
|
||||
{typeof value !== 'string' && value ? (value.label ?? '') : (value ?? '')}
|
||||
</span>
|
||||
</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4 text-gray-400"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={showAbove ? { transform: 'scaleY(-1)' } : {}}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</Trigger>
|
||||
<Portal>
|
||||
<Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
className={cn(
|
||||
'mr-3 mt-2 max-h-[52vh] w-full max-w-[85vw] overflow-hidden overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white sm:max-w-full lg:max-h-[52vh]',
|
||||
hasSearchRender && 'relative',
|
||||
)}
|
||||
>
|
||||
{searchRender}
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={option}
|
||||
title={option}
|
||||
value={option}
|
||||
selected={!!(value && value === option)}
|
||||
onClick={() => setValue(option)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{footer}
|
||||
</Content>
|
||||
</Portal>
|
||||
</div>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectDropDownPop;
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className = '', orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border-light',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { cn } from '~/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-pulse rounded-md bg-surface-tertiary opacity-50 dark:opacity-25',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & { onDoubleClick?: () => void }
|
||||
>(({ className, onDoubleClick, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer touch-none select-none items-center',
|
||||
className,
|
||||
)}
|
||||
onDoubleClick={onDoubleClick}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background 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" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { render } from '@testing-library/react';
|
||||
import SplitText from './SplitText';
|
||||
|
||||
// Mock IntersectionObserver
|
||||
class MockIntersectionObserver {
|
||||
observe = jest.fn();
|
||||
unobserve = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: MockIntersectionObserver,
|
||||
});
|
||||
|
||||
describe('SplitText', () => {
|
||||
it('renders emojis correctly', () => {
|
||||
const emojis = ['🚧', '❤️🔥', '💜', '🦎', '❌', '✅', '⚠️'];
|
||||
const originalText = emojis.join('');
|
||||
|
||||
const { container } = render(<SplitText text={originalText} />);
|
||||
const textSpans = container.querySelectorAll('p > span > span.inline-block');
|
||||
|
||||
// Reconstruct the text by joining all span contents
|
||||
const reconstructedText = Array.from(textSpans)
|
||||
.map((span) => span.textContent)
|
||||
.join('')
|
||||
.trim();
|
||||
// Compare the reconstructed text with the original
|
||||
expect(reconstructedText).toBe(originalText);
|
||||
|
||||
// Check the first character specifically as the reconstructed text could hide issues
|
||||
for (let i = 0; i < emojis.length; i++) {
|
||||
expect(Array.from(textSpans)[i].textContent).toBe(emojis[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
import { useSprings, animated, SpringConfig } from '@react-spring/web';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface SplitTextProps {
|
||||
text?: string;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
animationFrom?: { opacity: number; transform: string };
|
||||
animationTo?: { opacity: number; transform: string };
|
||||
easing?: SpringConfig['easing'];
|
||||
threshold?: number;
|
||||
rootMargin?: string;
|
||||
textAlign?: 'left' | 'right' | 'center' | 'justify' | 'start' | 'end';
|
||||
onLetterAnimationComplete?: () => void;
|
||||
onLineCountChange?: (lineCount: number) => void;
|
||||
}
|
||||
|
||||
const splitGraphemes = (text: string): string[] => {
|
||||
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
|
||||
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
||||
const segments = segmenter.segment(text);
|
||||
return Array.from(segments).map((s) => s.segment);
|
||||
} else {
|
||||
// Fallback for browsers without Intl.Segmenter
|
||||
return [...text];
|
||||
}
|
||||
};
|
||||
|
||||
const SplitText: React.FC<SplitTextProps> = ({
|
||||
text = '',
|
||||
className = '',
|
||||
delay = 100,
|
||||
animationFrom = { opacity: 0, transform: 'translate3d(0,40px,0)' },
|
||||
animationTo = { opacity: 1, transform: 'translate3d(0,0,0)' },
|
||||
easing = (t: number) => t,
|
||||
threshold = 0.1,
|
||||
rootMargin = '-100px',
|
||||
textAlign = 'center',
|
||||
onLetterAnimationComplete,
|
||||
onLineCountChange,
|
||||
}) => {
|
||||
const words = text.split(' ').map(splitGraphemes);
|
||||
const letters = words.flat();
|
||||
const [inView, setInView] = useState(false);
|
||||
const ref = useRef<HTMLParagraphElement>(null);
|
||||
const animatedCount = useRef(0);
|
||||
|
||||
const springs = useSprings(
|
||||
letters.length,
|
||||
letters.map((_, i) => ({
|
||||
from: animationFrom,
|
||||
to: inView
|
||||
? async (next: (props: any) => Promise<void>) => {
|
||||
await next(animationTo);
|
||||
animatedCount.current += 1;
|
||||
if (animatedCount.current === letters.length && onLetterAnimationComplete) {
|
||||
onLetterAnimationComplete();
|
||||
}
|
||||
}
|
||||
: animationFrom,
|
||||
delay: i * delay,
|
||||
config: { easing },
|
||||
})),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setInView(true);
|
||||
if (ref.current) {
|
||||
observer.unobserve(ref.current);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold, rootMargin },
|
||||
);
|
||||
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [threshold, rootMargin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current && inView) {
|
||||
const element = ref.current;
|
||||
setTimeout(() => {
|
||||
const lineHeight =
|
||||
parseInt(getComputedStyle(element).lineHeight) ||
|
||||
parseInt(getComputedStyle(element).fontSize) * 1.2;
|
||||
const height = element.offsetHeight;
|
||||
const lines = Math.round(height / lineHeight);
|
||||
|
||||
if (onLineCountChange) {
|
||||
onLineCountChange(lines);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [inView, text, onLineCountChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="sr-only">{text}</span>
|
||||
<p
|
||||
ref={ref}
|
||||
className={`split-parent inline overflow-hidden ${className}`}
|
||||
style={{ textAlign, whiteSpace: 'normal', wordWrap: 'break-word' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{words.map((word, wordIndex) => (
|
||||
<span key={wordIndex} style={{ display: 'inline-block', whiteSpace: 'nowrap' }}>
|
||||
{word.map((letter, letterIndex) => {
|
||||
const index =
|
||||
words.slice(0, wordIndex).reduce((acc, w) => acc + w.length, 0) + letterIndex;
|
||||
|
||||
return (
|
||||
<animated.span
|
||||
key={index}
|
||||
style={springs[index] as unknown as React.CSSProperties}
|
||||
className="inline-block transform transition-opacity will-change-transform"
|
||||
>
|
||||
{letter}
|
||||
</animated.span>
|
||||
);
|
||||
})}
|
||||
{wordIndex < words.length - 1 && (
|
||||
<span style={{ display: 'inline-block', width: '0.3em' }}> </span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SplitText;
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
import { cn } from '~/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-colors 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-switch-unchecked',
|
||||
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',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b border-border-light transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md bg-surface-primary',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
'inline-flex min-w-[100px] items-center justify-center rounded-[0.185rem] px-3 py-1.5 text-sm font-medium text-gray-700 transition-all disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-800 data-[state=active]:shadow-sm dark:data-[state=active]:bg-gray-700 dark:data-[state=active]:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<TabsPrimitive.Content className={cn('mt-2 rounded-md p-6', className)} {...props} ref={ref} />
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type TagProps = React.ComponentPropsWithoutRef<'div'> & {
|
||||
label: string;
|
||||
labelClassName?: string;
|
||||
CancelButton?: React.ReactNode;
|
||||
LabelNode?: React.ReactNode;
|
||||
onRemove?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
};
|
||||
|
||||
const TagPrimitiveRoot = React.forwardRef<HTMLDivElement, TagProps>(
|
||||
(
|
||||
{ CancelButton, LabelNode, label, onRemove, className = '', labelClassName = '', ...props },
|
||||
ref,
|
||||
) => (
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex max-h-8 items-center overflow-y-hidden rounded-3xl border-2 border-green-600 bg-green-600/20 text-xs text-green-600 dark:text-white',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn('ml-1 whitespace-pre-wrap px-2 py-1', labelClassName)}>
|
||||
{LabelNode ? <>{LabelNode} </> : null}
|
||||
{label}
|
||||
</div>
|
||||
{CancelButton
|
||||
? CancelButton
|
||||
: onRemove && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(e);
|
||||
}}
|
||||
className="rounded-full bg-green-600/50"
|
||||
aria-label={`Remove ${label}`}
|
||||
>
|
||||
<X className="m-[1.5px] p-1" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
TagPrimitiveRoot.displayName = 'Tag';
|
||||
|
||||
export const Tag = React.memo(TagPrimitiveRoot);
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import { useMemo } from 'react';
|
||||
import { OGDialog, DialogTemplate, useToastContext } from '@librechat/client';
|
||||
import type { TTermsOfService } from 'librechat-data-provider';
|
||||
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { useAcceptTermsMutation } from '~/data-provider';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { OGDialog } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const TermsAndConditionsModal = ({
|
||||
|
|
@ -73,7 +71,7 @@ const TermsAndConditionsModal = ({
|
|||
main={
|
||||
<section
|
||||
// Motivation: This is a dialog, so its content should be focusable
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
|
||||
tabIndex={0}
|
||||
className="max-h-[60vh] overflow-y-auto p-4"
|
||||
aria-label={localize('com_ui_terms_and_conditions')}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
/* eslint-disable */
|
||||
import * as React from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
|
||||
import { cn } from '../../utils';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className = '', ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex h-20 w-full resize-none rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-50 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { forwardRef, useLayoutEffect, useState } from 'react';
|
||||
import ReactTextareaAutosize from 'react-textarea-autosize';
|
||||
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
|
||||
import store from '~/store';
|
||||
|
||||
export const TextareaAutosize = forwardRef<HTMLTextAreaElement, TextareaAutosizeProps>(
|
||||
(props, ref) => {
|
||||
const [, setIsRerendered] = useState(false);
|
||||
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
|
||||
useLayoutEffect(() => setIsRerendered(true), []);
|
||||
return <ReactTextareaAutosize dir={chatDirection} {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
import React, { useContext, useCallback, useEffect, useState } from 'react';
|
||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||
import { ThemeContext } from '~/hooks';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
lastThemeChange?: number;
|
||||
}
|
||||
}
|
||||
|
||||
const Theme = ({ theme, onChange }: { theme: string; onChange: (value: string) => void }) => {
|
||||
const themeIcons = {
|
||||
system: <Monitor />,
|
||||
dark: <Moon color="white" />,
|
||||
light: <Sun />,
|
||||
};
|
||||
|
||||
const nextTheme = theme === 'dark' ? 'light' : 'dark';
|
||||
const label = `Switch to ${nextTheme} theme`;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 't') {
|
||||
e.preventDefault();
|
||||
onChange(nextTheme);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
return () => window.removeEventListener('keydown', handleKeyPress);
|
||||
}, [nextTheme, onChange]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-lg p-2 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
aria-label={label}
|
||||
aria-keyshortcuts="Ctrl+Shift+T"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(nextTheme);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onChange(nextTheme);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{themeIcons[theme]}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ThemeSelector = ({ returnThemeOnly }: { returnThemeOnly?: boolean }) => {
|
||||
const { theme, setTheme } = useContext(ThemeContext);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
|
||||
const changeTheme = useCallback(
|
||||
(value: string) => {
|
||||
const now = Date.now();
|
||||
if (typeof window.lastThemeChange === 'number' && now - window.lastThemeChange < 500) {
|
||||
return;
|
||||
}
|
||||
window.lastThemeChange = now;
|
||||
|
||||
setTheme(value);
|
||||
setAnnouncement(value === 'dark' ? 'Dark theme enabled' : 'Light theme enabled');
|
||||
},
|
||||
[setTheme],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'system') {
|
||||
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
setTheme(prefersDarkScheme ? 'dark' : 'light');
|
||||
}
|
||||
}, [theme, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (announcement) {
|
||||
const timeout = setTimeout(() => setAnnouncement(''), 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [announcement]);
|
||||
|
||||
if (returnThemeOnly === true) {
|
||||
return <Theme theme={theme} onChange={changeTheme} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center bg-white pt-6 dark:bg-gray-900 sm:pt-0">
|
||||
<div className="absolute bottom-0 left-0 m-4">
|
||||
<Theme theme={theme} onChange={changeTheme} />
|
||||
</div>
|
||||
{announcement && (
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{announcement}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSelector;
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import * as RadixToast from '@radix-ui/react-toast';
|
||||
import { NotificationSeverity } from '~/common/types';
|
||||
import { useToast } from '~/hooks';
|
||||
|
||||
export default function Toast() {
|
||||
const { toast, onOpenChange } = useToast();
|
||||
const severityClassName = {
|
||||
[NotificationSeverity.INFO]: 'border-gray-500 bg-gray-500',
|
||||
[NotificationSeverity.SUCCESS]: 'border-green-500 bg-green-500',
|
||||
[NotificationSeverity.WARNING]: 'border-orange-500 bg-orange-500',
|
||||
[NotificationSeverity.ERROR]: 'border-red-500 bg-red-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<RadixToast.Root
|
||||
open={toast.open}
|
||||
onOpenChange={onOpenChange}
|
||||
className="toast-root"
|
||||
style={{
|
||||
height: '74px',
|
||||
marginBottom: '0px',
|
||||
}}
|
||||
>
|
||||
<div className="w-full p-1 text-center md:w-auto md:text-justify">
|
||||
<div
|
||||
className={`alert-root pointer-events-auto inline-flex flex-row gap-2 rounded-md border px-3 py-2 text-white ${
|
||||
severityClassName[toast.severity]
|
||||
}`}
|
||||
>
|
||||
{toast.showIcon && (
|
||||
<div className="mt-1 flex-shrink-0 flex-grow-0">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="icon-sm"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<RadixToast.Description className="flex-1 justify-center gap-2">
|
||||
<div className="whitespace-pre-wrap text-left">{toast.message}</div>
|
||||
</RadixToast.Description>
|
||||
</div>
|
||||
</div>
|
||||
</RadixToast.Root>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { forwardRef, useMemo } from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface TooltipAnchorProps extends Ariakit.TooltipAnchorProps {
|
||||
description: string;
|
||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||
className?: string;
|
||||
focusable?: boolean;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(function TooltipAnchor(
|
||||
{ description, side = 'top', className, role, ...props },
|
||||
ref,
|
||||
) {
|
||||
const tooltip = Ariakit.useTooltipStore({ placement: side });
|
||||
const mounted = Ariakit.useStoreState(tooltip, (state) => state.mounted);
|
||||
const placement = Ariakit.useStoreState(tooltip, (state) => state.placement);
|
||||
|
||||
const { x, y } = useMemo(() => {
|
||||
const dir = placement.split('-')[0];
|
||||
switch (dir) {
|
||||
case 'top':
|
||||
return { x: 0, y: -8 };
|
||||
case 'bottom':
|
||||
return { x: 0, y: 8 };
|
||||
case 'left':
|
||||
return { x: -8, y: 0 };
|
||||
case 'right':
|
||||
return { x: 8, y: 0 };
|
||||
default:
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
}, [placement]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (role === 'button' && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
(event.target as HTMLDivElement).click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Ariakit.TooltipProvider store={tooltip} hideTimeout={0}>
|
||||
<Ariakit.TooltipAnchor
|
||||
{...props}
|
||||
ref={ref}
|
||||
role={role}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn('cursor-pointer', className)}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{mounted === true && (
|
||||
<Ariakit.Tooltip
|
||||
gutter={4}
|
||||
alwaysVisible
|
||||
className="tooltip"
|
||||
render={
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x, y }}
|
||||
animate={{ opacity: 1, x: 0, y: 0 }}
|
||||
exit={{ opacity: 0, x, y }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Ariakit.TooltipArrow />
|
||||
{description}
|
||||
</Ariakit.Tooltip>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Ariakit.TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,48 +1 @@
|
|||
export * from './Accordion';
|
||||
export * from './AnimatedTabs';
|
||||
export * from './AlertDialog';
|
||||
export * from './Breadcrumb';
|
||||
export * from './Button';
|
||||
export * from './Checkbox';
|
||||
export * from './DataTableColumnHeader';
|
||||
export * from './Dialog';
|
||||
export * from './DropdownMenu';
|
||||
export * from './HoverCard';
|
||||
export * from './Input';
|
||||
export * from './InputNumber';
|
||||
export * from './Label';
|
||||
export * from './OriginalDialog';
|
||||
export * from './Prompt';
|
||||
export * from './QuestionMark';
|
||||
export * from './Slider';
|
||||
export * from './Separator';
|
||||
export * from './InputCombobox';
|
||||
export * from './Skeleton';
|
||||
export * from './Switch';
|
||||
export * from './Table';
|
||||
export * from './Tabs';
|
||||
export * from './Tag';
|
||||
export * from './Textarea';
|
||||
export * from './TextareaAutosize';
|
||||
export * from './Tooltip';
|
||||
export * from './Pagination';
|
||||
export * from './Progress';
|
||||
export * from './InputOTP';
|
||||
export { default as Badge } from './Badge';
|
||||
export { default as Combobox } from './Combobox';
|
||||
export { default as Dropdown } from './Dropdown';
|
||||
export { default as SplitText } from './SplitText';
|
||||
export { default as FormInput } from './FormInput';
|
||||
export { default as PixelCard } from './PixelCard';
|
||||
export { default as FileUpload } from './FileUpload';
|
||||
export { default as DropdownPopup } from './DropdownPopup';
|
||||
export { default as DelayedRender } from './DelayedRender';
|
||||
export { default as ThemeSelector } from './ThemeSelector';
|
||||
export { default as SelectDropDown } from './SelectDropDown';
|
||||
export { default as MultiSelectPop } from './MultiSelectPop';
|
||||
export { default as ModelParameters } from './ModelParameters';
|
||||
export { default as OGDialogTemplate } from './OGDialogTemplate';
|
||||
export { default as InputWithDropdown } from './InputWithDropDown';
|
||||
export { default as SelectDropDownPop } from './SelectDropDownPop';
|
||||
export { default as AnimatedSearchInput } from './AnimatedSearchInput';
|
||||
export { default as MultiSelectDropDown } from './MultiSelectDropDown';
|
||||
export { default as TermsAndConditionsModal } from './TermsAndConditionsModal';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue