mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
♿️ 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:
parent
bcd97aad2f
commit
a5189052ec
56 changed files with 1158 additions and 857 deletions
|
|
@ -44,6 +44,7 @@ export default function ArtifactTabs({
|
|||
value="code"
|
||||
id="artifacts-code"
|
||||
className={cn('flex-grow overflow-auto')}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{isMermaid ? (
|
||||
<MermaidMarkdown content={content} isSubmitting={isSubmitting} />
|
||||
|
|
@ -58,7 +59,7 @@ export default function ArtifactTabs({
|
|||
/>
|
||||
)}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="preview" className="flex-grow overflow-auto">
|
||||
<Tabs.Content value="preview" className="flex-grow overflow-auto" tabIndex={-1}>
|
||||
<ArtifactPreview
|
||||
files={files}
|
||||
fileKey={fileKey}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ export function BrowserVoiceDropdown() {
|
|||
}
|
||||
};
|
||||
|
||||
const labelId = 'browser-voice-dropdown-label';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_voice_select')}</div>
|
||||
<div id={labelId}>{localize('com_nav_voice_select')}</div>
|
||||
<Dropdown
|
||||
key={`browser-voice-dropdown-${voices.length}`}
|
||||
value={voice ?? ''}
|
||||
|
|
@ -30,6 +32,7 @@ export function BrowserVoiceDropdown() {
|
|||
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
||||
testId="BrowserVoiceDropdown"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -48,9 +51,11 @@ export function ExternalVoiceDropdown() {
|
|||
}
|
||||
};
|
||||
|
||||
const labelId = 'external-voice-dropdown-label';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_voice_select')}</div>
|
||||
<div id={labelId}>{localize('com_nav_voice_select')}</div>
|
||||
<Dropdown
|
||||
key={`external-voice-dropdown-${voices.length}`}
|
||||
value={voice ?? ''}
|
||||
|
|
@ -59,6 +64,7 @@ export function ExternalVoiceDropdown() {
|
|||
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
||||
testId="ExternalVoiceDropdown"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ const LoadingSpinner = memo(() => {
|
|||
);
|
||||
});
|
||||
|
||||
LoadingSpinner.displayName = 'LoadingSpinner';
|
||||
|
||||
const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
|
|
@ -74,6 +76,7 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
isLoading,
|
||||
isSearchLoading,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const convoHeight = isSmallScreen ? 44 : 34;
|
||||
|
||||
|
|
@ -181,7 +184,7 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
{isSearchLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<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 className="flex-1">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
|
||||
)}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
tabIndex={renaming ? -1 : 0}
|
||||
aria-label={`${title || localize('com_ui_untitled')} conversation`}
|
||||
onClick={(e) => {
|
||||
if (renaming) {
|
||||
return;
|
||||
|
|
@ -149,7 +150,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
|
|||
if (renaming) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleNavigation(false);
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -40,8 +40,7 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
|
|||
e.stopPropagation();
|
||||
onRename();
|
||||
}}
|
||||
role="button"
|
||||
aria-label={isSmallScreen ? undefined : title || localize('com_ui_untitled')}
|
||||
aria-label={title || localize('com_ui_untitled')}
|
||||
>
|
||||
{title || localize('com_ui_untitled')}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ function ConvoOptions({
|
|||
<Menu.MenuButton
|
||||
id={`conversation-menu-${conversationId}`}
|
||||
aria-label={localize('com_nav_convo_menu_options')}
|
||||
aria-readonly={undefined}
|
||||
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',
|
||||
isActiveConvo === true || isPopoverActive
|
||||
|
|
|
|||
|
|
@ -1,16 +1,33 @@
|
|||
import React, { useState } from 'react';
|
||||
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 {
|
||||
useRevokeAllUserKeysMutation,
|
||||
useRevokeUserKeyMutation,
|
||||
} from 'librechat-data-provider/react-query';
|
||||
import type { TDialogProps } from '~/common';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { RevokeKeysButton } from '~/components/Nav';
|
||||
import { useUserKey, useLocalize } from '~/hooks';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import CustomConfig from './CustomEndpoint';
|
||||
import GoogleConfig from './GoogleConfig';
|
||||
import OpenAIConfig from './OpenAIConfig';
|
||||
import OtherConfig from './OtherConfig';
|
||||
import HelpText from './HelpText';
|
||||
import { logger } from '~/utils';
|
||||
|
||||
const endpointComponents = {
|
||||
[EModelEndpoint.google]: GoogleConfig,
|
||||
|
|
@ -42,6 +59,94 @@ const EXPIRY = {
|
|||
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 = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -83,7 +188,7 @@ const SetKeyDialog = ({
|
|||
|
||||
const submit = () => {
|
||||
const selectedOption = expirationOptions.find((option) => option.label === expiresAtLabel);
|
||||
let expiresAt;
|
||||
let expiresAt: number | null;
|
||||
|
||||
if (selectedOption?.value === 0) {
|
||||
expiresAt = null;
|
||||
|
|
@ -92,8 +197,20 @@ const SetKeyDialog = ({
|
|||
}
|
||||
|
||||
const saveKey = (key: string) => {
|
||||
saveUserKey(key, expiresAt);
|
||||
onOpenChange(false);
|
||||
try {
|
||||
saveUserKey(key, expiresAt);
|
||||
showToast({
|
||||
message: localize('com_ui_save_key_success'),
|
||||
status: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
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 ?? '')) {
|
||||
|
|
@ -148,6 +265,14 @@ const SetKeyDialog = ({
|
|||
return;
|
||||
}
|
||||
|
||||
if (!userKey.trim()) {
|
||||
showToast({
|
||||
message: localize('com_ui_key_required'),
|
||||
status: NotificationSeverity.ERROR,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
saveKey(userKey);
|
||||
setUserKey('');
|
||||
};
|
||||
|
|
@ -159,56 +284,54 @@ const SetKeyDialog = ({
|
|||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={onOpenChange}>
|
||||
<OGDialogTemplate
|
||||
title={`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`}
|
||||
className="w-11/12 max-w-2xl"
|
||||
showCancelButton={false}
|
||||
main={
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<small className="text-red-600">
|
||||
{expiryTime === 'never'
|
||||
? localize('com_endpoint_config_key_never_expires')
|
||||
: `${localize('com_endpoint_config_key_encryption')} ${new Date(
|
||||
expiryTime ?? 0,
|
||||
).toLocaleString()}`}
|
||||
</small>
|
||||
<Dropdown
|
||||
label="Expires "
|
||||
value={expiresAtLabel}
|
||||
onChange={handleExpirationChange}
|
||||
options={expirationOptions.map((option) => option.label)}
|
||||
sizeClasses="w-[185px]"
|
||||
portal={false}
|
||||
<OGDialogContent className="w-11/12 max-w-2xl">
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>
|
||||
{`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`}
|
||||
</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="grid w-full items-center gap-2 py-4">
|
||||
<small className="text-red-600">
|
||||
{expiryTime === 'never'
|
||||
? localize('com_endpoint_config_key_never_expires')
|
||||
: `${localize('com_endpoint_config_key_encryption')} ${new Date(
|
||||
expiryTime ?? 0,
|
||||
).toLocaleString()}`}
|
||||
</small>
|
||||
<Dropdown
|
||||
label="Expires "
|
||||
value={expiresAtLabel}
|
||||
onChange={handleExpirationChange}
|
||||
options={expirationOptions.map((option) => option.label)}
|
||||
sizeClasses="w-[185px]"
|
||||
portal={false}
|
||||
/>
|
||||
<div className="mt-2" />
|
||||
<FormProvider {...methods}>
|
||||
<EndpointComponent
|
||||
userKey={userKey}
|
||||
setUserKey={setUserKey}
|
||||
endpoint={
|
||||
endpoint === EModelEndpoint.gptPlugins && (config?.azure ?? false)
|
||||
? EModelEndpoint.azureOpenAI
|
||||
: endpoint
|
||||
}
|
||||
userProvideURL={userProvideURL}
|
||||
/>
|
||||
<div className="mt-2" />
|
||||
<FormProvider {...methods}>
|
||||
<EndpointComponent
|
||||
userKey={userKey}
|
||||
setUserKey={setUserKey}
|
||||
endpoint={
|
||||
endpoint === EModelEndpoint.gptPlugins && (config?.azure ?? false)
|
||||
? EModelEndpoint.azureOpenAI
|
||||
: endpoint
|
||||
}
|
||||
userProvideURL={userProvideURL}
|
||||
/>
|
||||
</FormProvider>
|
||||
<HelpText endpoint={endpoint} />
|
||||
</div>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: submit,
|
||||
selectClasses: 'btn btn-primary',
|
||||
selectText: localize('com_ui_submit'),
|
||||
}}
|
||||
leftButtons={
|
||||
</FormProvider>
|
||||
<HelpText endpoint={endpoint} />
|
||||
</div>
|
||||
<OGDialogFooter>
|
||||
<RevokeKeysButton
|
||||
endpoint={endpoint}
|
||||
disabled={!(expiryTime ?? '')}
|
||||
setDialogOpen={onOpenChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Button variant="submit" onClick={submit}>
|
||||
{localize('com_ui_submit')}
|
||||
</Button>
|
||||
</OGDialogFooter>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
<line x1="18" x2="6" y1="6" y2="18"></line>
|
||||
<line x1="6" x2="18" y1="6" y2="18"></line>
|
||||
</svg>
|
||||
<span className="sr-only">{localize('com_ui_close')}</span>
|
||||
<span className="sr-only">{localize('com_ui_close_settings')}</span>
|
||||
</button>
|
||||
</DialogTitle>
|
||||
<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>
|
||||
<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 />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={SettingsTabValues.CHAT}>
|
||||
<Tabs.Content value={SettingsTabValues.CHAT} tabIndex={-1}>
|
||||
<Chat />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={SettingsTabValues.COMMANDS}>
|
||||
<Tabs.Content value={SettingsTabValues.COMMANDS} tabIndex={-1}>
|
||||
<Commands />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={SettingsTabValues.SPEECH}>
|
||||
<Tabs.Content value={SettingsTabValues.SPEECH} tabIndex={-1}>
|
||||
<Speech />
|
||||
</Tabs.Content>
|
||||
{hasAnyPersonalizationFeature && (
|
||||
<Tabs.Content value={SettingsTabValues.PERSONALIZATION}>
|
||||
<Tabs.Content value={SettingsTabValues.PERSONALIZATION} tabIndex={-1}>
|
||||
<Personalization
|
||||
hasMemoryOptOut={hasMemoryOptOut}
|
||||
hasAnyPersonalizationFeature={hasAnyPersonalizationFeature}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
)}
|
||||
<Tabs.Content value={SettingsTabValues.DATA}>
|
||||
<Tabs.Content value={SettingsTabValues.DATA} tabIndex={-1}>
|
||||
<Data />
|
||||
</Tabs.Content>
|
||||
{startupConfig?.balance?.enabled && (
|
||||
<Tabs.Content value={SettingsTabValues.BALANCE}>
|
||||
<Tabs.Content value={SettingsTabValues.BALANCE} tabIndex={-1}>
|
||||
<Balance />
|
||||
</Tabs.Content>
|
||||
)}
|
||||
<Tabs.Content value={SettingsTabValues.ACCOUNT}>
|
||||
<Tabs.Content value={SettingsTabValues.ACCOUNT} tabIndex={-1}>
|
||||
<Account />
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
// @ts-ignore - no type definitions available
|
||||
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 {
|
||||
Label,
|
||||
Slider,
|
||||
Button,
|
||||
Spinner,
|
||||
|
|
@ -25,14 +27,20 @@ interface AvatarEditorRef {
|
|||
getImage: () => HTMLImageElement;
|
||||
}
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function Avatar() {
|
||||
const setUser = useSetRecoilState(store.user);
|
||||
|
||||
const [scale, setScale] = useState<number>(1);
|
||||
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const openButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const [image, setImage] = useState<string | File | null>(null);
|
||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
|
|
@ -48,7 +56,6 @@ function Avatar() {
|
|||
onSuccess: (data) => {
|
||||
showToast({ message: localize('com_ui_upload_success') });
|
||||
setUser((prev) => ({ ...prev, avatar: data.url }) as TUser);
|
||||
openButtonRef.current?.click();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error:', error);
|
||||
|
|
@ -61,29 +68,45 @@ function Avatar() {
|
|||
handleFile(file);
|
||||
};
|
||||
|
||||
const handleFile = (file: File | undefined) => {
|
||||
if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) {
|
||||
setImage(file);
|
||||
setScale(1);
|
||||
setRotation(0);
|
||||
} else {
|
||||
const megabytes =
|
||||
fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2;
|
||||
showToast({
|
||||
message: localize('com_ui_upload_invalid_var', { 0: megabytes + '' }),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleFile = useCallback(
|
||||
(file: File | undefined) => {
|
||||
if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) {
|
||||
setImage(file);
|
||||
setScale(1);
|
||||
setRotation(0);
|
||||
setPosition({ x: 0.5, y: 0.5 });
|
||||
} else {
|
||||
const megabytes =
|
||||
fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2;
|
||||
showToast({
|
||||
message: localize('com_ui_upload_invalid_var', { 0: megabytes + '' }),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[fileConfig.avatarSizeLimit, localize, showToast],
|
||||
);
|
||||
|
||||
const handleScaleChange = (value: number[]) => {
|
||||
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 = () => {
|
||||
setRotation((prev) => (prev + 90) % 360);
|
||||
};
|
||||
|
||||
const handlePositionChange = (position: Position) => {
|
||||
setPosition(position);
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (editorRef.current) {
|
||||
const canvas = editorRef.current.getImageScaledToCanvas();
|
||||
|
|
@ -98,11 +121,14 @@ function Avatar() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
handleFile(file);
|
||||
}, []);
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
handleFile(file);
|
||||
},
|
||||
[handleFile],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -116,8 +142,15 @@ function Avatar() {
|
|||
setImage(null);
|
||||
setScale(1);
|
||||
setRotation(0);
|
||||
setPosition({ x: 0.5, y: 0.5 });
|
||||
}, []);
|
||||
|
||||
const handleReset = () => {
|
||||
setScale(1);
|
||||
setRotation(0);
|
||||
setPosition({ x: 0.5, y: 0.5 });
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog
|
||||
open={isDialogOpen}
|
||||
|
|
@ -125,90 +158,190 @@ function Avatar() {
|
|||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
resetImage();
|
||||
setTimeout(() => {
|
||||
openButtonRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{localize('com_nav_profile_picture')}</span>
|
||||
<OGDialogTrigger ref={openButtonRef}>
|
||||
<OGDialogTrigger asChild>
|
||||
<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>
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
</div>
|
||||
|
||||
<OGDialogContent className="w-11/12 max-w-sm" style={{ borderRadius: '12px' }}>
|
||||
<OGDialogContent showCloseButton={false} className="w-11/12 max-w-md">
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle className="text-lg font-medium leading-6 text-text-primary">
|
||||
{image != null ? localize('com_ui_preview') : localize('com_ui_upload_image')}
|
||||
</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center p-2">
|
||||
{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
|
||||
ref={editorRef}
|
||||
image={image}
|
||||
width={250}
|
||||
height={250}
|
||||
width={280}
|
||||
height={280}
|
||||
border={0}
|
||||
borderRadius={125}
|
||||
borderRadius={140}
|
||||
color={[255, 255, 255, 0.6]}
|
||||
scale={scale}
|
||||
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>
|
||||
<div className="mt-4 flex w-full flex-col items-center space-y-4">
|
||||
<div className="flex w-full items-center justify-center space-x-4">
|
||||
<span className="text-sm">{localize('com_ui_zoom')}</span>
|
||||
<Slider
|
||||
value={[scale]}
|
||||
min={1}
|
||||
max={5}
|
||||
step={0.001}
|
||||
onValueChange={handleScaleChange}
|
||||
className="w-2/3 max-w-xs"
|
||||
/>
|
||||
|
||||
<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
|
||||
id="zoom-slider"
|
||||
value={[scale]}
|
||||
min={1}
|
||||
max={5}
|
||||
step={0.1}
|
||||
onValueChange={handleScaleChange}
|
||||
className="flex-1"
|
||||
aria-label={localize('com_ui_zoom_level')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
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>
|
||||
<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 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}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Spinner className="icon-sm mr-2" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{localize('com_ui_upload')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'btn btn-primary mt-4 flex w-full hover:bg-green-600',
|
||||
isUploading ? 'cursor-not-allowed opacity-90' : '',
|
||||
)}
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Spinner className="icon-sm mr-2" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
{localize('com_ui_upload')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<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}
|
||||
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" />
|
||||
<p className="mb-2 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<FileImage className="mb-4 size-16 text-gray-400" />
|
||||
<p className="mb-2 text-center text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_drag_drop')}
|
||||
</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')}
|
||||
</Button>
|
||||
<input
|
||||
|
|
@ -217,6 +350,7 @@ function Avatar() {
|
|||
className="hidden"
|
||||
accept=".png, .jpg, .jpeg"
|
||||
onChange={handleFileChange}
|
||||
aria-label={localize('com_ui_file_input_avatar_label')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { LockIcon, Trash } from 'lucide-react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Label,
|
||||
Input,
|
||||
Button,
|
||||
Spinner,
|
||||
|
|
@ -45,11 +46,11 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
|
|||
<>
|
||||
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
|
||||
<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>
|
||||
<Button
|
||||
aria-labelledby="delete-account-label"
|
||||
variant="destructive"
|
||||
className="flex items-center justify-center rounded-lg transition-colors duration-200"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export const DisableTwoFactorToggle: React.FC<DisableTwoFactorToggleProps> = ({
|
|||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<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 className="flex items-center gap-3">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export default function DisplayUsernameMessages() {
|
|||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<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')} />
|
||||
</div>
|
||||
<Switch
|
||||
|
|
@ -24,6 +24,7 @@ export default function DisplayUsernameMessages() {
|
|||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="UsernameDisplay"
|
||||
aria-labelledby="user-name-display-label"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,16 +19,16 @@ const ChatDirection = () => {
|
|||
</div>
|
||||
<Button
|
||||
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}
|
||||
data-testid="chatDirection"
|
||||
>
|
||||
<span aria-hidden="true">{direction.toLowerCase()}</span>
|
||||
<span id="chat-direction-status" className="sr-only">
|
||||
{direction === 'LTR'
|
||||
? localize('chat_direction_left_to_right')
|
||||
: localize('chat_direction_right_to_left')}
|
||||
</span>
|
||||
{direction.toLowerCase()}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ export default function FontSizeSelector() {
|
|||
{ value: 'text-xl', label: localize('com_nav_font_size_xl') },
|
||||
];
|
||||
|
||||
const labelId = 'font-size-selector-label';
|
||||
|
||||
return (
|
||||
<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
|
||||
value={fontSize}
|
||||
options={options}
|
||||
|
|
@ -30,6 +32,7 @@ export default function FontSizeSelector() {
|
|||
testId="font-size-selector"
|
||||
sizeClasses="w-[150px]"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,13 +20,14 @@ export const ForkSettings = () => {
|
|||
<>
|
||||
<div className="pb-3">
|
||||
<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
|
||||
id="rememberDefaultFork"
|
||||
checked={remember}
|
||||
onCheckedChange={setRemember}
|
||||
className="ml-4"
|
||||
data-testid="rememberDefaultFork"
|
||||
aria-labelledby="remember-default-fork-label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -34,7 +35,7 @@ export const ForkSettings = () => {
|
|||
<div className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<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
|
||||
side={ESide.Bottom}
|
||||
text={localize('com_nav_info_fork_change_default')}
|
||||
|
|
@ -47,6 +48,7 @@ export const ForkSettings = () => {
|
|||
sizeClasses="w-[200px]"
|
||||
testId="fork-setting-dropdown"
|
||||
className="z-[50]"
|
||||
aria-labelledby="fork-change-default-label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -54,7 +56,7 @@ export const ForkSettings = () => {
|
|||
<div className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<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
|
||||
side={ESide.Bottom}
|
||||
text={localize('com_nav_info_fork_split_target_setting')}
|
||||
|
|
@ -66,6 +68,7 @@ export const ForkSettings = () => {
|
|||
onCheckedChange={setSplitAtTarget}
|
||||
className="ml-4"
|
||||
data-testid="splitAtTarget"
|
||||
aria-labelledby="split-at-target-label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,33 @@
|
|||
import { memo } from 'react';
|
||||
import { InfoHoverCard, ESide } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import SlashCommandSwitch from './SlashCommandSwitch';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import PlusCommandSwitch from './PlusCommandSwitch';
|
||||
import AtCommandSwitch from './AtCommandSwitch';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
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() {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -19,6 +42,19 @@ function Commands() {
|
|||
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 (
|
||||
<div className="space-y-4 p-1">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -28,19 +64,16 @@ function Commands() {
|
|||
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_chat_commands_info')} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||
<div className="pb-3">
|
||||
<AtCommandSwitch />
|
||||
</div>
|
||||
{hasAccessToMultiConvo === true && (
|
||||
<div className="pb-3">
|
||||
<PlusCommandSwitch />
|
||||
{commandSwitchConfigs.map((config) => (
|
||||
<div key={config.key} className="pb-3">
|
||||
<ToggleSwitch
|
||||
stateAtom={config.stateAtom}
|
||||
localizationKey={config.localizationKey}
|
||||
switchId={config.switchId}
|
||||
showSwitch={getShowSwitch(config.permissionType)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasAccessToPrompts === true && (
|
||||
<div className="pb-3">
|
||||
<SlashCommandSwitch />
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -31,12 +31,12 @@ export const ClearChats = () => {
|
|||
|
||||
return (
|
||||
<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}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
aria-labelledby="clear-all-chats-label"
|
||||
variant="destructive"
|
||||
className="flex items-center justify-center rounded-lg transition-colors duration-200"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{localize('com_ui_delete')}
|
||||
|
|
@ -47,7 +47,7 @@ export const ClearChats = () => {
|
|||
title={localize('com_nav_confirm_clear')}
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<Label className="text-left text-sm font-medium">
|
||||
<Label className="break-words">
|
||||
{localize('com_nav_clear_conversation_confirm_message')}
|
||||
</Label>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import { useOnClickOutside } from '@librechat/client';
|
||||
import ImportConversations from './ImportConversations';
|
||||
import { RevokeAllKeys } from './RevokeAllKeys';
|
||||
import { RevokeKeys } from './RevokeKeys';
|
||||
import { DeleteCache } from './DeleteCache';
|
||||
import { ClearChats } from './ClearChats';
|
||||
import SharedLinks from './SharedLinks';
|
||||
|
|
@ -20,7 +20,7 @@ function Data() {
|
|||
<SharedLinks />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<RevokeAllKeys />
|
||||
<RevokeKeys />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<DeleteCache />
|
||||
|
|
|
|||
|
|
@ -38,14 +38,14 @@ export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => {
|
|||
|
||||
return (
|
||||
<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}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex items-center justify-center rounded-lg transition-colors duration-200"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled || isCacheEmpty}
|
||||
aria-labelledby="delete-cache-label"
|
||||
>
|
||||
{localize('com_ui_delete')}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,96 +1,114 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Import } from 'lucide-react';
|
||||
import { Spinner, useToastContext } from '@librechat/client';
|
||||
import type { TError } from 'librechat-data-provider';
|
||||
import { Spinner, useToastContext, Label, Button } from '@librechat/client';
|
||||
import { useUploadConversationsMutation } from '~/data-provider';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import { cn, logger } from '~/utils';
|
||||
|
||||
function ImportConversations() {
|
||||
const localize = useLocalize();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { showToast } = useToastContext();
|
||||
const [, setErrors] = useState<string[]>([]);
|
||||
const [allowImport, setAllowImport] = useState(true);
|
||||
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
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({
|
||||
onSuccess: () => {
|
||||
showToast({ message: localize('com_ui_import_conversation_success') });
|
||||
setAllowImport(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);
|
||||
},
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError,
|
||||
onMutate: () => setIsUploading(true),
|
||||
});
|
||||
|
||||
const startUpload = async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, encodeURIComponent(file.name || 'File'));
|
||||
const handleFileUpload = useCallback(
|
||||
async (file: File) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, encodeURIComponent(file.name || 'File'));
|
||||
uploadFile.mutate(formData);
|
||||
} catch (error) {
|
||||
logger.error('File processing error:', error);
|
||||
setIsUploading(false);
|
||||
showToast({
|
||||
message: localize('com_ui_import_conversation_upload_error'),
|
||||
status: NotificationSeverity.ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
[uploadFile, showToast, localize],
|
||||
);
|
||||
|
||||
uploadFile.mutate(formData);
|
||||
};
|
||||
const handleFileChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
event.target.value = '';
|
||||
},
|
||||
[handleFileUpload],
|
||||
);
|
||||
|
||||
const handleFiles = async (_file: File) => {
|
||||
try {
|
||||
await startUpload(_file);
|
||||
} catch (error) {
|
||||
console.log('file handling error', error);
|
||||
setError('An error occurred while processing the file.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
handleFiles(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
const handleImportClick = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleImportClick();
|
||||
}
|
||||
};
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleImportClick();
|
||||
}
|
||||
},
|
||||
[handleImportClick],
|
||||
);
|
||||
|
||||
const isImportDisabled = isUploading;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_ui_import_conversation_info')}</div>
|
||||
<button
|
||||
<Label id="import-conversation-label">{localize('com_ui_import_conversation_info')}</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleImportClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={!allowImport}
|
||||
disabled={isImportDisabled}
|
||||
aria-label={localize('com_ui_import')}
|
||||
className="btn btn-neutral relative"
|
||||
aria-labelledby="import-conversation-label"
|
||||
>
|
||||
{allowImport ? (
|
||||
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
|
||||
) : (
|
||||
{isUploading ? (
|
||||
<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>
|
||||
</button>
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="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>
|
||||
);
|
||||
};
|
||||
72
client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx
Normal file
72
client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -286,11 +286,13 @@ export default function SharedLinks() {
|
|||
|
||||
return (
|
||||
<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}>
|
||||
<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>
|
||||
|
||||
<OGDialogContent
|
||||
|
|
|
|||
|
|
@ -46,9 +46,11 @@ export const ThemeSelector = ({
|
|||
{ value: 'light', label: localize('com_nav_theme_light') },
|
||||
];
|
||||
|
||||
const labelId = 'theme-selector-label';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_theme')}</div>
|
||||
<div id={labelId}>{localize('com_nav_theme')}</div>
|
||||
|
||||
<Dropdown
|
||||
value={theme}
|
||||
|
|
@ -57,6 +59,7 @@ export const ThemeSelector = ({
|
|||
sizeClasses="w-[180px]"
|
||||
testId="theme-selector"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -112,9 +115,11 @@ export const LangSelector = ({
|
|||
{ value: 'uk-UA', label: localize('com_nav_lang_ukrainian') },
|
||||
];
|
||||
|
||||
const labelId = 'language-selector-label';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_language')}</div>
|
||||
<div id={labelId}>{localize('com_nav_language')}</div>
|
||||
|
||||
<Dropdown
|
||||
value={langcode}
|
||||
|
|
@ -122,6 +127,7 @@ export const LangSelector = ({
|
|||
sizeClasses="[--anchor-max-height:256px]"
|
||||
options={languageOptions}
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -65,10 +65,13 @@ export default function Personalization({
|
|||
|
||||
<div className="flex items-center justify-between">
|
||||
<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')}
|
||||
</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')}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -76,7 +79,8 @@ export default function Personalization({
|
|||
checked={referenceSavedMemories}
|
||||
onCheckedChange={handleMemoryToggle}
|
||||
disabled={updateMemoryPreferencesMutation.isLoading}
|
||||
aria-label={localize('com_ui_reference_saved_memories')}
|
||||
aria-labelledby="reference-saved-memories-label"
|
||||
aria-describedby="reference-saved-memories-description"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Switch } from '@librechat/client';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
export default function ConversationModeSwitch({
|
||||
|
|
@ -8,8 +7,6 @@ export default function ConversationModeSwitch({
|
|||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [conversationMode, setConversationMode] = useRecoilState<boolean>(store.conversationMode);
|
||||
const speechToText = useRecoilValue(store.speechToText);
|
||||
const textToSpeech = useRecoilValue(store.textToSpeech);
|
||||
const [, setAutoSendText] = useRecoilState(store.autoSendText);
|
||||
|
|
@ -20,27 +17,19 @@ export default function ConversationModeSwitch({
|
|||
setAutoTranscribeAudio(value);
|
||||
setAutoSendText(3);
|
||||
setDecibelValue(-45);
|
||||
setConversationMode(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<strong>{localize('com_nav_conversation_mode')}</strong>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Switch
|
||||
id="ConversationMode"
|
||||
checked={conversationMode}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="ConversationMode"
|
||||
disabled={!textToSpeech || !speechToText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
stateAtom={store.conversationMode}
|
||||
localizationKey={'com_nav_conversation_mode' as const}
|
||||
switchId="ConversationMode"
|
||||
onCheckedChange={handleCheckedChange}
|
||||
disabled={!textToSpeech || !speechToText}
|
||||
strongLabel={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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 { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
|
@ -11,40 +11,104 @@ export default function AutoSendTextSelector() {
|
|||
const speechToText = useRecoilValue(store.speechToText);
|
||||
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 (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_auto_send_text')}</div>
|
||||
<div className="w-2" />
|
||||
<small className="opacity-40">({localize('com_nav_auto_send_text_disabled')})</small>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Slider
|
||||
value={[autoSendText ?? -1]}
|
||||
onValueChange={(value) => setAutoSendText(value[0])}
|
||||
onDoubleClick={() => setAutoSendText(-1)}
|
||||
min={-1}
|
||||
max={60}
|
||||
step={1}
|
||||
className="ml-4 flex h-4 w-24"
|
||||
<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 className="w-2" />
|
||||
<InputNumber
|
||||
value={`${autoSendText} s`}
|
||||
disabled={!speechToText}
|
||||
onChange={(value) => setAutoSendText(value ? value[0] : 0)}
|
||||
min={-1}
|
||||
max={60}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
cn(
|
||||
optionText,
|
||||
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
|
||||
),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{isEnabled && (
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<div id="auto-send-delay-label" className="text-sm text-text-secondary">
|
||||
{localize('com_nav_setting_delay')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Slider
|
||||
value={[delayValue]}
|
||||
onValueChange={handleSliderChange}
|
||||
onDoubleClick={() => {
|
||||
setDelayValue(3);
|
||||
if (isEnabled) {
|
||||
setAutoSendText(3);
|
||||
}
|
||||
}}
|
||||
min={0}
|
||||
max={60}
|
||||
step={1}
|
||||
className="ml-4 flex h-4 w-24"
|
||||
disabled={!speechToText || !isEnabled}
|
||||
aria-labelledby="auto-send-delay-label"
|
||||
/>
|
||||
<div className="w-2" />
|
||||
<InputNumber
|
||||
value={`${delayValue} s`}
|
||||
disabled={!speechToText || !isEnabled}
|
||||
onChange={handleInputChange}
|
||||
min={0}
|
||||
max={60}
|
||||
aria-labelledby="auto-send-delay-label"
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
cn(
|
||||
optionText,
|
||||
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
|
||||
),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Switch } from '@librechat/client';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import ToggleSwitch from '../../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
export default function AutoTranscribeAudioSwitch({
|
||||
|
|
@ -8,30 +7,15 @@ export default function AutoTranscribeAudioSwitch({
|
|||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [autoTranscribeAudio, setAutoTranscribeAudio] = useRecoilState<boolean>(
|
||||
store.autoTranscribeAudio,
|
||||
);
|
||||
const speechToText = useRecoilValue(store.speechToText);
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setAutoTranscribeAudio(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_auto_transcribe_audio')}</div>
|
||||
<Switch
|
||||
id="AutoTranscribeAudio"
|
||||
checked={autoTranscribeAudio}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="AutoTranscribeAudio"
|
||||
disabled={!speechToText}
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
stateAtom={store.autoTranscribeAudio}
|
||||
localizationKey={'com_nav_auto_transcribe_audio' as const}
|
||||
switchId="AutoTranscribeAudio"
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={!speechToText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export default function DecibelSelector() {
|
|||
return (
|
||||
<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" />
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default_with_num', { 0: '-45' })})
|
||||
|
|
@ -29,6 +29,7 @@ export default function DecibelSelector() {
|
|||
step={1}
|
||||
className="ml-4 flex h-4 w-24"
|
||||
disabled={!speechToText}
|
||||
aria-labelledby="decibel-selector-label"
|
||||
/>
|
||||
<div className="w-2" />
|
||||
<InputNumber
|
||||
|
|
@ -37,6 +38,7 @@ export default function DecibelSelector() {
|
|||
onChange={(value) => setDecibelValue(value ? value[0] : 0)}
|
||||
min={-100}
|
||||
max={-30}
|
||||
aria-labelledby="decibel-selector-label"
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
cn(
|
||||
|
|
|
|||
|
|
@ -23,9 +23,11 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
|
|||
setEngineSTT(value);
|
||||
};
|
||||
|
||||
const labelId = 'engine-stt-dropdown-label';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_engine')}</div>
|
||||
<div id={labelId}>{localize('com_nav_engine')}</div>
|
||||
<Dropdown
|
||||
value={engineSTT}
|
||||
onChange={handleSelect}
|
||||
|
|
@ -33,6 +35,7 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
|
|||
sizeClasses="w-[180px]"
|
||||
testId="EngineSTTDropdown"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -94,9 +94,11 @@ export default function LanguageSTTDropdown() {
|
|||
setLanguageSTT(value);
|
||||
};
|
||||
|
||||
const labelId = 'language-stt-dropdown-label';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_language')}</div>
|
||||
<div id={labelId}>{localize('com_nav_language')}</div>
|
||||
<Dropdown
|
||||
value={languageSTT}
|
||||
onChange={handleSelect}
|
||||
|
|
@ -104,6 +106,7 @@ export default function LanguageSTTDropdown() {
|
|||
sizeClasses="[--anchor-max-height:256px]"
|
||||
testId="LanguageSTTDropdown"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import ToggleSwitch from '../../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
export default function SpeechToTextSwitch({
|
||||
|
|
@ -8,28 +6,13 @@ export default function SpeechToTextSwitch({
|
|||
}: {
|
||||
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 (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<strong>{localize('com_nav_speech_to_text')}</strong>
|
||||
</div>
|
||||
<Switch
|
||||
id="SpeechToText"
|
||||
checked={speechToText}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="SpeechToText"
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
stateAtom={store.speechToText}
|
||||
localizationKey={'com_nav_speech_to_text' as const}
|
||||
switchId="SpeechToText"
|
||||
onCheckedChange={onCheckedChange}
|
||||
strongLabel={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
} from './STT';
|
||||
import ConversationModeSwitch from './ConversationModeSwitch';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn, logger } from '~/utils';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
function Speech() {
|
||||
|
|
@ -186,7 +186,7 @@ function Speech() {
|
|||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Tabs.Content value={'simple'}>
|
||||
<Tabs.Content value={'simple'} tabIndex={-1}>
|
||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||
<SpeechToTextSwitch />
|
||||
<EngineSTTDropdown external={sttExternal} />
|
||||
|
|
@ -198,7 +198,7 @@ function Speech() {
|
|||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value={'advanced'}>
|
||||
<Tabs.Content value={'advanced'} tabIndex={-1}>
|
||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||
<ConversationModeSwitch />
|
||||
<div className="mt-2 h-px bg-border-medium" role="none" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import ToggleSwitch from '../../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
export default function AutomaticPlaybackSwitch({
|
||||
|
|
@ -8,26 +6,12 @@ export default function AutomaticPlaybackSwitch({
|
|||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [automaticPlayback, setAutomaticPlayback] = useRecoilState(store.automaticPlayback);
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setAutomaticPlayback(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_automatic_playback')}</div>
|
||||
<Switch
|
||||
id="AutomaticPlayback"
|
||||
checked={automaticPlayback}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="AutomaticPlayback"
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
stateAtom={store.automaticPlayback}
|
||||
localizationKey={'com_nav_automatic_playback' as const}
|
||||
switchId="AutomaticPlayback"
|
||||
onCheckedChange={onCheckedChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import ToggleSwitch from '../../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
export default function CacheTTSSwitch({
|
||||
|
|
@ -8,28 +7,15 @@ export default function CacheTTSSwitch({
|
|||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [cacheTTS, setCacheTTS] = useRecoilState<boolean>(store.cacheTTS);
|
||||
const [textToSpeech] = useRecoilState<boolean>(store.textToSpeech);
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setCacheTTS(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
const textToSpeech = useRecoilValue(store.textToSpeech);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_enable_cache_tts')}</div>
|
||||
<Switch
|
||||
id="CacheTTS"
|
||||
checked={cacheTTS}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="CacheTTS"
|
||||
disabled={!textToSpeech}
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
stateAtom={store.cacheTTS}
|
||||
localizationKey={'com_nav_enable_cache_tts' as const}
|
||||
switchId="CacheTTS"
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={!textToSpeech}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import ToggleSwitch from '../../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
export default function CloudBrowserVoicesSwitch({
|
||||
|
|
@ -8,30 +7,15 @@ export default function CloudBrowserVoicesSwitch({
|
|||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [cloudBrowserVoices, setCloudBrowserVoices] = useRecoilState<boolean>(
|
||||
store.cloudBrowserVoices,
|
||||
);
|
||||
const [textToSpeech] = useRecoilState<boolean>(store.textToSpeech);
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setCloudBrowserVoices(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
const textToSpeech = useRecoilValue(store.textToSpeech);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_enable_cloud_browser_voice')}</div>
|
||||
<Switch
|
||||
id="CloudBrowserVoices"
|
||||
checked={cloudBrowserVoices}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="CloudBrowserVoices"
|
||||
disabled={!textToSpeech}
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
stateAtom={store.cloudBrowserVoices}
|
||||
localizationKey={'com_nav_enable_cloud_browser_voice' as const}
|
||||
switchId="CloudBrowserVoices"
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={!textToSpeech}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,9 +23,11 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
|
|||
setEngineTTS(value);
|
||||
};
|
||||
|
||||
const labelId = 'engine-tts-dropdown-label';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_engine')}</div>
|
||||
<div id={labelId}>{localize('com_nav_engine')}</div>
|
||||
<Dropdown
|
||||
value={engineTTS}
|
||||
onChange={handleSelect}
|
||||
|
|
@ -33,6 +35,7 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
|
|||
sizeClasses="w-[180px]"
|
||||
testId="EngineTTSDropdown"
|
||||
className="z-50"
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export default function DecibelSelector() {
|
|||
return (
|
||||
<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" />
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default_with_num', { 0: '1' })})
|
||||
|
|
@ -29,6 +29,7 @@ export default function DecibelSelector() {
|
|||
step={0.1}
|
||||
className="ml-4 flex h-4 w-24"
|
||||
disabled={!textToSpeech}
|
||||
aria-labelledby="playback-rate-label"
|
||||
/>
|
||||
<div className="w-2" />
|
||||
<InputNumber
|
||||
|
|
@ -37,6 +38,7 @@ export default function DecibelSelector() {
|
|||
onChange={(value) => setPlaybackRate(value ? value[0] : 0)}
|
||||
min={0.1}
|
||||
max={2}
|
||||
aria-labelledby="playback-rate-label"
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
cn(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import ToggleSwitch from '../../ToggleSwitch';
|
||||
import store from '~/store';
|
||||
|
||||
export default function TextToSpeechSwitch({
|
||||
|
|
@ -8,28 +6,13 @@ export default function TextToSpeechSwitch({
|
|||
}: {
|
||||
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 (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<strong>{localize('com_nav_text_to_speech')}</strong>
|
||||
</div>
|
||||
<Switch
|
||||
id="TextToSpeech"
|
||||
checked={TextToSpeech}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="TextToSpeech"
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
stateAtom={store.textToSpeech}
|
||||
localizationKey={'com_nav_text_to_speech' as const}
|
||||
switchId="TextToSpeech"
|
||||
onCheckedChange={onCheckedChange}
|
||||
strongLabel={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ interface ToggleSwitchProps {
|
|||
hoverCardText?: LocalizeKey;
|
||||
switchId: string;
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
showSwitch?: boolean;
|
||||
disabled?: boolean;
|
||||
strongLabel?: boolean;
|
||||
}
|
||||
|
||||
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||
|
|
@ -19,6 +22,9 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
|||
hoverCardText,
|
||||
switchId,
|
||||
onCheckedChange,
|
||||
showSwitch = true,
|
||||
disabled = false,
|
||||
strongLabel = false,
|
||||
}) => {
|
||||
const [switchState, setSwitchState] = useRecoilState(stateAtom);
|
||||
const localize = useLocalize();
|
||||
|
|
@ -28,10 +34,18 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
|||
onCheckedChange?.(value);
|
||||
};
|
||||
|
||||
const labelId = `${switchId}-label`;
|
||||
|
||||
if (!showSwitch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<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)} />}
|
||||
</div>
|
||||
<Switch
|
||||
|
|
@ -40,6 +54,8 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
|||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid={switchId}
|
||||
aria-labelledby={labelId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
export { default as General } from './General/General';
|
||||
export { default as Chat } from './Chat/Chat';
|
||||
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 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';
|
||||
|
|
|
|||
|
|
@ -71,10 +71,12 @@ function ChatGroupItem({
|
|||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
id={`prompt-actions-${group._id}`}
|
||||
aria-label={`${group.name} - Actions Menu`}
|
||||
aria-expanded="false"
|
||||
aria-controls={`prompt-menu-${group._id}`}
|
||||
aria-haspopup="menu"
|
||||
type="button"
|
||||
aria-label={
|
||||
localize('com_ui_sr_actions_menu', { 0: group.name }) +
|
||||
' ' +
|
||||
localize('com_ui_prompt')
|
||||
}
|
||||
onClick={(e) => {
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
|
|
@ -98,30 +95,29 @@ function ChatGroupItem({
|
|||
aria-label={`Available actions for ${group.name}`}
|
||||
className="z-50 w-fit rounded-xl"
|
||||
collisionPadding={2}
|
||||
align="end"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
role="menuitem"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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>
|
||||
</DropdownMenuItem>
|
||||
{canEdit && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
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>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
|
|||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
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 items-center gap-2 truncate pr-2">
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export default function FilterPrompts({ className = '' }: { className?: string }
|
|||
value={categoryFilter || SystemCategories.ALL}
|
||||
onChange={onSelect}
|
||||
options={filterOptions}
|
||||
className="bg-transparent"
|
||||
className="rounded-lg bg-transparent"
|
||||
icon={<ListFilter className="h-4 w-4" />}
|
||||
label="Filter: "
|
||||
ariaLabel={localize('com_ui_filter_prompts')}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export default function List({
|
|||
</Button>
|
||||
</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">
|
||||
{isLoading && isChatRoute && (
|
||||
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const PreviewPrompt = ({
|
|||
return (
|
||||
<OGDialog open={open} onOpenChange={onOpenChange}>
|
||||
<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} />
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"chat_direction_left_to_right": "something needs to go here. was empty",
|
||||
"chat_direction_right_to_left": "something needs to go here. was empty",
|
||||
"chat_direction_left_to_right": "Left to Right",
|
||||
"chat_direction_right_to_left": "Right to Left",
|
||||
"com_a11y_ai_composing": "The AI is still composing.",
|
||||
"com_a11y_end": "The AI has finished 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_send_prompts": "Auto-send Prompts",
|
||||
"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_automatic_playback": "Autoplay Latest Message",
|
||||
"com_nav_balance": "Balance",
|
||||
|
|
@ -573,6 +572,7 @@
|
|||
"com_nav_slash_command_description": "Toggle command \"/\" for selecting a prompt via keyboard",
|
||||
"com_nav_speech_to_text": "Speech to Text",
|
||||
"com_nav_stop_generating": "Stop generating",
|
||||
"com_nav_setting_delay": "Delay (s)",
|
||||
"com_nav_text_to_speech": "Text to Speech",
|
||||
"com_nav_theme": "Theme",
|
||||
"com_nav_theme_dark": "Dark",
|
||||
|
|
@ -761,6 +761,7 @@
|
|||
"com_ui_close": "Close",
|
||||
"com_ui_close_menu": "Close Menu",
|
||||
"com_ui_close_window": "Close Window",
|
||||
"com_ui_close_settings": "Close Settings",
|
||||
"com_ui_code": "Code",
|
||||
"com_ui_collapse_chat": "Collapse Chat",
|
||||
"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_gen": "Image Gen",
|
||||
"com_ui_import": "Import",
|
||||
"com_ui_import_conversation_error": "There was an error importing your conversations",
|
||||
"com_ui_import_conversation_file_type_error": "Unsupported import type",
|
||||
"com_ui_import_conversation_error": "There was an error while importing your conversations",
|
||||
"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_success": "Conversations imported successfully",
|
||||
"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_share": "Allow sharing Prompts",
|
||||
"com_ui_prompts_allow_use": "Allow using Prompts",
|
||||
"com_ui_prompt_groups": "Prompt Groups List",
|
||||
"com_ui_provider": "Provider",
|
||||
"com_ui_quality": "Quality",
|
||||
"com_ui_read_aloud": "Read aloud",
|
||||
|
|
@ -1279,5 +1282,21 @@
|
|||
"com_ui_x_selected": "{{0}} selected",
|
||||
"com_ui_yes": "Yes",
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ interface DropdownProps {
|
|||
iconOnly?: boolean;
|
||||
renderValue?: (option: Option) => React.ReactNode;
|
||||
ariaLabel?: string;
|
||||
'aria-labelledby'?: string;
|
||||
portal?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ const Dropdown: React.FC<DropdownProps> = ({
|
|||
iconOnly = false,
|
||||
renderValue,
|
||||
ariaLabel,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
portal = true,
|
||||
}) => {
|
||||
const handleChange = (value: string) => {
|
||||
|
|
@ -77,6 +79,7 @@ const Dropdown: React.FC<DropdownProps> = ({
|
|||
)}
|
||||
data-testid={testId}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{icon}
|
||||
|
|
|
|||
|
|
@ -1,191 +1,225 @@
|
|||
import * as React from 'react';
|
||||
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';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className = '', inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-gray-100 data-[state=open]:bg-gray-100 dark:focus:bg-gray-900 dark:data-[state=open]:bg-gray-900',
|
||||
inset ? 'pl-8' : '',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-100 bg-white p-1 text-gray-700 shadow-md animate-in slide-in-from-left-1 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className = '', sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
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',
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className = '', inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-900',
|
||||
inset ? 'pl-8' : '',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className = '', children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-900',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className = '', children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
className,
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className = '', inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold text-gray-900 dark:text-gray-300',
|
||||
inset ? 'pl-8' : '',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-border-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className = '',
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span className={cn('ml-auto text-xs tracking-widest text-gray-500', className)} {...props} />
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-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,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
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(
|
||||
'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',
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { CircleHelpIcon } from 'lucide-react';
|
||||
import { HoverCard, HoverCardTrigger, HoverCardPortal, HoverCardContent } from './HoverCard';
|
||||
import { ESide } from '~/common';
|
||||
|
|
@ -8,15 +9,23 @@ type InfoHoverCardProps = {
|
|||
};
|
||||
|
||||
const InfoHoverCard = ({ side, text }: InfoHoverCardProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger className="cursor-help">
|
||||
<CircleHelpIcon className="h-5 w-5 text-text-tertiary" />{' '}
|
||||
<HoverCard openDelay={50} open={isOpen} onOpenChange={setIsOpen}>
|
||||
<HoverCardTrigger
|
||||
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>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side={side} className="z-[999] w-80">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">{text}</p>
|
||||
<span className="text-sm text-text-secondary">{text}</span>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const Label = React.forwardRef<
|
|||
{...props}
|
||||
{...{
|
||||
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,
|
||||
),
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -2,37 +2,56 @@ import * as React from 'react';
|
|||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
|
||||
className?: string;
|
||||
onDoubleClick?: () => void;
|
||||
}
|
||||
>(({ className, onDoubleClick, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
{...props}
|
||||
{...{
|
||||
className: cn(
|
||||
'relative flex w-full cursor-pointer touch-none select-none items-center',
|
||||
className,
|
||||
),
|
||||
type SliderProps = React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
|
||||
className?: string;
|
||||
onDoubleClick?: () => void;
|
||||
'aria-describedby'?: string;
|
||||
} & (
|
||||
| { '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,
|
||||
}}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
{...{ className: 'relative h-2 w-full grow overflow-hidden rounded-full bg-secondary' }}
|
||||
>
|
||||
<SliderPrimitive.Range {...{ className: 'absolute h-full bg-primary' }} />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
{...props}
|
||||
{...{
|
||||
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',
|
||||
className: cn(
|
||||
'relative flex w-full cursor-pointer touch-none select-none items-center',
|
||||
className,
|
||||
),
|
||||
onDoubleClick,
|
||||
}}
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
{...{ className: 'relative h-2 w-full grow overflow-hidden rounded-full bg-secondary' }}
|
||||
>
|
||||
<SliderPrimitive.Range {...{ className: 'absolute h-full bg-primary' }} />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
{...{
|
||||
className:
|
||||
'block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
}}
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
),
|
||||
);
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue