️ fix: Accessibility, UI consistency, dialog & avatar refactors (#9975)

* 🔧 refactor: Improve accessibility and styling in ChatGroupItem and FilterPrompts components

* 🔧 fix: Add button type and keyboard accessibility to dropdown menu trigger in ChatGroupItem

* 🔧 fix(757): Enhance accessibility by updating aria-labels and adding localization for prompt groups

* 🔧 fix(618): Update version to 0.3.1 and enhance accessibility in InfoHoverCard component

* 🔧 fix(618): Update aria-label in InfoHoverCard to use dynamic text prop for improved accessibility

* 🔧 fix: Enhance accessibility by updating aria-labels and roles in Conversations components

* 🔧 fix(620): Enhance accessibility by adding tabIndex to Tabs.Content components in ArtifactTabs, Settings, and Speech components

* refactor: remove RevokeKeysButton component and update related components for accessibility

- Deleted RevokeKeysButton component.
- Updated SharedLinks and General components to use Label for accessibility.
- Enhanced Personalization component with aria-labelledby and aria-describedby attributes.
- Refactored ConversationModeSwitch to use ToggleSwitch for better state management.
- Improved AutoSendTextSelector with local state management and accessibility attributes.
- Replaced Switch components with ToggleSwitch in various Speech and TTS components for consistency.
- Added aria-labelledby attributes to Dropdown components for better accessibility.
- Updated translation.json to include new localization keys and improved existing ones.
- Enhanced Slider component to support aria attributes for better accessibility.

* 🔧 fix: Enhance user feedback for API key operations with success and error messages

* 🔧 fix: Update aria-labels in Avatar component for improved localization and accessibility

* 🔧 fix: Refactor handleFile and handleDrop functions for improved readability and maintainability
This commit is contained in:
Marco Beretta 2025-10-07 20:12:49 +02:00 committed by GitHub
parent bcd97aad2f
commit a5189052ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1158 additions and 857 deletions

View file

@ -44,6 +44,7 @@ export default function ArtifactTabs({
value="code" value="code"
id="artifacts-code" id="artifacts-code"
className={cn('flex-grow overflow-auto')} className={cn('flex-grow overflow-auto')}
tabIndex={-1}
> >
{isMermaid ? ( {isMermaid ? (
<MermaidMarkdown content={content} isSubmitting={isSubmitting} /> <MermaidMarkdown content={content} isSubmitting={isSubmitting} />
@ -58,7 +59,7 @@ export default function ArtifactTabs({
/> />
)} )}
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="preview" className="flex-grow overflow-auto"> <Tabs.Content value="preview" className="flex-grow overflow-auto" tabIndex={-1}>
<ArtifactPreview <ArtifactPreview
files={files} files={files}
fileKey={fileKey} fileKey={fileKey}

View file

@ -19,9 +19,11 @@ export function BrowserVoiceDropdown() {
} }
}; };
const labelId = 'browser-voice-dropdown-label';
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_voice_select')}</div> <div id={labelId}>{localize('com_nav_voice_select')}</div>
<Dropdown <Dropdown
key={`browser-voice-dropdown-${voices.length}`} key={`browser-voice-dropdown-${voices.length}`}
value={voice ?? ''} value={voice ?? ''}
@ -30,6 +32,7 @@ export function BrowserVoiceDropdown() {
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]" sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
testId="BrowserVoiceDropdown" testId="BrowserVoiceDropdown"
className="z-50" className="z-50"
aria-labelledby={labelId}
/> />
</div> </div>
); );
@ -48,9 +51,11 @@ export function ExternalVoiceDropdown() {
} }
}; };
const labelId = 'external-voice-dropdown-label';
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_voice_select')}</div> <div id={labelId}>{localize('com_nav_voice_select')}</div>
<Dropdown <Dropdown
key={`external-voice-dropdown-${voices.length}`} key={`external-voice-dropdown-${voices.length}`}
value={voice ?? ''} value={voice ?? ''}
@ -59,6 +64,7 @@ export function ExternalVoiceDropdown() {
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]" sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
testId="ExternalVoiceDropdown" testId="ExternalVoiceDropdown"
className="z-50" className="z-50"
aria-labelledby={labelId}
/> />
</div> </div>
); );

View file

@ -28,6 +28,8 @@ const LoadingSpinner = memo(() => {
); );
}); });
LoadingSpinner.displayName = 'LoadingSpinner';
const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => { const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
const localize = useLocalize(); const localize = useLocalize();
return ( return (
@ -74,6 +76,7 @@ const Conversations: FC<ConversationsProps> = ({
isLoading, isLoading,
isSearchLoading, isSearchLoading,
}) => { }) => {
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)'); const isSmallScreen = useMediaQuery('(max-width: 768px)');
const convoHeight = isSmallScreen ? 44 : 34; const convoHeight = isSmallScreen ? 44 : 34;
@ -181,7 +184,7 @@ const Conversations: FC<ConversationsProps> = ({
{isSearchLoading ? ( {isSearchLoading ? (
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<Spinner className="text-text-primary" /> <Spinner className="text-text-primary" />
<span className="ml-2 text-text-primary">Loading...</span> <span className="ml-2 text-text-primary">{localize('com_ui_loading')}</span>
</div> </div>
) : ( ) : (
<div className="flex-1"> <div className="flex-1">

View file

@ -135,8 +135,9 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9', 'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9',
isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt', isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
)} )}
role="listitem" role="button"
tabIndex={0} tabIndex={renaming ? -1 : 0}
aria-label={`${title || localize('com_ui_untitled')} conversation`}
onClick={(e) => { onClick={(e) => {
if (renaming) { if (renaming) {
return; return;
@ -149,7 +150,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
if (renaming) { if (renaming) {
return; return;
} }
if (e.key === 'Enter') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleNavigation(false); handleNavigation(false);
} }
}} }}

View file

@ -40,8 +40,7 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
e.stopPropagation(); e.stopPropagation();
onRename(); onRename();
}} }}
role="button" aria-label={title || localize('com_ui_untitled')}
aria-label={isSmallScreen ? undefined : title || localize('com_ui_untitled')}
> >
{title || localize('com_ui_untitled')} {title || localize('com_ui_untitled')}
</div> </div>

View file

@ -201,6 +201,7 @@ function ConvoOptions({
<Menu.MenuButton <Menu.MenuButton
id={`conversation-menu-${conversationId}`} id={`conversation-menu-${conversationId}`}
aria-label={localize('com_nav_convo_menu_options')} aria-label={localize('com_nav_convo_menu_options')}
aria-readonly={undefined}
className={cn( className={cn(
'inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50', 'inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50',
isActiveConvo === true || isPopoverActive isActiveConvo === true || isPopoverActive

View file

@ -1,16 +1,33 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import { OGDialogTemplate, OGDialog, Dropdown, useToastContext } from '@librechat/client'; import {
OGDialog,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
OGDialogFooter,
Dropdown,
useToastContext,
Button,
Label,
OGDialogTrigger,
Spinner,
} from '@librechat/client';
import { EModelEndpoint, alternateName, isAssistantsEndpoint } from 'librechat-data-provider'; import { EModelEndpoint, alternateName, isAssistantsEndpoint } from 'librechat-data-provider';
import {
useRevokeAllUserKeysMutation,
useRevokeUserKeyMutation,
} from 'librechat-data-provider/react-query';
import type { TDialogProps } from '~/common'; import type { TDialogProps } from '~/common';
import { useGetEndpointsQuery } from '~/data-provider'; import { useGetEndpointsQuery } from '~/data-provider';
import { RevokeKeysButton } from '~/components/Nav';
import { useUserKey, useLocalize } from '~/hooks'; import { useUserKey, useLocalize } from '~/hooks';
import { NotificationSeverity } from '~/common';
import CustomConfig from './CustomEndpoint'; import CustomConfig from './CustomEndpoint';
import GoogleConfig from './GoogleConfig'; import GoogleConfig from './GoogleConfig';
import OpenAIConfig from './OpenAIConfig'; import OpenAIConfig from './OpenAIConfig';
import OtherConfig from './OtherConfig'; import OtherConfig from './OtherConfig';
import HelpText from './HelpText'; import HelpText from './HelpText';
import { logger } from '~/utils';
const endpointComponents = { const endpointComponents = {
[EModelEndpoint.google]: GoogleConfig, [EModelEndpoint.google]: GoogleConfig,
@ -42,6 +59,94 @@ const EXPIRY = {
NEVER: { label: 'never', value: 0 }, NEVER: { label: 'never', value: 0 },
}; };
const RevokeKeysButton = ({
endpoint,
disabled,
setDialogOpen,
}: {
endpoint: string;
disabled: boolean;
setDialogOpen: (open: boolean) => void;
}) => {
const localize = useLocalize();
const [open, setOpen] = useState(false);
const { showToast } = useToastContext();
const revokeKeyMutation = useRevokeUserKeyMutation(endpoint);
const revokeKeysMutation = useRevokeAllUserKeysMutation();
const handleSuccess = () => {
showToast({
message: localize('com_ui_revoke_key_success'),
status: NotificationSeverity.SUCCESS,
});
if (!setDialogOpen) {
return;
}
setDialogOpen(false);
};
const handleError = () => {
showToast({
message: localize('com_ui_revoke_key_error'),
status: NotificationSeverity.ERROR,
});
};
const onClick = () => {
revokeKeyMutation.mutate(
{},
{
onSuccess: handleSuccess,
onError: handleError,
},
);
};
const isLoading = revokeKeyMutation.isLoading || revokeKeysMutation.isLoading;
return (
<div className="flex items-center justify-between">
<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}
>
{localize('com_ui_revoke')}
</Button>
</OGDialogTrigger>
<OGDialogContent className="max-w-[450px]">
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_revoke_key_endpoint', { 0: endpoint })}</OGDialogTitle>
</OGDialogHeader>
<div className="py-4">
<Label className="text-left text-sm font-medium">
{localize('com_ui_revoke_key_confirm')}
</Label>
</div>
<OGDialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{localize('com_ui_cancel')}
</Button>
<Button
variant="destructive"
onClick={onClick}
disabled={isLoading}
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
>
{isLoading ? <Spinner /> : localize('com_ui_revoke')}
</Button>
</OGDialogFooter>
</OGDialogContent>
</OGDialog>
</div>
);
};
const SetKeyDialog = ({ const SetKeyDialog = ({
open, open,
onOpenChange, onOpenChange,
@ -83,7 +188,7 @@ const SetKeyDialog = ({
const submit = () => { const submit = () => {
const selectedOption = expirationOptions.find((option) => option.label === expiresAtLabel); const selectedOption = expirationOptions.find((option) => option.label === expiresAtLabel);
let expiresAt; let expiresAt: number | null;
if (selectedOption?.value === 0) { if (selectedOption?.value === 0) {
expiresAt = null; expiresAt = null;
@ -92,8 +197,20 @@ const SetKeyDialog = ({
} }
const saveKey = (key: string) => { const saveKey = (key: string) => {
try {
saveUserKey(key, expiresAt); saveUserKey(key, expiresAt);
showToast({
message: localize('com_ui_save_key_success'),
status: NotificationSeverity.SUCCESS,
});
onOpenChange(false); onOpenChange(false);
} catch (error) {
logger.error('Error saving user key:', error);
showToast({
message: localize('com_ui_save_key_error'),
status: NotificationSeverity.ERROR,
});
}
}; };
if (formSet.has(endpoint) || formSet.has(endpointType ?? '')) { if (formSet.has(endpoint) || formSet.has(endpointType ?? '')) {
@ -148,6 +265,14 @@ const SetKeyDialog = ({
return; return;
} }
if (!userKey.trim()) {
showToast({
message: localize('com_ui_key_required'),
status: NotificationSeverity.ERROR,
});
return;
}
saveKey(userKey); saveKey(userKey);
setUserKey(''); setUserKey('');
}; };
@ -159,12 +284,13 @@ const SetKeyDialog = ({
return ( return (
<OGDialog open={open} onOpenChange={onOpenChange}> <OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogTemplate <OGDialogContent className="w-11/12 max-w-2xl">
title={`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`} <OGDialogHeader>
className="w-11/12 max-w-2xl" <OGDialogTitle>
showCancelButton={false} {`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`}
main={ </OGDialogTitle>
<div className="grid w-full items-center gap-2"> </OGDialogHeader>
<div className="grid w-full items-center gap-2 py-4">
<small className="text-red-600"> <small className="text-red-600">
{expiryTime === 'never' {expiryTime === 'never'
? localize('com_endpoint_config_key_never_expires') ? localize('com_endpoint_config_key_never_expires')
@ -195,20 +321,17 @@ const SetKeyDialog = ({
</FormProvider> </FormProvider>
<HelpText endpoint={endpoint} /> <HelpText endpoint={endpoint} />
</div> </div>
} <OGDialogFooter>
selection={{
selectHandler: submit,
selectClasses: 'btn btn-primary',
selectText: localize('com_ui_submit'),
}}
leftButtons={
<RevokeKeysButton <RevokeKeysButton
endpoint={endpoint} endpoint={endpoint}
disabled={!(expiryTime ?? '')} disabled={!(expiryTime ?? '')}
setDialogOpen={onOpenChange} setDialogOpen={onOpenChange}
/> />
} <Button variant="submit" onClick={submit}>
/> {localize('com_ui_submit')}
</Button>
</OGDialogFooter>
</OGDialogContent>
</OGDialog> </OGDialog>
); );
}; };

View file

@ -182,7 +182,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<line x1="18" x2="6" y1="6" y2="18"></line> <line x1="18" x2="6" y1="6" y2="18"></line>
<line x1="6" x2="18" y1="6" y2="18"></line> <line x1="6" x2="18" y1="6" y2="18"></line>
</svg> </svg>
<span className="sr-only">{localize('com_ui_close')}</span> <span className="sr-only">{localize('com_ui_close_settings')}</span>
</button> </button>
</DialogTitle> </DialogTitle>
<div className="max-h-[550px] overflow-auto px-6 md:max-h-[400px] md:min-h-[400px] md:w-[680px]"> <div className="max-h-[550px] overflow-auto px-6 md:max-h-[400px] md:min-h-[400px] md:w-[680px]">
@ -220,35 +220,35 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
))} ))}
</Tabs.List> </Tabs.List>
<div className="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} tabIndex={-1}>
<General /> <General />
</Tabs.Content> </Tabs.Content>
<Tabs.Content value={SettingsTabValues.CHAT}> <Tabs.Content value={SettingsTabValues.CHAT} tabIndex={-1}>
<Chat /> <Chat />
</Tabs.Content> </Tabs.Content>
<Tabs.Content value={SettingsTabValues.COMMANDS}> <Tabs.Content value={SettingsTabValues.COMMANDS} tabIndex={-1}>
<Commands /> <Commands />
</Tabs.Content> </Tabs.Content>
<Tabs.Content value={SettingsTabValues.SPEECH}> <Tabs.Content value={SettingsTabValues.SPEECH} tabIndex={-1}>
<Speech /> <Speech />
</Tabs.Content> </Tabs.Content>
{hasAnyPersonalizationFeature && ( {hasAnyPersonalizationFeature && (
<Tabs.Content value={SettingsTabValues.PERSONALIZATION}> <Tabs.Content value={SettingsTabValues.PERSONALIZATION} tabIndex={-1}>
<Personalization <Personalization
hasMemoryOptOut={hasMemoryOptOut} hasMemoryOptOut={hasMemoryOptOut}
hasAnyPersonalizationFeature={hasAnyPersonalizationFeature} hasAnyPersonalizationFeature={hasAnyPersonalizationFeature}
/> />
</Tabs.Content> </Tabs.Content>
)} )}
<Tabs.Content value={SettingsTabValues.DATA}> <Tabs.Content value={SettingsTabValues.DATA} tabIndex={-1}>
<Data /> <Data />
</Tabs.Content> </Tabs.Content>
{startupConfig?.balance?.enabled && ( {startupConfig?.balance?.enabled && (
<Tabs.Content value={SettingsTabValues.BALANCE}> <Tabs.Content value={SettingsTabValues.BALANCE} tabIndex={-1}>
<Balance /> <Balance />
</Tabs.Content> </Tabs.Content>
)} )}
<Tabs.Content value={SettingsTabValues.ACCOUNT}> <Tabs.Content value={SettingsTabValues.ACCOUNT} tabIndex={-1}>
<Account /> <Account />
</Tabs.Content> </Tabs.Content>
</div> </div>

View file

@ -1,9 +1,11 @@
import React, { useState, useRef, useCallback } from 'react'; import React, { useState, useRef, useCallback } from 'react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
// @ts-ignore - no type definitions available
import AvatarEditor from 'react-avatar-editor'; import AvatarEditor from 'react-avatar-editor';
import { FileImage, RotateCw, Upload } from 'lucide-react'; import { FileImage, RotateCw, Upload, ZoomIn, ZoomOut, Move, X } from 'lucide-react';
import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider'; import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider';
import { import {
Label,
Slider, Slider,
Button, Button,
Spinner, Spinner,
@ -25,14 +27,20 @@ interface AvatarEditorRef {
getImage: () => HTMLImageElement; getImage: () => HTMLImageElement;
} }
interface Position {
x: number;
y: number;
}
function Avatar() { function Avatar() {
const setUser = useSetRecoilState(store.user); const setUser = useSetRecoilState(store.user);
const [scale, setScale] = useState<number>(1); const [scale, setScale] = useState<number>(1);
const [rotation, setRotation] = useState<number>(0); const [rotation, setRotation] = useState<number>(0);
const [position, setPosition] = useState<Position>({ x: 0.5, y: 0.5 });
const [isDragging, setIsDragging] = useState<boolean>(false);
const editorRef = useRef<AvatarEditorRef | null>(null); const editorRef = useRef<AvatarEditorRef | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const openButtonRef = useRef<HTMLButtonElement>(null);
const [image, setImage] = useState<string | File | null>(null); const [image, setImage] = useState<string | File | null>(null);
const [isDialogOpen, setDialogOpen] = useState<boolean>(false); const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
@ -48,7 +56,6 @@ function Avatar() {
onSuccess: (data) => { onSuccess: (data) => {
showToast({ message: localize('com_ui_upload_success') }); showToast({ message: localize('com_ui_upload_success') });
setUser((prev) => ({ ...prev, avatar: data.url }) as TUser); setUser((prev) => ({ ...prev, avatar: data.url }) as TUser);
openButtonRef.current?.click();
}, },
onError: (error) => { onError: (error) => {
console.error('Error:', error); console.error('Error:', error);
@ -61,11 +68,13 @@ function Avatar() {
handleFile(file); handleFile(file);
}; };
const handleFile = (file: File | undefined) => { const handleFile = useCallback(
(file: File | undefined) => {
if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) { if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) {
setImage(file); setImage(file);
setScale(1); setScale(1);
setRotation(0); setRotation(0);
setPosition({ x: 0.5, y: 0.5 });
} else { } else {
const megabytes = const megabytes =
fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2; fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2;
@ -74,16 +83,30 @@ function Avatar() {
status: 'error', status: 'error',
}); });
} }
}; },
[fileConfig.avatarSizeLimit, localize, showToast],
);
const handleScaleChange = (value: number[]) => { const handleScaleChange = (value: number[]) => {
setScale(value[0]); setScale(value[0]);
}; };
const handleZoomIn = () => {
setScale((prev) => Math.min(prev + 0.2, 5));
};
const handleZoomOut = () => {
setScale((prev) => Math.max(prev - 0.2, 1));
};
const handleRotate = () => { const handleRotate = () => {
setRotation((prev) => (prev + 90) % 360); setRotation((prev) => (prev + 90) % 360);
}; };
const handlePositionChange = (position: Position) => {
setPosition(position);
};
const handleUpload = () => { const handleUpload = () => {
if (editorRef.current) { if (editorRef.current) {
const canvas = editorRef.current.getImageScaledToCanvas(); const canvas = editorRef.current.getImageScaledToCanvas();
@ -98,11 +121,14 @@ function Avatar() {
} }
}; };
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => { const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
const file = e.dataTransfer.files[0]; const file = e.dataTransfer.files[0];
handleFile(file); handleFile(file);
}, []); },
[handleFile],
);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => { const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
@ -116,8 +142,15 @@ function Avatar() {
setImage(null); setImage(null);
setScale(1); setScale(1);
setRotation(0); setRotation(0);
setPosition({ x: 0.5, y: 0.5 });
}, []); }, []);
const handleReset = () => {
setScale(1);
setRotation(0);
setPosition({ x: 0.5, y: 0.5 });
};
return ( return (
<OGDialog <OGDialog
open={isDialogOpen} open={isDialogOpen}
@ -125,90 +158,190 @@ function Avatar() {
setDialogOpen(open); setDialogOpen(open);
if (!open) { if (!open) {
resetImage(); resetImage();
setTimeout(() => {
openButtonRef.current?.focus();
}, 0);
} }
}} }}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>{localize('com_nav_profile_picture')}</span> <span>{localize('com_nav_profile_picture')}</span>
<OGDialogTrigger ref={openButtonRef}> <OGDialogTrigger asChild>
<Button variant="outline"> <Button variant="outline">
<FileImage className="mr-2 flex w-[22px] items-center stroke-1" /> <FileImage className="mr-2 flex w-[22px] items-center" />
<span>{localize('com_nav_change_picture')}</span> <span>{localize('com_nav_change_picture')}</span>
</Button> </Button>
</OGDialogTrigger> </OGDialogTrigger>
</div> </div>
<OGDialogContent className="w-11/12 max-w-sm" style={{ borderRadius: '12px' }}> <OGDialogContent showCloseButton={false} className="w-11/12 max-w-md">
<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 != null ? localize('com_ui_preview') : localize('com_ui_upload_image')} {image != null ? localize('com_ui_preview') : localize('com_ui_upload_image')}
</OGDialogTitle> </OGDialogTitle>
</OGDialogHeader> </OGDialogHeader>
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center p-2">
{image != null ? ( {image != null ? (
<> <>
<div className="relative overflow-hidden rounded-full"> <div
className={cn(
'relative overflow-hidden rounded-full ring-4 ring-gray-200 transition-all dark:ring-gray-700',
isDragging && 'cursor-move ring-blue-500 dark:ring-blue-400',
)}
onMouseDown={() => setIsDragging(true)}
onMouseUp={() => setIsDragging(false)}
onMouseLeave={() => setIsDragging(false)}
>
<AvatarEditor <AvatarEditor
ref={editorRef} ref={editorRef}
image={image} image={image}
width={250} width={280}
height={250} height={280}
border={0} border={0}
borderRadius={125} borderRadius={140}
color={[255, 255, 255, 0.6]} color={[255, 255, 255, 0.6]}
scale={scale} scale={scale}
rotate={rotation} rotate={rotation}
position={position}
onPositionChange={handlePositionChange}
className="cursor-move"
/> />
{!isDragging && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 transition-opacity hover:opacity-100">
<div className="rounded-full bg-black/50 p-2">
<Move className="h-6 w-6 text-white" />
</div> </div>
<div className="mt-4 flex w-full flex-col items-center space-y-4"> </div>
<div className="flex w-full items-center justify-center space-x-4"> )}
<span className="text-sm">{localize('com_ui_zoom')}</span> </div>
<div className="mt-6 w-full space-y-6">
{/* Zoom Controls */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="zoom-slider" className="text-sm font-medium">
{localize('com_ui_zoom')}
</Label>
<span className="text-sm text-text-secondary">{Math.round(scale * 100)}%</span>
</div>
<div className="flex items-center space-x-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleZoomOut}
disabled={scale <= 1}
aria-label={localize('com_ui_zoom_out')}
className="shrink-0"
>
<ZoomOut className="h-4 w-4" />
</Button>
<Slider <Slider
id="zoom-slider"
value={[scale]} value={[scale]}
min={1} min={1}
max={5} max={5}
step={0.001} step={0.1}
onValueChange={handleScaleChange} onValueChange={handleScaleChange}
className="w-2/3 max-w-xs" className="flex-1"
aria-label={localize('com_ui_zoom_level')}
/> />
</div>
<button
onClick={handleRotate}
className="rounded-full bg-gray-200 p-2 transition-colors hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500"
>
<RotateCw className="h-5 w-5" />
</button>
</div>
<Button <Button
className={cn( type="button"
'btn btn-primary mt-4 flex w-full hover:bg-green-600', variant="outline"
isUploading ? 'cursor-not-allowed opacity-90' : '', size="sm"
)} onClick={handleZoomIn}
disabled={scale >= 5}
aria-label={localize('com_ui_zoom_in')}
className="shrink-0"
>
<ZoomIn className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex items-center justify-center space-x-3">
<Button
type="button"
variant="outline"
onClick={handleRotate}
className="flex items-center space-x-2"
aria-label={localize('com_ui_rotate_90')}
>
<RotateCw className="h-4 w-4" />
<span className="text-sm">{localize('com_ui_rotate')}</span>
</Button>
<Button
type="button"
variant="outline"
onClick={handleReset}
className="flex items-center space-x-2"
aria-label={localize('com_ui_reset_adjustments')}
>
<X className="h-4 w-4" />
<span className="text-sm">{localize('com_ui_reset')}</span>
</Button>
</div>
{/* Helper Text */}
<p className="text-center text-xs text-gray-500 dark:text-gray-400">
{localize('com_ui_editor_instructions')}
</p>
</div>
{/* Action Buttons */}
<div className="mt-6 flex w-full space-x-3">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={resetImage}
disabled={isUploading}
>
{localize('com_ui_cancel')}
</Button>
<Button
variant="submit"
type="button"
className={cn('w-full', isUploading ? 'cursor-not-allowed opacity-90' : '')}
onClick={handleUpload} onClick={handleUpload}
disabled={isUploading} disabled={isUploading}
> >
{isUploading ? ( {isUploading ? (
<Spinner className="icon-sm mr-2" /> <Spinner className="icon-sm mr-2" />
) : ( ) : (
<Upload className="mr-2 h-5 w-5" /> <Upload className="mr-2 h-4 w-4" />
)} )}
{localize('com_ui_upload')} {localize('com_ui_upload')}
</Button> </Button>
</div>
</> </>
) : ( ) : (
<div <div
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" className="flex h-72 w-full flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-transparent transition-colors hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500"
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
role="button"
tabIndex={0}
onClick={openFileDialog}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openFileDialog();
}
}}
aria-label={localize('com_ui_upload_avatar_label')}
> >
<FileImage className="mb-4 size-12 text-gray-400" /> <FileImage className="mb-4 size-16 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 font-medium text-text-primary">
{localize('com_ui_drag_drop')} {localize('com_ui_drag_drop')}
</p> </p>
<Button variant="secondary" onClick={openFileDialog}> <p className="mb-4 text-center text-xs text-text-secondary">
{localize('com_ui_max_file_size', {
0:
fileConfig.avatarSizeLimit != null
? formatBytes(fileConfig.avatarSizeLimit)
: '2MB',
})}
</p>
<Button type="button" variant="secondary" onClick={openFileDialog}>
{localize('com_ui_select_file')} {localize('com_ui_select_file')}
</Button> </Button>
<input <input
@ -217,6 +350,7 @@ function Avatar() {
className="hidden" className="hidden"
accept=".png, .jpg, .jpeg" accept=".png, .jpg, .jpeg"
onChange={handleFileChange} onChange={handleFileChange}
aria-label={localize('com_ui_file_input_avatar_label')}
/> />
</div> </div>
)} )}

View file

@ -1,6 +1,7 @@
import { LockIcon, Trash } from 'lucide-react'; import { LockIcon, Trash } from 'lucide-react';
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { import {
Label,
Input, Input,
Button, Button,
Spinner, Spinner,
@ -45,11 +46,11 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
<> <>
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}> <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> <Label id="delete-account-label">{localize('com_nav_delete_account')}</Label>
<OGDialogTrigger asChild> <OGDialogTrigger asChild>
<Button <Button
aria-labelledby="delete-account-label"
variant="destructive" variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setDialogOpen(true)} onClick={() => setDialogOpen(true)}
disabled={disabled} disabled={disabled}
> >

View file

@ -20,7 +20,7 @@ export const DisableTwoFactorToggle: React.FC<DisableTwoFactorToggleProps> = ({
return ( return (
<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">
<Label className="font-light"> {localize('com_nav_2fa')}</Label> <Label> {localize('com_nav_2fa')}</Label>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button <Button

View file

@ -15,7 +15,7 @@ export default function DisplayUsernameMessages() {
return ( return (
<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">
<Label className="font-light">{localize('com_nav_user_name_display')}</Label> <Label id="user-name-display-label">{localize('com_nav_user_name_display')}</Label>
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_user_name_display')} /> <InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_user_name_display')} />
</div> </div>
<Switch <Switch
@ -24,6 +24,7 @@ export default function DisplayUsernameMessages() {
onCheckedChange={handleCheckedChange} onCheckedChange={handleCheckedChange}
className="ml-4" className="ml-4"
data-testid="UsernameDisplay" data-testid="UsernameDisplay"
aria-labelledby="user-name-display-label"
/> />
</div> </div>
); );

View file

@ -19,16 +19,16 @@ const ChatDirection = () => {
</div> </div>
<Button <Button
variant="outline" variant="outline"
aria-label="Toggle chat direction" aria-label={`${localize('com_nav_chat_direction')}: ${localize('com_ui_x_selected', {
0:
direction === 'LTR'
? localize('chat_direction_left_to_right')
: localize('chat_direction_right_to_left'),
})}`}
onClick={toggleChatDirection} onClick={toggleChatDirection}
data-testid="chatDirection" data-testid="chatDirection"
> >
<span aria-hidden="true">{direction.toLowerCase()}</span> {direction.toLowerCase()}
<span id="chat-direction-status" className="sr-only">
{direction === 'LTR'
? localize('chat_direction_left_to_right')
: localize('chat_direction_right_to_left')}
</span>
</Button> </Button>
</div> </div>
); );

View file

@ -20,9 +20,11 @@ export default function FontSizeSelector() {
{ value: 'text-xl', label: localize('com_nav_font_size_xl') }, { value: 'text-xl', label: localize('com_nav_font_size_xl') },
]; ];
const labelId = 'font-size-selector-label';
return ( return (
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div>{localize('com_nav_font_size')}</div> <div id={labelId}>{localize('com_nav_font_size')}</div>
<Dropdown <Dropdown
value={fontSize} value={fontSize}
options={options} options={options}
@ -30,6 +32,7 @@ export default function FontSizeSelector() {
testId="font-size-selector" testId="font-size-selector"
sizeClasses="w-[150px]" sizeClasses="w-[150px]"
className="z-50" className="z-50"
aria-labelledby={labelId}
/> />
</div> </div>
); );

View file

@ -20,13 +20,14 @@ export const ForkSettings = () => {
<> <>
<div className="pb-3"> <div className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> {localize('com_ui_fork_default')} </div> <div id="remember-default-fork-label"> {localize('com_ui_fork_default')} </div>
<Switch <Switch
id="rememberDefaultFork" id="rememberDefaultFork"
checked={remember} checked={remember}
onCheckedChange={setRemember} onCheckedChange={setRemember}
className="ml-4" className="ml-4"
data-testid="rememberDefaultFork" data-testid="rememberDefaultFork"
aria-labelledby="remember-default-fork-label"
/> />
</div> </div>
</div> </div>
@ -34,7 +35,7 @@ export const ForkSettings = () => {
<div className="pb-3"> <div className="pb-3">
<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 id="fork-change-default-label">{localize('com_ui_fork_change_default')}</div>
<InfoHoverCard <InfoHoverCard
side={ESide.Bottom} side={ESide.Bottom}
text={localize('com_nav_info_fork_change_default')} text={localize('com_nav_info_fork_change_default')}
@ -47,6 +48,7 @@ export const ForkSettings = () => {
sizeClasses="w-[200px]" sizeClasses="w-[200px]"
testId="fork-setting-dropdown" testId="fork-setting-dropdown"
className="z-[50]" className="z-[50]"
aria-labelledby="fork-change-default-label"
/> />
</div> </div>
</div> </div>
@ -54,7 +56,7 @@ export const ForkSettings = () => {
<div className="pb-3"> <div className="pb-3">
<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 id="split-at-target-label">{localize('com_ui_fork_split_target_setting')}</div>
<InfoHoverCard <InfoHoverCard
side={ESide.Bottom} side={ESide.Bottom}
text={localize('com_nav_info_fork_split_target_setting')} text={localize('com_nav_info_fork_split_target_setting')}
@ -66,6 +68,7 @@ export const ForkSettings = () => {
onCheckedChange={setSplitAtTarget} onCheckedChange={setSplitAtTarget}
className="ml-4" className="ml-4"
data-testid="splitAtTarget" data-testid="splitAtTarget"
aria-labelledby="split-at-target-label"
/> />
</div> </div>
</div> </div>

View file

@ -1,26 +0,0 @@
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function AtCommandSwitch() {
const [atCommand, setAtCommand] = useRecoilState<boolean>(store.atCommand);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setAtCommand(value);
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_at_command_description')}</div>
<Switch
id="atCommand"
checked={atCommand}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="atCommand"
/>
</div>
);
}

View file

@ -1,10 +1,33 @@
import { memo } from 'react'; import { memo } from 'react';
import { InfoHoverCard, ESide } from '@librechat/client'; import { InfoHoverCard, ESide } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider'; import { PermissionTypes, Permissions } from 'librechat-data-provider';
import SlashCommandSwitch from './SlashCommandSwitch';
import { useLocalize, useHasAccess } from '~/hooks'; import { useLocalize, useHasAccess } from '~/hooks';
import PlusCommandSwitch from './PlusCommandSwitch'; import ToggleSwitch from '../ToggleSwitch';
import AtCommandSwitch from './AtCommandSwitch'; import store from '~/store';
const commandSwitchConfigs = [
{
stateAtom: store.atCommand,
localizationKey: 'com_nav_at_command_description' as const,
switchId: 'atCommand',
key: 'atCommand',
permissionType: undefined,
},
{
stateAtom: store.plusCommand,
localizationKey: 'com_nav_plus_command_description' as const,
switchId: 'plusCommand',
key: 'plusCommand',
permissionType: PermissionTypes.MULTI_CONVO,
},
{
stateAtom: store.slashCommand,
localizationKey: 'com_nav_slash_command_description' as const,
switchId: 'slashCommand',
key: 'slashCommand',
permissionType: PermissionTypes.PROMPTS,
},
] as const;
function Commands() { function Commands() {
const localize = useLocalize(); const localize = useLocalize();
@ -19,6 +42,19 @@ function Commands() {
permission: Permissions.USE, permission: Permissions.USE,
}); });
const getShowSwitch = (permissionType?: PermissionTypes) => {
if (!permissionType) {
return true;
}
if (permissionType === PermissionTypes.MULTI_CONVO) {
return hasAccessToMultiConvo === true;
}
if (permissionType === PermissionTypes.PROMPTS) {
return hasAccessToPrompts === true;
}
return true;
};
return ( return (
<div className="space-y-4 p-1"> <div className="space-y-4 p-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -28,19 +64,16 @@ function Commands() {
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_chat_commands_info')} /> <InfoHoverCard side={ESide.Bottom} text={localize('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="pb-3"> {commandSwitchConfigs.map((config) => (
<AtCommandSwitch /> <div key={config.key} className="pb-3">
<ToggleSwitch
stateAtom={config.stateAtom}
localizationKey={config.localizationKey}
switchId={config.switchId}
showSwitch={getShowSwitch(config.permissionType)}
/>
</div> </div>
{hasAccessToMultiConvo === true && ( ))}
<div className="pb-3">
<PlusCommandSwitch />
</div>
)}
{hasAccessToPrompts === true && (
<div className="pb-3">
<SlashCommandSwitch />
</div>
)}
</div> </div>
</div> </div>
); );

View file

@ -1,26 +0,0 @@
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function PlusCommandSwitch() {
const [plusCommand, setPlusCommand] = useRecoilState<boolean>(store.plusCommand);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setPlusCommand(value);
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_plus_command_description')}</div>
<Switch
id="plusCommand"
checked={plusCommand}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="plusCommand"
/>
</div>
);
}

View file

@ -1,25 +0,0 @@
import { useRecoilState } from 'recoil';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function SlashCommandSwitch() {
const [slashCommand, setSlashCommand] = useRecoilState<boolean>(store.slashCommand);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setSlashCommand(value);
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_slash_command_description')}</div>
<Switch
id="slashCommand"
checked={slashCommand}
onCheckedChange={handleCheckedChange}
data-testid="slashCommand"
/>
</div>
);
}

View file

@ -31,12 +31,12 @@ export const ClearChats = () => {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="font-light">{localize('com_nav_clear_all_chats')}</Label> <Label id="clear-all-chats-label">{localize('com_nav_clear_all_chats')}</Label>
<OGDialog open={open} onOpenChange={setOpen}> <OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild> <OGDialogTrigger asChild>
<Button <Button
aria-labelledby="clear-all-chats-label"
variant="destructive" variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
{localize('com_ui_delete')} {localize('com_ui_delete')}
@ -47,7 +47,7 @@ export const ClearChats = () => {
title={localize('com_nav_confirm_clear')} title={localize('com_nav_confirm_clear')}
className="max-w-[450px]" className="max-w-[450px]"
main={ main={
<Label className="text-left text-sm font-medium"> <Label className="break-words">
{localize('com_nav_clear_conversation_confirm_message')} {localize('com_nav_clear_conversation_confirm_message')}
</Label> </Label>
} }

View file

@ -1,7 +1,7 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { useOnClickOutside } from '@librechat/client'; import { useOnClickOutside } from '@librechat/client';
import ImportConversations from './ImportConversations'; import ImportConversations from './ImportConversations';
import { RevokeAllKeys } from './RevokeAllKeys'; import { RevokeKeys } from './RevokeKeys';
import { DeleteCache } from './DeleteCache'; import { DeleteCache } from './DeleteCache';
import { ClearChats } from './ClearChats'; import { ClearChats } from './ClearChats';
import SharedLinks from './SharedLinks'; import SharedLinks from './SharedLinks';
@ -20,7 +20,7 @@ function Data() {
<SharedLinks /> <SharedLinks />
</div> </div>
<div className="pb-3"> <div className="pb-3">
<RevokeAllKeys /> <RevokeKeys />
</div> </div>
<div className="pb-3"> <div className="pb-3">
<DeleteCache /> <DeleteCache />

View file

@ -38,14 +38,14 @@ export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="font-light">{localize('com_nav_delete_cache_storage')}</Label> <Label id="delete-cache-label">{localize('com_nav_delete_cache_storage')}</Label>
<OGDialog open={open} onOpenChange={setOpen}> <OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild> <OGDialogTrigger asChild>
<Button <Button
variant="destructive" variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
disabled={disabled || isCacheEmpty} disabled={disabled || isCacheEmpty}
aria-labelledby="delete-cache-label"
> >
{localize('com_ui_delete')} {localize('com_ui_delete')}
</Button> </Button>

View file

@ -1,96 +1,114 @@
import { useState, useRef } from 'react'; import { useState, useRef, useCallback } from 'react';
import { Import } from 'lucide-react'; import { Import } from 'lucide-react';
import { Spinner, useToastContext } from '@librechat/client'; import { Spinner, useToastContext, Label, Button } from '@librechat/client';
import type { TError } from 'librechat-data-provider';
import { useUploadConversationsMutation } from '~/data-provider'; import { useUploadConversationsMutation } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn } from '~/utils'; import { cn, logger } from '~/utils';
function ImportConversations() { function ImportConversations() {
const localize = useLocalize(); const localize = useLocalize();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const [, setErrors] = useState<string[]>([]);
const [allowImport, setAllowImport] = useState(true); const [isUploading, setIsUploading] = useState(false);
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
const handleSuccess = useCallback(() => {
showToast({
message: localize('com_ui_import_conversation_success'),
status: NotificationSeverity.SUCCESS,
});
setIsUploading(false);
}, [localize, showToast]);
const handleError = useCallback(
(error: unknown) => {
logger.error('Import error:', error);
setIsUploading(false);
const isUnsupportedType = error?.toString().includes('Unsupported import type');
showToast({
message: localize(
isUnsupportedType
? 'com_ui_import_conversation_file_type_error'
: 'com_ui_import_conversation_error',
),
status: NotificationSeverity.ERROR,
});
},
[localize, showToast],
);
const uploadFile = useUploadConversationsMutation({ const uploadFile = useUploadConversationsMutation({
onSuccess: () => { onSuccess: handleSuccess,
showToast({ message: localize('com_ui_import_conversation_success') }); onError: handleError,
setAllowImport(true); onMutate: () => setIsUploading(true),
},
onError: (error) => {
console.error('Error: ', error);
setAllowImport(true);
setError(
(error as TError).response?.data?.message ?? 'An error occurred while uploading the file.',
);
if (error?.toString().includes('Unsupported import type') === true) {
showToast({
message: localize('com_ui_import_conversation_file_type_error'),
status: 'error',
});
} else {
showToast({ message: localize('com_ui_import_conversation_error'), status: 'error' });
}
},
onMutate: () => {
setAllowImport(false);
},
}); });
const startUpload = async (file: File) => { const handleFileUpload = useCallback(
async (file: File) => {
try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file, encodeURIComponent(file.name || 'File')); formData.append('file', file, encodeURIComponent(file.name || 'File'));
uploadFile.mutate(formData); uploadFile.mutate(formData);
};
const handleFiles = async (_file: File) => {
try {
await startUpload(_file);
} catch (error) { } catch (error) {
console.log('file handling error', error); logger.error('File processing error:', error);
setError('An error occurred while processing the file.'); setIsUploading(false);
showToast({
message: localize('com_ui_import_conversation_upload_error'),
status: NotificationSeverity.ERROR,
});
} }
}; },
[uploadFile, showToast, localize],
);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (file) {
handleFiles(file); handleFileUpload(file);
} }
}; event.target.value = '';
},
[handleFileUpload],
);
const handleImportClick = () => { const handleImportClick = useCallback(() => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; }, []);
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => { const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); event.preventDefault();
handleImportClick(); handleImportClick();
} }
}; },
[handleImportClick],
);
const isImportDisabled = isUploading;
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_ui_import_conversation_info')}</div> <Label id="import-conversation-label">{localize('com_ui_import_conversation_info')}</Label>
<button <Button
variant="outline"
onClick={handleImportClick} onClick={handleImportClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
disabled={!allowImport} disabled={isImportDisabled}
aria-label={localize('com_ui_import')} aria-label={localize('com_ui_import')}
className="btn btn-neutral relative" aria-labelledby="import-conversation-label"
> >
{allowImport ? ( {isUploading ? (
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
) : (
<Spinner className="mr-1 w-4" /> <Spinner className="mr-1 w-4" />
) : (
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
)} )}
<span>{localize('com_ui_import')}</span> <span>{localize('com_ui_import')}</span>
</button> </Button>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"

View file

@ -1,15 +0,0 @@
import React from 'react';
import { Label } from '@librechat/client';
import { RevokeKeysButton } from './RevokeKeysButton';
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

@ -0,0 +1,72 @@
import React, { useState } from 'react';
import { useRevokeAllUserKeysMutation } from 'librechat-data-provider/react-query';
import {
OGDialogTemplate,
Button,
Label,
OGDialog,
OGDialogTrigger,
Spinner,
} from '@librechat/client';
import { useLocalize } from '~/hooks';
export const RevokeKeys = ({
disabled = false,
setDialogOpen,
}: {
disabled?: boolean;
setDialogOpen?: (open: boolean) => void;
}) => {
const localize = useLocalize();
const [open, setOpen] = useState(false);
const revokeKeysMutation = useRevokeAllUserKeysMutation();
const handleSuccess = () => {
if (!setDialogOpen) {
return;
}
setDialogOpen(false);
};
const onClick = () => {
revokeKeysMutation.mutate({}, { onSuccess: handleSuccess });
};
const isLoading = revokeKeysMutation.isLoading;
return (
<div className="flex items-center justify-between">
<Label id="revoke-info-label">{localize('com_ui_revoke_info')}</Label>
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<Button
variant="destructive"
onClick={() => setOpen(true)}
disabled={disabled}
aria-labelledby="revoke-info-label"
>
{localize('com_ui_revoke')}
</Button>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_revoke_keys')}
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_revoke_keys_confirm')}
</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>
</div>
);
};

View file

@ -1,84 +0,0 @@
import React, { useState } from 'react';
import {
useRevokeAllUserKeysMutation,
useRevokeUserKeyMutation,
} from 'librechat-data-provider/react-query';
import {
OGDialogTemplate,
Button,
Label,
OGDialog,
OGDialogTrigger,
Spinner,
} from '@librechat/client';
import { useLocalize } from '~/hooks';
export const RevokeKeysButton = ({
endpoint = '',
all = false,
disabled = false,
setDialogOpen,
}: {
endpoint?: string;
all?: boolean;
disabled?: boolean;
setDialogOpen?: (open: boolean) => void;
}) => {
const localize = useLocalize();
const [open, setOpen] = useState(false);
const revokeKeyMutation = useRevokeUserKeyMutation(endpoint);
const revokeKeysMutation = useRevokeAllUserKeysMutation();
const handleSuccess = () => {
if (!setDialogOpen) {
return;
}
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', { 0: endpoint });
const dialogMessage = all
? localize('com_ui_revoke_keys_confirm')
: localize('com_ui_revoke_key_confirm');
const isLoading = revokeKeyMutation.isLoading || revokeKeysMutation.isLoading;
return (
<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}
>
{localize('com_ui_revoke')}
</Button>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={dialogTitle}
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

@ -286,11 +286,13 @@ export default function SharedLinks() {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_shared_links')}</div> <Label id="shared-links-label">{localize('com_nav_shared_links')}</Label>
<OGDialog open={isOpen} onOpenChange={setIsOpen}> <OGDialog open={isOpen} onOpenChange={setIsOpen}>
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}> <OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
<Button variant="outline">{localize('com_ui_manage')}</Button> <Button aria-labelledby="shared-links-label" variant="outline">
{localize('com_ui_manage')}
</Button>
</OGDialogTrigger> </OGDialogTrigger>
<OGDialogContent <OGDialogContent

View file

@ -46,9 +46,11 @@ export const ThemeSelector = ({
{ value: 'light', label: localize('com_nav_theme_light') }, { value: 'light', label: localize('com_nav_theme_light') },
]; ];
const labelId = 'theme-selector-label';
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_theme')}</div> <div id={labelId}>{localize('com_nav_theme')}</div>
<Dropdown <Dropdown
value={theme} value={theme}
@ -57,6 +59,7 @@ export const ThemeSelector = ({
sizeClasses="w-[180px]" sizeClasses="w-[180px]"
testId="theme-selector" testId="theme-selector"
className="z-50" className="z-50"
aria-labelledby={labelId}
/> />
</div> </div>
); );
@ -112,9 +115,11 @@ export const LangSelector = ({
{ value: 'uk-UA', label: localize('com_nav_lang_ukrainian') }, { value: 'uk-UA', label: localize('com_nav_lang_ukrainian') },
]; ];
const labelId = 'language-selector-label';
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_language')}</div> <div id={labelId}>{localize('com_nav_language')}</div>
<Dropdown <Dropdown
value={langcode} value={langcode}
@ -122,6 +127,7 @@ export const LangSelector = ({
sizeClasses="[--anchor-max-height:256px]" sizeClasses="[--anchor-max-height:256px]"
options={languageOptions} options={languageOptions}
className="z-50" className="z-50"
aria-labelledby={labelId}
/> />
</div> </div>
); );

View file

@ -65,10 +65,13 @@ export default function Personalization({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="flex items-center gap-2"> <div id="reference-saved-memories-label" className="flex items-center gap-2">
{localize('com_ui_reference_saved_memories')} {localize('com_ui_reference_saved_memories')}
</div> </div>
<div className="mt-1 text-xs text-text-secondary"> <div
id="reference-saved-memories-description"
className="mt-1 text-xs text-text-secondary"
>
{localize('com_ui_reference_saved_memories_description')} {localize('com_ui_reference_saved_memories_description')}
</div> </div>
</div> </div>
@ -76,7 +79,8 @@ export default function Personalization({
checked={referenceSavedMemories} checked={referenceSavedMemories}
onCheckedChange={handleMemoryToggle} onCheckedChange={handleMemoryToggle}
disabled={updateMemoryPreferencesMutation.isLoading} disabled={updateMemoryPreferencesMutation.isLoading}
aria-label={localize('com_ui_reference_saved_memories')} aria-labelledby="reference-saved-memories-label"
aria-describedby="reference-saved-memories-description"
/> />
</div> </div>
</> </>

View file

@ -1,6 +1,5 @@
import { Switch } from '@librechat/client';
import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilState, useRecoilValue } from 'recoil';
import { useLocalize } from '~/hooks'; import ToggleSwitch from '../ToggleSwitch';
import store from '~/store'; import store from '~/store';
export default function ConversationModeSwitch({ export default function ConversationModeSwitch({
@ -8,8 +7,6 @@ export default function ConversationModeSwitch({
}: { }: {
onCheckedChange?: (value: boolean) => void; onCheckedChange?: (value: boolean) => void;
}) { }) {
const localize = useLocalize();
const [conversationMode, setConversationMode] = useRecoilState<boolean>(store.conversationMode);
const speechToText = useRecoilValue(store.speechToText); const speechToText = useRecoilValue(store.speechToText);
const textToSpeech = useRecoilValue(store.textToSpeech); const textToSpeech = useRecoilValue(store.textToSpeech);
const [, setAutoSendText] = useRecoilState(store.autoSendText); const [, setAutoSendText] = useRecoilState(store.autoSendText);
@ -20,27 +17,19 @@ export default function ConversationModeSwitch({
setAutoTranscribeAudio(value); setAutoTranscribeAudio(value);
setAutoSendText(3); setAutoSendText(3);
setDecibelValue(-45); setDecibelValue(-45);
setConversationMode(value);
if (onCheckedChange) { if (onCheckedChange) {
onCheckedChange(value); onCheckedChange(value);
} }
}; };
return ( return (
<div className="flex items-center justify-between"> <ToggleSwitch
<div> stateAtom={store.conversationMode}
<strong>{localize('com_nav_conversation_mode')}</strong> localizationKey={'com_nav_conversation_mode' as const}
</div> switchId="ConversationMode"
<div className="flex items-center justify-between">
<Switch
id="ConversationMode"
checked={conversationMode}
onCheckedChange={handleCheckedChange} onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="ConversationMode"
disabled={!textToSpeech || !speechToText} disabled={!textToSpeech || !speechToText}
strongLabel={true}
/> />
</div>
</div>
); );
} }

View file

@ -1,6 +1,6 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilState, useRecoilValue } from 'recoil';
import { Slider, InputNumber } from '@librechat/client'; import { Slider, InputNumber, Switch } from '@librechat/client';
import { cn, defaultTextProps, optionText } from '~/utils/'; import { cn, defaultTextProps, optionText } from '~/utils/';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
@ -11,31 +11,93 @@ export default function AutoSendTextSelector() {
const speechToText = useRecoilValue(store.speechToText); const speechToText = useRecoilValue(store.speechToText);
const [autoSendText, setAutoSendText] = useRecoilState(store.autoSendText); const [autoSendText, setAutoSendText] = useRecoilState(store.autoSendText);
// Local state for enabled/disabled toggle
const [isEnabled, setIsEnabled] = useState(autoSendText !== -1);
const [delayValue, setDelayValue] = useState(autoSendText === -1 ? 3 : autoSendText);
// Sync local state when autoSendText changes externally
useEffect(() => {
setIsEnabled(autoSendText !== -1);
if (autoSendText !== -1) {
setDelayValue(autoSendText);
}
}, [autoSendText]);
const handleToggle = (enabled: boolean) => {
setIsEnabled(enabled);
if (enabled) {
setAutoSendText(delayValue);
} else {
setAutoSendText(-1);
}
};
const handleSliderChange = (value: number[]) => {
const newValue = value[0];
setDelayValue(newValue);
if (isEnabled) {
setAutoSendText(newValue);
}
};
const handleInputChange = (value: number[] | null) => {
const newValue = value ? value[0] : 3;
setDelayValue(newValue);
if (isEnabled) {
setAutoSendText(newValue);
}
};
const labelId = 'auto-send-text-label';
return ( return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div id={labelId}>{localize('com_nav_auto_send_text')}</div>
</div>
<Switch
id="autoSendTextToggle"
checked={isEnabled}
onCheckedChange={handleToggle}
className="ml-4"
data-testid="autoSendTextToggle"
aria-labelledby={labelId}
disabled={!speechToText}
/>
</div>
{isEnabled && (
<div className="mt-2 flex items-center justify-between">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_auto_send_text')}</div> <div id="auto-send-delay-label" className="text-sm text-text-secondary">
<div className="w-2" /> {localize('com_nav_setting_delay')}
<small className="opacity-40">({localize('com_nav_auto_send_text_disabled')})</small> </div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Slider <Slider
value={[autoSendText ?? -1]} value={[delayValue]}
onValueChange={(value) => setAutoSendText(value[0])} onValueChange={handleSliderChange}
onDoubleClick={() => setAutoSendText(-1)} onDoubleClick={() => {
min={-1} setDelayValue(3);
if (isEnabled) {
setAutoSendText(3);
}
}}
min={0}
max={60} max={60}
step={1} step={1}
className="ml-4 flex h-4 w-24" className="ml-4 flex h-4 w-24"
disabled={!speechToText} disabled={!speechToText || !isEnabled}
aria-labelledby="auto-send-delay-label"
/> />
<div className="w-2" /> <div className="w-2" />
<InputNumber <InputNumber
value={`${autoSendText} s`} value={`${delayValue} s`}
disabled={!speechToText} disabled={!speechToText || !isEnabled}
onChange={(value) => setAutoSendText(value ? value[0] : 0)} onChange={handleInputChange}
min={-1} min={0}
max={60} max={60}
aria-labelledby="auto-send-delay-label"
className={cn( className={cn(
defaultTextProps, defaultTextProps,
cn( cn(
@ -46,5 +108,7 @@ export default function AutoSendTextSelector() {
/> />
</div> </div>
</div> </div>
)}
</div>
); );
} }

View file

@ -1,6 +1,5 @@
import { Switch } from '@librechat/client'; import { useRecoilValue } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil'; import ToggleSwitch from '../../ToggleSwitch';
import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
export default function AutoTranscribeAudioSwitch({ export default function AutoTranscribeAudioSwitch({
@ -8,30 +7,15 @@ export default function AutoTranscribeAudioSwitch({
}: { }: {
onCheckedChange?: (value: boolean) => void; onCheckedChange?: (value: boolean) => void;
}) { }) {
const localize = useLocalize();
const [autoTranscribeAudio, setAutoTranscribeAudio] = useRecoilState<boolean>(
store.autoTranscribeAudio,
);
const speechToText = useRecoilValue(store.speechToText); const speechToText = useRecoilValue(store.speechToText);
const handleCheckedChange = (value: boolean) => {
setAutoTranscribeAudio(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return ( return (
<div className="flex items-center justify-between"> <ToggleSwitch
<div>{localize('com_nav_auto_transcribe_audio')}</div> stateAtom={store.autoTranscribeAudio}
<Switch localizationKey={'com_nav_auto_transcribe_audio' as const}
id="AutoTranscribeAudio" switchId="AutoTranscribeAudio"
checked={autoTranscribeAudio} onCheckedChange={onCheckedChange}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="AutoTranscribeAudio"
disabled={!speechToText} disabled={!speechToText}
/> />
</div>
); );
} }

View file

@ -13,7 +13,7 @@ export default function DecibelSelector() {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_db_sensitivity')}</div> <div id="decibel-selector-label">{localize('com_nav_db_sensitivity')}</div>
<div className="w-2" /> <div className="w-2" />
<small className="opacity-40"> <small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '-45' })}) ({localize('com_endpoint_default_with_num', { 0: '-45' })})
@ -29,6 +29,7 @@ export default function DecibelSelector() {
step={1} step={1}
className="ml-4 flex h-4 w-24" className="ml-4 flex h-4 w-24"
disabled={!speechToText} disabled={!speechToText}
aria-labelledby="decibel-selector-label"
/> />
<div className="w-2" /> <div className="w-2" />
<InputNumber <InputNumber
@ -37,6 +38,7 @@ export default function DecibelSelector() {
onChange={(value) => setDecibelValue(value ? value[0] : 0)} onChange={(value) => setDecibelValue(value ? value[0] : 0)}
min={-100} min={-100}
max={-30} max={-30}
aria-labelledby="decibel-selector-label"
className={cn( className={cn(
defaultTextProps, defaultTextProps,
cn( cn(

View file

@ -23,9 +23,11 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
setEngineSTT(value); setEngineSTT(value);
}; };
const labelId = 'engine-stt-dropdown-label';
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_engine')}</div> <div id={labelId}>{localize('com_nav_engine')}</div>
<Dropdown <Dropdown
value={engineSTT} value={engineSTT}
onChange={handleSelect} onChange={handleSelect}
@ -33,6 +35,7 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
sizeClasses="w-[180px]" sizeClasses="w-[180px]"
testId="EngineSTTDropdown" testId="EngineSTTDropdown"
className="z-50" className="z-50"
aria-labelledby={labelId}
/> />
</div> </div>
); );

View file

@ -94,9 +94,11 @@ export default function LanguageSTTDropdown() {
setLanguageSTT(value); setLanguageSTT(value);
}; };
const labelId = 'language-stt-dropdown-label';
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_language')}</div> <div id={labelId}>{localize('com_nav_language')}</div>
<Dropdown <Dropdown
value={languageSTT} value={languageSTT}
onChange={handleSelect} onChange={handleSelect}
@ -104,6 +106,7 @@ export default function LanguageSTTDropdown() {
sizeClasses="[--anchor-max-height:256px]" sizeClasses="[--anchor-max-height:256px]"
testId="LanguageSTTDropdown" testId="LanguageSTTDropdown"
className="z-50" className="z-50"
aria-labelledby={labelId}
/> />
</div> </div>
); );

View file

@ -1,6 +1,4 @@
import { useRecoilState } from 'recoil'; import ToggleSwitch from '../../ToggleSwitch';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
export default function SpeechToTextSwitch({ export default function SpeechToTextSwitch({
@ -8,28 +6,13 @@ export default function SpeechToTextSwitch({
}: { }: {
onCheckedChange?: (value: boolean) => void; onCheckedChange?: (value: boolean) => void;
}) { }) {
const localize = useLocalize();
const [speechToText, setSpeechToText] = useRecoilState<boolean>(store.speechToText);
const handleCheckedChange = (value: boolean) => {
setSpeechToText(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return ( return (
<div className="flex items-center justify-between"> <ToggleSwitch
<div> stateAtom={store.speechToText}
<strong>{localize('com_nav_speech_to_text')}</strong> localizationKey={'com_nav_speech_to_text' as const}
</div> switchId="SpeechToText"
<Switch onCheckedChange={onCheckedChange}
id="SpeechToText" strongLabel={true}
checked={speechToText}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="SpeechToText"
/> />
</div>
); );
} }

View file

@ -23,7 +23,7 @@ import {
} from './STT'; } from './STT';
import ConversationModeSwitch from './ConversationModeSwitch'; import ConversationModeSwitch from './ConversationModeSwitch';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn, logger } from '~/utils'; import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
function Speech() { function Speech() {
@ -186,7 +186,7 @@ function Speech() {
</Tabs.List> </Tabs.List>
</div> </div>
<Tabs.Content value={'simple'}> <Tabs.Content value={'simple'} tabIndex={-1}>
<div className="flex flex-col gap-3 text-sm text-text-primary"> <div className="flex flex-col gap-3 text-sm text-text-primary">
<SpeechToTextSwitch /> <SpeechToTextSwitch />
<EngineSTTDropdown external={sttExternal} /> <EngineSTTDropdown external={sttExternal} />
@ -198,7 +198,7 @@ function Speech() {
</div> </div>
</Tabs.Content> </Tabs.Content>
<Tabs.Content value={'advanced'}> <Tabs.Content value={'advanced'} tabIndex={-1}>
<div className="flex flex-col gap-3 text-sm text-text-primary"> <div className="flex flex-col gap-3 text-sm text-text-primary">
<ConversationModeSwitch /> <ConversationModeSwitch />
<div className="mt-2 h-px bg-border-medium" role="none" /> <div className="mt-2 h-px bg-border-medium" role="none" />

View file

@ -1,6 +1,4 @@
import { useRecoilState } from 'recoil'; import ToggleSwitch from '../../ToggleSwitch';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
export default function AutomaticPlaybackSwitch({ export default function AutomaticPlaybackSwitch({
@ -8,26 +6,12 @@ export default function AutomaticPlaybackSwitch({
}: { }: {
onCheckedChange?: (value: boolean) => void; onCheckedChange?: (value: boolean) => void;
}) { }) {
const localize = useLocalize();
const [automaticPlayback, setAutomaticPlayback] = useRecoilState(store.automaticPlayback);
const handleCheckedChange = (value: boolean) => {
setAutomaticPlayback(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return ( return (
<div className="flex items-center justify-between"> <ToggleSwitch
<div>{localize('com_nav_automatic_playback')}</div> stateAtom={store.automaticPlayback}
<Switch localizationKey={'com_nav_automatic_playback' as const}
id="AutomaticPlayback" switchId="AutomaticPlayback"
checked={automaticPlayback} onCheckedChange={onCheckedChange}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="AutomaticPlayback"
/> />
</div>
); );
} }

View file

@ -1,6 +1,5 @@
import { useRecoilState } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Switch } from '@librechat/client'; import ToggleSwitch from '../../ToggleSwitch';
import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
export default function CacheTTSSwitch({ export default function CacheTTSSwitch({
@ -8,28 +7,15 @@ export default function CacheTTSSwitch({
}: { }: {
onCheckedChange?: (value: boolean) => void; onCheckedChange?: (value: boolean) => void;
}) { }) {
const localize = useLocalize(); const textToSpeech = useRecoilValue(store.textToSpeech);
const [cacheTTS, setCacheTTS] = useRecoilState<boolean>(store.cacheTTS);
const [textToSpeech] = useRecoilState<boolean>(store.textToSpeech);
const handleCheckedChange = (value: boolean) => {
setCacheTTS(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return ( return (
<div className="flex items-center justify-between"> <ToggleSwitch
<div>{localize('com_nav_enable_cache_tts')}</div> stateAtom={store.cacheTTS}
<Switch localizationKey={'com_nav_enable_cache_tts' as const}
id="CacheTTS" switchId="CacheTTS"
checked={cacheTTS} onCheckedChange={onCheckedChange}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="CacheTTS"
disabled={!textToSpeech} disabled={!textToSpeech}
/> />
</div>
); );
} }

View file

@ -1,6 +1,5 @@
import { useRecoilState } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Switch } from '@librechat/client'; import ToggleSwitch from '../../ToggleSwitch';
import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
export default function CloudBrowserVoicesSwitch({ export default function CloudBrowserVoicesSwitch({
@ -8,30 +7,15 @@ export default function CloudBrowserVoicesSwitch({
}: { }: {
onCheckedChange?: (value: boolean) => void; onCheckedChange?: (value: boolean) => void;
}) { }) {
const localize = useLocalize(); const textToSpeech = useRecoilValue(store.textToSpeech);
const [cloudBrowserVoices, setCloudBrowserVoices] = useRecoilState<boolean>(
store.cloudBrowserVoices,
);
const [textToSpeech] = useRecoilState<boolean>(store.textToSpeech);
const handleCheckedChange = (value: boolean) => {
setCloudBrowserVoices(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return ( return (
<div className="flex items-center justify-between"> <ToggleSwitch
<div>{localize('com_nav_enable_cloud_browser_voice')}</div> stateAtom={store.cloudBrowserVoices}
<Switch localizationKey={'com_nav_enable_cloud_browser_voice' as const}
id="CloudBrowserVoices" switchId="CloudBrowserVoices"
checked={cloudBrowserVoices} onCheckedChange={onCheckedChange}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="CloudBrowserVoices"
disabled={!textToSpeech} disabled={!textToSpeech}
/> />
</div>
); );
} }

View file

@ -23,9 +23,11 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
setEngineTTS(value); setEngineTTS(value);
}; };
const labelId = 'engine-tts-dropdown-label';
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_engine')}</div> <div id={labelId}>{localize('com_nav_engine')}</div>
<Dropdown <Dropdown
value={engineTTS} value={engineTTS}
onChange={handleSelect} onChange={handleSelect}
@ -33,6 +35,7 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
sizeClasses="w-[180px]" sizeClasses="w-[180px]"
testId="EngineTTSDropdown" testId="EngineTTSDropdown"
className="z-50" className="z-50"
aria-labelledby={labelId}
/> />
</div> </div>
); );

View file

@ -13,7 +13,7 @@ export default function DecibelSelector() {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_playback_rate')}</div> <div id="playback-rate-label">{localize('com_nav_playback_rate')}</div>
<div className="w-2" /> <div className="w-2" />
<small className="opacity-40"> <small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '1' })}) ({localize('com_endpoint_default_with_num', { 0: '1' })})
@ -29,6 +29,7 @@ export default function DecibelSelector() {
step={0.1} step={0.1}
className="ml-4 flex h-4 w-24" className="ml-4 flex h-4 w-24"
disabled={!textToSpeech} disabled={!textToSpeech}
aria-labelledby="playback-rate-label"
/> />
<div className="w-2" /> <div className="w-2" />
<InputNumber <InputNumber
@ -37,6 +38,7 @@ export default function DecibelSelector() {
onChange={(value) => setPlaybackRate(value ? value[0] : 0)} onChange={(value) => setPlaybackRate(value ? value[0] : 0)}
min={0.1} min={0.1}
max={2} max={2}
aria-labelledby="playback-rate-label"
className={cn( className={cn(
defaultTextProps, defaultTextProps,
cn( cn(

View file

@ -1,6 +1,4 @@
import { useRecoilState } from 'recoil'; import ToggleSwitch from '../../ToggleSwitch';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
export default function TextToSpeechSwitch({ export default function TextToSpeechSwitch({
@ -8,28 +6,13 @@ export default function TextToSpeechSwitch({
}: { }: {
onCheckedChange?: (value: boolean) => void; onCheckedChange?: (value: boolean) => void;
}) { }) {
const localize = useLocalize();
const [TextToSpeech, setTextToSpeech] = useRecoilState<boolean>(store.textToSpeech);
const handleCheckedChange = (value: boolean) => {
setTextToSpeech(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return ( return (
<div className="flex items-center justify-between"> <ToggleSwitch
<div> stateAtom={store.textToSpeech}
<strong>{localize('com_nav_text_to_speech')}</strong> localizationKey={'com_nav_text_to_speech' as const}
</div> switchId="TextToSpeech"
<Switch onCheckedChange={onCheckedChange}
id="TextToSpeech" strongLabel={true}
checked={TextToSpeech}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="TextToSpeech"
/> />
</div>
); );
} }

View file

@ -11,6 +11,9 @@ interface ToggleSwitchProps {
hoverCardText?: LocalizeKey; hoverCardText?: LocalizeKey;
switchId: string; switchId: string;
onCheckedChange?: (value: boolean) => void; onCheckedChange?: (value: boolean) => void;
showSwitch?: boolean;
disabled?: boolean;
strongLabel?: boolean;
} }
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
@ -19,6 +22,9 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
hoverCardText, hoverCardText,
switchId, switchId,
onCheckedChange, onCheckedChange,
showSwitch = true,
disabled = false,
strongLabel = false,
}) => { }) => {
const [switchState, setSwitchState] = useRecoilState(stateAtom); const [switchState, setSwitchState] = useRecoilState(stateAtom);
const localize = useLocalize(); const localize = useLocalize();
@ -28,10 +34,18 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
onCheckedChange?.(value); onCheckedChange?.(value);
}; };
const labelId = `${switchId}-label`;
if (!showSwitch) {
return null;
}
return ( return (
<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(localizationKey)}</div> <div id={labelId}>
{strongLabel ? <strong>{localize(localizationKey)}</strong> : localize(localizationKey)}
</div>
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />} {hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
</div> </div>
<Switch <Switch
@ -40,6 +54,8 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
onCheckedChange={handleCheckedChange} onCheckedChange={handleCheckedChange}
className="ml-4" className="ml-4"
data-testid={switchId} data-testid={switchId}
aria-labelledby={labelId}
disabled={disabled}
/> />
</div> </div>
); );

View file

@ -1,9 +1,8 @@
export { default as General } from './General/General';
export { default as Chat } from './Chat/Chat'; export { default as Chat } from './Chat/Chat';
export { default as Data } from './Data/Data'; export { default as Data } from './Data/Data';
export { default as Commands } from './Commands/Commands';
export { RevokeKeysButton } from './Data/RevokeKeysButton';
export { default as Account } from './Account/Account';
export { default as Balance } from './Balance/Balance';
export { default as Speech } from './Speech/Speech'; export { default as Speech } from './Speech/Speech';
export { default as Balance } from './Balance/Balance';
export { default as General } from './General/General';
export { default as Account } from './Account/Account';
export { default as Commands } from './Commands/Commands';
export { default as Personalization } from './Personalization'; export { default as Personalization } from './Personalization';

View file

@ -71,10 +71,12 @@ function ChatGroupItem({
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
id={`prompt-actions-${group._id}`} id={`prompt-actions-${group._id}`}
aria-label={`${group.name} - Actions Menu`} type="button"
aria-expanded="false" aria-label={
aria-controls={`prompt-menu-${group._id}`} localize('com_ui_sr_actions_menu', { 0: group.name }) +
aria-haspopup="menu" ' ' +
localize('com_ui_prompt')
}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
@ -86,11 +88,6 @@ function ChatGroupItem({
className="z-50 inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border-medium bg-transparent p-0 text-sm font-medium transition-all duration-300 ease-in-out hover:border-border-heavy hover:bg-surface-hover focus:border-border-heavy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" className="z-50 inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border-medium bg-transparent p-0 text-sm font-medium transition-all duration-300 ease-in-out hover:border-border-heavy hover:bg-surface-hover focus:border-border-heavy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
> >
<MenuIcon className="icon-md text-text-secondary" aria-hidden="true" /> <MenuIcon className="icon-md text-text-secondary" aria-hidden="true" />
<span className="sr-only">
{localize('com_ui_sr_actions_menu', { 0: group.name }) +
' ' +
localize('com_ui_prompt')}
</span>
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@ -98,30 +95,29 @@ function ChatGroupItem({
aria-label={`Available actions for ${group.name}`} aria-label={`Available actions for ${group.name}`}
className="z-50 w-fit rounded-xl" className="z-50 w-fit rounded-xl"
collisionPadding={2} collisionPadding={2}
align="end" align="start"
> >
<DropdownMenuItem <DropdownMenuItem
role="menuitem"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPreviewDialogOpen(true); setPreviewDialogOpen(true);
}} }}
className="w-full cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed" className="w-full cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
> >
<TextSearch className="mr-2 h-4 w-4" aria-hidden="true" /> <TextSearch className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
<span>{localize('com_ui_preview')}</span> <span>{localize('com_ui_preview')}</span>
</DropdownMenuItem> </DropdownMenuItem>
{canEdit && ( {canEdit && (
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem <DropdownMenuItem
disabled={!canEdit} disabled={!canEdit}
className="cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed" className="cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onEditClick(e); onEditClick(e);
}} }}
> >
<EditIcon className="mr-2 h-4 w-4" aria-hidden="true" /> <EditIcon className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
<span>{localize('com_ui_edit')}</span> <span>{localize('com_ui_edit')}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>

View file

@ -89,7 +89,7 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label={`${group.name} prompt group`} aria-label={`${group.name} Prompt, ${localize('com_ui_category')}: ${group.category ?? ''}`}
> >
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2 truncate pr-2"> <div className="flex items-center gap-2 truncate pr-2">

View file

@ -87,7 +87,7 @@ export default function FilterPrompts({ className = '' }: { className?: string }
value={categoryFilter || SystemCategories.ALL} value={categoryFilter || SystemCategories.ALL}
onChange={onSelect} onChange={onSelect}
options={filterOptions} options={filterOptions}
className="bg-transparent" className="rounded-lg bg-transparent"
icon={<ListFilter className="h-4 w-4" />} icon={<ListFilter className="h-4 w-4" />}
label="Filter: " label="Filter: "
ariaLabel={localize('com_ui_filter_prompts')} ariaLabel={localize('com_ui_filter_prompts')}

View file

@ -40,7 +40,7 @@ export default function List({
</Button> </Button>
</div> </div>
)} )}
<div className="flex-grow overflow-y-auto"> <div className="flex-grow overflow-y-auto" aria-label={localize('com_ui_prompt_groups')}>
<div className="overflow-y-auto overflow-x-hidden"> <div className="overflow-y-auto overflow-x-hidden">
{isLoading && isChatRoute && ( {isLoading && isChatRoute && (
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" /> <Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />

View file

@ -14,7 +14,7 @@ const PreviewPrompt = ({
return ( return (
<OGDialog open={open} onOpenChange={onOpenChange}> <OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogContent className="max-h-[90vh] w-11/12 max-w-full overflow-y-auto md:max-w-[60vw]"> <OGDialogContent className="max-h-[90vh] w-11/12 max-w-full overflow-y-auto md:max-w-[60vw]">
<div className="p-2"> <div>
<PromptDetails group={group} /> <PromptDetails group={group} />
</div> </div>
</OGDialogContent> </OGDialogContent>

View file

@ -1,6 +1,6 @@
{ {
"chat_direction_left_to_right": "something needs to go here. was empty", "chat_direction_left_to_right": "Left to Right",
"chat_direction_right_to_left": "something needs to go here. was empty", "chat_direction_right_to_left": "Right to Left",
"com_a11y_ai_composing": "The AI is still composing.", "com_a11y_ai_composing": "The AI is still composing.",
"com_a11y_end": "The AI has finished their reply.", "com_a11y_end": "The AI has finished their reply.",
"com_a11y_start": "The AI has started their reply.", "com_a11y_start": "The AI has started their reply.",
@ -408,7 +408,6 @@
"com_nav_auto_scroll": "Auto-Scroll to latest message on chat open", "com_nav_auto_scroll": "Auto-Scroll to latest message on chat open",
"com_nav_auto_send_prompts": "Auto-send Prompts", "com_nav_auto_send_prompts": "Auto-send Prompts",
"com_nav_auto_send_text": "Auto send text", "com_nav_auto_send_text": "Auto send text",
"com_nav_auto_send_text_disabled": "set -1 to disable",
"com_nav_auto_transcribe_audio": "Auto transcribe audio", "com_nav_auto_transcribe_audio": "Auto transcribe audio",
"com_nav_automatic_playback": "Autoplay Latest Message", "com_nav_automatic_playback": "Autoplay Latest Message",
"com_nav_balance": "Balance", "com_nav_balance": "Balance",
@ -573,6 +572,7 @@
"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_speech_to_text": "Speech to Text", "com_nav_speech_to_text": "Speech to Text",
"com_nav_stop_generating": "Stop generating", "com_nav_stop_generating": "Stop generating",
"com_nav_setting_delay": "Delay (s)",
"com_nav_text_to_speech": "Text to Speech", "com_nav_text_to_speech": "Text to Speech",
"com_nav_theme": "Theme", "com_nav_theme": "Theme",
"com_nav_theme_dark": "Dark", "com_nav_theme_dark": "Dark",
@ -761,6 +761,7 @@
"com_ui_close": "Close", "com_ui_close": "Close",
"com_ui_close_menu": "Close Menu", "com_ui_close_menu": "Close Menu",
"com_ui_close_window": "Close Window", "com_ui_close_window": "Close Window",
"com_ui_close_settings": "Close Settings",
"com_ui_code": "Code", "com_ui_code": "Code",
"com_ui_collapse_chat": "Collapse Chat", "com_ui_collapse_chat": "Collapse Chat",
"com_ui_command_placeholder": "Optional: Enter a command for the prompt or name will be used", "com_ui_command_placeholder": "Optional: Enter a command for the prompt or name will be used",
@ -950,8 +951,9 @@
"com_ui_image_edited": "Image edited", "com_ui_image_edited": "Image edited",
"com_ui_image_gen": "Image Gen", "com_ui_image_gen": "Image Gen",
"com_ui_import": "Import", "com_ui_import": "Import",
"com_ui_import_conversation_error": "There was an error importing your conversations", "com_ui_import_conversation_error": "There was an error while importing your conversations",
"com_ui_import_conversation_file_type_error": "Unsupported import type", "com_ui_import_conversation_file_type_error": "Error with file type. Please select a valid JSON file.",
"com_ui_import_conversation_upload_error": "Error uploading file. Please try again.",
"com_ui_import_conversation_info": "Import conversations from a JSON file", "com_ui_import_conversation_info": "Import conversations from a JSON file",
"com_ui_import_conversation_success": "Conversations imported successfully", "com_ui_import_conversation_success": "Conversations imported successfully",
"com_ui_include_shadcnui": "Include shadcn/ui components instructions", "com_ui_include_shadcnui": "Include shadcn/ui components instructions",
@ -1077,6 +1079,7 @@
"com_ui_prompts_allow_create": "Allow creating Prompts", "com_ui_prompts_allow_create": "Allow creating Prompts",
"com_ui_prompts_allow_share": "Allow sharing Prompts", "com_ui_prompts_allow_share": "Allow sharing Prompts",
"com_ui_prompts_allow_use": "Allow using Prompts", "com_ui_prompts_allow_use": "Allow using Prompts",
"com_ui_prompt_groups": "Prompt Groups List",
"com_ui_provider": "Provider", "com_ui_provider": "Provider",
"com_ui_quality": "Quality", "com_ui_quality": "Quality",
"com_ui_read_aloud": "Read aloud", "com_ui_read_aloud": "Read aloud",
@ -1279,5 +1282,21 @@
"com_ui_x_selected": "{{0}} selected", "com_ui_x_selected": "{{0}} selected",
"com_ui_yes": "Yes", "com_ui_yes": "Yes",
"com_ui_zoom": "Zoom", "com_ui_zoom": "Zoom",
"com_user_message": "You" "com_user_message": "You",
"com_ui_rotate": "Rotate",
"com_ui_reset": "Reset",
"com_ui_zoom_in": "Zoom in",
"com_ui_zoom_out": "Zoom out",
"com_ui_zoom_level": "Zoom level",
"com_ui_rotate_90": "Rotate 90 degrees",
"com_ui_reset_adjustments": "Reset adjustments",
"com_ui_editor_instructions": "Drag the image to reposition • Use zoom slider or buttons to adjust size",
"com_ui_save_key_success": "API key saved successfully",
"com_ui_save_key_error": "Failed to save API key. Please try again.",
"com_ui_revoke_key_success": "API key revoked successfully",
"com_ui_revoke_key_error": "Failed to revoke API key. Please try again.",
"com_ui_key_required": "API key is required",
"com_ui_max_file_size": "PNG, JPG or JPEG (max {{0}})",
"com_ui_upload_avatar_label": "Upload avatar image",
"com_ui_file_input_avatar_label": "File input for avatar"
} }

View file

@ -16,6 +16,7 @@ interface DropdownProps {
iconOnly?: boolean; iconOnly?: boolean;
renderValue?: (option: Option) => React.ReactNode; renderValue?: (option: Option) => React.ReactNode;
ariaLabel?: string; ariaLabel?: string;
'aria-labelledby'?: string;
portal?: boolean; portal?: boolean;
} }
@ -37,6 +38,7 @@ const Dropdown: React.FC<DropdownProps> = ({
iconOnly = false, iconOnly = false,
renderValue, renderValue,
ariaLabel, ariaLabel,
'aria-labelledby': ariaLabelledBy,
portal = true, portal = true,
}) => { }) => {
const handleChange = (value: string) => { const handleChange = (value: string) => {
@ -77,6 +79,7 @@ const Dropdown: React.FC<DropdownProps> = ({
)} )}
data-testid={testId} data-testid={testId}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
> >
<div className="flex w-full items-center gap-2"> <div className="flex w-full items-center gap-2">
{icon} {icon}

View file

@ -1,191 +1,225 @@
import * as React from 'react'; import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react'; import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '~/utils'; import { cn } from '~/utils';
const DropdownMenu = DropdownMenuPrimitive.Root; function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
} }
>(({ className = '', inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-gray-100 data-[state=open]:bg-gray-100 dark:focus:bg-gray-900 dark:data-[state=open]:bg-gray-900',
inset ? 'pl-8' : '',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef< function DropdownMenuPortal({
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, ...props
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
>(({ className = '', ...props }, ref) => ( return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
<DropdownMenuPrimitive.SubContent }
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-100 bg-white p-1 text-gray-700 shadow-md animate-in slide-in-from-left-1 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef< function DropdownMenuTrigger({
React.ElementRef<typeof DropdownMenuPrimitive.Content>, ...props
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
>(({ className = '', sideOffset = 4, ...props }, ref) => ( return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
ref={ref} data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-100 bg-white p-1 text-gray-700 shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400', 'text-popover-foreground max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border border-border-light bg-surface-secondary p-1 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className, className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
)); );
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
} }
>(({ className = '', inset, ...props }, ref) => (
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn( className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-900', "data-[variant=destructive]:*:[svg]:!text-destructive outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
inset ? 'pl-8' : '',
className, className,
)} )}
{...props} {...props}
/> />
)); );
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; }
const DropdownMenuCheckboxItem = React.forwardRef< function DropdownMenuCheckboxItem({
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, className,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> children,
>(({ className = '', children, checked, ...props }, ref) => ( checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
ref={ref} data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-900', "outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
> >
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" /> <CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
)); );
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; }
const DropdownMenuRadioItem = React.forwardRef< function DropdownMenuRadioGroup({
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, ...props
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
>(({ className = '', children, ...props }, ref) => ( return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
<DropdownMenuPrimitive.RadioItem }
ref={ref}
className={cn( function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className, className,
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800',
)} )}
{...props} {...props}
> >
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" /> <CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
)); );
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
} }
>(({ className = '', inset, ...props }, ref) => (
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
ref={ref} data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('-mx-1 my-1 h-px bg-surface-hover', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn( className={cn(
'px-2 py-1.5 text-sm font-semibold text-gray-900 dark:text-gray-300', 'outline-hidden flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground',
inset ? 'pl-8' : '', className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'text-popover-foreground origin-(--radix-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border border-border-medium bg-surface-secondary p-1 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className, className,
)} )}
{...props} {...props}
/> />
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className = '', ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border-medium', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className = '',
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn('ml-auto text-xs tracking-widest text-gray-500', className)} {...props} />
); );
}; }
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuRadioGroup, DropdownMenuSubContent,
}; };

View file

@ -1,3 +1,4 @@
import { useState } from 'react';
import { CircleHelpIcon } from 'lucide-react'; import { CircleHelpIcon } from 'lucide-react';
import { HoverCard, HoverCardTrigger, HoverCardPortal, HoverCardContent } from './HoverCard'; import { HoverCard, HoverCardTrigger, HoverCardPortal, HoverCardContent } from './HoverCard';
import { ESide } from '~/common'; import { ESide } from '~/common';
@ -8,15 +9,23 @@ type InfoHoverCardProps = {
}; };
const InfoHoverCard = ({ side, text }: InfoHoverCardProps) => { const InfoHoverCard = ({ side, text }: InfoHoverCardProps) => {
const [isOpen, setIsOpen] = useState(false);
return ( return (
<HoverCard openDelay={50}> <HoverCard openDelay={50} open={isOpen} onOpenChange={setIsOpen}>
<HoverCardTrigger className="cursor-help"> <HoverCardTrigger
<CircleHelpIcon className="h-5 w-5 text-text-tertiary" />{' '} tabIndex={0}
className="inline-flex cursor-help items-center justify-center rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary focus-visible:ring-offset-2"
onFocus={() => setIsOpen(true)}
onBlur={() => setIsOpen(false)}
aria-label={text}
>
<CircleHelpIcon className="h-5 w-5 text-text-tertiary" aria-hidden="true" />
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardPortal> <HoverCardPortal>
<HoverCardContent side={side} className="z-[999] w-80"> <HoverCardContent side={side} className="z-[999] w-80">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm text-text-secondary">{text}</p> <span className="text-sm text-text-secondary">{text}</span>
</div> </div>
</HoverCardContent> </HoverCardContent>
</HoverCardPortal> </HoverCardPortal>

View file

@ -13,7 +13,7 @@ const Label = React.forwardRef<
{...props} {...props}
{...{ {...{
className: cn( className: cn(
'block w-full break-all text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-200', 'block w-full break-all text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-200',
className, className,
), ),
}} }}

View file

@ -2,13 +2,28 @@ import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider'; import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '~/utils'; import { cn } from '~/utils';
const Slider = React.forwardRef< type SliderProps = React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
className?: string; className?: string;
onDoubleClick?: () => void; onDoubleClick?: () => void;
} 'aria-describedby'?: string;
>(({ className, onDoubleClick, ...props }, ref) => ( } & (
| { 'aria-label': string; 'aria-labelledby'?: never }
| { 'aria-labelledby': string; 'aria-label'?: never }
| { 'aria-label': string; 'aria-labelledby': string }
);
const Slider = React.forwardRef<React.ElementRef<typeof SliderPrimitive.Root>, SliderProps>(
(
{
className,
onDoubleClick,
'aria-labelledby': ariaLabelledBy,
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
...props
},
ref,
) => (
<SliderPrimitive.Root <SliderPrimitive.Root
ref={ref} ref={ref}
{...props} {...props}
@ -29,10 +44,14 @@ const Slider = React.forwardRef<
{...{ {...{
className: 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', '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',
'aria-labelledby': ariaLabelledBy,
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
}} }}
/> />
</SliderPrimitive.Root> </SliderPrimitive.Root>
)); ),
);
Slider.displayName = SliderPrimitive.Root.displayName; Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider }; export { Slider };