🎨 refactor: UI stlye (#4438)

* feat: Refactor ChatForm and StopButton components for improved styling and localization

* feat: Refactor AudioRecorder, ChatForm, AttachFile, and SendButton components for improved styling and layout

* feat: Add RevokeAllKeys component and update styling for buttons and inputs

* feat: Refactor ClearChats component and update ClearConvos functionality for improved clarity and user experience

* feat: Remove ClearConvos component and update related imports and functionality in Avatar and DeleteCacheButton components

* feat: Rename DeleteCacheButton to DeleteCache and update related imports; enhance confirmation message in localization

* feat: Update ChatForm layout for RTL support and improve component structure

* feat: Adjust ChatForm layout for improved RTL support and alignment

* feat: Refactor Bookmark components to use new UI elements and improve styling

* feat: Update FileSearch and ShareAgent components for improved button styling and layout

* feat: Update ChatForm and TextareaHeader styles for improved UI consistency

* feat: Refactor Nav components for improved styling and layout adjustments

* feat: Update button sizes and padding for improved UI consistency across chat components

* feat: Remove ClearChatsButton test file as part of code cleanup
This commit is contained in:
Marco Beretta 2024-10-19 14:30:52 +02:00 committed by GitHub
parent 20fb7f05ae
commit 8f3de7d11f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 471 additions and 564 deletions

View file

@ -2,10 +2,9 @@ import React, { useRef, Dispatch, SetStateAction } from 'react';
import { TConversationTag, TConversation } from 'librechat-data-provider'; import { TConversationTag, TConversation } from 'librechat-data-provider';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useConversationTagMutation } from '~/data-provider'; import { useConversationTagMutation } from '~/data-provider';
import { OGDialog, Button, Spinner } from '~/components';
import { NotificationSeverity } from '~/common'; import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers'; import { useToastContext } from '~/Providers';
import { OGDialog } from '~/components/ui';
import { Spinner } from '~/components/svg';
import BookmarkForm from './BookmarkForm'; import BookmarkForm from './BookmarkForm';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { logger } from '~/utils'; import { logger } from '~/utils';
@ -75,6 +74,7 @@ const BookmarkEditDialog = ({
<OGDialogTemplate <OGDialogTemplate
title="Bookmark" title="Bookmark"
showCloseButton={false} showCloseButton={false}
className="w-11/12 md:max-w-2xl"
main={ main={
<BookmarkForm <BookmarkForm
tags={tags} tags={tags}
@ -86,14 +86,14 @@ const BookmarkEditDialog = ({
/> />
} }
buttons={ buttons={
<button <Button
variant="submit"
type="submit" type="submit"
disabled={mutation.isLoading} disabled={mutation.isLoading}
onClick={handleSubmitForm} onClick={handleSubmitForm}
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
> >
{mutation.isLoading ? <Spinner /> : localize('com_ui_save')} {mutation.isLoading ? <Spinner /> : localize('com_ui_save')}
</button> </Button>
} }
/> />
</OGDialog> </OGDialog>

View file

@ -8,7 +8,7 @@ import type {
TConversationTagRequest, TConversationTagRequest,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import { cn, removeFocusOutlines, defaultTextProps, logger } from '~/utils'; import { cn, removeFocusOutlines, defaultTextProps, logger } from '~/utils';
import { Checkbox, Label, TextareaAutosize } from '~/components/ui'; import { Checkbox, Label, TextareaAutosize, Input } from '~/components';
import { useBookmarkContext } from '~/Providers/BookmarkContext'; import { useBookmarkContext } from '~/Providers/BookmarkContext';
import { useConversationTagMutation } from '~/data-provider'; import { useConversationTagMutation } from '~/data-provider';
import { useToastContext } from '~/Providers'; import { useToastContext } from '~/Providers';
@ -100,7 +100,7 @@ const BookmarkForm = ({
<Label htmlFor="bookmark-tag" className="text-left text-sm font-medium"> <Label htmlFor="bookmark-tag" className="text-left text-sm font-medium">
{localize('com_ui_bookmarks_title')} {localize('com_ui_bookmarks_title')}
</Label> </Label>
<input <Input
type="text" type="text"
id="bookmark-tag" id="bookmark-tag"
aria-label="Bookmark" aria-label="Bookmark"
@ -119,17 +119,12 @@ const BookmarkForm = ({
}, },
})} })}
aria-invalid={!!errors.tag} aria-invalid={!!errors.tag}
className={cn( placeholder="Bookmark"
defaultTextProps,
'flex h-10 max-h-10 w-full resize-none px-3 py-2',
removeFocusOutlines,
)}
placeholder=" "
/> />
{errors.tag && <span className="text-sm text-red-500">{errors.tag.message}</span>} {errors.tag && <span className="text-sm text-red-500">{errors.tag.message}</span>}
</div> </div>
<div className="grid w-full items-center gap-2"> <div className="mt-4 grid w-full items-center gap-2">
<Label htmlFor="bookmark-description" className="text-left text-sm font-medium"> <Label htmlFor="bookmark-description" className="text-left text-sm font-medium">
{localize('com_ui_bookmarks_description')} {localize('com_ui_bookmarks_description')}
</Label> </Label>
@ -143,8 +138,7 @@ const BookmarkForm = ({
id="bookmark-description" id="bookmark-description"
disabled={false} disabled={false}
className={cn( className={cn(
defaultTextProps, 'flex h-10 max-h-[250px] min-h-[100px] w-full resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background focus-visible:outline-none',
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2',
)} )}
/> />
</div> </div>

View file

@ -80,10 +80,8 @@ export default function AudioRecorder({
onClick={isListening ? handleStopRecording : handleStartRecording} onClick={isListening ? handleStopRecording : handleStartRecording}
disabled={disabled} disabled={disabled}
className={cn( className={cn(
'absolute flex h-[30px] w-[30px] items-center justify-center rounded-lg p-0.5 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700', 'absolute flex size-[35px] items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover',
isRTL isRTL ? 'bottom-2 left-2' : 'bottom-2 right-2',
? 'bottom-1.5 left-4 md:bottom-3 md:left-12'
: 'bottom-1.5 right-12 md:bottom-3 md:right-12',
)} )}
description={localize('com_ui_use_micrphone')} description={localize('com_ui_use_micrphone')}
> >

View file

@ -156,7 +156,7 @@ const ChatForm = ({ index = 0 }) => {
/> />
)} )}
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} /> <PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border dark:border-gray-600 dark:text-white [&:has(textarea:focus)]:border-gray-300 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)] dark:[&:has(textarea:focus)]:border-gray-500"> <div className="transitional-all relative flex w-full flex-grow flex-col overflow-hidden rounded-3xl bg-surface-tertiary text-text-primary duration-200">
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} /> <TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<FileFormWrapper disableInputs={disableInputs}> <FileFormWrapper disableInputs={disableInputs}>
{endpoint && ( {endpoint && (
@ -181,7 +181,7 @@ const ChatForm = ({ index = 0 }) => {
endpointSupportsFiles && !isUploadDisabled endpointSupportsFiles && !isUploadDisabled
? 'pl-10 md:pl-[55px]' ? 'pl-10 md:pl-[55px]'
: 'pl-3 md:pl-4', : 'pl-3 md:pl-4',
'm-0 w-full resize-none border-0 bg-transparent py-[10px] placeholder-black/50 focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:placeholder-white/50 md:py-3.5 ', 'md:py-3.5- m-0 w-full resize-none bg-surface-tertiary py-[13px] placeholder-black/50 dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]',
SpeechToText && !isRTL ? 'pr-20 md:pr-[85px]' : 'pr-10 md:pr-12', SpeechToText && !isRTL ? 'pr-20 md:pr-[85px]' : 'pr-10 md:pr-12',
'max-h-[65vh] md:max-h-[75vh]', 'max-h-[65vh] md:max-h-[75vh]',
removeFocusRings, removeFocusRings,
@ -189,22 +189,6 @@ const ChatForm = ({ index = 0 }) => {
/> />
)} )}
</FileFormWrapper> </FileFormWrapper>
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
<StopButton
stop={handleStopGenerating}
setShowStopButton={setShowStopButton}
isRTL={isRTL}
/>
) : (
endpoint && (
<SendButton
ref={submitButtonRef}
control={methods.control}
isRTL={isRTL}
disabled={!!(filesLoading || isSubmitting || disableInputs)}
/>
)
)}
{SpeechToText && ( {SpeechToText && (
<AudioRecorder <AudioRecorder
disabled={!!disableInputs} disabled={!!disableInputs}
@ -216,6 +200,25 @@ const ChatForm = ({ index = 0 }) => {
)} )}
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />} {TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
</div> </div>
<div
className={cn(
'mb-[5px] ml-[8px] flex flex-col items-end justify-end',
isRTL && 'order-first mr-[8px]',
)}
style={{ alignSelf: 'flex-end' }}
>
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
) : (
endpoint && (
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={!!(filesLoading || isSubmitting || disableInputs)}
/>
)
)}
</div>
</div> </div>
</div> </div>
</form> </form>

View file

@ -17,21 +17,15 @@ const AttachFile = ({
const isUploadDisabled = disabled ?? false; const isUploadDisabled = disabled ?? false;
return ( return (
<div
className={cn(
'absolute',
isRTL
? 'bottom-2 right-14 md:bottom-3.5 md:right-3'
: 'bottom-2 left-2 md:bottom-3.5 md:left-4',
)}
>
<FileUpload handleFileChange={handleFileChange} className="flex"> <FileUpload handleFileChange={handleFileChange} className="flex">
<TooltipAnchor <TooltipAnchor
id="audio-recorder" id="audio-recorder"
disabled={isUploadDisabled}
aria-label={localize('com_sidepanel_attach_files')} aria-label={localize('com_sidepanel_attach_files')}
className="btn relative text-black focus:outline-none focus:ring-2 focus:ring-border-xheavy focus:ring-opacity-50 dark:text-white" disabled={isUploadDisabled}
style={{ padding: 0 }} className={cn(
'absolute flex size-[35px] items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover',
isRTL ? 'bottom-2 right-2' : 'bottom-2 left-2',
)}
description={localize('com_sidepanel_attach_files')} description={localize('com_sidepanel_attach_files')}
> >
<div className="flex w-full items-center justify-center gap-2"> <div className="flex w-full items-center justify-center gap-2">
@ -39,7 +33,6 @@ const AttachFile = ({
</div> </div>
</TooltipAnchor> </TooltipAnchor>
</FileUpload> </FileUpload>
</div>
); );
}; };

View file

@ -13,19 +13,17 @@ import AttachFile from './AttachFile';
import FileRow from './FileRow'; import FileRow from './FileRow';
import store from '~/store'; import store from '~/store';
function FileFormWrapper({ children, disableInputs } : { function FileFormWrapper({
children,
disableInputs,
}: {
disableInputs: boolean; disableInputs: boolean;
children?: React.ReactNode; children?: React.ReactNode;
}) { }) {
const { handleFileChange, abortUpload } = useFileHandling(); const { handleFileChange, abortUpload } = useFileHandling();
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase(); const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
const { const { files, setFiles, conversation, setFilesLoading } = useChatContext();
files,
setFiles,
conversation,
setFilesLoading,
} = useChatContext();
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data), select: (data) => mergeFileConfig(data),
}); });
@ -33,11 +31,14 @@ function FileFormWrapper({ children, disableInputs } : {
const isRTL = chatDirection === 'rtl'; const isRTL = chatDirection === 'rtl';
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null }; const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as EndpointFileConfig | undefined; const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as
| EndpointFileConfig
| undefined;
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false; const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false;
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false; const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
return (<> return (
<>
<FileRow <FileRow
files={files} files={files}
setFiles={setFiles} setFiles={setFiles}
@ -45,18 +46,15 @@ function FileFormWrapper({ children, disableInputs } : {
setFilesLoading={setFilesLoading} setFilesLoading={setFilesLoading}
isRTL={isRTL} isRTL={isRTL}
Wrapper={({ children }) => ( Wrapper={({ children }) => (
<div className="mx-2 mt-2 flex flex-wrap gap-2 px-2.5 md:pl-0 md:pr-4"> <div className="mx-2 mt-2 flex flex-wrap gap-2 px-2.5 md:pl-0 md:pr-4">{children}</div>
{children}
</div>
)} )}
/> />
{children} {children}
{endpointSupportsFiles && !isUploadDisabled && <AttachFile {endpointSupportsFiles && !isUploadDisabled && (
isRTL={isRTL} <AttachFile isRTL={isRTL} disabled={disableInputs} handleFileChange={handleFileChange} />
disabled={disableInputs} )}
handleFileChange={handleFileChange} </>
/>} );
</>);
} }
export default memo(FileFormWrapper); export default memo(FileFormWrapper);

View file

@ -9,12 +9,10 @@ import { cn } from '~/utils';
type SendButtonProps = { type SendButtonProps = {
disabled: boolean; disabled: boolean;
control: Control<{ text: string }>; control: Control<{ text: string }>;
isRTL: boolean;
}; };
const SubmitButton = React.memo( const SubmitButton = React.memo(
forwardRef( forwardRef((props: { disabled: boolean }, ref: React.ForwardedRef<HTMLButtonElement>) => {
(props: { disabled: boolean; isRTL: boolean }, ref: React.ForwardedRef<HTMLButtonElement>) => {
const localize = useLocalize(); const localize = useLocalize();
return ( return (
<TooltipAnchor <TooltipAnchor
@ -26,10 +24,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:cursor-not-allowed disabled:bg-black disabled:text-gray-400 disabled:opacity-10 dark:border-white dark:bg-white dark:disabled:bg-white', 'rounded-full bg-text-primary p-2 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
props.isRTL
? 'bottom-1.5 left-2 md:bottom-3 md:left-3'
: 'bottom-1.5 right-2 md:bottom-3 md:right-3',
)} )}
data-testid="send-button" data-testid="send-button"
type="submit" type="submit"
@ -41,14 +36,13 @@ const SubmitButton = React.memo(
} }
></TooltipAnchor> ></TooltipAnchor>
); );
}, }),
),
); );
const SendButton = React.memo( const SendButton = React.memo(
forwardRef((props: SendButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) => { forwardRef((props: SendButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) => {
const data = useWatch({ control: props.control }); const data = useWatch({ control: props.control });
return <SubmitButton ref={ref} disabled={props.disabled || !data.text} isRTL={props.isRTL} />; return <SubmitButton ref={ref} disabled={props.disabled || !data.text} />;
}), }),
); );

View file

@ -1,36 +1,37 @@
import { TooltipAnchor } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
export default function StopButton({ stop, setShowStopButton, isRTL }) { export default function StopButton({ stop, setShowStopButton }) {
const localize = useLocalize();
return ( return (
<div <TooltipAnchor
className={cn( description={localize('com_nav_stop_generating')}
'absolute', render={
isRTL ? 'bottom-3 left-2 md:bottom-4 md:left-4' : 'bottom-3 right-2 md:bottom-4 md:right-4',
)}
>
<button <button
type="button" type="button"
className="border-gizmo-gray-900 rounded-full border-2 p-1 dark:border-gray-200" className={cn(
aria-label="Stop generating" 'rounded-full bg-text-primary p-1.5 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
)}
aria-label={localize('com_nav_stop_generating')}
onClick={(e) => { onClick={(e) => {
setShowStopButton(false); setShowStopButton(false);
stop(e); stop(e);
}} }}
> >
<svg <svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" className="icon-lg text-surface-primary"
fill="currentColor"
className="text-gizmo-gray-900 h-2 w-2 dark:text-gray-200"
height="16"
width="16"
> >
<path <rect x="7" y="7" width="10" height="10" rx="1.25" fill="currentColor"></rect>
d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2z"
strokeWidth="0"
></path>
</svg> </svg>
</button> </button>
</div> }
></TooltipAnchor>
); );
} }

View file

@ -13,7 +13,7 @@ export default function TextareaHeader({
return null; return null;
} }
return ( return (
<div className="divide-token-border-light m-1.5 flex flex-col divide-y overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-primary-contrast"> <div className="divide-token-border-light m-1.5 flex flex-col divide-y overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-secondary-alt">
<AddedConvo addedConvo={addedConvo} setAddedConvo={setAddedConvo} /> <AddedConvo addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
</div> </div>
); );

View file

@ -29,6 +29,7 @@ const InputWithLabel: FC<InputWithLabelProps> = forwardRef((props, ref) => {
)} )}
<br /> <br />
</div> </div>
<div className="h-1" />
<Input <Input
id={id} id={id}
data-testid={`input-${id}`} data-testid={`input-${id}`}
@ -36,12 +37,7 @@ const InputWithLabel: FC<InputWithLabelProps> = forwardRef((props, ref) => {
onChange={onChange} onChange={onChange}
ref={ref} ref={ref}
placeholder={`${localize('com_endpoint_config_value')} ${label}`} placeholder={`${localize('com_endpoint_config_value')} ${label}`}
className={cn( className={cn('flex h-10 max-h-10 w-full resize-none px-3 py-2')}
defaultTextProps,
'flex h-10 max-h-10 w-full resize-none px-3 py-2',
removeFocusOutlines,
inputClassName,
)}
/> />
</> </>
); );

View file

@ -3,9 +3,9 @@ import { useForm, FormProvider } from 'react-hook-form';
import { EModelEndpoint, alternateName, isAssistantsEndpoint } from 'librechat-data-provider'; import { EModelEndpoint, alternateName, isAssistantsEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query'; import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { TDialogProps } from '~/common'; import type { TDialogProps } from '~/common';
import DialogTemplate from '~/components/ui/DialogTemplate'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { RevokeKeysButton } from '~/components/Nav'; import { RevokeKeysButton } from '~/components/Nav';
import { Dialog, Dropdown } from '~/components/ui'; import { OGDialog, Dropdown } from '~/components/ui';
import { useUserKey, useLocalize } from '~/hooks'; import { useUserKey, useLocalize } from '~/hooks';
import { useToastContext } from '~/Providers'; import { useToastContext } from '~/Providers';
import CustomConfig from './CustomEndpoint'; import CustomConfig from './CustomEndpoint';
@ -160,10 +160,11 @@ const SetKeyDialog = ({
const config = endpointsConfig?.[endpoint]; const config = endpointsConfig?.[endpoint];
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <OGDialog open={open} onOpenChange={onOpenChange}>
<DialogTemplate <OGDialogTemplate
title={`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`} title={`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`}
className="w-11/12 max-w-[650px] sm:w-3/4 md:w-3/4 lg:w-3/4" className="w-11/12 max-w-[650px] sm:w-3/4 md:w-3/4 lg:w-3/4"
showCancelButton={false}
main={ main={
<div className="grid w-full items-center gap-2"> <div className="grid w-full items-center gap-2">
<small className="text-red-600"> <small className="text-red-600">
@ -172,7 +173,7 @@ const SetKeyDialog = ({
: `${localize('com_endpoint_config_key_encryption')} ${new Date( : `${localize('com_endpoint_config_key_encryption')} ${new Date(
expiryTime ?? 0, expiryTime ?? 0,
).toLocaleString()}`} ).toLocaleString()}`}
</small>{' '} </small>
<Dropdown <Dropdown
label="Expires " label="Expires "
value={expiresAtLabel} value={expiresAtLabel}
@ -180,6 +181,7 @@ const SetKeyDialog = ({
options={expirationOptions.map((option) => option.label)} options={expirationOptions.map((option) => option.label)}
sizeClasses="w-[185px]" sizeClasses="w-[185px]"
/> />
<div className="mt-2" />
<FormProvider {...methods}> <FormProvider {...methods}>
<EndpointComponent <EndpointComponent
userKey={userKey} userKey={userKey}
@ -197,14 +199,18 @@ const SetKeyDialog = ({
} }
selection={{ selection={{
selectHandler: submit, selectHandler: submit,
selectClasses: 'bg-green-500 hover:bg-green-600 dark:hover:bg-green-600 text-white', selectClasses: 'btn btn-primary',
selectText: localize('com_ui_submit'), selectText: localize('com_ui_submit'),
}} }}
leftButtons={ leftButtons={
<RevokeKeysButton endpoint={endpoint} showText={false} disabled={!expiryTime} /> <RevokeKeysButton
endpoint={endpoint}
disabled={!expiryTime}
setDialogOpen={onOpenChange}
/>
} }
/> />
</Dialog> </OGDialog>
); );
}; };

View file

@ -30,7 +30,7 @@ function AccountSettings() {
<Select.Select <Select.Select
aria-label={localize('com_nav_account_settings')} aria-label={localize('com_nav_account_settings')}
data-testid="nav-user" data-testid="nav-user"
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" className="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"
> >
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0"> <div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
<div className="relative flex"> <div className="relative flex">

View file

@ -41,7 +41,7 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags, isSmallScreen }: Boo
className={cn( className={cn(
'mt-text-sm flex h-10 w-full items-center gap-2 rounded-lg p-2 text-sm transition-colors duration-200 hover:bg-surface-hover', 'mt-text-sm flex h-10 w-full items-center gap-2 rounded-lg p-2 text-sm transition-colors duration-200 hover:bg-surface-hover',
open ? 'bg-surface-hover' : '', open ? 'bg-surface-hover' : '',
isSmallScreen ? 'h-14 rounded-2xl' : '', isSmallScreen ? 'h-12' : '',
)} )}
data-testid="bookmark-menu" data-testid="bookmark-menu"
> >

View file

@ -1,53 +0,0 @@
import { useState } from 'react';
import { useClearConversationsMutation } from 'librechat-data-provider/react-query';
import { useLocalize, useConversation, useConversations } from '~/hooks';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { ClearChatsButton } from './SettingsTabs';
import { Dialog } from '~/components/ui';
const ClearConvos = ({ open, onOpenChange }) => {
const { newConversation } = useConversation();
const { refreshConversations } = useConversations();
const clearConvosMutation = useClearConversationsMutation();
const [confirmClear, setConfirmClear] = useState(false);
const localize = useLocalize();
// Clear all conversations
const clearConvos = () => {
if (confirmClear) {
clearConvosMutation.mutate(
{},
{
onSuccess: () => {
newConversation();
refreshConversations();
},
},
);
setConfirmClear(false);
} else {
setConfirmClear(true);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTemplate
title={localize('com_nav_clear_conversation')}
className="w-11/12 max-w-[650px] sm:w-3/4 md:w-3/4 lg:w-3/4"
headerClassName="border-none"
description={localize('com_nav_clear_conversation_confirm_message')}
buttons={
<ClearChatsButton
showText={false}
confirmClear={confirmClear}
onClick={clearConvos}
className="w-[77px]"
/>
}
/>
</Dialog>
);
};
export default ClearConvos;

View file

@ -168,22 +168,9 @@ const Nav = ({
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
ref={containerRef} ref={containerRef}
> >
{isSmallScreen == true ? (
<div className="pt-3.5">
{isSearchEnabled === true && (
<SearchBar clearSearch={clearSearch} isSmallScreen={isSmallScreen} />
)}
{hasAccessToBookmarks === true && (
<BookmarkNav
tags={tags}
setTags={setTags}
isSmallScreen={isSmallScreen}
/>
)}
</div>
) : (
<NewChat <NewChat
toggleNav={itemToggleNav} toggleNav={itemToggleNav}
isSmallScreen={isSmallScreen}
subHeaders={ subHeaders={
<> <>
{isSearchEnabled === true && ( {isSearchEnabled === true && (
@ -197,7 +184,6 @@ const Nav = ({
</> </>
} }
/> />
)}
<Conversations <Conversations
conversations={conversations} conversations={conversations}

View file

@ -8,6 +8,7 @@ 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 { NewChatIcon } from '~/components/svg'; import { NewChatIcon } from '~/components/svg';
import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
const NewChatButtonIcon = ({ conversation }: { conversation: TConversation | null }) => { const NewChatButtonIcon = ({ conversation }: { conversation: TConversation | null }) => {
@ -57,10 +58,12 @@ export default function NewChat({
index = 0, index = 0,
toggleNav, toggleNav,
subHeaders, subHeaders,
isSmallScreen,
}: { }: {
index?: number; index?: number;
toggleNav: () => void; toggleNav: () => void;
subHeaders?: React.ReactNode; subHeaders?: React.ReactNode;
isSmallScreen: boolean;
}) { }) {
/** Note: this component needs an explicit index passed if using more than one */ /** Note: this component needs an explicit index passed if using more than one */
const { newConversation: newConvo } = useNewConvo(index); const { newConversation: newConvo } = useNewConvo(index);
@ -86,7 +89,10 @@ export default function NewChat({
tabIndex={0} tabIndex={0}
data-testid="nav-new-chat" data-testid="nav-new-chat"
onClick={clickHandler} onClick={clickHandler}
className="group flex h-10 items-center gap-2 rounded-lg px-2 font-medium transition-colors duration-200 hover:bg-surface-hover" className={cn(
'group flex h-10 items-center gap-2 rounded-lg px-2 font-medium transition-colors duration-200 hover:bg-surface-hover',
isSmallScreen ? 'h-14' : '',
)}
aria-label={localize('com_ui_new_chat')} aria-label={localize('com_ui_new_chat')}
> >
<NewChatButtonIcon conversation={conversation} /> <NewChatButtonIcon conversation={conversation} />

View file

@ -76,11 +76,11 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<div className={cn('fixed inset-0 flex w-screen items-center justify-center p-4')}> <div className={cn('fixed inset-0 flex w-screen items-center justify-center p-4')}>
<DialogPanel <DialogPanel
className={cn( className={cn(
'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]', 'min-h-[600px] overflow-hidden rounded-xl rounded-b-lg bg-background pb-6 shadow-2xl backdrop-blur-2xl animate-in sm:rounded-2xl md:min-h-[373px] md:w-[680px]',
)} )}
> >
<DialogTitle <DialogTitle
className="mb-3 flex items-center justify-between border-b border-border-medium p-6 pb-5 text-left" className="mb-1 flex items-center justify-between p-6 pb-5 text-left"
as="div" as="div"
> >
<h2 className="text-lg font-medium leading-6 text-text-primary"> <h2 className="text-lg font-medium leading-6 text-text-primary">

View file

@ -5,16 +5,17 @@ import AvatarEditor from 'react-avatar-editor';
import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider'; import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider';
import type { TUser } from 'librechat-data-provider'; import type { TUser } from 'librechat-data-provider';
import { import {
Slider,
Button,
Spinner,
OGDialog, OGDialog,
OGDialogContent, OGDialogContent,
OGDialogHeader, OGDialogHeader,
OGDialogTitle, OGDialogTitle,
OGDialogTrigger, OGDialogTrigger,
Slider, } from '~/components';
} from '~/components/ui';
import { useUploadAvatarMutation, useGetFileConfig } from '~/data-provider'; import { useUploadAvatarMutation, useGetFileConfig } from '~/data-provider';
import { useToastContext } from '~/Providers'; import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import { cn, formatBytes } from '~/utils'; import { cn, formatBytes } from '~/utils';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
@ -130,10 +131,7 @@ function Avatar() {
</OGDialogTrigger> </OGDialogTrigger>
</div> </div>
<OGDialogContent <OGDialogContent className="w-11/12 max-w-sm" style={{ borderRadius: '12px' }}>
className={cn('bg-surface-tertiary text-text-primary shadow-2xl md:h-auto md:w-[450px]')}
style={{ borderRadius: '12px' }}
>
<OGDialogHeader> <OGDialogHeader>
<OGDialogTitle className="text-lg font-medium leading-6 text-text-primary"> <OGDialogTitle className="text-lg font-medium leading-6 text-text-primary">
{image ? localize('com_ui_preview') : localize('com_ui_upload_image')} {image ? localize('com_ui_preview') : localize('com_ui_upload_image')}
@ -174,10 +172,10 @@ function Avatar() {
<RotateCw className="h-5 w-5" /> <RotateCw className="h-5 w-5" />
</button> </button>
</div> </div>
<button <Button
className={cn( className={cn(
'mt-4 flex items-center rounded px-4 py-2 text-white transition-colors hover:bg-green-600 hover:text-gray-200', 'btn btn-primary mt-4 flex w-full hover:bg-green-600',
isUploading ? 'cursor-not-allowed bg-green-600' : 'bg-green-500', isUploading ? 'cursor-not-allowed opacity-90' : '',
)} )}
onClick={handleUpload} onClick={handleUpload}
disabled={isUploading} disabled={isUploading}
@ -188,24 +186,21 @@ function Avatar() {
<Upload className="mr-2 h-5 w-5" /> <Upload className="mr-2 h-5 w-5" />
)} )}
{localize('com_ui_upload')} {localize('com_ui_upload')}
</button> </Button>
</> </>
) : ( ) : (
<div <div
className="flex h-64 w-64 flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-700" className="flex h-64 w-11/12 flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-transparent dark:border-gray-600"
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
> >
<FileImage className="mb-4 h-12 w-12 text-gray-400" /> <FileImage className="mb-4 size-12 text-gray-400" />
<p className="mb-2 text-center text-sm text-gray-500 dark:text-gray-400"> <p className="mb-2 text-center text-sm text-gray-500 dark:text-gray-400">
{localize('com_ui_drag_drop')} {localize('com_ui_drag_drop')}
</p> </p>
<button <Button variant="secondary" onClick={openFileDialog}>
onClick={openFileDialog}
className="rounded bg-gray-200 px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500"
>
{localize('com_ui_select_file')} {localize('com_ui_select_file')}
</button> </Button>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"

View file

@ -1,11 +1,19 @@
import { LockIcon, Trash } from 'lucide-react';
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, Input } from '~/components/ui'; import {
import { cn, defaultTextProps, removeFocusOutlines } from '~/utils'; Input,
Button,
Spinner,
OGDialog,
OGDialogContent,
OGDialogTrigger,
OGDialogHeader,
OGDialogTitle,
} from '~/components';
import { useDeleteUserMutation } from '~/data-provider'; import { useDeleteUserMutation } from '~/data-provider';
import { Spinner, LockIcon } from '~/components/svg';
import { useAuthContext } from '~/hooks/AuthContext'; import { useAuthContext } from '~/hooks/AuthContext';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import DangerButton from '../DangerButton'; import { cn } from '~/utils';
const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolean }) => { const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolean }) => {
const localize = useLocalize(); const localize = useLocalize();
@ -15,14 +23,8 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
}); });
const [isDialogOpen, setDialogOpen] = useState<boolean>(false); const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
const [deleteInput, setDeleteInput] = useState('');
const [emailInput, setEmailInput] = useState('');
const [isLocked, setIsLocked] = useState(true); const [isLocked, setIsLocked] = useState(true);
const onClick = useCallback(() => {
setDialogOpen(true);
}, []);
const handleDeleteUser = () => { const handleDeleteUser = () => {
if (!isLocked) { if (!isLocked) {
deleteUser(undefined); deleteUser(undefined);
@ -30,47 +32,38 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
}; };
const handleInputChange = useCallback( const handleInputChange = useCallback(
(newEmailInput: string, newDeleteInput: string) => { (newEmailInput: string) => {
const isEmailCorrect = const isEmailCorrect =
newEmailInput.trim().toLowerCase() === user?.email?.trim().toLowerCase(); newEmailInput.trim().toLowerCase() === user?.email.trim().toLowerCase();
const isDeleteInputCorrect = newDeleteInput === 'DELETE'; setIsLocked(!isEmailCorrect);
setIsLocked(!(isEmailCorrect && isDeleteInputCorrect));
}, },
[user?.email], [user?.email],
); );
return ( return (
<> <>
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>{localize('com_nav_delete_account')}</span> <span>{localize('com_nav_delete_account')}</span>
<label> <OGDialogTrigger asChild>
<DangerButton <Button
id={'delete-user-account'} variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setDialogOpen(true)}
disabled={disabled} disabled={disabled}
onClick={onClick}
actionTextCode="com_ui_delete"
className={cn(
'btn relative border-none bg-red-500 text-white hover:bg-red-700 dark:hover:bg-red-700',
)}
confirmClear={false}
infoTextCode={''}
dataTestIdInitial={''}
dataTestIdConfirm={''}
/>
</label>
</div>
<Dialog open={isDialogOpen} onOpenChange={() => setDialogOpen(false)}>
<DialogContent
className={cn('shadow-2xl md:h-[500px] md:w-[450px]')}
style={{ borderRadius: '12px', padding: '20px' }}
> >
<DialogHeader> {localize('com_ui_delete')}
<DialogTitle className="text-lg font-medium leading-6"> </Button>
</OGDialogTrigger>
</div>
<OGDialogContent className="w-11/12 max-w-2xl">
<OGDialogHeader>
<OGDialogTitle className="text-lg font-medium leading-6">
{localize('com_nav_delete_account_confirm')} {localize('com_nav_delete_account_confirm')}
</DialogTitle> </OGDialogTitle>
</DialogHeader> </OGDialogHeader>
<div className="mb-20 text-sm text-black dark:text-white"> <div className="mb-8 text-sm text-black dark:text-white">
<ul> <ul className="font-semibold text-amber-600">
<li>{localize('com_nav_delete_warning')}</li> <li>{localize('com_nav_delete_warning')}</li>
<li>{localize('com_nav_delete_data_info')}</li> <li>{localize('com_nav_delete_data_info')}</li>
</ul> </ul>
@ -80,28 +73,14 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
{renderInput( {renderInput(
localize('com_nav_delete_account_email_placeholder'), localize('com_nav_delete_account_email_placeholder'),
'email-confirm-input', 'email-confirm-input',
user?.email || '', user?.email ?? '',
(e) => { (e) => handleInputChange(e.target.value),
setEmailInput(e.target.value);
handleInputChange(e.target.value, deleteInput);
},
)}
</div>
<div className="mb-4">
{renderInput(
localize('com_nav_delete_account_confirm_placeholder'),
'delete-confirm-input',
'',
(e) => {
setDeleteInput(e.target.value);
handleInputChange(emailInput, e.target.value);
},
)} )}
</div> </div>
{renderDeleteButton(handleDeleteUser, isDeleting, isLocked, localize)} {renderDeleteButton(handleDeleteUser, isDeleting, isLocked, localize)}
</div> </div>
</DialogContent> </OGDialogContent>
</Dialog> </OGDialog>
</> </>
); );
}; };
@ -113,17 +92,10 @@ const renderInput = (
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void, onChange: (e: React.ChangeEvent<HTMLInputElement>) => void,
) => ( ) => (
<div className="mb-4"> <div className="mb-4">
<label className="mb-1 block text-sm font-medium text-black dark:text-white">{label}</label> <label className="mb-1 block text-sm font-medium text-black dark:text-white" htmlFor={id}>
<Input {label}
id={id} </label>
onChange={onChange} <Input id={id} onChange={onChange} placeholder={value} />
placeholder={value}
className={cn(
defaultTextProps,
'h-10 max-h-10 w-full max-w-full rounded-md bg-white px-3 py-2',
removeFocusOutlines,
)}
/>
</div> </div>
); );
@ -135,12 +107,8 @@ const renderDeleteButton = (
) => ( ) => (
<button <button
className={cn( className={cn(
'mt-4 flex w-full items-center justify-center rounded-lg px-4 py-2 transition-colors duration-200', 'mt-4 flex w-full items-center justify-center rounded-lg bg-surface-tertiary px-4 py-2 transition-all duration-200',
isLocked isLocked ? 'cursor-not-allowed opacity-30' : 'bg-destructive text-destructive-foreground',
? 'cursor-not-allowed bg-gray-200 text-gray-300 dark:bg-gray-500 dark:text-gray-600'
: isDeleting
? 'cursor-not-allowed bg-gray-100 text-gray-700 dark:bg-gray-400 dark:text-gray-700'
: 'bg-red-700 text-white hover:bg-red-800 ',
)} )}
onClick={handleDeleteUser} onClick={handleDeleteUser}
disabled={isDeleting || isLocked} disabled={isDeleting || isLocked}
@ -153,12 +121,12 @@ const renderDeleteButton = (
<> <>
{isLocked ? ( {isLocked ? (
<> <>
<LockIcon /> <LockIcon className="size-5" />
<span className="ml-2">{localize('com_ui_locked')}</span> <span className="ml-2">{localize('com_ui_locked')}</span>
</> </>
) : ( ) : (
<> <>
<LockIcon /> <Trash className="size-5" />
<span className="ml-2">{localize('com_nav_delete_account_button')}</span> <span className="ml-2">{localize('com_nav_delete_account_button')}</span>
</> </>
)} )}

View file

@ -1,29 +1,58 @@
import type { TDangerButtonProps } from '~/common'; import React, { useState } from 'react';
import DangerButton from '../DangerButton'; import { useClearConversationsMutation } from 'librechat-data-provider/react-query';
import { Label, Button, OGDialog, OGDialogTrigger, Spinner } from '~/components';
import { useConversation, useConversations, useLocalize } from '~/hooks';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
export const ClearChatsButton = ({ export const ClearChats = () => {
confirmClear, const localize = useLocalize();
className = '', const [open, setOpen] = useState(false);
showText = true, const { newConversation } = useConversation();
mutation, const { refreshConversations } = useConversations();
onClick, const clearConvosMutation = useClearConversationsMutation();
}: Pick<
TDangerButtonProps, const clearConvos = () => {
'confirmClear' | 'mutation' | 'className' | 'showText' | 'onClick' clearConvosMutation.mutate(
>) => { {},
return ( {
<DangerButton onSuccess: () => {
id="clearConvosBtn" newConversation();
mutation={mutation} refreshConversations();
confirmClear={confirmClear} },
className={className} },
showText={showText} );
infoTextCode="com_nav_clear_all_chats" };
actionTextCode="com_ui_clear"
confirmActionTextCode="com_nav_confirm_clear" return (
dataTestIdInitial="clear-convos-initial" <div className="flex items-center justify-between">
dataTestIdConfirm="clear-convos-confirm" <Label className="font-light">{localize('com_nav_clear_all_chats')}</Label>
onClick={onClick} <OGDialog open={open} onOpenChange={setOpen}>
/> <OGDialogTrigger asChild>
<Button
variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setOpen(true)}
>
{localize('com_ui_delete')}
</Button>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_nav_confirm_clear')}
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_nav_clear_conversation_confirm_message')}
</Label>
}
selection={{
selectHandler: clearConvos,
selectClasses:
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
selectText: clearConvosMutation.isLoading ? <Spinner /> : localize('com_ui_delete'),
}}
/>
</OGDialog>
</div>
); );
}; };

View file

@ -1,47 +0,0 @@
import 'test/matchMedia.mock';
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { ClearChatsButton } from './ClearChats';
import { RecoilRoot } from 'recoil';
describe('ClearChatsButton', () => {
let mockOnClick;
beforeEach(() => {
mockOnClick = jest.fn();
});
it('renders correctly', () => {
const { getByText } = render(
<RecoilRoot>
<ClearChatsButton confirmClear={false} showText={true} onClick={mockOnClick} />
</RecoilRoot>,
);
expect(getByText('Clear all chats')).toBeInTheDocument();
expect(getByText('Clear')).toBeInTheDocument();
});
it('renders confirm clear when confirmClear is true', () => {
const { getByText } = render(
<RecoilRoot>
<ClearChatsButton confirmClear={true} showText={true} onClick={mockOnClick} />
</RecoilRoot>,
);
expect(getByText('Confirm Clear')).toBeInTheDocument();
});
it('calls onClick when the button is clicked', () => {
const { getByText } = render(
<RecoilRoot>
<ClearChatsButton confirmClear={false} showText={true} onClick={mockOnClick} />
</RecoilRoot>,
);
fireEvent.click(getByText('Clear'));
expect(mockOnClick).toHaveBeenCalled();
});
});

View file

@ -1,10 +1,9 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { useClearConversationsMutation } from 'librechat-data-provider/react-query';
import { useConversation, useConversations, useOnClickOutside } from '~/hooks';
import { RevokeKeysButton } from './RevokeKeysButton';
import { DeleteCacheButton } from './DeleteCacheButton';
import ImportConversations from './ImportConversations'; import ImportConversations from './ImportConversations';
import { ClearChatsButton } from './ClearChats'; import { RevokeAllKeys } from './RevokeAllKeys';
import { DeleteCache } from './DeleteCache';
import { useOnClickOutside } from '~/hooks';
import { ClearChats } from './ClearChats';
import SharedLinks from './SharedLinks'; import SharedLinks from './SharedLinks';
function Data() { function Data() {
@ -12,28 +11,6 @@ function Data() {
const [confirmClearConvos, setConfirmClearConvos] = useState(false); const [confirmClearConvos, setConfirmClearConvos] = useState(false);
useOnClickOutside(dataTabRef, () => confirmClearConvos && setConfirmClearConvos(false), []); useOnClickOutside(dataTabRef, () => confirmClearConvos && setConfirmClearConvos(false), []);
const { newConversation } = useConversation();
const { refreshConversations } = useConversations();
const clearConvosMutation = useClearConversationsMutation();
const clearConvos = () => {
if (confirmClearConvos) {
console.log('Clearing conversations...');
setConfirmClearConvos(false);
clearConvosMutation.mutate(
{},
{
onSuccess: () => {
newConversation();
refreshConversations();
},
},
);
} else {
setConfirmClearConvos(true);
}
};
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-medium pb-3 last-of-type:border-b-0">
@ -43,18 +20,13 @@ function Data() {
<SharedLinks /> <SharedLinks />
</div> </div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0"> <div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<RevokeKeysButton all={true} /> <RevokeAllKeys />
</div> </div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0"> <div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<DeleteCacheButton /> <DeleteCache />
</div> </div>
<div className="border-b border-border-medium pb-3 last-of-type:border-b-0"> <div className="border-b border-border-medium pb-3 last-of-type:border-b-0">
<ClearChatsButton <ClearChats />
confirmClear={confirmClearConvos}
onClick={clearConvos}
showText={true}
mutation={clearConvosMutation}
/>
</div> </div>
</div> </div>
); );

View file

@ -0,0 +1,65 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Label, Button, OGDialog, OGDialogTrigger, Spinner } from '~/components';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useOnClickOutside, useLocalize } from '~/hooks';
export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => {
const localize = useLocalize();
const [open, setOpen] = useState(false);
const [isCacheEmpty, setIsCacheEmpty] = useState(true);
const [confirmClear, setConfirmClear] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const contentRef = useRef(null);
useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []);
const checkCache = useCallback(async () => {
const cache = await caches.open('tts-responses');
const keys = await cache.keys();
setIsCacheEmpty(keys.length === 0);
}, []);
useEffect(() => {
checkCache();
}, [checkCache]);
const revokeAllUserKeys = useCallback(async () => {
setIsLoading(true);
const cache = await caches.open('tts-responses');
await cache.keys().then((keys) => Promise.all(keys.map((key) => cache.delete(key))));
setIsLoading(false);
}, []);
return (
<div className="flex items-center justify-between">
<Label className="font-light">{localize('com_nav_delete_cache_storage')}</Label>
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<Button
variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setOpen(true)}
disabled={disabled || isCacheEmpty}
>
{localize('com_ui_delete')}
</Button>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_nav_confirm_clear')}
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_nav_clear_cache_confirm_message')}
</Label>
}
selection={{
selectHandler: revokeAllUserKeys,
selectClasses:
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
selectText: isLoading ? <Spinner /> : localize('com_ui_delete'),
}}
/>
</OGDialog>
</div>
);
};

View file

@ -1,53 +0,0 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { useOnClickOutside } from '~/hooks';
import DangerButton from '../DangerButton';
export const DeleteCacheButton = ({
showText = true,
disabled = false,
}: {
showText?: boolean;
disabled?: boolean;
}) => {
const [confirmClear, setConfirmClear] = useState(false);
const [isCacheEmpty, setIsCacheEmpty] = useState(true);
const contentRef = useRef(null);
useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []);
const checkCache = useCallback(async () => {
const cache = await caches.open('tts-responses');
const keys = await cache.keys();
setIsCacheEmpty(keys.length === 0);
}, []);
useEffect(() => {
checkCache();
}, [confirmClear]);
const revokeAllUserKeys = useCallback(async () => {
if (confirmClear) {
const cache = await caches.open('tts-responses');
await cache.keys().then((keys) => Promise.all(keys.map((key) => cache.delete(key))));
setConfirmClear(false);
} else {
setConfirmClear(true);
}
}, [confirmClear]);
return (
<DangerButton
ref={contentRef}
showText={showText}
onClick={revokeAllUserKeys}
disabled={disabled || isCacheEmpty}
confirmClear={confirmClear}
id={'delete-cache'}
actionTextCode={'com_ui_delete'}
infoTextCode={'com_nav_delete_cache_storage'}
infoDescriptionCode={'com_nav_info_delete_cache_storage'}
dataTestIdInitial={'delete-cache-initial'}
dataTestIdConfirm={'delete-cache-confirm'}
/>
);
};

View file

@ -0,0 +1,15 @@
import React from 'react';
import { RevokeKeysButton } from './RevokeKeysButton';
import { Label } from '~/components/ui';
import { useLocalize } from '~/hooks';
export const RevokeAllKeys = () => {
const localize = useLocalize();
return (
<div className="flex items-center justify-between">
<Label className="font-light">{localize('com_ui_revoke_info')}</Label>
<RevokeKeysButton all={true} />
</div>
);
};

View file

@ -2,64 +2,77 @@ import {
useRevokeAllUserKeysMutation, useRevokeAllUserKeysMutation,
useRevokeUserKeyMutation, useRevokeUserKeyMutation,
} from 'librechat-data-provider/react-query'; } from 'librechat-data-provider/react-query';
import React, { useState, useCallback, useRef } from 'react'; import React, { useState } from 'react';
import { useOnClickOutside } from '~/hooks'; import { Button, Label, OGDialog, OGDialogTrigger, Spinner } from '~/components';
import DangerButton from '../DangerButton'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize } from '~/hooks';
export const RevokeKeysButton = ({ export const RevokeKeysButton = ({
showText = true,
endpoint = '', endpoint = '',
all = false, all = false,
disabled = false, disabled = false,
setDialogOpen,
}: { }: {
showText?: boolean;
endpoint?: string; endpoint?: string;
all?: boolean; all?: boolean;
disabled?: boolean; disabled?: boolean;
setDialogOpen?: (open: boolean) => void;
}) => { }) => {
const [confirmClear, setConfirmClear] = useState(false); const localize = useLocalize();
const [open, setOpen] = useState(false);
const revokeKeyMutation = useRevokeUserKeyMutation(endpoint); const revokeKeyMutation = useRevokeUserKeyMutation(endpoint);
const revokeKeysMutation = useRevokeAllUserKeysMutation(); const revokeKeysMutation = useRevokeAllUserKeysMutation();
const contentRef = useRef(null); const handleSuccess = () => {
useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []); if (!setDialogOpen) {
const revokeAllUserKeys = useCallback(() => {
if (confirmClear) {
revokeKeysMutation.mutate({});
setConfirmClear(false);
} else {
setConfirmClear(true);
}
}, [confirmClear, revokeKeysMutation]);
const revokeUserKey = useCallback(() => {
if (!endpoint) {
return; return;
} else if (confirmClear) {
revokeKeyMutation.mutate({});
setConfirmClear(false);
} else {
setConfirmClear(true);
} }
}, [confirmClear, revokeKeyMutation, endpoint]);
const onClick = all ? revokeAllUserKeys : revokeUserKey; setDialogOpen(false);
};
const onClick = () => {
if (all) {
revokeKeysMutation.mutate({});
} else {
revokeKeyMutation.mutate({}, { onSuccess: handleSuccess });
}
};
const dialogTitle = all
? localize('com_ui_revoke_keys')
: localize('com_ui_revoke_key_endpoint', endpoint);
const dialogMessage = all
? localize('com_ui_revoke_keys_confirm')
: localize('com_ui_revoke_key_confirm');
const isLoading = revokeKeyMutation.isLoading || revokeKeysMutation.isLoading;
return ( return (
<DangerButton <OGDialog open={open} onOpenChange={setOpen}>
ref={contentRef} <OGDialogTrigger asChild>
showText={showText} <Button
onClick={onClick} variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setOpen(true)}
disabled={disabled} disabled={disabled}
confirmClear={confirmClear} >
id={'revoke-all-user-keys'} {localize('com_ui_revoke')}
actionTextCode={'com_ui_revoke'} </Button>
infoTextCode={'com_ui_revoke_info'} </OGDialogTrigger>
infoDescriptionCode={'com_nav_info_revoke'} <OGDialogTemplate
dataTestIdInitial={'revoke-all-keys-initial'} showCloseButton={false}
dataTestIdConfirm={'revoke-all-keys-confirm'} title={dialogTitle}
mutation={all ? revokeKeysMutation : revokeKeyMutation} className="max-w-[450px]"
main={<Label className="text-left text-sm font-medium">{dialogMessage}</Label>}
selection={{
selectHandler: onClick,
selectClasses:
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
selectText: isLoading ? <Spinner /> : localize('com_ui_revoke'),
}}
/> />
</OGDialog>
); );
}; };

View file

@ -1,6 +1,5 @@
export * from './ExportConversation'; export * from './ExportConversation';
export * from './SettingsTabs/'; export * from './SettingsTabs/';
export { default as ClearConvos } from './ClearConvos';
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

@ -78,7 +78,7 @@ export default function FileSearch({
<button <button
type="button" type="button"
disabled={!agent_id || fileSearchChecked === false} disabled={!agent_id || fileSearchChecked === false}
className="btn btn-neutral border-token-border-light relative h-8 rounded-lg font-medium" className="btn btn-neutral border-token-border-light relative h-8 w-full rounded-lg font-medium"
onClick={handleButtonClick} onClick={handleButtonClick}
> >
<div className="flex w-full items-center justify-center gap-1"> <div className="flex w-full items-center justify-center gap-1">

View file

@ -5,6 +5,7 @@ import { Permissions } from 'librechat-data-provider';
import { useGetStartupConfig } from 'librechat-data-provider/react-query'; import { useGetStartupConfig } from 'librechat-data-provider/react-query';
import type { TStartupConfig, AgentUpdateParams } from 'librechat-data-provider'; import type { TStartupConfig, AgentUpdateParams } from 'librechat-data-provider';
import { import {
Button,
Switch, Switch,
OGDialog, OGDialog,
OGDialogTitle, OGDialogTitle,
@ -146,7 +147,7 @@ export default function ShareAgent({
</div> </div>
</button> </button>
</OGDialogTrigger> </OGDialogTrigger>
<OGDialogContent className="w-1/4 border-border-light bg-surface-primary-alt text-text-secondary"> <OGDialogContent className="w-11/12 md:max-w-xl">
<OGDialogTitle> <OGDialogTitle>
{localize( {localize(
'com_ui_share_var', 'com_ui_share_var',
@ -255,13 +256,14 @@ export default function ShareAgent({
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<OGDialogClose asChild> <OGDialogClose asChild>
<button <Button
variant="submit"
size="sm"
type="submit" type="submit"
disabled={isSubmitting || isFetching} disabled={isSubmitting || isFetching}
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
> >
{localize('com_ui_save')} {localize('com_ui_save')}
</button> </Button>
</OGDialogClose> </OGDialogClose>
</div> </div>
</form> </form>

View file

@ -18,8 +18,8 @@ const BookmarkPanel = () => {
<BookmarkTable /> <BookmarkTable />
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<BookmarkEditDialog context="BookmarkPanel" open={open} setOpen={setOpen} /> <BookmarkEditDialog context="BookmarkPanel" open={open} setOpen={setOpen} />
<Button variant="outline" className="w-full text-sm" onClick={() => setOpen(!open)}> <Button variant="outline" className="w-full gap-2 text-sm" onClick={() => setOpen(!open)}>
<BookmarkPlusIcon className="mr-1 size-4" /> <BookmarkPlusIcon className="size-4" />
<div className="break-all">{localize('com_ui_bookmarks_new')}</div> <div className="break-all">{localize('com_ui_bookmarks_new')}</div>
</Button> </Button>
</div> </div>

View file

@ -9,12 +9,13 @@ const buttonVariants = cva(
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90', default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: outline:
'text-text-primary border border-input bg-background hover:bg-accent hover:text-accent-foreground', 'text-text-primary border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
submit: 'bg-surface-submit text-text-primary hover:bg-surface-submit/90',
}, },
size: { size: {
default: 'h-10 px-4 py-2', default: 'h-10 px-4 py-2',

View file

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

View file

@ -13,7 +13,7 @@ import { cn } from '~/utils/';
type SelectionProps = { type SelectionProps = {
selectHandler?: () => void; selectHandler?: () => void;
selectClasses?: string; selectClasses?: string;
selectText?: string; selectText?: string | ReactNode;
}; };
type DialogTemplateProps = { type DialogTemplateProps = {
@ -81,7 +81,7 @@ const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDi
onClick={selectHandler} onClick={selectHandler}
className={`${ className={`${
selectClasses ?? defaultSelect selectClasses ?? defaultSelect
} inline-flex h-10 items-center justify-center rounded-lg border-none px-4 py-2 text-sm`} } flex h-10 w-full items-center justify-center rounded-lg border-none px-4 py-2 text-sm`}
> >
{selectText} {selectText}
</OGDialogClose> </OGDialogClose>

View file

@ -41,7 +41,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'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', 'max-w-11/12 fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl bg-background p-6 text-text-primary shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
className, className,
)} )}
{...props} {...props}

View file

@ -280,6 +280,10 @@ export default {
com_ui_clear: 'Clear', com_ui_clear: 'Clear',
com_ui_revoke: 'Revoke', com_ui_revoke: 'Revoke',
com_ui_revoke_info: 'Revoke all user provided credentials', com_ui_revoke_info: 'Revoke all user provided credentials',
com_ui_revoke_keys: 'Revoke Keys',
com_ui_revoke_keys_confirm: 'Are you sure you want to revoke all keys?',
com_ui_revoke_key_endpoint: 'Revoke Key for {0}',
com_ui_revoke_key_confirm: 'Are you sure you want to revoke this key?',
com_ui_import_conversation: 'Import', com_ui_import_conversation: 'Import',
com_ui_nothing_found: 'Nothing found', com_ui_nothing_found: 'Nothing found',
com_ui_go_to_conversation: 'Go to conversation', com_ui_go_to_conversation: 'Go to conversation',
@ -717,10 +721,12 @@ export default {
com_nav_auto_send_prompts: 'Auto-send Prompts', com_nav_auto_send_prompts: 'Auto-send Prompts',
com_nav_always_make_prod: 'Always make new versions production', com_nav_always_make_prod: 'Always make new versions production',
com_nav_clear_all_chats: 'Clear all chats', com_nav_clear_all_chats: 'Clear all chats',
com_nav_clear_cache_confirm_message: 'Are you sure you want to clear the cache?',
com_nav_confirm_clear: 'Confirm Clear', com_nav_confirm_clear: 'Confirm Clear',
com_nav_close_sidebar: 'Close sidebar', com_nav_close_sidebar: 'Close sidebar',
com_nav_open_sidebar: 'Open sidebar', com_nav_open_sidebar: 'Open sidebar',
com_nav_send_message: 'Send message', com_nav_send_message: 'Send message',
com_nav_stop_generating: 'Stop generating',
com_nav_log_out: 'Log out', com_nav_log_out: 'Log out',
com_nav_user: 'USER', com_nav_user: 'USER',
com_nav_archived_chats: 'Archived chats', com_nav_archived_chats: 'Archived chats',

View file

@ -18,6 +18,17 @@
--gray-800: #212121; --gray-800: #212121;
--gray-850: #171717; --gray-850: #171717;
--gray-900: #0d0d0d; --gray-900: #0d0d0d;
--green-50: #ecfdf5;
--green-100: #d1fae5;
--green-200: #a7f3d0;
--green-300: #6ee7b7;
--green-400: #34d399;
--green-500: #10b981;
--green-600: #059669;
--green-700: #047857;
--green-800: #065f46;
--green-900: #064e3b;
--green-950: #022c22;
--gizmo-gray-500: #999; --gizmo-gray-500: #999;
--gizmo-gray-600: #666; --gizmo-gray-600: #666;
--gizmo-gray-950: #0f0f0f; --gizmo-gray-950: #0f0f0f;
@ -42,9 +53,11 @@ html {
--surface-primary-alt: var(--gray-50); --surface-primary-alt: var(--gray-50);
--surface-primary-contrast: var(--gray-100); --surface-primary-contrast: var(--gray-100);
--surface-secondary: var(--gray-50); --surface-secondary: var(--gray-50);
--surface-secondary-alt: var(--gray-300);
--surface-tertiary: var(--gray-100); --surface-tertiary: var(--gray-100);
--surface-tertiary-alt: var(--white); --surface-tertiary-alt: var(--white);
--surface-dialog: var(--white); --surface-dialog: var(--white);
--surface-submit: var(--green-500);
--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);
@ -90,9 +103,11 @@ html {
--surface-primary-alt: var(--gray-850); --surface-primary-alt: var(--gray-850);
--surface-primary-contrast: var(--gray-850); --surface-primary-contrast: var(--gray-850);
--surface-secondary: var(--gray-800); --surface-secondary: var(--gray-800);
--surface-secondary-alt: 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); --surface-dialog: var(--gray-850);
--surface-submit: var(--green-600);
--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);
@ -112,7 +127,7 @@ html {
--muted-foreground: 0 0% 63.9%; --muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%; --accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%; --accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 40.6%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%; --border: 0 0% 14.9%;
--input: 0 0% 14.9%; --input: 0 0% 14.9%;
@ -2351,7 +2366,7 @@ button.scroll-convo {
font-size: 1rem; font-size: 1rem;
line-height: 1.5rem; line-height: 1.5rem;
color: black; color: black;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.25); box-shadow: 0 2px 4px 0 rgb(0 0 0 / 0.25);
} }
.tooltip:where(.dark, .dark *) { .tooltip:where(.dark, .dark *) {

View file

@ -75,9 +75,11 @@ module.exports = {
'surface-primary-alt': 'var(--surface-primary-alt)', 'surface-primary-alt': 'var(--surface-primary-alt)',
'surface-primary-contrast': 'var(--surface-primary-contrast)', 'surface-primary-contrast': 'var(--surface-primary-contrast)',
'surface-secondary': 'var(--surface-secondary)', 'surface-secondary': 'var(--surface-secondary)',
'surface-secondary-alt': 'var(--surface-secondary-alt)',
'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)', 'surface-dialog': 'var(--surface-dialog)',
'surface-submit': 'var(--surface-submit)',
'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)',

View file

@ -314,7 +314,10 @@ export const getFileConfig = (): Promise<f.FileConfig> => {
return request.get(`${endpoints.files()}/config`); return request.get(`${endpoints.files()}/config`);
}; };
export const uploadImage = (data: FormData, signal?: AbortSignal | null): Promise<f.TFileUpload> => { export const uploadImage = (
data: FormData,
signal?: AbortSignal | null,
): Promise<f.TFileUpload> => {
const requestConfig = signal ? { signal } : undefined; const requestConfig = signal ? { signal } : undefined;
return request.postMultiPart(endpoints.images(), data, requestConfig); return request.postMultiPart(endpoints.images(), data, requestConfig);
}; };