mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-07 19:18:52 +01:00
feat: init @librechat/client
This commit is contained in:
parent
545a909953
commit
dcaa5af598
207 changed files with 21329 additions and 239 deletions
50
packages/client/src/components/Accordion.tsx
Normal file
50
packages/client/src/components/Accordion.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
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="h-4 w-4 shrink-0 text-muted-foreground 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 };
|
||||
131
packages/client/src/components/AlertDialog.tsx
Normal file
131
packages/client/src/components/AlertDialog.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
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,
|
||||
};
|
||||
79
packages/client/src/components/AnimatedSearchInput.tsx
Normal file
79
packages/client/src/components/AnimatedSearchInput.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
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;
|
||||
57
packages/client/src/components/AnimatedTabs.css
Normal file
57
packages/client/src/components/AnimatedTabs.css
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/* 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%;
|
||||
}
|
||||
160
packages/client/src/components/AnimatedTabs.tsx
Normal file
160
packages/client/src/components/AnimatedTabs.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
96
packages/client/src/components/Badge.tsx
Normal file
96
packages/client/src/components/Badge.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
100
packages/client/src/components/Breadcrumb.tsx
Normal file
100
packages/client/src/components/Breadcrumb.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
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(
|
||||
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground 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('transition-colors hover:text-foreground', 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('font-normal text-foreground', 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,
|
||||
};
|
||||
52
packages/client/src/components/Button.tsx
Normal file
52
packages/client/src/components/Button.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
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 };
|
||||
25
packages/client/src/components/Checkbox.tsx
Normal file
25
packages/client/src/components/Checkbox.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import * as React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
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 };
|
||||
76
packages/client/src/components/CheckboxButton.tsx
Normal file
76
packages/client/src/components/CheckboxButton.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import * as React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Checkbox, useStoreState, useCheckboxStore } from '@ariakit/react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
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;
|
||||
9
packages/client/src/components/Collapsible.tsx
Normal file
9
packages/client/src/components/Collapsible.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
176
packages/client/src/components/Combobox.tsx
Normal file
176
packages/client/src/components/Combobox.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
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';
|
||||
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(
|
||||
'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 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="flex items-center justify-center gap-3 dark:text-white [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-foreground">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
182
packages/client/src/components/ControlCombobox.tsx
Normal file
182
packages/client/src/components/ControlCombobox.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
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);
|
||||
476
packages/client/src/components/DataTable.tsx
Normal file
476
packages/client/src/components/DataTable.tsx
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react';
|
||||
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 '~/svg';
|
||||
import { LocalizeFunction } from '~/common';
|
||||
import { useMediaQuery } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
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;
|
||||
enableSearch?: 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,
|
||||
}: {
|
||||
onDelete?: () => Promise<void>;
|
||||
isDeleting: boolean;
|
||||
disabled: boolean;
|
||||
isSmallScreen: boolean;
|
||||
}) => {
|
||||
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">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,
|
||||
enableSearch = true,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
{filterColumn !== undefined && table.getColumn(filterColumn) && enableSearch && (
|
||||
<div className="relative flex-1">
|
||||
<AnimatedSearchInput
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
isSearching={isSearching}
|
||||
placeholder="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">
|
||||
No data available
|
||||
</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>
|
||||
);
|
||||
}
|
||||
60
packages/client/src/components/DataTableColumnHeader.tsx
Normal file
60
packages/client/src/components/DataTableColumnHeader.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { Column } from '@tanstack/react-table';
|
||||
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './DropdownMenu';
|
||||
import { Button } from './Button';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
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="-ml-3 h-8 data-[state=open]:bg-accent">
|
||||
<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="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Asc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Desc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
packages/client/src/components/DelayedRender.tsx
Normal file
5
packages/client/src/components/DelayedRender.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { useDelayedRender } from '~/hooks';
|
||||
|
||||
const DelayedRender = ({ delay, children }) => useDelayedRender(delay)(() => children);
|
||||
|
||||
export default DelayedRender;
|
||||
170
packages/client/src/components/Dialog.tsx
Normal file
170
packages/client/src/components/Dialog.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useMediaQuery } from '~/hooks';
|
||||
import { Button } from './Button';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
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,
|
||||
};
|
||||
89
packages/client/src/components/DialogTemplate.spec.tsx
Normal file
89
packages/client/src/components/DialogTemplate.spec.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
94
packages/client/src/components/DialogTemplate.tsx
Normal file
94
packages/client/src/components/DialogTemplate.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { forwardRef, ReactNode, Ref } from 'react';
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
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 {
|
||||
title,
|
||||
description,
|
||||
main,
|
||||
buttons,
|
||||
leftButtons,
|
||||
selection,
|
||||
className,
|
||||
headerClassName,
|
||||
footerClassName,
|
||||
showCloseButton,
|
||||
showCancelButton = true,
|
||||
} = props;
|
||||
const { selectHandler, selectClasses, selectText } = selection || {};
|
||||
const Cancel = '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;
|
||||
150
packages/client/src/components/Dropdown.tsx
Normal file
150
packages/client/src/components/Dropdown.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
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;
|
||||
191
packages/client/src/components/DropdownMenu.tsx
Normal file
191
packages/client/src/components/DropdownMenu.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
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,
|
||||
};
|
||||
140
packages/client/src/components/DropdownNoState.tsx
Normal file
140
packages/client/src/components/DropdownNoState.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
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;
|
||||
134
packages/client/src/components/DropdownPopup.tsx
Normal file
134
packages/client/src/components/DropdownPopup.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
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;
|
||||
29
packages/client/src/components/FileUpload.tsx
Normal file
29
packages/client/src/components/FileUpload.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
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;
|
||||
63
packages/client/src/components/FormInput.tsx
Normal file
63
packages/client/src/components/FormInput.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React from 'react';
|
||||
import { Label } from './Label';
|
||||
import { Input } from './Input';
|
||||
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>
|
||||
);
|
||||
}
|
||||
34
packages/client/src/components/HoverCard.tsx
Normal file
34
packages/client/src/components/HoverCard.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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 };
|
||||
21
packages/client/src/components/Input.tsx
Normal file
21
packages/client/src/components/Input.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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 };
|
||||
105
packages/client/src/components/InputCombobox.tsx
Normal file
105
packages/client/src/components/InputCombobox.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
45
packages/client/src/components/InputNumber.tsx
Normal file
45
packages/client/src/components/InputNumber.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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 };
|
||||
68
packages/client/src/components/InputOTP.tsx
Normal file
68
packages/client/src/components/InputOTP.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
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 };
|
||||
154
packages/client/src/components/InputWithDropDown.tsx
Normal file
154
packages/client/src/components/InputWithDropDown.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import * as React from 'react';
|
||||
import { Input } from './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 absolute inset-y-0 right-0 flex items-center rounded-md px-2 hover:text-secondary 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
|
||||
? 'bg-surface-active text-primary'
|
||||
: '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;
|
||||
20
packages/client/src/components/Label.tsx
Normal file
20
packages/client/src/components/Label.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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 };
|
||||
186
packages/client/src/components/ModelParameters.tsx
Normal file
186
packages/client/src/components/ModelParameters.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
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);
|
||||
157
packages/client/src/components/MultiSearch.tsx
Normal file
157
packages/client/src/components/MultiSearch.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
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];
|
||||
}
|
||||
144
packages/client/src/components/MultiSelect.tsx
Normal file
144
packages/client/src/components/MultiSelect.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
231
packages/client/src/components/MultiSelectDropDown.tsx
Normal file
231
packages/client/src/components/MultiSelectDropDown.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import { Wrench, ArrowRight } from 'lucide-react';
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
Label,
|
||||
ListboxOptions,
|
||||
ListboxOption,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import { useMultiSearch } from './MultiSearch';
|
||||
import { useOnClickOutside } from '~/hooks';
|
||||
import { CheckMark } from '~/svgs';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
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;
|
||||
154
packages/client/src/components/MultiSelectPop.tsx
Normal file
154
packages/client/src/components/MultiSelectPop.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
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 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;
|
||||
109
packages/client/src/components/OGDialogTemplate.tsx
Normal file
109
packages/client/src/components/OGDialogTemplate.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
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 '~/svgs';
|
||||
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;
|
||||
141
packages/client/src/components/OriginalDialog.tsx
Normal file
141
packages/client/src/components/OriginalDialog.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
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,
|
||||
};
|
||||
105
packages/client/src/components/Pagination.tsx
Normal file
105
packages/client/src/components/Pagination.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
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,
|
||||
};
|
||||
376
packages/client/src/components/PixelCard.tsx
Normal file
376
packages/client/src/components/PixelCard.tsx
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
22
packages/client/src/components/Progress.tsx
Normal file
22
packages/client/src/components/Progress.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
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 };
|
||||
22
packages/client/src/components/Prompt.tsx
Normal file
22
packages/client/src/components/Prompt.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
16
packages/client/src/components/QuestionMark.tsx
Normal file
16
packages/client/src/components/QuestionMark.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
62
packages/client/src/components/Resizable.tsx
Normal file
62
packages/client/src/components/Resizable.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
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 };
|
||||
160
packages/client/src/components/Select.tsx
Normal file
160
packages/client/src/components/Select.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
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,
|
||||
};
|
||||
285
packages/client/src/components/SelectDropDown.tsx
Normal file
285
packages/client/src/components/SelectDropDown.tsx
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import React, { useRef } from 'react';
|
||||
import {
|
||||
Label,
|
||||
Listbox,
|
||||
Transition,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from '@headlessui/react';
|
||||
import type { Option, OptionWithIcon, DropdownValueSetter } from '~/common';
|
||||
import { useMultiSearch } from './MultiSearch';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { CheckMark } from '~/svgs';
|
||||
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;
|
||||
24
packages/client/src/components/Separator.tsx
Normal file
24
packages/client/src/components/Separator.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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 };
|
||||
15
packages/client/src/components/Skeleton.tsx
Normal file
15
packages/client/src/components/Skeleton.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
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 };
|
||||
26
packages/client/src/components/Slider.tsx
Normal file
26
packages/client/src/components/Slider.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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 };
|
||||
38
packages/client/src/components/SplitText.spec.tsx
Normal file
38
packages/client/src/components/SplitText.spec.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
138
packages/client/src/components/SplitText.tsx
Normal file
138
packages/client/src/components/SplitText.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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;
|
||||
26
packages/client/src/components/Switch.tsx
Normal file
26
packages/client/src/components/Switch.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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 };
|
||||
90
packages/client/src/components/Table.tsx
Normal file
90
packages/client/src/components/Table.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
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 };
|
||||
45
packages/client/src/components/Tabs.tsx
Normal file
45
packages/client/src/components/Tabs.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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 };
|
||||
50
packages/client/src/components/Tag.tsx
Normal file
50
packages/client/src/components/Tag.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
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);
|
||||
111
packages/client/src/components/TermsAndConditionsModal.tsx
Normal file
111
packages/client/src/components/TermsAndConditionsModal.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { useMemo } from 'react';
|
||||
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 './OriginalDialog';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const TermsAndConditionsModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAccept,
|
||||
onDecline,
|
||||
title,
|
||||
modalContent,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
onAccept: () => void;
|
||||
onDecline: () => void;
|
||||
title?: string;
|
||||
contentUrl?: string;
|
||||
modalContent?: TTermsOfService['modalContent'];
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const acceptTermsMutation = useAcceptTermsMutation({
|
||||
onSuccess: () => {
|
||||
onAccept();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
showToast({ message: 'Failed to accept terms' });
|
||||
},
|
||||
});
|
||||
|
||||
const handleAccept = () => {
|
||||
acceptTermsMutation.mutate();
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
onDecline();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (open && !isOpen) {
|
||||
return;
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
};
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (typeof modalContent === 'string') {
|
||||
return modalContent;
|
||||
}
|
||||
|
||||
if (Array.isArray(modalContent)) {
|
||||
return modalContent.join('\n');
|
||||
}
|
||||
|
||||
return '';
|
||||
}, [modalContent]);
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTemplate
|
||||
title={title ?? localize('com_ui_terms_and_conditions')}
|
||||
className="w-11/12 max-w-3xl sm:w-3/4 md:w-1/2 lg:w-2/5"
|
||||
showCloseButton={false}
|
||||
showCancelButton={false}
|
||||
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')}
|
||||
>
|
||||
<div className="prose dark:prose-invert w-full max-w-none !text-text-primary">
|
||||
{content !== '' ? (
|
||||
<MarkdownLite content={content} />
|
||||
) : (
|
||||
<p>{localize('com_ui_no_terms_content')}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
buttons={
|
||||
<>
|
||||
<button
|
||||
onClick={handleDecline}
|
||||
className="inline-flex h-10 items-center justify-center rounded-lg border border-border-heavy bg-surface-secondary px-4 py-2 text-sm text-text-primary hover:bg-surface-active"
|
||||
>
|
||||
{localize('com_ui_decline')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
className="inline-flex h-10 items-center justify-center rounded-lg border border-border-heavy bg-surface-secondary px-4 py-2 text-sm text-text-primary hover:bg-green-500 hover:text-white focus:bg-green-500 focus:text-white dark:hover:bg-green-600 dark:focus:bg-green-600"
|
||||
>
|
||||
{localize('com_ui_accept')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default TermsAndConditionsModal;
|
||||
24
packages/client/src/components/Textarea.tsx
Normal file
24
packages/client/src/components/Textarea.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/* 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 };
|
||||
14
packages/client/src/components/TextareaAutosize.tsx
Normal file
14
packages/client/src/components/TextareaAutosize.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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} />;
|
||||
},
|
||||
);
|
||||
103
packages/client/src/components/ThemeSelector.tsx
Normal file
103
packages/client/src/components/ThemeSelector.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
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;
|
||||
57
packages/client/src/components/Toast.tsx
Normal file
57
packages/client/src/components/Toast.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
75
packages/client/src/components/Tooltip.tsx
Normal file
75
packages/client/src/components/Tooltip.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
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>
|
||||
);
|
||||
});
|
||||
48
packages/client/src/components/index.ts
Normal file
48
packages/client/src/components/index.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
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 '../../../../client/src/components/Input/ModelSelect/SelectDropDownPop';
|
||||
export { default as AnimatedSearchInput } from './AnimatedSearchInput';
|
||||
export { default as MultiSelectDropDown } from './MultiSelectDropDown';
|
||||
Loading…
Add table
Add a link
Reference in a new issue