👐 style: Improve a11y/theming for Settings Dialog, Dropdown Menus; fix: SearchBar focus issues (#4091)

* fix: cursor pointer not applying correct in the root component

* fix: add cursor-not-allowed to disabled state in SendButton component

* feat: update Dropdown to ariakit and changed LLM error's style

* feat: switched to ariakit's Dropdown and style improvements

* feat: archive updates

* refactor: delete conversations in archive

* refactor: settings

* add cool settings animation

* a11y: settings update

* style: update settings

* style: settings account settings menu; a11y(AccountSettings): switched to AriaKit

* a11y: account settings update

* style: update my files dialog

* fix: tests

* chore: remove console.log()

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2024-09-22 04:45:50 +02:00 committed by GitHub
parent eba2c9a032
commit 2d62eca612
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1054 additions and 824 deletions

View file

@ -50,7 +50,7 @@ const DeleteBookmarkButton: FC<{
<OGDialogTrigger asChild> <OGDialogTrigger asChild>
<TooltipAnchor <TooltipAnchor
description={localize('com_ui_delete')} description={localize('com_ui_delete')}
className="flex size-7 cursor-pointer items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-hover" className="flex size-7 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-hover"
tabIndex={tabIndex} tabIndex={tabIndex}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}

View file

@ -35,7 +35,7 @@ const EditBookmarkButton: FC<{
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
className="flex size-7 cursor-pointer items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-hover" className="flex size-7 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-hover"
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<EditIcon /> <EditIcon />

View file

@ -1,4 +1,4 @@
import { memo, useRef, useMemo } from 'react'; import { memo, useRef, useMemo, useEffect } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilState, useRecoilValue } from 'recoil';
import { import {
supportsFiles, supportsFiles,
@ -44,6 +44,7 @@ const ChatForm = ({ index = 0 }) => {
const TextToSpeech = useRecoilValue(store.textToSpeech); const TextToSpeech = useRecoilValue(store.textToSpeech);
const automaticPlayback = useRecoilValue(store.automaticPlayback); const automaticPlayback = useRecoilValue(store.automaticPlayback);
const isSearching = useRecoilValue(store.isSearching);
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index)); const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
const [showPlusPopover, setShowPlusPopover] = useRecoilState(store.showPlusPopoverFamily(index)); const [showPlusPopover, setShowPlusPopover] = useRecoilState(store.showPlusPopoverFamily(index));
const [showMentionPopover, setShowMentionPopover] = useRecoilState( const [showMentionPopover, setShowMentionPopover] = useRecoilState(
@ -123,6 +124,12 @@ const ChatForm = ({ index = 0 }) => {
}, },
}); });
useEffect(() => {
if (!isSearching && textAreaRef.current && !disableInputs) {
textAreaRef.current.focus();
}
}, [isSearching, disableInputs]);
return ( return (
<form <form
onSubmit={methods.handleSubmit((data) => submitMessage(data))} onSubmit={methods.handleSubmit((data) => submitMessage(data))}
@ -164,9 +171,6 @@ const ChatForm = ({ index = 0 }) => {
{endpoint && ( {endpoint && (
<TextareaAutosize <TextareaAutosize
{...registerProps} {...registerProps}
// TODO: remove autofocus due to a11y issues
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
ref={(e) => { ref={(e) => {
ref(e); ref(e);
textAreaRef.current = e; textAreaRef.current = e;

View file

@ -1,10 +1,9 @@
import { FileSources, FileContext } from 'librechat-data-provider'; import { FileSources, FileContext } from 'librechat-data-provider';
import type { TFile } from 'librechat-data-provider'; import type { TFile } from 'librechat-data-provider';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui'; import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle } from '~/components';
import { useGetFiles } from '~/data-provider'; import { useGetFiles } from '~/data-provider';
import { DataTable, columns } from './Table'; import { DataTable, columns } from './Table';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn } from '~/utils/';
export default function Files({ open, onOpenChange }) { export default function Files({ open, onOpenChange }) {
const localize = useLocalize(); const localize = useLocalize();
@ -19,20 +18,16 @@ export default function Files({ open, onOpenChange }) {
}); });
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <OGDialog open={open} onOpenChange={onOpenChange}>
<DialogContent <OGDialogContent
className={cn('w-11/12 overflow-x-auto shadow-2xl dark:bg-gray-700 dark:text-white')} title={localize('com_nav_my_files')}
className="w-11/12 overflow-x-auto bg-background text-text-primary shadow-2xl"
> >
<DialogHeader> <OGDialogHeader>
<DialogTitle className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-200"> <OGDialogTitle>{localize('com_nav_my_files')}</OGDialogTitle>
{localize('com_nav_my_files')} </OGDialogHeader>
</DialogTitle> <DataTable columns={columns} data={files} />
</DialogHeader> </OGDialogContent>
<div className="overflow-x-auto p-0 sm:p-6 sm:pt-4"> </OGDialog>
<DataTable columns={columns} data={files} />
<div className="mt-5 sm:mt-4" />
</div>
</DialogContent>
</Dialog>
); );
} }

View file

@ -26,7 +26,7 @@ const SubmitButton = React.memo(
id="send-button" id="send-button"
disabled={props.disabled} disabled={props.disabled}
className={cn( className={cn(
'absolute rounded-lg border border-black p-0.5 text-white outline-offset-4 transition-colors enabled:bg-black disabled:bg-black disabled:text-gray-400 disabled:opacity-10 dark:border-white dark:bg-white dark:disabled:bg-white', 'absolute rounded-lg border border-black p-0.5 text-white outline-offset-4 transition-colors enabled:bg-black disabled:cursor-not-allowed disabled:bg-black disabled:text-gray-400 disabled:opacity-10 dark:border-white dark:bg-white dark:disabled:bg-white',
props.isRTL props.isRTL
? 'bottom-1.5 left-2 md:bottom-3 md:left-3' ? 'bottom-1.5 left-2 md:bottom-3 md:left-3'
: 'bottom-1.5 right-2 md:bottom-3 md:right-3', : 'bottom-1.5 right-2 md:bottom-3 md:right-3',

View file

@ -57,7 +57,7 @@ export const ErrorMessage = ({
<Container message={message}> <Container message={message}>
<div <div
className={cn( className={cn(
'rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200', 'rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
className, className,
)} )}
> >

View file

@ -1,13 +1,12 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback } from 'react';
import { QueryKeys } from 'librechat-data-provider'; import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import type { TMessage } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider';
import { useDeleteConversationMutation } from '~/data-provider'; import { useDeleteConversationMutation } from '~/data-provider';
import { OGDialog, OGDialogTrigger, Label, TooltipAnchor } from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { TrashIcon } from '~/components/svg';
import { useLocalize, useNewConvo } from '~/hooks'; import { useLocalize, useNewConvo } from '~/hooks';
import { OGDialog, Label } from '~/components';
type DeleteButtonProps = { type DeleteButtonProps = {
conversationId: string; conversationId: string;
@ -17,19 +16,21 @@ type DeleteButtonProps = {
setShowDeleteDialog?: (value: boolean) => void; setShowDeleteDialog?: (value: boolean) => void;
}; };
export default function DeleteButton({ export function DeleteConversationDialog({
conversationId, conversationId,
retainView, retainView,
title, title,
showDeleteDialog, }: {
setShowDeleteDialog, conversationId: string;
}: DeleteButtonProps) { retainView: () => void;
title: string;
}) {
const localize = useLocalize(); const localize = useLocalize();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { newConversation } = useNewConvo(); const { newConversation } = useNewConvo();
const { conversationId: currentConvoId } = useParams(); const { conversationId: currentConvoId } = useParams();
const [open, setOpen] = useState(false);
const deleteConvoMutation = useDeleteConversationMutation({ const deleteConvoMutation = useDeleteConversationMutation({
onSuccess: () => { onSuccess: () => {
if (currentConvoId === conversationId || currentConvoId === 'new') { if (currentConvoId === conversationId || currentConvoId === 'new') {
@ -47,7 +48,7 @@ export default function DeleteButton({
deleteConvoMutation.mutate({ conversationId, thread_id, source: 'button' }); deleteConvoMutation.mutate({ conversationId, thread_id, source: 'button' });
}, [conversationId, deleteConvoMutation, queryClient]); }, [conversationId, deleteConvoMutation, queryClient]);
const dialogContent = ( return (
<OGDialogTemplate <OGDialogTemplate
showCloseButton={false} showCloseButton={false}
title={localize('com_ui_delete_conversation')} title={localize('com_ui_delete_conversation')}
@ -71,25 +72,26 @@ export default function DeleteButton({
}} }}
/> />
); );
}
if (showDeleteDialog !== undefined && setShowDeleteDialog !== undefined) { export default function DeleteButton({
return ( conversationId,
<OGDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> retainView,
{dialogContent} title,
</OGDialog> showDeleteDialog,
); setShowDeleteDialog,
}: DeleteButtonProps) {
if (showDeleteDialog === undefined && setShowDeleteDialog === undefined) {
return null;
} }
return ( return (
<OGDialog open={open} onOpenChange={setOpen}> <OGDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<TooltipAnchor description={localize('com_ui_delete')}> <DeleteConversationDialog
<OGDialogTrigger asChild> conversationId={conversationId}
<button> retainView={retainView}
<TrashIcon className="h-5 w-5" /> title={title}
</button> />
</OGDialogTrigger>
</TooltipAnchor>
{dialogContent}
</OGDialog> </OGDialog>
); );
} }

View file

@ -1,4 +1,4 @@
export { default as DeleteButton } from './DeleteButton'; export * from './DeleteButton';
export { default as ShareButton } from './ShareButton'; export { default as ShareButton } from './ShareButton';
export { default as SharedLinkButton } from './SharedLinkButton'; export { default as SharedLinkButton } from './SharedLinkButton';
export { default as ConvoOptions } from './ConvoOptions'; export { default as ConvoOptions } from './ConvoOptions';

View file

@ -1,23 +1,20 @@
import { FileText } from 'lucide-react';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import * as Select from '@ariakit/react/select';
import { Fragment, useState, memo } from 'react'; import { Fragment, useState, memo } from 'react';
import { Menu, MenuItem, MenuButton, MenuItems, Transition } from '@headlessui/react'; import { FileText, LogOut } from 'lucide-react';
import { useGetUserBalance, useGetStartupConfig } from 'librechat-data-provider/react-query'; import { useGetUserBalance, useGetStartupConfig } from 'librechat-data-provider/react-query';
import { LinkIcon, GearIcon, DropdownMenuSeparator } from '~/components';
import FilesView from '~/components/Chat/Input/Files/FilesView'; import FilesView from '~/components/Chat/Input/Files/FilesView';
import { useAuthContext } from '~/hooks/AuthContext'; import { useAuthContext } from '~/hooks/AuthContext';
import useAvatar from '~/hooks/Messages/useAvatar'; import useAvatar from '~/hooks/Messages/useAvatar';
import { LinkIcon, GearIcon } from '~/components';
import { UserIcon } from '~/components/svg'; import { UserIcon } from '~/components/svg';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import Settings from './Settings'; import Settings from './Settings';
import NavLink from './NavLink';
import Logout from './Logout';
import { cn } from '~/utils/';
import store from '~/store'; import store from '~/store';
function AccountSettings() { function AccountSettings() {
const localize = useLocalize(); const localize = useLocalize();
const { user, isAuthenticated } = useAuthContext(); const { user, isAuthenticated, logout } = useAuthContext();
const { data: startupConfig } = useGetStartupConfig(); const { data: startupConfig } = useGetStartupConfig();
const balanceQuery = useGetUserBalance({ const balanceQuery = useGetUserBalance({
enabled: !!isAuthenticated && startupConfig?.checkBalance, enabled: !!isAuthenticated && startupConfig?.checkBalance,
@ -29,115 +26,105 @@ function AccountSettings() {
const name = user?.avatar ?? user?.username ?? ''; const name = user?.avatar ?? user?.username ?? '';
return ( return (
<> <Select.SelectProvider>
<Menu as="div" className="group relative"> <Select.Select
{({ open }) => ( aria-label={localize('com_nav_account_settings')}
<> data-testid="nav-user"
<MenuButton className="duration-350 mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-accent"
aria-label={localize('com_nav_account_settings')} >
className={cn( <div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
'group-ui-open:bg-surface-tertiary duration-350 mt-text-sm flex h-auto w-full items-center gap-2 rounded-lg p-2 text-sm transition-colors hover:bg-surface-secondary', <div className="relative flex">
open ? 'bg-surface-secondary' : '', {name.length === 0 ? (
)}
data-testid="nav-user"
>
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
<div className="relative flex">
{name.length === 0 ? (
<div
style={{
backgroundColor: 'rgb(121, 137, 255)',
width: '32px',
height: '32px',
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
}}
className="relative flex items-center justify-center rounded-full p-1 text-text-primary"
>
<UserIcon />
</div>
) : (
<img className="rounded-full" src={user?.avatar ?? avatarSrc} alt="avatar" />
)}
</div>
</div>
<div <div
className="mt-2 grow overflow-hidden text-ellipsis whitespace-nowrap text-left text-text-primary" style={{
style={{ marginTop: '0', marginLeft: '0' }} backgroundColor: 'rgb(121, 137, 255)',
width: '32px',
height: '32px',
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
}}
className="relative flex items-center justify-center rounded-full p-1 text-text-primary"
aria-hidden="true"
> >
{user?.name ?? user?.username ?? localize('com_nav_user')} <UserIcon />
</div> </div>
</MenuButton> ) : (
<img
<Transition className="rounded-full"
as={Fragment} src={user?.avatar ?? avatarSrc}
enter="transition ease-out duration-100 transform" alt={`${name}'s avatar`}
enterFrom="translate-y-2 opacity-0" />
enterTo="translate-y-0 opacity-100" )}
leave="transition ease-in duration-100 transform" </div>
leaveFrom="translate-y-0 opacity-100" </div>
leaveTo="translate-y-2 opacity-0" <div
> className="mt-2 grow overflow-hidden text-ellipsis whitespace-nowrap text-left text-text-primary"
<MenuItems className="absolute bottom-full left-0 z-[100] mb-1 mt-1 w-full translate-y-0 overflow-hidden rounded-lg border border-border-medium bg-header-primary p-1.5 opacity-100 shadow-lg outline-none"> style={{ marginTop: '0', marginLeft: '0' }}
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="none"> >
{user?.email ?? localize('com_nav_user')} {user?.name ?? user?.username ?? localize('com_nav_user')}
</div> </div>
<div className="my-1.5 h-px border-b border-border-medium" role="none" /> </Select.Select>
{startupConfig?.checkBalance === true && <Select.SelectPopover
balanceQuery.data != null && className="popover-ui w-[235px]"
!isNaN(parseFloat(balanceQuery.data)) && ( style={{
<> transformOrigin: 'bottom',
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm"> marginRight: '0px',
{`Balance: ${parseFloat(balanceQuery.data).toFixed(2)}`} translate: '0px',
</div> }}
<div className="my-1.5 h-px border-b border-border-medium" role="none" /> >
</> <div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="note">
)} {user?.email ?? localize('com_nav_user')}
<MenuItem> </div>
{({ focus }) => ( <DropdownMenuSeparator />
<NavLink {startupConfig?.checkBalance === true &&
className={focus ? 'bg-surface-hover' : ''} balanceQuery.data != null &&
svg={() => <FileText className="icon-md" />} !isNaN(parseFloat(balanceQuery.data)) && (
text={localize('com_nav_my_files')} <>
clickHandler={() => setShowFiles(true)} <div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="note">
/> {`Balance: ${parseFloat(balanceQuery.data).toFixed(2)}`}
)} </div>
</MenuItem> <DropdownMenuSeparator />
{startupConfig?.helpAndFaqURL !== '/' && (
<MenuItem>
{({ focus }) => (
<NavLink
className={focus ? 'bg-surface-hover' : ''}
svg={() => <LinkIcon />}
text={localize('com_nav_help_faq')}
clickHandler={() => window.open(startupConfig?.helpAndFaqURL, '_blank')}
/>
)}
</MenuItem>
)}
<MenuItem>
{({ focus }) => (
<NavLink
className={focus ? 'bg-surface-hover' : ''}
svg={() => <GearIcon className="icon-md" />}
text={localize('com_nav_settings')}
clickHandler={() => {
setTimeout(() => setShowSettings(true), 50);
}}
/>
)}
</MenuItem>
<div className="my-1.5 h-px border-b border-border-medium" role="none" />
<MenuItem>
{({ focus }) => <Logout className={focus ? 'bg-surface-hover' : ''} />}
</MenuItem>
</MenuItems>
</Transition>
</> </>
)} )}
</Menu> <Select.SelectItem
value=""
onClick={() => setShowFiles(true)}
className="select-item text-sm"
>
<FileText className="icon-md" aria-hidden="true" />
{localize('com_nav_my_files')}
</Select.SelectItem>
{startupConfig?.helpAndFaqURL !== '/' && (
<Select.SelectItem
value=""
onClick={() => window.open(startupConfig?.helpAndFaqURL, '_blank')}
className="select-item text-sm"
>
<LinkIcon aria-hidden="true" />
{localize('com_nav_help_faq')}
</Select.SelectItem>
)}
<Select.SelectItem
value=""
onClick={() => setShowSettings(true)}
className="select-item text-sm"
>
<GearIcon className="icon-md" aria-hidden="true" />
{localize('com_nav_settings')}
</Select.SelectItem>
<DropdownMenuSeparator />
<Select.SelectItem
aria-selected={true}
onClick={() => logout()}
value="logout"
className="select-item text-sm"
>
<LogOut className="icon-md" />
{localize('com_nav_log_out')}
</Select.SelectItem>
</Select.SelectPopover>
{showFiles && <FilesView open={showFiles} onOpenChange={setShowFiles} />} {showFiles && <FilesView open={showFiles} onOpenChange={setShowFiles} />}
{showSettings && <Settings open={showSettings} onOpenChange={setShowSettings} />} {showSettings && <Settings open={showSettings} onOpenChange={setShowSettings} />}
</> </Select.SelectProvider>
); );
} }

View file

@ -1,26 +0,0 @@
import { forwardRef } from 'react';
import { useAuthContext } from '~/hooks/AuthContext';
import { LogOutIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
const Logout = forwardRef<HTMLButtonElement, { className?: string }>((props, ref) => {
const { logout } = useAuthContext();
const localize = useLocalize();
return (
<button
ref={ref}
className={cn(
'group group flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm text-text-primary transition-colors duration-200 hover:bg-surface-hover data-[focus]:bg-surface-hover',
props.className ?? '',
)}
onClick={() => logout()}
>
<LogOutIcon />
{localize('com_nav_log_out')}
</button>
);
});
export default Logout;

View file

@ -16,7 +16,7 @@ const NavLink: FC<Props> = forwardRef<HTMLButtonElement, Props>((props, ref) =>
onClick?: React.MouseEventHandler<HTMLButtonElement>; onClick?: React.MouseEventHandler<HTMLButtonElement>;
} = { } = {
className: cn( className: cn(
'w-full flex gap-2 rounded p-2.5 text-sm cursor-pointer group items-center transition-colors duration-200 text-text-primary hover:bg-surface-hover', 'w-full flex gap-2 rounded p-2.5 text-sm cursor-pointer group items-center transition-colors duration-200 text-text-primary',
className, className,
{ {
'opacity-50 pointer-events-none': disabled, 'opacity-50 pointer-events-none': disabled,

View file

@ -41,7 +41,7 @@ export default function NavToggle({
description={ description={
navVisible ? localize('com_nav_close_sidebar') : localize('com_nav_open_sidebar') navVisible ? localize('com_nav_close_sidebar') : localize('com_nav_open_sidebar')
} }
className="flex cursor-pointer items-center justify-center" className="flex items-center justify-center"
tabIndex={0} tabIndex={0}
> >
<span className="" data-state="closed"> <span className="" data-state="closed">

View file

@ -7,7 +7,6 @@ import { getEndpointField, getIconEndpoint, getIconKey } from '~/utils';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons'; import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { useLocalize, useNewConvo } from '~/hooks'; import { useLocalize, useNewConvo } from '~/hooks';
import { TooltipAnchor } from '~/components/ui';
import { NewChatIcon } from '~/components/svg'; import { NewChatIcon } from '~/components/svg';
import store from '~/store'; import store from '~/store';
@ -96,15 +95,7 @@ export default function NewChat({
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<span className="flex items-center" data-state="closed"> <span className="flex items-center" data-state="closed">
<TooltipAnchor <NewChatIcon className="size-5" />
side="right"
id="nav-new-chat-btn"
aria-label="nav-new-chat-btn"
description={localize('com_ui_new_chat')}
className="text-text-primary"
>
<NewChatIcon className="size-5" />
</TooltipAnchor>
</span> </span>
</div> </div>
</a> </a>

View file

@ -20,6 +20,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
const setSearchQuery = useSetRecoilState(store.searchQuery); const setSearchQuery = useSetRecoilState(store.searchQuery);
const [showClearIcon, setShowClearIcon] = useState(false); const [showClearIcon, setShowClearIcon] = useState(false);
const [text, setText] = useState(''); const [text, setText] = useState('');
const setIsSearching = useSetRecoilState(store.isSearching);
const localize = useLocalize(); const localize = useLocalize();
const clearText = useCallback(() => { const clearText = useCallback(() => {
@ -47,6 +48,8 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
}, },
[queryClient, clearConvoState, setSearchQuery], [queryClient, clearConvoState, setSearchQuery],
); );
// TODO: make the debounce time configurable via yaml
const debouncedSendRequest = useMemo(() => debounce(sendRequest, 350), [sendRequest]); const debouncedSendRequest = useMemo(() => debounce(sendRequest, 350), [sendRequest]);
const onChange = (e: React.FormEvent<HTMLInputElement>) => { const onChange = (e: React.FormEvent<HTMLInputElement>) => {
@ -54,6 +57,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
setShowClearIcon(value.length > 0); setShowClearIcon(value.length > 0);
setText(value); setText(value);
debouncedSendRequest(value); debouncedSendRequest(value);
setIsSearching(true);
}; };
return ( return (
@ -78,6 +82,8 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
aria-label={localize('com_nav_search_placeholder')} aria-label={localize('com_nav_search_placeholder')}
placeholder={localize('com_nav_search_placeholder')} placeholder={localize('com_nav_search_placeholder')}
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
onFocus={() => setIsSearching(true)}
onBlur={() => setIsSearching(true)}
autoComplete="off" autoComplete="off"
dir="auto" dir="auto"
/> />

View file

@ -1,4 +1,4 @@
import * as React from 'react'; import { useState, useRef } from 'react';
import * as Tabs from '@radix-ui/react-tabs'; import * as Tabs from '@radix-ui/react-tabs';
import { MessageSquare, Command } from 'lucide-react'; import { MessageSquare, Command } from 'lucide-react';
import { SettingsTabValues } from 'librechat-data-provider'; import { SettingsTabValues } from 'librechat-data-provider';
@ -12,7 +12,8 @@ import { cn } from '~/utils';
export default function Settings({ open, onOpenChange }: TDialogProps) { export default function Settings({ open, onOpenChange }: TDialogProps) {
const isSmallScreen = useMediaQuery('(max-width: 767px)'); const isSmallScreen = useMediaQuery('(max-width: 767px)');
const localize = useLocalize(); const localize = useLocalize();
const [activeTab, setActiveTab] = React.useState(SettingsTabValues.GENERAL); const [activeTab, setActiveTab] = useState(SettingsTabValues.GENERAL);
const tabRefs = useRef({});
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
const tabs = [ const tabs = [
@ -28,12 +29,10 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
switch (event.key) { switch (event.key) {
case 'ArrowDown': case 'ArrowDown':
case 'ArrowRight':
event.preventDefault(); event.preventDefault();
setActiveTab(tabs[(currentIndex + 1) % tabs.length]); setActiveTab(tabs[(currentIndex + 1) % tabs.length]);
break; break;
case 'ArrowUp': case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault(); event.preventDefault();
setActiveTab(tabs[(currentIndex - 1 + tabs.length) % tabs.length]); setActiveTab(tabs[(currentIndex - 1 + tabs.length) % tabs.length]);
break; break;
@ -48,6 +47,10 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
} }
}; };
const handleTabChange = (value: string) => {
setActiveTab(value as SettingsTabValues);
};
return ( return (
<Transition appear show={open}> <Transition appear show={open}>
<Dialog as="div" className="relative z-50" onClose={onOpenChange}> <Dialog as="div" className="relative z-50" onClose={onOpenChange}>
@ -55,7 +58,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
enter="ease-out duration-200" enter="ease-out duration-200"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-100" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
@ -70,15 +73,10 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<div <div className={cn('fixed inset-0 flex w-screen items-center justify-center p-4')}>
className={cn(
'fixed inset-0 flex w-screen items-center justify-center p-4',
isSmallScreen ? '' : '',
)}
>
<DialogPanel <DialogPanel
className={cn( className={cn(
'overflow-hidden rounded-xl rounded-b-lg bg-surface-tertiary-alt pb-6 shadow-2xl backdrop-blur-2xl animate-in sm:rounded-lg md:min-h-[373px] md:w-[680px]', 'min-h-[600px] overflow-hidden rounded-xl rounded-b-lg bg-background pb-6 shadow-2xl backdrop-blur-2xl animate-in sm:rounded-lg md:min-h-[373px] md:w-[680px]',
)} )}
> >
<DialogTitle <DialogTitle
@ -111,18 +109,18 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</button> </button>
</DialogTitle> </DialogTitle>
<div className="max-h-[373px] overflow-auto px-6 md:min-h-[373px] md:w-[680px]"> <div className="max-h-[550px] overflow-auto px-6 md:max-h-[400px] md:min-h-[400px] md:w-[680px]">
<Tabs.Root <Tabs.Root
value={activeTab} value={activeTab}
onValueChange={(value: string) => setActiveTab(value as SettingsTabValues)} onValueChange={handleTabChange}
className="flex flex-col gap-10 md:flex-row" className="flex flex-col gap-10 md:flex-row"
orientation="horizontal" orientation="vertical"
> >
<Tabs.List <Tabs.List
aria-label="Settings" aria-label="Settings"
className={cn( className={cn(
'min-w-auto max-w-auto -ml-[8px] flex flex-shrink-0 flex-col flex-nowrap overflow-auto sm:max-w-none', 'min-w-auto max-w-auto relative -ml-[8px] flex flex-shrink-0 flex-col flex-nowrap overflow-auto sm:max-w-none',
isSmallScreen ? 'flex-row rounded-lg bg-surface-secondary' : '', isSmallScreen ? 'flex-row rounded-xl bg-surface-secondary' : '',
)} )}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
@ -166,19 +164,20 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<Tabs.Trigger <Tabs.Trigger
key={value} key={value}
className={cn( className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary transition-all duration-200 ease-in-out radix-state-active:bg-surface-tertiary radix-state-active:text-text-primary dark:radix-state-active:bg-surface-active', 'group relative z-10 m-1 flex items-center justify-start gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out',
isSmallScreen isSmallScreen
? 'flex-1 items-center justify-center text-nowrap p-1 px-3 text-sm text-text-secondary' ? 'flex-1 justify-center text-nowrap rounded-xl p-1 px-3 text-sm text-text-secondary radix-state-active:bg-surface-hover radix-state-active:text-text-primary'
: 'bg-surface-tertiary-alt', : 'rounded-md bg-transparent text-text-primary radix-state-active:bg-surface-tertiary',
)} )}
value={value} value={value}
ref={(el) => (tabRefs.current[value] = el)}
> >
{icon} {icon}
{localize(label)} {localize(label)}
</Tabs.Trigger> </Tabs.Trigger>
))} ))}
</Tabs.List> </Tabs.List>
<div className="max-h-[373px] overflow-auto sm:w-full sm:max-w-none md:pr-0.5 md:pt-0.5"> <div className="overflow-auto sm:w-full sm:max-w-none md:pr-0.5 md:pt-0.5">
<Tabs.Content value={SettingsTabValues.GENERAL}> <Tabs.Content value={SettingsTabValues.GENERAL}>
<General /> <General />
</Tabs.Content> </Tabs.Content>

View file

@ -4,7 +4,7 @@ import CodeArtifacts from './CodeArtifacts';
function Beta() { function Beta() {
return ( return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary"> <div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<CodeArtifacts /> <CodeArtifacts />
</div> </div>
</div> </div>

View file

@ -11,26 +11,26 @@ import SaveDraft from './SaveDraft';
function Chat() { function Chat() {
return ( return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary"> <div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<FontSizeSelector /> <FontSizeSelector />
</div> </div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<ChatDirection /> <ChatDirection />
</div> </div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<SendMessageKeyEnter /> <SendMessageKeyEnter />
</div> </div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<ShowCodeSwitch /> <ShowCodeSwitch />
</div> </div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<SaveDraft /> <SaveDraft />
</div> </div>
<ForkSettings /> <ForkSettings />
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<ModularChat /> <ModularChat />
</div> </div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<LaTeXParsing /> <LaTeXParsing />
</div> </div>
</div> </div>

View file

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { Button } from '~/components';
import store from '~/store'; import store from '~/store';
const ChatDirection = () => { const ChatDirection = () => {
@ -16,12 +17,11 @@ const ChatDirection = () => {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span id="chat-direction-label">{localize('com_nav_chat_direction')}</span> <span id="chat-direction-label">{localize('com_nav_chat_direction')}</span>
</div> </div>
<button <Button
variant="outline"
aria-label="Toggle chat direction"
onClick={toggleChatDirection} onClick={toggleChatDirection}
data-testid="chatDirection" data-testid="chatDirection"
className="btn btn-neutral relative ring-ring-primary"
aria-labelledby="chat-direction-label chat-direction-status"
aria-pressed={direction === 'RTL'}
> >
<span aria-hidden="true">{direction.toLowerCase()}</span> <span aria-hidden="true">{direction.toLowerCase()}</span>
<span id="chat-direction-status" className="sr-only"> <span id="chat-direction-status" className="sr-only">
@ -29,7 +29,7 @@ const ChatDirection = () => {
? localize('chat_direction_left_to_right') ? localize('chat_direction_left_to_right')
: localize('chat_direction_right_to_left')} : localize('chat_direction_right_to_left')}
</span> </span>
</button> </Button>
</div> </div>
); );
}; };

View file

@ -19,7 +19,7 @@ export const ForkSettings = () => {
return ( return (
<> <>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div>{localize('com_ui_fork_change_default')}</div> <div>{localize('com_ui_fork_change_default')}</div>
@ -30,12 +30,11 @@ export const ForkSettings = () => {
onChange={setForkSetting} onChange={setForkSetting}
options={forkOptions} options={forkOptions}
sizeClasses="w-[200px]" sizeClasses="w-[200px]"
anchor="bottom start"
testId="fork-setting-dropdown" testId="fork-setting-dropdown"
/> />
</div> </div>
</div> </div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> {localize('com_ui_fork_default')} </div> <div> {localize('com_ui_fork_default')} </div>
<Switch <Switch
@ -47,7 +46,7 @@ export const ForkSettings = () => {
/> />
</div> </div>
</div> </div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div>{localize('com_ui_fork_split_target_setting')}</div> <div>{localize('com_ui_fork_split_target_setting')}</div>

View file

@ -28,16 +28,16 @@ function Commands() {
<HoverCardSettings side="bottom" text="com_nav_chat_commands_info" /> <HoverCardSettings side="bottom" text="com_nav_chat_commands_info" />
</div> </div>
<div className="flex flex-col gap-3 text-sm text-text-primary"> <div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<AtCommandSwitch /> <AtCommandSwitch />
</div> </div>
{hasAccessToMultiConvo === true && ( {hasAccessToMultiConvo === true && (
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<PlusCommandSwitch /> <PlusCommandSwitch />
</div> </div>
)} )}
{hasAccessToPrompts === true && ( {hasAccessToPrompts === true && (
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<SlashCommandSwitch /> <SlashCommandSwitch />
</div> </div>
)} )}

View file

@ -1,36 +1,45 @@
import { useMemo, useState } from 'react'; import { useState, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Link as LinkIcon } from 'lucide-react'; import { Link as LinkIcon, TrashIcon } from 'lucide-react';
import type { SharedLinksResponse, TSharedLink } from 'librechat-data-provider'; import type { SharedLinksResponse, TSharedLink } from 'librechat-data-provider';
import { useDeleteSharedLinkMutation, useSharedLinksInfiniteQuery } from '~/data-provider'; import { useDeleteSharedLinkMutation, useSharedLinksInfiniteQuery } from '~/data-provider';
import { useAuthContext, useLocalize, useNavScrolling } from '~/hooks'; import { useAuthContext, useLocalize, useNavScrolling } from '~/hooks';
import { Spinner, TooltipAnchor, TrashIcon } from '~/components'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { NotificationSeverity } from '~/common'; import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers'; import { useToastContext } from '~/Providers';
import { cn } from '~/utils'; import { cn } from '~/utils';
import {
Button,
Label,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TooltipAnchor,
Skeleton,
Spinner,
OGDialog,
OGDialogTrigger,
} from '~/components';
function SharedLinkDeleteButton({ function ShareLinkRow({ sharedLink }: { sharedLink: TSharedLink }) {
shareId, const [isDeleting, setIsDeleting] = useState(false);
setIsDeleting,
}: {
shareId: string;
setIsDeleting: (isDeleting: boolean) => void;
}) {
const localize = useLocalize(); const localize = useLocalize();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const mutation = useDeleteSharedLinkMutation({ const mutation = useDeleteSharedLinkMutation({
onError: () => { onError: () => {
showToast({ showToast({
message: localize('com_ui_share_delete_error'), message: localize('com_ui_share_delete_error'),
severity: NotificationSeverity.ERROR, severity: NotificationSeverity.ERROR,
showIcon: true,
}); });
setIsDeleting(false); setIsDeleting(false);
}, },
}); });
const handleDelete = async (e: React.MouseEvent<HTMLDivElement>) => { const confirmDelete = async (shareId: TSharedLink['shareId']) => {
e.preventDefault();
if (mutation.isLoading) { if (mutation.isLoading) {
return; return;
} }
@ -38,67 +47,78 @@ function SharedLinkDeleteButton({
await mutation.mutateAsync({ shareId }); await mutation.mutateAsync({ shareId });
setIsDeleting(false); setIsDeleting(false);
}; };
return (
<TooltipAnchor
description={localize('com_ui_delete')}
id="delete-shared-link"
aria-label="Delete shared link"
onClick={handleDelete}
>
<TrashIcon className="size-4" />
</TooltipAnchor>
);
}
function ShareLinkRow({ sharedLink }: { sharedLink: TSharedLink }) {
const [isDeleting, setIsDeleting] = useState(false);
return ( return (
<tr <TableRow className={(cn(isDeleting && 'opacity-50'), 'hover:bg-transparent')}>
key={sharedLink.conversationId} <TableCell>
className="border-b border-gray-200 text-sm font-normal dark:border-white/10" <Link
> to={`/share/${sharedLink.shareId}`}
<td target="_blank"
className={cn( rel="noreferrer"
'flex items-center py-3 text-blue-800/70 dark:text-blue-500', className="flex items-center text-blue-500 hover:underline"
isDeleting && 'opacity-50', >
)} <LinkIcon className="mr-2 h-4 w-4" />
>
<Link to={`/share/${sharedLink.shareId}`} target="_blank" rel="noreferrer" className="flex">
<LinkIcon className="mr-1 h-5 w-5" />
{sharedLink.title} {sharedLink.title}
</Link> </Link>
</td> </TableCell>
<td className="p-3"> <TableCell>
<div className="flex justify-between"> {new Date(sharedLink.createdAt).toLocaleDateString('en-US', {
<div className={cn('flex justify-start dark:text-gray-200', isDeleting && 'opacity-50')}> month: 'long',
{new Date(sharedLink.createdAt).toLocaleDateString('en-US', { day: 'numeric',
month: 'long', year: 'numeric',
day: 'numeric', })}
year: 'numeric', </TableCell>
})} <TableCell className="text-right">
</div> {sharedLink.conversationId && (
<div <OGDialog>
className={cn( <OGDialogTrigger asChild>
'flex items-center justify-end gap-3 text-gray-400', <TooltipAnchor
isDeleting && 'opacity-50', description={localize('com_ui_delete')}
)} render={
> <Button
{sharedLink.conversationId && ( aria-label="Delete shared link"
<div className={cn('cursor-pointer', !isDeleting && 'hover:text-gray-300')}> variant="ghost"
<SharedLinkDeleteButton size="icon"
shareId={sharedLink.shareId} className="size-8"
setIsDeleting={setIsDeleting} >
/> <TrashIcon className="size-4" />
</div> </Button>
)} }
</div> ></TooltipAnchor>
</div> </OGDialogTrigger>
</td> <OGDialogTemplate
</tr> showCloseButton={false}
title={localize('com_ui_delete_conversation')}
className="max-w-[450px]"
main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label
htmlFor="dialog-confirm-delete"
className="text-left text-sm font-medium"
>
{localize('com_ui_delete_confirm')} <strong>{sharedLink.title}</strong>
</Label>
</div>
</div>
</>
}
selection={{
selectHandler: () => confirmDelete(sharedLink.shareId),
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
)}
</TableCell>
</TableRow>
); );
} }
export default function ShareLinkTable({ className }: { className?: string }) {
export default function ShareLinkTable({ className }) {
const localize = useLocalize(); const localize = useLocalize();
const { isAuthenticated } = useAuthContext(); const { isAuthenticated } = useAuthContext();
const [showLoading, setShowLoading] = useState(false); const [showLoading, setShowLoading] = useState(false);
@ -114,15 +134,28 @@ export default function ShareLinkTable({ className }: { className?: string }) {
}); });
const sharedLinks = useMemo(() => data?.pages.flatMap((page) => page.sharedLinks) || [], [data]); const sharedLinks = useMemo(() => data?.pages.flatMap((page) => page.sharedLinks) || [], [data]);
const classProp: { className?: string } = {
className: 'p-1 hover:text-black dark:hover:text-white', const getRandomWidth = () => Math.floor(Math.random() * (400 - 170 + 1)) + 170;
};
if (className) { const skeletons = Array.from({ length: 11 }, (_, index) => {
classProp.className = className; const randomWidth = getRandomWidth();
} return (
<div key={index} className="flex h-10 w-full items-center">
<div className="flex w-[410px] items-center">
<Skeleton className="h-4" style={{ width: `${randomWidth}px` }} />
</div>
<div className="flex flex-grow justify-center">
<Skeleton className="h-4 w-28" />
</div>
<div className="mr-2 flex justify-end">
<Skeleton className="h-4 w-12" />
</div>
</div>
);
});
if (isLoading) { if (isLoading) {
return <Spinner className="m-1 mx-auto mb-4 h-4 w-4 text-black dark:text-white" />; return <div className="text-gray-300">{skeletons}</div>;
} }
if (isError) { if (isError) {
@ -132,35 +165,34 @@ export default function ShareLinkTable({ className }: { className?: string }) {
</div> </div>
); );
} }
if (!sharedLinks || sharedLinks.length === 0) {
if (sharedLinks.length === 0) {
return <div className="text-gray-300">{localize('com_nav_shared_links_empty')}</div>; return <div className="text-gray-300">{localize('com_nav_shared_links_empty')}</div>;
} }
return ( return (
<div <div
className={cn( className={cn(
'grid w-full gap-2', '-mr-2 grid max-h-[350px] w-full flex-1 flex-col gap-2 overflow-y-auto pr-2 transition-opacity duration-500',
'-mr-2 flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500', className,
'max-h-[350px]',
)} )}
ref={containerRef} ref={containerRef}
> >
<table className="table-fixed text-left"> <Table>
<thead className="sticky top-0 bg-white dark:bg-gray-700"> <TableHeader>
<tr className="border-b border-gray-200 text-sm font-semibold text-gray-500 dark:border-white/10 dark:text-gray-200"> <TableRow>
<th className="p-3">{localize('com_nav_shared_links_name')}</th> <TableHead>{localize('com_nav_shared_links_name')}</TableHead>
<th className="p-3">{localize('com_nav_shared_links_date_shared')}</th> <TableHead>{localize('com_nav_shared_links_date_shared')}</TableHead>
</tr> <TableHead className="text-right">{localize('com_assistants_actions')}</TableHead>
</thead> </TableRow>
<tbody> </TableHeader>
<TableBody>
{sharedLinks.map((sharedLink) => ( {sharedLinks.map((sharedLink) => (
<ShareLinkRow key={sharedLink.shareId} sharedLink={sharedLink} /> <ShareLinkRow key={sharedLink.shareId} sharedLink={sharedLink} />
))} ))}
</tbody> </TableBody>
</table> </Table>
{(isFetchingNextPage || showLoading) && ( {(isFetchingNextPage || showLoading) && <Spinner className="mx-auto my-4" />}
<Spinner className={cn('m-1 mx-auto mb-4 h-4 w-4 text-black dark:text-white')} />
)}
</div> </div>
); );
} }

View file

@ -1,6 +1,6 @@
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { OGDialog, OGDialogTrigger } from '~/components/ui'; import { OGDialog, OGDialogTrigger, Button } from '~/components';
import ArchivedChatsTable from './ArchivedChatsTable'; import ArchivedChatsTable from './ArchivedChatsTable';
@ -12,9 +12,9 @@ export default function ArchivedChats() {
<div>{localize('com_nav_archived_chats')}</div> <div>{localize('com_nav_archived_chats')}</div>
<OGDialog> <OGDialog>
<OGDialogTrigger asChild> <OGDialogTrigger asChild>
<button className="btn btn-neutral relative "> <Button variant="outline" aria-label="Archived chats">
{localize('com_nav_archived_chats_manage')} {localize('com_nav_archived_chats_manage')}
</button> </Button>
</OGDialogTrigger> </OGDialogTrigger>
<OGDialogTemplate <OGDialogTemplate
title={localize('com_nav_archived_chats')} title={localize('com_nav_archived_chats')}

View file

@ -1,109 +1,252 @@
import { useMemo, useState, useCallback } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { MessageCircle, ArchiveRestore } from 'lucide-react';
import { useConversationsInfiniteQuery } from '~/data-provider'; import { useConversationsInfiniteQuery } from '~/data-provider';
import { ConversationListResponse } from 'librechat-data-provider'; import {
import { useAuthContext, useLocalize, useNavScrolling, useArchiveHandler } from '~/hooks'; Search,
import { DeleteButton } from '~/components/Conversations/ConvoOptions'; ChevronRight,
import { TooltipAnchor } from '~/components/ui'; ChevronLeft,
import { Spinner } from '~/components/svg'; TrashIcon,
MessageCircle,
ArchiveRestore,
ChevronsRight,
ChevronsLeft,
} from 'lucide-react';
import type { TConversation } from 'librechat-data-provider';
import { useAuthContext, useLocalize, useArchiveHandler } from '~/hooks';
import { DeleteConversationDialog } from '~/components/Conversations/ConvoOptions';
import {
TooltipAnchor,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Separator,
Skeleton,
Button,
Input,
OGDialog,
OGDialogTrigger,
} from '~/components';
import { cn } from '~/utils'; import { cn } from '~/utils';
export default function ArchivedChatsTable() { export default function ArchivedChatsTable() {
const localize = useLocalize(); const localize = useLocalize();
const { isAuthenticated } = useAuthContext(); const { isAuthenticated } = useAuthContext();
const [showLoading, setShowLoading] = useState(false);
const [conversationId, setConversationId] = useState<string | null>(null); const [conversationId, setConversationId] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const [totalPages, setTotalPages] = useState(1);
const [isOpened, setIsOpened] = useState(false);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useConversationsInfiniteQuery( const { data, isLoading, refetch } = useConversationsInfiniteQuery(
{ pageNumber: '1', isArchived: true }, { pageNumber: currentPage.toString(), limit: 10, isArchived: true },
{ enabled: isAuthenticated }, { enabled: isAuthenticated && isOpened },
); );
const { containerRef, moveToTop } = useNavScrolling<ConversationListResponse>({ useEffect(() => {
setShowLoading, if (data) {
hasNextPage: hasNextPage, setTotalPages(Math.ceil(Number(data.pages)));
fetchNextPage: fetchNextPage, }
isFetchingNextPage: isFetchingNextPage, }, [data]);
const archiveHandler = useArchiveHandler(conversationId ?? '', false, () => {
refetch();
}); });
const conversations = useMemo( const handleChatClick = useCallback((conversationId) => {
() => data?.pages.flatMap((page) => page.conversations) || [], window.open(`/c/${conversationId}`, '_blank');
[data], }, []);
);
const archiveHandler = useArchiveHandler(conversationId ?? '', false, moveToTop); const handlePageChange = useCallback((newPage) => {
setCurrentPage(newPage);
}, []);
if (!data || conversations.length === 0) { const handleSearch = useCallback((query) => {
setSearchQuery(query);
setCurrentPage(1);
}, []);
const getRandomWidth = () => Math.floor(Math.random() * (400 - 170 + 1)) + 170;
const skeletons = Array.from({ length: 11 }, (_, index) => {
const randomWidth = getRandomWidth();
return (
<div key={index} className="flex h-10 w-full items-center">
<div className="flex w-[410px] items-center">
<Skeleton className="h-4" style={{ width: `${randomWidth}px` }} />
</div>
<div className="flex flex-grow justify-center">
<Skeleton className="h-4 w-28" />
</div>
<div className="mr-2 flex justify-end">
<Skeleton className="h-4 w-12" />
</div>
</div>
);
});
if (isLoading) {
return <div className="text-gray-300">{skeletons}</div>;
}
if (!data || data.pages.length === 0 || data.pages[0].conversations.length === 0) {
return <div className="text-gray-300">{localize('com_nav_archived_chats_empty')}</div>; return <div className="text-gray-300">{localize('com_nav_archived_chats_empty')}</div>;
} }
const conversations = data.pages.flatMap((page) => page.conversations);
return ( return (
<div <div
className={cn( className={cn(
'grid w-full gap-2', 'grid w-full gap-2',
'flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500', 'flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500',
'max-h-[350px]', 'max-h-[629px]',
)} )}
ref={containerRef} onMouseEnter={() => setIsOpened(true)}
> >
<table className="table-fixed text-left"> <div className="flex items-center">
<thead className="sticky top-0 bg-white dark:bg-gray-700"> <Search className="size-4 text-text-secondary" />
<tr className="border-b border-gray-200 text-sm font-semibold text-gray-500 dark:border-white/10 dark:text-gray-200"> <Input
<th className="p-3">{localize('com_nav_archive_name')}</th> type="text"
<th className="p-3">{localize('com_nav_archive_created_at')}</th> placeholder={localize('com_nav_search_placeholder')}
</tr> value={searchQuery}
</thead> onChange={(e) => handleSearch(e.target.value)}
<tbody> className="w-full border-none"
{conversations.map((conversation) => { />
if (!conversation.conversationId) { </div>
return null; <Separator />
} {conversations.length === 0 ? (
return ( <div className="mt-4 text-text-secondary">{localize('com_nav_no_search_results')}</div>
<tr ) : (
key={conversation.conversationId} <>
className="border-b border-gray-200 text-sm font-normal dark:border-white/10" <Table>
> <TableHeader>
<td className="flex items-center py-3 text-blue-800/70 dark:text-blue-500"> <TableRow>
<MessageCircle className="mr-1 h-5 w-5" /> <TableHead className="w-[50%] p-4">{localize('com_nav_archive_name')}</TableHead>
{conversation.title} <TableHead className="w-[35%] p-1">
</td> {localize('com_nav_archive_created_at')}
<td className="p-1"> </TableHead>
<div className="flex justify-between"> <TableHead className="w-[15%] p-1 text-right">
<div className="flex justify-start dark:text-gray-200"> {localize('com_assistants_actions')}
{new Date(conversation.createdAt).toLocaleDateString('en-US', { </TableHead>
month: 'long', </TableRow>
day: 'numeric', </TableHeader>
year: 'numeric', <TableBody>
})} {conversations.map((conversation: TConversation) => (
</div> <TableRow key={conversation.conversationId} className="hover:bg-transparent">
<div className="ml-auto mr-4 flex items-center justify-end gap-1 text-gray-400"> <TableCell className="flex items-center py-3 text-text-primary">
<TooltipAnchor <button
description={localize('com_ui_unarchive')} className="flex"
onClick={() => { aria-label="Open conversation in a new tab"
setConversationId(conversation.conversationId); onClick={() => handleChatClick(conversation.conversationId)}
archiveHandler(); >
}} <MessageCircle className="mr-1 h-5 w-5" />
className="cursor-pointer hover:text-black dark:hover:text-white" <u>{conversation.title}</u>
> </button>
<ArchiveRestore className="size-4 hover:text-gray-300" /> </TableCell>
</TooltipAnchor> <TableCell className="p-1">
<div className="size-5 hover:text-gray-300"> <div className="flex justify-between">
<DeleteButton <div className="flex justify-start text-text-secondary">
conversationId={conversation.conversationId} {new Date(conversation.createdAt).toLocaleDateString('en-US', {
retainView={moveToTop} month: 'long',
title={conversation.title ?? ''} day: 'numeric',
/> year: 'numeric',
})}
</div> </div>
</div> </div>
</div> </TableCell>
</td> <TableCell className="flex items-center justify-end gap-2 p-1">
</tr> <TooltipAnchor
); description={localize('com_ui_unarchive')}
})} render={
</tbody> <Button
</table> aria-label="Unarchive conversation"
{(isFetchingNextPage || showLoading) && ( variant="ghost"
<Spinner className={cn('m-1 mx-auto mb-4 h-4 w-4 text-black dark:text-white')} /> size="icon"
className="size-8"
onClick={() => {
setConversationId(conversation.conversationId);
archiveHandler();
}}
>
<ArchiveRestore className="size-4" />
</Button>
}
></TooltipAnchor>
<OGDialog>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete')}
render={
<Button
aria-label="Delete archived conversation"
variant="ghost"
size="icon"
className="size-8"
>
<TrashIcon className="size-4" />
</Button>
}
></TooltipAnchor>
</OGDialogTrigger>
{DeleteConversationDialog({
conversationId: conversation.conversationId ?? '',
retainView: refetch,
title: conversation.title ?? '',
})}
</OGDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-end gap-6 px-2 py-4">
<div className="text-sm font-bold text-text-primary">
Page {currentPage} of {totalPages}
</div>
<div className="flex space-x-2">
<Button
variant="outline"
size="icon"
aria-label="Go to the previous 10 pages"
onClick={() => handlePageChange(Math.max(currentPage - 10, 1))}
disabled={currentPage === 1}
>
<ChevronsLeft className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Go to the previous page"
onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Go to the next page"
onClick={() => handlePageChange(Math.min(currentPage + 1, totalPages))}
disabled={currentPage === totalPages}
>
<ChevronRight className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Go to the next 10 pages"
onClick={() => handlePageChange(Math.min(currentPage + 10, totalPages))}
disabled={currentPage === totalPages}
>
<ChevronsRight className="size-4" />
</Button>
</div>
</div>
</>
)} )}
</div> </div>
); );

View file

@ -24,6 +24,7 @@ export default function AutoScrollSwitch({
<Switch <Switch
id="autoScroll" id="autoScroll"
checked={autoScroll} checked={autoScroll}
aria-label="Auto-Scroll to latest message on chat open"
onCheckedChange={handleCheckedChange} onCheckedChange={handleCheckedChange}
className="ml-4 mt-2 ring-ring-primary" className="ml-4 mt-2 ring-ring-primary"
data-testid="autoScroll" data-testid="autoScroll"

View file

@ -34,8 +34,7 @@ export const ThemeSelector = ({
value={theme} value={theme}
onChange={onChange} onChange={onChange}
options={themeOptions} options={themeOptions}
sizeClasses="w-[220px]" sizeClasses="w-[180px]"
anchor="bottom start"
testId="theme-selector" testId="theme-selector"
/> />
</div> </div>
@ -112,7 +111,6 @@ export const LangSelector = ({
value={langcode} value={langcode}
onChange={onChange} onChange={onChange}
sizeClasses="[--anchor-max-height:256px]" sizeClasses="[--anchor-max-height:256px]"
anchor="bottom start"
options={languageOptions} options={languageOptions}
/> />
</div> </div>
@ -149,26 +147,24 @@ function General() {
return ( return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary"> <div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<ThemeSelector theme={theme} onChange={changeTheme} /> <ThemeSelector theme={theme} onChange={changeTheme} />
</div> </div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<LangSelector langcode={langcode} onChange={changeLang} /> <LangSelector langcode={langcode} onChange={changeLang} />
</div> </div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<UserMsgMarkdownSwitch /> <UserMsgMarkdownSwitch />
</div> </div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<AutoScrollSwitch /> <AutoScrollSwitch />
</div> </div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<HideSidePanelSwitch /> <HideSidePanelSwitch />
</div> </div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0"> <div className="border-b border-border-light pb-3 last-of-type:border-b-0">
<ArchivedChats /> <ArchivedChats />
</div> </div>
{/* <div className="border-b pb-3 last-of-type:border-b-0 border-border-medium">
</div> */}
</div> </div>
); );
} }

View file

@ -25,6 +25,7 @@ export default function HideSidePanelSwitch({
<Switch <Switch
id="hideSidePanel" id="hideSidePanel"
checked={hideSidePanel} checked={hideSidePanel}
aria-label="Hide right-most side panel"
onCheckedChange={handleCheckedChange} onCheckedChange={handleCheckedChange}
className="ml-4 mt-2" className="ml-4 mt-2"
data-testid="hideSidePanel" data-testid="hideSidePanel"

View file

@ -1,6 +1,6 @@
import 'test/matchMedia.mock'; import 'test/matchMedia.mock';
import React from 'react'; import React from 'react';
import { render, fireEvent } from '@testing-library/react'; import { render, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect'; import '@testing-library/jest-dom/extend-expect';
import { LangSelector } from './General'; import { LangSelector } from './General';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
@ -18,14 +18,15 @@ describe('LangSelector', () => {
unobserve = jest.fn(); unobserve = jest.fn();
disconnect = jest.fn(); disconnect = jest.fn();
}; };
const { getByText } = render( const { getByText, getByRole } = render(
<RecoilRoot> <RecoilRoot>
<LangSelector langcode="en-US" onChange={mockOnChange} /> <LangSelector langcode="en-US" onChange={mockOnChange} />
</RecoilRoot>, </RecoilRoot>,
); );
expect(getByText('Language')).toBeInTheDocument(); expect(getByText('Language')).toBeInTheDocument();
expect(getByText('English')).toBeInTheDocument(); const dropdownButton = getByRole('combobox');
expect(dropdownButton).toHaveTextContent('English');
}); });
it('calls onChange when the select value changes', async () => { it('calls onChange when the select value changes', async () => {
@ -34,25 +35,23 @@ describe('LangSelector', () => {
unobserve = jest.fn(); unobserve = jest.fn();
disconnect = jest.fn(); disconnect = jest.fn();
}; };
const { getByText, getByTestId } = render( const { getByRole, getByTestId } = render(
<RecoilRoot> <RecoilRoot>
<LangSelector langcode="en-US" onChange={mockOnChange} /> <LangSelector langcode="en-US" onChange={mockOnChange} />
</RecoilRoot>, </RecoilRoot>,
); );
expect(getByText('English')).toBeInTheDocument(); expect(getByRole('combobox')).toHaveTextContent('English');
// Find the dropdown button by data-testid
const dropdownButton = getByTestId('dropdown-menu'); const dropdownButton = getByTestId('dropdown-menu');
// Open the dropdown
fireEvent.click(dropdownButton); fireEvent.click(dropdownButton);
// Find the option by text and click it const italianOption = getByRole('option', { name: 'Italiano' });
const darkOption = getByText('Italiano'); fireEvent.click(italianOption);
fireEvent.click(darkOption);
// Ensure that the onChange is called with the expected value after a short delay await waitFor(() => {
await new Promise((resolve) => setTimeout(resolve, 0)); expect(mockOnChange).toHaveBeenCalledWith('it-IT');
});
}); });
}); });

View file

@ -20,14 +20,15 @@ describe('ThemeSelector', () => {
unobserve = jest.fn(); unobserve = jest.fn();
disconnect = jest.fn(); disconnect = jest.fn();
}; };
const { getByText } = render( const { getByText, getByRole } = render(
<RecoilRoot> <RecoilRoot>
<ThemeSelector theme="system" onChange={mockOnChange} /> <ThemeSelector theme="system" onChange={mockOnChange} />
</RecoilRoot>, </RecoilRoot>,
); );
expect(getByText('Theme')).toBeInTheDocument(); expect(getByText('Theme')).toBeInTheDocument();
expect(getByText('System')).toBeInTheDocument(); const dropdownButton = getByRole('combobox');
expect(dropdownButton).toHaveTextContent('System');
}); });
it('calls onChange when the select value changes', async () => { it('calls onChange when the select value changes', async () => {
@ -44,17 +45,13 @@ describe('ThemeSelector', () => {
expect(getByText('Theme')).toBeInTheDocument(); expect(getByText('Theme')).toBeInTheDocument();
// Find the dropdown button by data-testid
const dropdownButton = getByTestId('theme-selector'); const dropdownButton = getByTestId('theme-selector');
// Open the dropdown
fireEvent.click(dropdownButton); fireEvent.click(dropdownButton);
// Find the option by text and click it
const darkOption = getByText('Dark'); const darkOption = getByText('Dark');
fireEvent.click(darkOption); fireEvent.click(darkOption);
// Ensure that the onChange is called with the expected value
await waitFor(() => { await waitFor(() => {
expect(mockOnChange).toHaveBeenCalledWith('dark'); expect(mockOnChange).toHaveBeenCalledWith('dark');
}); });

View file

@ -31,7 +31,6 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
onChange={handleSelect} onChange={handleSelect}
options={endpointOptions} options={endpointOptions}
sizeClasses="w-[180px]" sizeClasses="w-[180px]"
anchor="bottom start"
testId="EngineSTTDropdown" testId="EngineSTTDropdown"
/> />
</div> </div>

View file

@ -146,14 +146,12 @@ function Speech() {
value={advancedMode ? 'advanced' : 'simple'} value={advancedMode ? 'advanced' : 'simple'}
> >
<div className="sticky -top-1 z-50 mb-4 bg-white dark:bg-gray-700"> <div className="sticky -top-1 z-50 mb-4 bg-white dark:bg-gray-700">
<Tabs.List className="flex justify-center bg-white dark:bg-gray-700"> <Tabs.List className="flex justify-center bg-background">
<Tabs.Trigger <Tabs.Trigger
onClick={() => setAdvancedMode(false)} onClick={() => setAdvancedMode(false)}
className={cn( className={cn(
'group m-1 flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600', 'group m-1 flex items-center justify-center gap-2 bg-transparent px-4 py-2 text-sm text-text-secondary transition-all duration-200 ease-in-out radix-state-active:bg-secondary radix-state-active:text-foreground radix-state-active:shadow-lg',
isSmallScreen isSmallScreen ? 'flex-row rounded-lg' : 'rounded-xl',
? 'flex-row items-center justify-center text-sm text-gray-700 radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-gray-300 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-100 dark:bg-gray-700',
'w-full', 'w-full',
)} )}
value="simple" value="simple"
@ -165,10 +163,8 @@ function Speech() {
<Tabs.Trigger <Tabs.Trigger
onClick={() => setAdvancedMode(true)} onClick={() => setAdvancedMode(true)}
className={cn( className={cn(
'group m-1 flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm text-black transition-all duration-200 ease-in-out radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600', 'group m-1 flex items-center justify-center gap-2 bg-transparent px-4 py-2 text-sm text-text-secondary transition-all duration-200 ease-in-out radix-state-active:bg-secondary radix-state-active:text-foreground radix-state-active:shadow-lg',
isSmallScreen isSmallScreen ? 'flex-row rounded-lg' : 'rounded-xl',
? 'flex-row items-center justify-center text-sm text-gray-700 radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-gray-300 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-100 dark:bg-gray-700',
'w-full', 'w-full',
)} )}
value="advanced" value="advanced"
@ -181,79 +177,53 @@ function Speech() {
</div> </div>
<Tabs.Content value={'simple'}> <Tabs.Content value={'simple'}>
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50"> <div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="border-b last-of-type:border-b-0 dark:border-gray-700"> <SpeechToTextSwitch />
<SpeechToTextSwitch /> <EngineSTTDropdown external={sttExternal} />
</div> <LanguageSTTDropdown />
<div className="border-b last-of-type:border-b-0 dark:border-gray-700"> <div className="h-px bg-border-medium" role="none" />
<EngineSTTDropdown external={sttExternal} /> <TextToSpeechSwitch />
</div> <EngineTTSDropdown external={ttsExternal} />
<div className="border-b last-of-type:border-b-0 dark:border-gray-700"> <VoiceDropdown />
<LanguageSTTDropdown />
</div>
<div className="h-px bg-black/20 bg-white/20" role="none" />
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<TextToSpeechSwitch />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<EngineTTSDropdown external={ttsExternal} />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<VoiceDropdown />
</div>
</div> </div>
</Tabs.Content> </Tabs.Content>
<Tabs.Content value={'advanced'}> <Tabs.Content value={'advanced'}>
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50"> <div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700"> <ConversationModeSwitch />
<ConversationModeSwitch /> <div className="mt-2 h-px bg-border-medium" role="none" />
</div> <SpeechToTextSwitch />
<div className="h-px bg-black/20 bg-white/20" role="none" />
<div className="border-b last-of-type:border-b-0 dark:border-gray-700"> <EngineSTTDropdown external={sttExternal} />
<SpeechToTextSwitch />
</div> <LanguageSTTDropdown />
<div className="border-b last-of-type:border-b-0 dark:border-gray-700"> <div className="pb-2">
<EngineSTTDropdown external={sttExternal} />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<LanguageSTTDropdown />
</div>
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700">
<AutoTranscribeAudioSwitch /> <AutoTranscribeAudioSwitch />
</div> </div>
{autoTranscribeAudio && ( {autoTranscribeAudio && (
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700"> <div className="pb-2">
<DecibelSelector /> <DecibelSelector />
</div> </div>
)} )}
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700"> <div className="pb-2">
<AutoSendTextSelector /> <AutoSendTextSelector />
</div> </div>
<div className="h-px bg-black/20 bg-white/20" role="none" /> <div className="h-px bg-border-medium" role="none" />
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700"> <div className="pb-3">
<TextToSpeechSwitch /> <TextToSpeechSwitch />
</div> </div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700"> <AutomaticPlaybackSwitch />
<AutomaticPlaybackSwitch /> <EngineTTSDropdown external={ttsExternal} />
</div> <VoiceDropdown />
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<EngineTTSDropdown external={ttsExternal} />
</div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700">
<VoiceDropdown />
</div>
{engineTTS === 'browser' && ( {engineTTS === 'browser' && (
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700"> <div className="pb-2">
<CloudBrowserVoicesSwitch /> <CloudBrowserVoicesSwitch />
</div> </div>
)} )}
<div className="border-b pb-2 last-of-type:border-b-0 dark:border-gray-700"> <div className="pb-2">
<PlaybackRate /> <PlaybackRate />
</div> </div>
<div className="border-b last-of-type:border-b-0 dark:border-gray-700"> <CacheTTSSwitch />
<CacheTTSSwitch />
</div>
</div> </div>
</Tabs.Content> </Tabs.Content>
</Tabs.Root> </Tabs.Root>

View file

@ -1,7 +1,6 @@
export * from './ExportConversation'; export * from './ExportConversation';
export * from './SettingsTabs/'; export * from './SettingsTabs/';
export { default as ClearConvos } from './ClearConvos'; export { default as ClearConvos } from './ClearConvos';
export { default as Logout } from './Logout';
export { default as MobileNav } from './MobileNav'; export { default as MobileNav } from './MobileNav';
export { default as Nav } from './Nav'; export { default as Nav } from './Nav';
export { default as NavLink } from './NavLink'; export { default as NavLink } from './NavLink';

View file

@ -31,7 +31,7 @@ export function FilterItem({
return ( return (
<DropdownMenuItem <DropdownMenuItem
onClick={onClick} onClick={onClick}
className="relative cursor-pointer gap-2 text-text-secondary hover:bg-surface-tertiary focus:bg-surface-tertiary dark:focus:bg-surface-tertiary" className="relative cursor-pointer gap-2 text-text-secondary hover:bg-surface-tertiary focus:bg-surface-tertiary"
> >
{icon} {icon}
<span>{label}</span> <span>{label}</span>

View file

@ -31,7 +31,7 @@ export default function List({
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button <Button
variant="outline" variant="outline"
className="mx-2 w-full px-3" className="mx-2 w-full bg-transparent px-3"
onClick={() => navigate('/d/prompts/new')} onClick={() => navigate('/d/prompts/new')}
> >
+ {localize('com_ui_create_prompt')} + {localize('com_ui_create_prompt')}

View file

@ -1,7 +1,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { useLocalize, useCustomLink } from '~/hooks'; import { useLocalize, useCustomLink } from '~/hooks';
import { buttonVariants } from '~/components/ui'; import { Button } from '~/components/ui';
import { cn } from '~/utils'; import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
@ -15,13 +15,10 @@ export default function ManagePrompts({ className }: { className?: string }) {
}, [setPromptsName, setPromptsCategory]); }, [setPromptsName, setPromptsCategory]);
const clickHandler = useCustomLink('/d/prompts', clickCallback); const clickHandler = useCustomLink('/d/prompts', clickCallback);
return ( return (
<a <Button variant="outline" className={cn(className, 'bg-transparent')} onClick={clickHandler}>
className={cn(buttonVariants({ variant: 'outline' }), className)}
href="/d/prompts"
onClick={clickHandler}
>
{localize('com_ui_manage')} {localize('com_ui_manage')}
</a> </Button>
); );
} }

View file

@ -41,7 +41,7 @@ export default function PromptsView() {
<GroupSidePanel isDetailView={isDetailView} {...groupsNav}> <GroupSidePanel isDetailView={isDetailView} {...groupsNav}>
<div className="mx-2 mt-1 flex flex-row items-center justify-between"> <div className="mx-2 mt-1 flex flex-row items-center justify-between">
<FilterPrompts setName={groupsNav.setName} /> <FilterPrompts setName={groupsNav.setName} />
<AutoSendPrompt className="text-xs dark:text-white" /> <AutoSendPrompt className="text-xs text-text-primary" />
</div> </div>
</GroupSidePanel> </GroupSidePanel>
<div <div

View file

@ -3,8 +3,8 @@ import * as AccordionPrimitive from '@radix-ui/react-accordion';
import type { NavLink, NavProps } from '~/common'; import type { NavLink, NavProps } from '~/common';
import { Accordion, AccordionItem, AccordionContent } from '~/components/ui/Accordion'; import { Accordion, AccordionItem, AccordionContent } from '~/components/ui/Accordion';
import { buttonVariants } from '~/components/ui/Button'; import { buttonVariants } from '~/components/ui/Button';
import { TooltipAnchor, Button } from '~/components';
import { cn, removeFocusOutlines } from '~/utils'; import { cn, removeFocusOutlines } from '~/utils';
import { TooltipAnchor } from '~/components/ui';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
export default function Nav({ links, isCollapsed, resize, defaultActive }: NavProps) { export default function Nav({ links, isCollapsed, resize, defaultActive }: NavProps) {
@ -31,29 +31,27 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
const variant = getVariant(link); const variant = getVariant(link);
return isCollapsed ? ( return isCollapsed ? (
<TooltipAnchor <TooltipAnchor
className={cn(
buttonVariants({ variant, size: 'icon' }),
removeFocusOutlines,
'h-9 w-9 cursor-pointer',
variant === 'default'
? 'dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted bg-surface-terniary dark:hover:text-white'
: '',
)}
onClick={(e) => {
if (link.onClick) {
link.onClick(e);
setActive('');
return;
}
setActive(link.id);
resize && resize(25);
}}
description={localize(link.title)} description={localize(link.title)}
side="left" side="left"
> render={
<link.icon className="h-4 w-4" /> <Button
<span className="sr-only">{link.title}</span> variant="ghost"
</TooltipAnchor> size="icon"
onClick={(e) => {
if (link.onClick) {
link.onClick(e);
setActive('');
return;
}
setActive(link.id);
resize && resize(25);
}}
>
<link.icon className="h-4 w-4 text-text-secondary" />
<span className="sr-only">{link.title}</span>
</Button>
}
></TooltipAnchor>
) : ( ) : (
<Accordion <Accordion
key={index} key={index}
@ -65,16 +63,10 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
<AccordionItem value={link.id} className="w-full border-none"> <AccordionItem value={link.id} className="w-full border-none">
<AccordionPrimitive.Header asChild> <AccordionPrimitive.Header asChild>
<AccordionPrimitive.Trigger asChild> <AccordionPrimitive.Trigger asChild>
<button <Button
className={cn( variant="outline"
buttonVariants({ variant, size: 'sm' }), size="sm"
removeFocusOutlines, className="w-full justify-start bg-transparent text-text-secondary data-[state=open]:bg-surface-secondary data-[state=open]:text-text-primary"
variant === 'default'
? 'dark:bg-muted dark:hover:bg-muted dark:text-white dark:hover:text-white'
: '',
'hover:bg-gray-200 data-[state=open]:bg-gray-200 data-[state=open]:text-black dark:hover:bg-gray-700 dark:data-[state=open]:bg-gray-700 dark:data-[state=open]:text-white',
'w-full justify-start rounded-md border dark:border-gray-700',
)}
onClick={(e) => { onClick={(e) => {
if (link.onClick) { if (link.onClick) {
link.onClick(e); link.onClick(e);
@ -95,7 +87,7 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
{link.label} {link.label}
</span> </span>
)} )}
</button> </Button>
</AccordionPrimitive.Trigger> </AccordionPrimitive.Trigger>
</AccordionPrimitive.Header> </AccordionPrimitive.Header>

View file

@ -254,7 +254,7 @@ const SidePanel = ({
localStorage.setItem('react-resizable-panels:collapsed', 'true'); localStorage.setItem('react-resizable-panels:collapsed', 'true');
}} }}
className={cn( className={cn(
'sidenav hide-scrollbar border-l border-border-light bg-surface-primary-alt transition-opacity', 'sidenav hide-scrollbar border-l border-border-light bg-background transition-opacity',
isCollapsed ? 'min-w-[50px]' : 'min-w-[340px] sm:min-w-[352px]', isCollapsed ? 'min-w-[50px]' : 'min-w-[340px] sm:min-w-[352px]',
(isSmallScreen && isCollapsed && (minSize === 0 || collapsedSize === 0)) || fullCollapse (isSmallScreen && isCollapsed && (minSize === 0 || collapsedSize === 0)) || fullCollapse
? 'hidden min-w-0' ? 'hidden min-w-0'
@ -264,7 +264,7 @@ const SidePanel = ({
{interfaceConfig.modelSelect && ( {interfaceConfig.modelSelect && (
<div <div
className={cn( className={cn(
'sticky left-0 right-0 top-0 z-[100] flex h-[52px] flex-wrap items-center justify-center bg-surface-primary-alt', 'sticky left-0 right-0 top-0 z-[100] flex h-[52px] flex-wrap items-center justify-center bg-background',
isCollapsed ? 'h-[52px]' : 'px-2', isCollapsed ? 'h-[52px]' : 'px-2',
)} )}
> >

View file

@ -1,52 +1,28 @@
import * as React from 'react'; import * as React from 'react';
import { VariantProps, cva } from 'class-variance-authority'; import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '~/utils'; import { cn } from '~/utils';
const buttonVariants = cva( const buttonVariants = cva(
'rounded-md inline-flex items-center justify-center text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none', 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{ {
variants: { variants: {
variant: { variant: {
default: default: 'bg-primary text-primary-foreground hover:bg-primary/90',
'bg-gray-600 text-white hover:bg-gray-800 dark:bg-gray-200 dark:text-gray-900 dark:hover:bg-gray-300', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
destructive: 'bg-red-600 text-white hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700',
outline: outline:
'bg-transparent border border-gray-200 text-gray-700 hover:bg-gray-200 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-700', 'text-text-primary border border-input bg-background hover:bg-accent hover:text-accent-foreground',
subtle: secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
'bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600', ghost: 'hover:bg-accent hover:text-accent-foreground',
ghost: link: 'text-primary underline-offset-4 hover:underline',
'bg-transparent text-gray-900 hover:bg-gray-100 dark:text-gray-100 dark:hover:bg-gray-800 data-[state=open]:bg-transparent',
link: 'bg-transparent underline-offset-4 hover:underline text-gray-600 dark:text-gray-400 hover:bg-transparent dark:hover:bg-transparent',
success:
'bg-green-500 text-white hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-700',
warning:
'bg-yellow-500 text-white hover:bg-yellow-600 dark:bg-yellow-600 dark:hover:bg-yellow-700',
info: 'bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700',
}, },
size: { size: {
default: 'h-10 py-2 px-4', default: 'h-10 px-4 py-2',
sm: 'h-8 px-3 rounded', sm: 'h-9 rounded-md px-3',
lg: 'h-12 px-6 rounded-md', lg: 'h-11 rounded-md px-8',
xl: 'h-14 px-8 rounded-lg text-base', icon: 'size-10',
icon: 'h-10 w-10',
},
fullWidth: {
true: 'w-full',
},
loading: {
true: 'opacity-80 pointer-events-none',
}, },
}, },
compoundVariants: [
{
variant: ['default', 'destructive', 'success', 'warning', 'info'],
className: 'focus-visible:ring-white focus-visible:ring-offset-2',
},
{
variant: 'outline',
className: 'focus-visible:ring-gray-400 dark:focus-visible:ring-gray-500',
},
],
defaultVariants: { defaultVariants: {
variant: 'default', variant: 'default',
size: 'default', size: 'default',
@ -57,62 +33,14 @@ const buttonVariants = cva(
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
loading?: boolean; asChild?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps & { customId?: string }>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
( ({ className, variant, size, asChild = false, ...props }, ref) => {
{ const Comp = asChild ? Slot : 'button';
className,
variant,
size,
fullWidth,
loading,
leftIcon,
rightIcon,
children,
customId,
...props
},
ref,
) => {
return ( return (
<button <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
className={cn(buttonVariants({ variant, size, fullWidth, loading, className }))}
ref={ref}
{...props}
id={customId ?? props.id ?? 'shadcn-button'}
disabled={props.disabled || loading}
aria-busy={loading}
>
{loading && (
<svg
className="-ml-1 mr-3 h-5 w-5 animate-spin text-current"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)}
{leftIcon && <span className="mr-2">{leftIcon}</span>}
{children}
{rightIcon && <span className="ml-2">{rightIcon}</span>}
</button>
); );
}, },
); );

View file

@ -1,14 +1,7 @@
import React, { FC, useState } from 'react'; import React, { useState } from 'react';
import { import * as Select from '@ariakit/react/select';
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/'; import { cn } from '~/utils/';
import type { Option } from '~/common';
interface DropdownProps { interface DropdownProps {
value: string; value: string;
@ -16,112 +9,89 @@ interface DropdownProps {
onChange: (value: string) => void; onChange: (value: string) => void;
options: string[] | Option[]; options: string[] | Option[];
className?: string; className?: string;
anchor?: AnchorPropsWithSelection;
sizeClasses?: string; sizeClasses?: string;
testId?: string; testId?: string;
} }
const Dropdown: FC<DropdownProps> = ({ const Dropdown: React.FC<DropdownProps> = ({
value: initialValue, value: initialValue,
label = '', label = '',
onChange, onChange,
options, options,
className = '', className = '',
anchor,
sizeClasses, sizeClasses,
testId = 'dropdown-menu', testId = 'dropdown-menu',
}) => { }) => {
const [selectedValue, setSelectedValue] = useState(initialValue); const [selectedValue, setSelectedValue] = useState(initialValue);
const handleChange = (value: string) => {
setSelectedValue(value);
onChange(value);
};
const selectProps = Select.useSelectStore({
value: selectedValue,
setValue: handleChange,
});
return ( return (
<div className={cn('relative', className)}> <div className={cn('relative', className)}>
<Listbox <Select.Select
value={selectedValue} store={selectProps}
onChange={(newValue) => { className={cn(
setSelectedValue(newValue); 'focus:ring-offset-ring-offset relative inline-flex w-auto items-center justify-between rounded-lg border border-input bg-background py-2 pl-3 pr-8 text-text-primary transition-all duration-200 ease-in-out hover:bg-accent hover:text-accent-foreground focus:ring-ring-primary',
onChange(newValue); className,
}} )}
data-testid={testId}
> >
<div className={cn('relative', className)}> <div className="flex w-full items-center justify-between">
<ListboxButton <span className="block truncate">
data-testid={testId} {label}
className={cn( {options
'btn-neutral focus:ring-offset-ring-offset relative inline-flex w-auto items-center justify-between rounded-md border-border-light bg-header-primary py-2 pl-3 pr-8 text-text-primary transition-all duration-100 ease-in-out hover:bg-header-hover focus:ring-ring-primary', .map((o) => (typeof o === 'string' ? { value: o, label: o } : o))
className, .find((o) => o.value === selectedValue)?.label ?? selectedValue}
)} </span>
aria-label="Select an option" <Select.SelectArrow />
>
<span className="block truncate">
{label}
{options
.map((o) => (typeof o === 'string' ? { value: o, label: o } : o))
.find((o) => o.value === selectedValue)?.label ?? selectedValue}
</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-text-primary transition-transform duration-300 ease-in-out"
>
<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-border-medium bg-header-primary p-1.5 shadow-lg transition-opacity',
sizeClasses,
className,
)}
anchor={anchor}
aria-label="List of options"
>
{options.map((item, index) => (
<ListboxOption
key={index}
value={typeof item === 'string' ? item : item.value}
className="focus-visible:ring-offset ring-offset-ring-offset relative cursor-pointer select-none rounded border-border-light bg-header-primary py-2.5 pl-3 pr-3 text-sm text-text-secondary ring-ring-primary hover:bg-header-hover focus-visible:ring data-[focus]:bg-surface-hover data-[focus]:text-text-primary"
style={{ width: '100%' }}
data-theme={typeof item === 'string' ? item : (item as Option).value}
>
<div className="flex w-full items-center justify-between">
<span className="block truncate">
{typeof item === 'string' ? item : (item as Option).label}
</span>
{selectedValue === (typeof item === 'string' ? item : item.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>
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</div> </div>
</Listbox> </Select.Select>
<Select.SelectPopover
store={selectProps}
className={cn('popover-ui', sizeClasses, className)}
>
{options.map((item, index) => (
<Select.SelectItem
key={index}
value={typeof item === 'string' ? item : item.value}
className="select-item"
data-theme={typeof item === 'string' ? item : (item as Option).value}
>
<div className="flex w-full items-center justify-between">
<span className="block truncate">
{typeof item === 'string' ? item : (item as Option).label}
</span>
{selectedValue === (typeof item === 'string' ? item : item.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> </div>
); );
}; };

View file

@ -156,7 +156,7 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className = '', ...props }, ref) => ( >(({ className = '', ...props }, ref) => (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
ref={ref} ref={ref}
className={cn('-mx-1 my-1 h-px bg-gray-100 dark:bg-gray-900', className)} className={cn('-mx-1 my-1 h-px bg-border-medium', className)}
{...props} {...props}
/> />
)); ));

View file

@ -21,6 +21,10 @@ interface DropdownProps {
testId?: string; testId?: string;
} }
/*
* Mainly used for the Speech Voice Selection Dropdown
*/
const Dropdown: FC<DropdownProps> = ({ const Dropdown: FC<DropdownProps> = ({
value, value,
label = '', label = '',

View file

@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...pr
return ( return (
<input <input
className={cn( className={cn(
'dark:border-gray-00 flex h-10 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:text-gray-50', 'flex h-10 w-full rounded-md border border-border-light bg-transparent px-3 py-2 text-sm placeholder:text-text-tertiary focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-50',
className ?? '', className ?? '',
)} )}
ref={ref} ref={ref}

View file

@ -59,10 +59,7 @@ const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDi
overlayClassName={overlayClassName} overlayClassName={overlayClassName}
showCloseButton={showCloseButton} showCloseButton={showCloseButton}
ref={ref} ref={ref}
className={cn( className={cn('border-none bg-background text-foreground', className ?? '')}
'bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300',
className ?? '',
)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<OGDialogHeader className={cn(headerClassName ?? '')}> <OGDialogHeader className={cn(headerClassName ?? '')}>

View file

@ -18,7 +18,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( 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', '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, className,
)} )}
{...props} {...props}
@ -41,14 +41,14 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', 'max-w-11/12 fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 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%] sm:rounded-lg',
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 ring-ring-primary transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"> <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" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
@ -89,7 +89,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}
className={cn('text-muted-foreground text-sm', className)} className={cn('text-sm text-muted-foreground', className)}
{...props} {...props}
/> />
)); ));

View 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,
};

View file

@ -12,7 +12,7 @@ const Separator = React.forwardRef<
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
'bg-border shrink-0', 'shrink-0 bg-border-light',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]', orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className, className,
)} )}

View file

@ -1,41 +1,22 @@
import * as React from 'react'; import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider'; import * as SliderPrimitive from '@radix-ui/react-slider';
import { useDoubleClick } from '@zattoo/use-double-click';
import type { clickEvent } from '@zattoo/use-double-click';
import { cn } from '~/utils'; import { cn } from '~/utils';
interface SliderProps extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> { const Slider = React.forwardRef<
doubleClickHandler?: clickEvent; React.ElementRef<typeof SliderPrimitive.Root>,
trackClassName?: string; React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
} >(({ className, ...props }, ref) => (
<SliderPrimitive.Root
const Slider = React.forwardRef<React.ElementRef<typeof SliderPrimitive.Root>, SliderProps>( ref={ref}
( className={cn('relative flex w-full touch-none select-none items-center', className)}
{ className, trackClassName = 'bg-gray-200 dark:bg-gray-850', doubleClickHandler, ...props }, {...props}
ref, >
) => ( <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Root <SliderPrimitive.Range className="absolute h-full bg-primary" />
ref={ref} </SliderPrimitive.Track>
className={cn('relative flex w-full touch-none select-none items-center', className ?? '')} <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" />
{...props} </SliderPrimitive.Root>
> ));
<SliderPrimitive.Track
className={cn('relative h-1 w-full grow overflow-hidden rounded-full', trackClassName)}
>
<SliderPrimitive.Range className="absolute h-full bg-gray-850 dark:bg-white" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
onClick={
useDoubleClick(doubleClickHandler as clickEvent) ??
(() => {
return;
})
}
className="block h-4 w-4 cursor-pointer rounded-full border border-border-medium-alt bg-white shadow ring-ring-primary transition-colors focus-visible:ring-1 focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 dark:border-none"
/>
</SliderPrimitive.Root>
),
);
Slider.displayName = SliderPrimitive.Root.displayName; Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider }; export { Slider };

View file

@ -1,16 +1,14 @@
import * as React from 'react'; import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch'; import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '~/utils';
import { cn } from '../../utils';
const Switch = React.forwardRef< const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>, React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className = '', ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SwitchPrimitives.Root <SwitchPrimitives.Root
className={cn( className={cn(
'focus-visible:ring-ring focus-visible:ring-offset-background peer inline-flex h-[20px] w-[32px] 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-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-black data-[state=unchecked]:bg-gray-200 dark:data-[state=checked]:bg-green-500 dark:data-[state=unchecked]:bg-gray-500', '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-input',
'ring-ring-primary',
className, className,
)} )}
{...props} {...props}
@ -18,7 +16,7 @@ const Switch = React.forwardRef<
> >
<SwitchPrimitives.Thumb <SwitchPrimitives.Thumb
className={cn( className={cn(
'pointer-events-none block h-4 w-4 -translate-x-0.5 rounded-full bg-white transition-transform data-[state=checked]:translate-x-3 data-[state=unchecked]:translate-x-0', '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> </SwitchPrimitives.Root>

View file

@ -1,10 +1,11 @@
import * as React from 'react'; import * as React from 'react';
import { cn } from '~/utils';
import { cn } from '../../utils';
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>( const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className = '', ...props }, ref) => ( ({ className, ...props }, ref) => (
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} /> <div className="relative w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
), ),
); );
Table.displayName = 'Table'; Table.displayName = 'Table';
@ -12,7 +13,7 @@ Table.displayName = 'Table';
const TableHeader = React.forwardRef< const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className = '', ...props }, ref) => ( >(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} /> <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
)); ));
TableHeader.displayName = 'TableHeader'; TableHeader.displayName = 'TableHeader';
@ -20,7 +21,7 @@ TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef< const TableBody = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className = '', ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} /> <tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
)); ));
TableBody.displayName = 'TableBody'; TableBody.displayName = 'TableBody';
@ -28,21 +29,21 @@ TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef< const TableFooter = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className = '', ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tfoot <tfoot
ref={ref} ref={ref}
className={cn('bg-primary text-primary-foreground font-medium', className)} className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
{...props} {...props}
/> />
)); ));
TableFooter.displayName = 'TableFooter'; TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>( const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className = '', ...props }, ref) => ( ({ className, ...props }, ref) => (
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', 'hover:bg-muted/50 data-[state=selected]:bg-muted border-b border-border-light transition-colors',
className, className,
)} )}
{...props} {...props}
@ -54,7 +55,7 @@ TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef< const TableHead = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement> React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className = '', ...props }, ref) => ( >(({ className, ...props }, ref) => (
<th <th
ref={ref} ref={ref}
className={cn( className={cn(
@ -69,10 +70,10 @@ TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef< const TableCell = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement> React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className = '', ...props }, ref) => ( >(({ className, ...props }, ref) => (
<td <td
ref={ref} ref={ref}
className={cn('align-middle [&:has([role=checkbox])]:pr-0', className)} className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props} {...props}
/> />
)); ));
@ -81,7 +82,7 @@ TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef< const TableCaption = React.forwardRef<
HTMLTableCaptionElement, HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement> React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className = '', ...props }, ref) => ( >(({ className, ...props }, ref) => (
<caption ref={ref} className={cn('text-muted-foreground mt-4 text-sm', className)} {...props} /> <caption ref={ref} className={cn('text-muted-foreground mt-4 text-sm', className)} {...props} />
)); ));
TableCaption.displayName = 'TableCaption'; TableCaption.displayName = 'TableCaption';

View file

@ -1,7 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs'; import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '~/utils';
import { cn } from '../../utils';
const Tabs = TabsPrimitive.Root; const Tabs = TabsPrimitive.Root;
@ -12,7 +11,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
'inline-flex items-center justify-center rounded-md bg-gray-200 p-1 dark:bg-gray-800', 'inline-flex items-center justify-center rounded-md bg-surface-primary',
className, className,
)} )}
{...props} {...props}
@ -26,7 +25,7 @@ const TabsTrigger = React.forwardRef<
>(({ className = '', ...props }, ref) => ( >(({ className = '', ...props }, ref) => (
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
className={cn( 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:text-gray-200 dark:data-[state=active]:bg-gray-700 dark:data-[state=active]:text-gray-200', '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, className,
)} )}
{...props} {...props}
@ -39,11 +38,7 @@ const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>, React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className = '', ...props }, ref) => ( >(({ className = '', ...props }, ref) => (
<TabsPrimitive.Content <TabsPrimitive.Content className={cn('mt-2 rounded-md p-6', className)} {...props} ref={ref} />
className={cn('mt-2 rounded-md border border-gray-200 p-6 dark:border-gray-700', className)}
{...props}
ref={ref}
/>
)); ));
TabsContent.displayName = TabsPrimitive.Content.displayName; TabsContent.displayName = TabsPrimitive.Content.displayName;

View file

@ -1,14 +1,16 @@
import * as Ariakit from '@ariakit/react'; import * as Ariakit from '@ariakit/react';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { forwardRef, useMemo } from 'react'; import { forwardRef, useMemo } from 'react';
import { cn } from '~/utils';
interface TooltipAnchorProps extends Ariakit.TooltipAnchorProps { interface TooltipAnchorProps extends Ariakit.TooltipAnchorProps {
description: string; description: string;
side?: 'top' | 'bottom' | 'left' | 'right'; side?: 'top' | 'bottom' | 'left' | 'right';
className?: string;
} }
export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(function TooltipAnchor( export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(function TooltipAnchor(
{ description, side = 'top', role, ...props }, { description, side = 'top', className, role, ...props },
ref, ref,
) { ) {
const tooltip = Ariakit.useTooltipStore({ placement: side }); const tooltip = Ariakit.useTooltipStore({ placement: side });
@ -40,7 +42,13 @@ export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(func
return ( return (
<Ariakit.TooltipProvider store={tooltip} hideTimeout={0}> <Ariakit.TooltipProvider store={tooltip} hideTimeout={0}>
<Ariakit.TooltipAnchor {...props} ref={ref} role={role} onKeyDown={handleKeyDown} /> <Ariakit.TooltipAnchor
{...props}
ref={ref}
role={role}
onKeyDown={handleKeyDown}
className={cn('cursor-pointer', className)}
/>
<AnimatePresence> <AnimatePresence>
{mounted && ( {mounted && (
<Ariakit.Tooltip <Ariakit.Tooltip

View file

@ -23,6 +23,7 @@ export * from './Tag';
export * from './Textarea'; export * from './Textarea';
export * from './TextareaAutosize'; export * from './TextareaAutosize';
export * from './Tooltip'; export * from './Tooltip';
export * from './Pagination';
export { default as Combobox } from './Combobox'; export { default as Combobox } from './Combobox';
export { default as Dropdown } from './Dropdown'; export { default as Dropdown } from './Dropdown';
export { default as FileUpload } from './FileUpload'; export { default as FileUpload } from './FileUpload';

View file

@ -150,12 +150,12 @@ export const useConversationsInfiniteQuery = (
config?: UseInfiniteQueryOptions<ConversationListResponse, unknown>, config?: UseInfiniteQueryOptions<ConversationListResponse, unknown>,
) => { ) => {
return useInfiniteQuery<ConversationListResponse, unknown>( return useInfiniteQuery<ConversationListResponse, unknown>(
params?.isArchived ? [QueryKeys.archivedConversations] : [QueryKeys.allConversations], params?.isArchived === true ? [QueryKeys.archivedConversations] : [QueryKeys.allConversations],
({ pageParam = '' }) => ({ pageParam = '' }) =>
dataService.listConversations({ dataService.listConversations({
...params, ...params,
pageNumber: pageParam?.toString(), pageNumber: pageParam?.toString(),
isArchived: params?.isArchived || false, isArchived: params?.isArchived ?? false,
tags: params?.tags || [], tags: params?.tags || [],
}), }),
{ {

View file

@ -777,6 +777,7 @@ export default {
com_nav_slash_command_description: 'Toggle command "/" for selecting a prompt via keyboard', com_nav_slash_command_description: 'Toggle command "/" for selecting a prompt via keyboard',
com_nav_command_settings: 'Command Settings', com_nav_command_settings: 'Command Settings',
com_nav_command_settings_description: 'Customize which commands are available in the chat', com_nav_command_settings_description: 'Customize which commands are available in the chat',
com_nav_no_search_results: 'No search results found',
com_nav_setting_general: 'General', com_nav_setting_general: 'General',
com_nav_setting_chat: 'Chat', com_nav_setting_chat: 'Chat',
com_nav_setting_beta: 'Beta features', com_nav_setting_beta: 'Beta features',

View file

@ -10,7 +10,13 @@ const searchQuery = atom({
default: '', default: '',
}); });
const isSearching = atom({
key: 'isSearching',
default: false,
});
export default { export default {
isSearchEnabled, isSearchEnabled,
searchQuery, searchQuery,
isSearching,
}; };

View file

@ -44,11 +44,37 @@ html {
--surface-secondary: var(--gray-50); --surface-secondary: var(--gray-50);
--surface-tertiary: var(--gray-100); --surface-tertiary: var(--gray-100);
--surface-tertiary-alt: var(--white); --surface-tertiary-alt: var(--white);
--surface-dialog: var(--white);
--border-light: var(--gray-200); --border-light: var(--gray-200);
--border-medium-alt: var(--gray-300); --border-medium-alt: var(--gray-300);
--border-medium: var(--gray-300); --border-medium: var(--gray-300);
--border-heavy: var(--gray-400); --border-heavy: var(--gray-400);
--border-xheavy: var(--gray-500); --border-xheavy: var(--gray-500);
/* These are test styles */
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
} }
.dark { .dark {
--text-primary: var(--gray-100); --text-primary: var(--gray-100);
@ -66,11 +92,36 @@ html {
--surface-secondary: var(--gray-800); --surface-secondary: var(--gray-800);
--surface-tertiary: var(--gray-700); --surface-tertiary: var(--gray-700);
--surface-tertiary-alt: var(--gray-700); --surface-tertiary-alt: var(--gray-700);
--surface-dialog: var(--gray-850);
--border-light: var(--gray-700); --border-light: var(--gray-700);
--border-medium-alt: var(--gray-600); --border-medium-alt: var(--gray-600);
--border-medium: var(--gray-600); --border-medium: var(--gray-600);
--border-heavy: var(--gray-500); --border-heavy: var(--gray-500);
--border-xheavy: var(--gray-400); --border-xheavy: var(--gray-400);
/* These are test styles */
--background: 0 0% 7%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
} }
.gizmo { .gizmo {
--text-primary: var(--gizmo-gray-950); --text-primary: var(--gizmo-gray-950);
@ -2292,7 +2343,7 @@ button.scroll-convo {
z-index: 50; z-index: 50;
cursor: pointer; cursor: pointer;
border-radius: 0.275rem; border-radius: 0.275rem;
background-color: var(--bg-gray-600); background-color: var(--surface-primary);
padding-top: 0.25rem; padding-top: 0.25rem;
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
padding-left: 0.5rem; padding-left: 0.5rem;
@ -2322,3 +2373,67 @@ button.scroll-convo {
outline: 2px solid #fff; outline: 2px solid #fff;
outline-offset: 2px; outline-offset: 2px;
} }
.popover-ui {
z-index: 1000;
display: flex;
max-height: min(var(--popover-available-height, 300px), 300px);
flex-direction: column;
overflow: auto;
overscroll-behavior: contain;
border-radius: 1rem;
border-width: 1px;
border-style: solid;
border-color: var(--border-light);
background-color: hsl(var(--background));
padding: 0.5rem;
color: var(--text-primary);
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
transform-origin: top;
opacity: 0;
transition-property: opacity, scale, translate;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
scale: 0.95;
translate: 0 -0.5rem;
margin-top: 4px;
margin-right: -2px;
}
.popover-ui:focus-visible,
.popover-ui[data-focus-visible] {
outline: var(--bg-surface-hover);
outline-offset: -1px;
}
.popover-ui:where(.dark, .dark *) {
background-color: hsl(var(--background));
color: var(--text-secondary);
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.25), 0 4px 6px -4px rgb(0 0 0 / 0.1);
}
.select-item {
display: flex;
cursor: pointer;
scroll-margin: 0.5rem;
align-items: center;
gap: 0.5rem;
border-radius: 0.5rem;
padding: 0.5rem;
outline: none !important;
}
.select-item[aria-disabled='true'] {
opacity: 0.5;
}
.select-item[data-active-item] {
background-color: hsl(var(--accent));
color: var(--text-primary);
}
.popover-ui[data-enter] {
opacity: 1;
scale: 1;
translate: 0;
}

View file

@ -77,11 +77,47 @@ module.exports = {
'surface-secondary': 'var(--surface-secondary)', 'surface-secondary': 'var(--surface-secondary)',
'surface-tertiary': 'var(--surface-tertiary)', 'surface-tertiary': 'var(--surface-tertiary)',
'surface-tertiary-alt': 'var(--surface-tertiary-alt)', 'surface-tertiary-alt': 'var(--surface-tertiary-alt)',
'surface-dialog': 'var(--surface-dialog)',
'border-light': 'var(--border-light)', 'border-light': 'var(--border-light)',
'border-medium': 'var(--border-medium)', 'border-medium': 'var(--border-medium)',
'border-medium-alt': 'var(--border-medium-alt)', 'border-medium-alt': 'var(--border-medium-alt)',
'border-heavy': 'var(--border-heavy)', 'border-heavy': 'var(--border-heavy)',
'border-xheavy': 'var(--border-xheavy)', 'border-xheavy': 'var(--border-xheavy)',
/* These are test styles */
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
}, },
}, },
}, },

15
package-lock.json generated
View file

@ -8087,12 +8087,13 @@
} }
}, },
"node_modules/@floating-ui/dom": { "node_modules/@floating-ui/dom": {
"version": "1.6.1", "version": "1.6.11",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.11.tgz",
"integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==", "integrity": "sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.6.0", "@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.1" "@floating-ui/utils": "^0.2.8"
} }
}, },
"node_modules/@floating-ui/react": { "node_modules/@floating-ui/react": {
@ -8124,9 +8125,9 @@
} }
}, },
"node_modules/@floating-ui/utils": { "node_modules/@floating-ui/utils": {
"version": "0.2.4", "version": "0.2.8",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.4.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz",
"integrity": "sha512-dWO2pw8hhi+WrXq1YJy2yCuWoL20PddgGaqTgVe4cOS9Q6qklXCiA1tJEqX6BEwRNSCP84/afac9hd4MS+zEUA==", "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@google/generative-ai": { "node_modules/@google/generative-ai": {