mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-28 13:16:13 +01:00
🪄 refactor: UI Polish and Admin Dialog Unification (#11108)
* refactor(OpenSidebar): removed useless classNames
* style(Header): update hover styles across various components for improved UI consistency
* style(Nav): update hover styles in AccountSettings and SearchBar for improved UI consistency
* style: update button classes for consistent hover effects and improved UI responsiveness
* style(Nav, OpenSidebar, Header, Convo): improve UI responsiveness and animation transitions
* style(PresetsMenu, NewChat): update icon sizes and improve component styling for better UI consistency
* style(Nav, Root): enhance sidebar mobile animations and responsiveness for better UI experience
* style(ExportAndShareMenu, BookmarkMenu): update icon sizes for improved UI consistency
* style: remove transition duration from button classes for improved UI responsiveness
* style(CustomMenu, ModelSelector): update background colors for improved UI consistency and responsiveness
* style(ExportAndShareMenu): update icon color for improved UI consistency
* style(TemporaryChat): refine button styles for improved UI consistency and responsiveness
* style(BookmarkNav): refactor to use DropdownPopup and remove BookmarkNavItems for improved UI consistency and functionality
* style(CustomMenu, EndpointItem): enhance UI elements for improved consistency and accessibility
* style(EndpointItem): adjust gap in icon container for improved layout consistency
* style(CustomMenu, EndpointItem): update focus ring color for improved UI consistency
* style(EndpointItem): update icon color for improved UI consistency in dark theme
* style: update focus styles for improved accessibility and consistency across components
* refactor(Nav): extract sidebar width to NAV_WIDTH constant
Centralize mobile (320px) and desktop (260px) sidebar widths in a single
exported constant to avoid magic numbers and ensure consistency.
* fix(BookmarkNav): memoize handlers used in useMemo
Wrap handleTagClick and handleClear in useCallback and add them to the
dropdownItems useMemo dependency array to prevent stale closures.
* feat: introduce FilterInput component and replace existing inputs with it across multiple components
* feat(DataTable): replace custom input with FilterInput component for improved filtering
* fix: Nested dialog overlay stacking issue
Fixes overlay appearing behind content when opening nested dialogs.
Introduced dynamic z-index calculation based on dialog depth using React context.
- First dialog: overlay z-50, content z-100
- Nested dialogs increment by 60: overlay z-110/content z-160, etc.
Preserves a11y escape key handling from #10975 and #10851.
Regression from #11008 (afb67fcf1) which increased content z-index
without adjusting overlay z-index for nested dialog scenarios.
* Refactor admin settings components to use a unified AdminSettingsDialog
- Removed redundant code from AdminSettings, MCPAdminSettings, and Memories AdminSettings components.
- Introduced AdminSettingsDialog component to handle permission management for different sections.
- Updated permission handling logic to use a consistent structure across components.
- Enhanced role selection and permission confirmation features in the new dialog.
- Improved UI consistency and maintainability by centralizing dialog functionality.
* refactor(Memory): memory management UI components and replace MemoryViewer with MemoryPanel
* refactor(Memory): enhance UI components for Memory dialogs and improve input styling
* refactor(Bookmarks): improve bookmark management UI with enhanced styling
* refactor(translations): remove redundant filter input and bookmark count entries
* refactor(Convo): integrate useShiftKey hook for enhanced keyboard interaction and improve UI responsiveness
This commit is contained in:
parent
c21733930c
commit
5181356bef
71 changed files with 2115 additions and 2191 deletions
|
|
@ -268,7 +268,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
variant="outline"
|
||||
data-testid="agents-new-chat-button"
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
|
||||
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-active-alt max-md:hidden"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<NewChatIcon />
|
||||
|
|
|
|||
|
|
@ -1,75 +1,20 @@
|
|||
import { useMemo, useEffect, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Switch,
|
||||
OGDialog,
|
||||
DropdownPopup,
|
||||
OGDialogTitle,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
|
||||
import { Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import { Button, useToastContext } from '@librechat/client';
|
||||
import { AdminSettingsDialog } from '~/components/ui';
|
||||
import { useUpdateMarketplacePermissionsMutation } from '~/data-provider';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import type { PermissionConfig } from '~/components/ui';
|
||||
|
||||
type FormValues = {
|
||||
[Permissions.USE]: boolean;
|
||||
};
|
||||
|
||||
type LabelControllerProps = {
|
||||
label: string;
|
||||
marketplacePerm: Permissions.USE;
|
||||
control: Control<FormValues, unknown, FormValues>;
|
||||
setValue: UseFormSetValue<FormValues>;
|
||||
getValues: UseFormGetValues<FormValues>;
|
||||
};
|
||||
|
||||
const LabelController: React.FC<LabelControllerProps> = ({
|
||||
control,
|
||||
marketplacePerm,
|
||||
label,
|
||||
getValues,
|
||||
setValue,
|
||||
}) => (
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
<button
|
||||
className="cursor-pointer select-none"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setValue(marketplacePerm, !getValues(marketplacePerm), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<Controller
|
||||
name={marketplacePerm}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
value={field.value.toString()}
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const permissions: PermissionConfig[] = [
|
||||
{ permission: Permissions.USE, labelKey: 'com_ui_marketplace_allow_use' },
|
||||
];
|
||||
|
||||
const MarketplaceAdminSettings = () => {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { user, roles } = useAuthContext();
|
||||
const { mutate, isLoading } = useUpdateMarketplacePermissionsMutation({
|
||||
|
||||
const mutation = useUpdateMarketplacePermissionsMutation({
|
||||
onSuccess: () => {
|
||||
showToast({ status: 'success', message: localize('com_ui_saved') });
|
||||
},
|
||||
|
|
@ -78,133 +23,27 @@ const MarketplaceAdminSettings = () => {
|
|||
},
|
||||
});
|
||||
|
||||
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const rolePerms = roles?.[selectedRole]?.permissions;
|
||||
if (rolePerms) {
|
||||
return rolePerms[PermissionTypes.MARKETPLACE];
|
||||
}
|
||||
return roleDefaults[selectedRole].permissions[PermissionTypes.MARKETPLACE];
|
||||
}, [roles, selectedRole]);
|
||||
|
||||
const {
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const value = roles?.[selectedRole]?.permissions?.[PermissionTypes.MARKETPLACE];
|
||||
if (value) {
|
||||
reset(value);
|
||||
} else {
|
||||
reset(roleDefaults[selectedRole].permissions[PermissionTypes.MARKETPLACE]);
|
||||
}
|
||||
}, [roles, selectedRole, reset]);
|
||||
|
||||
if (user?.role !== SystemRoles.ADMIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelControllerData: {
|
||||
marketplacePerm: Permissions.USE;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
marketplacePerm: Permissions.USE,
|
||||
label: localize('com_ui_marketplace_allow_use'),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
mutate({ roleName: selectedRole, updates: data });
|
||||
};
|
||||
|
||||
const roleDropdownItems = [
|
||||
{
|
||||
label: SystemRoles.USER,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.USER);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: SystemRoles.ADMIN,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.ADMIN);
|
||||
},
|
||||
},
|
||||
];
|
||||
const trigger = (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="relative h-12 rounded-xl border-border-medium font-medium"
|
||||
aria-label={localize('com_ui_admin_settings')}
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="relative h-12 rounded-xl border-border-medium font-medium"
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-11/12 max-w-md border-border-light bg-surface-primary text-text-primary">
|
||||
<OGDialogTitle>
|
||||
{localize('com_ui_admin_settings_section', { section: localize('com_ui_marketplace') })}
|
||||
</OGDialogTitle>
|
||||
<div className="p-2">
|
||||
{/* Role selection dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{localize('com_ui_role_select')}:</span>
|
||||
<DropdownPopup
|
||||
unmountOnHide={true}
|
||||
menuId="role-dropdown"
|
||||
isOpen={isRoleMenuOpen}
|
||||
setIsOpen={setIsRoleMenuOpen}
|
||||
trigger={
|
||||
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
|
||||
{selectedRole}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
itemClassName="items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
{/* Permissions form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-5">
|
||||
{labelControllerData.map(({ marketplacePerm, label }) => (
|
||||
<div key={marketplacePerm}>
|
||||
<LabelController
|
||||
control={control}
|
||||
marketplacePerm={marketplacePerm}
|
||||
label={label}
|
||||
getValues={getValues}
|
||||
setValue={setValue}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
<AdminSettingsDialog
|
||||
permissionType={PermissionTypes.MARKETPLACE}
|
||||
sectionKey="com_ui_marketplace"
|
||||
permissions={permissions}
|
||||
menuId="marketplace-role-dropdown"
|
||||
mutation={mutation}
|
||||
trigger={trigger}
|
||||
dialogContentClassName="w-11/12 max-w-md border-border-light bg-surface-primary text-text-primary"
|
||||
showAdminWarning={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ const BookmarkEditDialog = ({
|
|||
<OGDialogTemplate
|
||||
title={bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')}
|
||||
showCloseButton={false}
|
||||
className="w-11/12 md:max-w-2xl"
|
||||
className="w-11/12 md:max-w-lg"
|
||||
main={
|
||||
<BookmarkForm
|
||||
tags={tags}
|
||||
|
|
|
|||
|
|
@ -85,16 +85,11 @@ const BookmarkForm = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
className="mt-6"
|
||||
aria-label="Bookmark form"
|
||||
method="POST"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label htmlFor="bookmark-tag" className="text-left text-sm font-medium">
|
||||
<form ref={formRef} aria-label="Bookmark form" method="POST" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-4">
|
||||
{/* Tag name input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bookmark-tag" className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_bookmarks_title')}
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -118,24 +113,24 @@ const BookmarkForm = ({
|
|||
);
|
||||
},
|
||||
})}
|
||||
className="w-full"
|
||||
aria-invalid={!!errors.tag}
|
||||
placeholder={
|
||||
bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')
|
||||
}
|
||||
placeholder={localize('com_ui_enter_name')}
|
||||
aria-describedby={errors.tag ? 'bookmark-tag-error' : undefined}
|
||||
/>
|
||||
{errors.tag && (
|
||||
<span id="bookmark-tag-error" role="alert" className="text-sm font-bold text-red-500">
|
||||
<span id="bookmark-tag-error" role="alert" className="text-sm text-red-500">
|
||||
{errors.tag.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid w-full items-center gap-2">
|
||||
{/* Description textarea */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
id="bookmark-description-label"
|
||||
htmlFor="bookmark-description"
|
||||
className="text-left text-sm font-medium"
|
||||
className="text-sm font-medium text-text-primary"
|
||||
>
|
||||
{localize('com_ui_bookmarks_description')}
|
||||
</Label>
|
||||
|
|
@ -151,14 +146,20 @@ const BookmarkForm = ({
|
|||
})}
|
||||
id="bookmark-description"
|
||||
disabled={false}
|
||||
placeholder={localize('com_ui_enter_description')}
|
||||
className={cn(
|
||||
'flex h-10 max-h-[250px] min-h-[100px] w-full resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background focus-visible:outline-none',
|
||||
'min-h-[100px] w-full resize-none rounded-lg border border-border-light',
|
||||
'bg-transparent px-3 py-2 text-sm text-text-primary',
|
||||
'placeholder:text-text-tertiary',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-heavy',
|
||||
)}
|
||||
aria-labelledby="bookmark-description-label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add to conversation checkbox */}
|
||||
{conversationId != null && conversationId && (
|
||||
<div className="mt-2 flex w-full items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Controller
|
||||
name="addToConversation"
|
||||
control={control}
|
||||
|
|
@ -167,7 +168,7 @@ const BookmarkForm = ({
|
|||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
|
||||
className="size-4 cursor-pointer"
|
||||
value={field.value?.toString()}
|
||||
aria-label={localize('com_ui_bookmarks_add_to_conversation')}
|
||||
/>
|
||||
|
|
@ -176,16 +177,14 @@ const BookmarkForm = ({
|
|||
<button
|
||||
type="button"
|
||||
aria-label={localize('com_ui_bookmarks_add_to_conversation')}
|
||||
className="form-check-label w-full cursor-pointer text-text-primary"
|
||||
className="cursor-pointer text-sm text-text-primary"
|
||||
onClick={() =>
|
||||
setValue('addToConversation', !(getValues('addToConversation') ?? false), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="flex select-none items-center">
|
||||
{localize('com_ui_bookmarks_add_to_conversation')}
|
||||
</div>
|
||||
{localize('com_ui_bookmarks_add_to_conversation')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export default function ExportAndShareMenu({
|
|||
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
>
|
||||
<Share2
|
||||
className="icon-md text-text-secondary"
|
||||
className="icon-lg text-text-primary"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -45,10 +45,10 @@ export default function Header() {
|
|||
{!navVisible && (
|
||||
<motion.div
|
||||
className="flex items-center gap-2"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 'auto', opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
key="header-buttons"
|
||||
>
|
||||
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const AttachFile = ({ disabled }: { disabled?: boolean | null }) => {
|
|||
aria-label={localize('com_sidepanel_attach_files')}
|
||||
disabled={isUploadDisabled}
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
||||
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',
|
||||
)}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (!inputRef.current) {
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ const AttachFileMenu = ({
|
|||
id="attach-file-menu-button"
|
||||
aria-label="Attach File Options"
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
||||
'flex size-9 items-center justify-center rounded-full p-1 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',
|
||||
isPopoverActive && 'bg-surface-hover',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import {
|
||||
flexRender,
|
||||
|
|
@ -17,7 +16,6 @@ import type {
|
|||
} from '@tanstack/react-table';
|
||||
import { FileContext } from 'librechat-data-provider';
|
||||
import {
|
||||
Input,
|
||||
Table,
|
||||
Button,
|
||||
Spinner,
|
||||
|
|
@ -26,6 +24,7 @@ import {
|
|||
TableCell,
|
||||
TableHead,
|
||||
TrashIcon,
|
||||
FilterInput,
|
||||
TableHeader,
|
||||
useMediaQuery,
|
||||
} from '@librechat/client';
|
||||
|
|
@ -115,23 +114,13 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
)}
|
||||
{!isSmallScreen && <span className="ml-2">{localize('com_ui_delete')}</span>}
|
||||
</Button>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-text-secondary" />
|
||||
<Input
|
||||
id="files-filter"
|
||||
placeholder=" "
|
||||
value={(table.getColumn('filename')?.getFilterValue() as string | undefined) ?? ''}
|
||||
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
|
||||
className="peer w-full pl-10 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={localize('com_files_filter_input')}
|
||||
/>
|
||||
<label
|
||||
htmlFor="files-filter"
|
||||
className="pointer-events-none absolute left-10 top-1/2 -translate-y-1/2 text-sm text-text-secondary transition-all duration-200 peer-focus:top-0 peer-focus:bg-background peer-focus:px-1 peer-focus:text-xs peer-[:not(:placeholder-shown)]:top-0 peer-[:not(:placeholder-shown)]:bg-background peer-[:not(:placeholder-shown)]:px-1 peer-[:not(:placeholder-shown)]:text-xs"
|
||||
>
|
||||
{localize('com_files_filter')}
|
||||
</label>
|
||||
</div>
|
||||
<FilterInput
|
||||
inputId="files-filter"
|
||||
label={localize('com_files_filter')}
|
||||
value={(table.getColumn('filename')?.getFilterValue() as string | undefined) ?? ''}
|
||||
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
|
||||
containerClassName="flex-1"
|
||||
/>
|
||||
<div className="relative focus-within:z-[100]">
|
||||
<ColumnVisibilityDropdown
|
||||
table={table}
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
|||
id="tools-dropdown-button"
|
||||
aria-label="Tools Options"
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
||||
'flex size-9 items-center justify-center rounded-full p-1 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',
|
||||
isPopoverActive && 'bg-surface-hover',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -147,9 +147,9 @@ const BookmarkMenu: FC = () => {
|
|||
return <Spinner aria-label="Spinner" />;
|
||||
}
|
||||
if ((tags?.length ?? 0) > 0) {
|
||||
return <BookmarkFilledIcon className="icon-sm" aria-label="Filled Bookmark" />;
|
||||
return <BookmarkFilledIcon className="icon-lg" aria-label="Filled Bookmark" />;
|
||||
}
|
||||
return <BookmarkIcon className="icon-sm" aria-label="Bookmark" />;
|
||||
return <BookmarkIcon className="icon-lg" aria-label="Bookmark" />;
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(func
|
|||
!parent &&
|
||||
'flex h-10 w-full items-center justify-center gap-2 rounded-xl border border-border-light px-3 py-2 text-sm text-text-primary',
|
||||
menuStore.useState('open')
|
||||
? 'bg-surface-tertiary hover:bg-surface-tertiary'
|
||||
: 'bg-surface-secondary hover:bg-surface-tertiary',
|
||||
? 'bg-surface-active-alt hover:bg-surface-active-alt'
|
||||
: 'bg-presentation hover:bg-surface-active-alt',
|
||||
props.className,
|
||||
)}
|
||||
render={parent ? <CustomMenuItem render={trigger} /> : trigger}
|
||||
|
|
@ -66,7 +66,7 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(func
|
|||
className={cn(
|
||||
`${parent ? 'animate-popover-left ml-3' : 'animate-popover'} outline-none! z-50 flex max-h-[min(450px,var(--popover-available-height))] w-full`,
|
||||
'w-[var(--menu-width,auto)] min-w-[300px] flex-col overflow-auto rounded-xl border border-border-light',
|
||||
'bg-surface-secondary px-3 py-2 text-sm text-text-primary shadow-lg',
|
||||
'bg-presentation px-3 py-2 text-sm text-text-primary shadow-lg',
|
||||
'max-w-[calc(100vw-4rem)] sm:max-h-[calc(65vh)] sm:max-w-[400px]',
|
||||
searchable && 'p-0',
|
||||
)}
|
||||
|
|
@ -80,13 +80,13 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(func
|
|||
autoSelect
|
||||
render={combobox}
|
||||
className={cn(
|
||||
'peer mt-1 h-10 w-full rounded-lg border-none bg-transparent px-2 text-base',
|
||||
'peer flex h-10 w-full items-center justify-center rounded-lg border-none bg-transparent px-2 text-base',
|
||||
'sm:h-8 sm:text-sm',
|
||||
'focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-white',
|
||||
'focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-primary',
|
||||
)}
|
||||
/>
|
||||
{comboboxLabel && (
|
||||
<label className="pointer-events-none absolute left-2.5 top-2.5 text-sm text-text-secondary transition-all duration-200 peer-[:not(:placeholder-shown)]:-top-1.5 peer-[:not(:placeholder-shown)]:left-1.5 peer-[:not(:placeholder-shown)]:bg-surface-secondary peer-[:not(:placeholder-shown)]:text-xs">
|
||||
<label className="pointer-events-none absolute left-2.5 top-2.5 text-sm text-text-secondary transition-all duration-200 peer-[:not(:placeholder-shown)]:-top-1.5 peer-[:not(:placeholder-shown)]:left-1.5 peer-[:not(:placeholder-shown)]:bg-presentation peer-[:not(:placeholder-shown)]:text-xs sm:top-1.5">
|
||||
{comboboxLabel}
|
||||
</label>
|
||||
)}
|
||||
|
|
@ -168,7 +168,7 @@ export const CustomMenuItem = React.forwardRef<HTMLDivElement, CustomMenuItemPro
|
|||
blurOnHoverEnd: false,
|
||||
...props,
|
||||
className: cn(
|
||||
'relative flex cursor-default items-center gap-2 rounded-lg p-2 outline-none! scroll-m-1 scroll-mt-[calc(var(--combobox-height,0px)+var(--label-height,4px))] aria-disabled:opacity-25 data-[active-item]:bg-black/[0.075] data-[active-item]:text-black dark:data-[active-item]:bg-white/10 dark:data-[active-item]:text-white sm:py-1 sm:text-sm min-w-0 w-full before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:bg-transparent before:rounded-full data-[active-item]:before:bg-black dark:data-[active-item]:before:bg-white',
|
||||
'relative flex cursor-default items-center gap-2 rounded-lg px-2 py-1 outline-none! scroll-m-1 scroll-mt-[calc(var(--combobox-height,0px)+var(--label-height,4px))] aria-disabled:opacity-25 data-[active-item]:bg-black/[0.075] data-[active-item]:text-black dark:data-[active-item]:bg-white/10 dark:data-[active-item]:text-white sm:text-sm min-w-0 w-full before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:bg-transparent before:rounded-full data-[active-item]:before:bg-black dark:data-[active-item]:before:bg-white',
|
||||
props.className,
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ function ModelSelectorContent() {
|
|||
description={localize('com_ui_select_model')}
|
||||
render={
|
||||
<button
|
||||
className="my-1 flex h-10 w-full max-w-[70vw] items-center justify-center gap-2 rounded-xl border border-border-light bg-surface-secondary px-3 py-2 text-sm text-text-primary hover:bg-surface-tertiary"
|
||||
className="my-1 flex h-10 w-full max-w-[70vw] items-center justify-center gap-2 rounded-xl border border-border-light bg-presentation px-3 py-2 text-sm text-text-primary hover:bg-surface-active-alt"
|
||||
aria-label={localize('com_ui_select_model')}
|
||||
>
|
||||
{selectedIcon && React.isValidElement(selectedIcon) && (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo } from 'react';
|
||||
import { SettingsIcon } from 'lucide-react';
|
||||
import { Spinner } from '@librechat/client';
|
||||
import { Spinner, TooltipAnchor } from '@librechat/client';
|
||||
import { CheckCircle2, MousePointerClick, SettingsIcon } from 'lucide-react';
|
||||
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type { TModelSpec } from 'librechat-data-provider';
|
||||
import type { Endpoint } from '~/common';
|
||||
|
|
@ -28,29 +28,53 @@ const SettingsButton = ({
|
|||
}) => {
|
||||
const localize = useLocalize();
|
||||
const text = localize('com_endpoint_config_key');
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!endpoint.value) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (endpoint.value) {
|
||||
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e as unknown as React.MouseEvent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
id={`endpoint-${endpoint.value}-settings`}
|
||||
onClick={(e) => {
|
||||
if (!endpoint.value) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e);
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'flex items-center overflow-visible text-text-primary transition-all duration-300 ease-in-out',
|
||||
'group/button rounded-md px-1 hover:bg-surface-secondary focus:bg-surface-secondary',
|
||||
'group/button flex items-center gap-1.5 rounded-md px-1.5',
|
||||
'text-text-secondary transition-colors duration-150',
|
||||
'hover:bg-surface-tertiary hover:text-text-primary',
|
||||
'focus-visible:bg-surface-tertiary focus-visible:text-text-primary',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
|
||||
className,
|
||||
)}
|
||||
aria-label={`${text} ${endpoint.label}`}
|
||||
>
|
||||
<div className="flex w-[28px] items-center gap-1 whitespace-nowrap transition-all duration-300 ease-in-out group-hover:w-auto group-focus/button:w-auto">
|
||||
<SettingsIcon className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-300 ease-in-out group-hover:max-w-[100px] group-hover:opacity-100 group-focus/button:max-w-[100px] group-focus/button:opacity-100">
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
<SettingsIcon className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-[grid-template-columns,opacity] duration-150 ease-out',
|
||||
'grid-cols-[0fr] opacity-0',
|
||||
'group-hover/button:grid-cols-[1fr] group-hover/button:opacity-100',
|
||||
'group-focus-visible/button:grid-cols-[1fr] group-focus-visible/button:opacity-100',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate pr-0.5">{text}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -88,21 +112,17 @@ export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) {
|
|||
[endpointRequiresUserKey, endpoint.value],
|
||||
);
|
||||
|
||||
const isAssistantsNotLoaded =
|
||||
isAssistantsEndpoint(endpoint.value) && endpoint.models === undefined;
|
||||
|
||||
const renderIconLabel = () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{endpoint.icon && (
|
||||
<div className="flex flex-shrink-0 items-center justify-center overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-center" aria-hidden="true">
|
||||
{endpoint.icon}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'truncate text-left',
|
||||
isUserProvided ? 'group-hover:w-24 group-focus:w-24' : '',
|
||||
)}
|
||||
>
|
||||
{endpoint.label}
|
||||
</span>
|
||||
<span className="truncate text-left">{endpoint.label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -124,17 +144,14 @@ export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) {
|
|||
<Menu
|
||||
id={`endpoint-${endpoint.value}-menu`}
|
||||
key={`endpoint-${endpoint.value}-item`}
|
||||
className="transition-opacity duration-200 ease-in-out"
|
||||
defaultOpen={endpoint.value === selectedEndpoint}
|
||||
searchValue={searchValue}
|
||||
onSearch={(value) => setEndpointSearchValue(endpoint.value, value)}
|
||||
combobox={<input placeholder=" " />}
|
||||
comboboxLabel={placeholder}
|
||||
onClick={() => handleSelectEndpoint(endpoint)}
|
||||
label={
|
||||
<div
|
||||
onClick={() => handleSelectEndpoint(endpoint)}
|
||||
className="group flex w-full flex-shrink cursor-pointer items-center justify-between rounded-xl px-1 py-1 text-sm"
|
||||
>
|
||||
<div className="group flex w-full min-w-0 items-center justify-between gap-1.5 py-1 text-sm">
|
||||
{renderIconLabel()}
|
||||
{isUserProvided && (
|
||||
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
|
||||
|
|
@ -143,8 +160,12 @@ export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) {
|
|||
}
|
||||
>
|
||||
{isAssistantsEndpoint(endpoint.value) && endpoint.models === undefined ? (
|
||||
<div className="flex items-center justify-center p-2">
|
||||
<Spinner />
|
||||
<div
|
||||
className="flex items-center justify-center p-2"
|
||||
role="status"
|
||||
aria-label={localize('com_ui_loading')}
|
||||
>
|
||||
<Spinner aria-hidden="true" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -179,32 +200,27 @@ export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) {
|
|||
id={`endpoint-${endpoint.value}-menu`}
|
||||
key={`endpoint-${endpoint.value}-item`}
|
||||
onClick={() => handleSelectEndpoint(endpoint)}
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-xl px-3 py-2 text-sm"
|
||||
className="group flex w-full cursor-pointer items-center justify-between gap-1.5 py-2 text-sm"
|
||||
>
|
||||
<div className="group flex w-full min-w-0 items-center justify-between">
|
||||
{renderIconLabel()}
|
||||
<div className="flex items-center gap-2">
|
||||
{endpointRequiresUserKey(endpoint.value) && (
|
||||
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
|
||||
)}
|
||||
{selectedEndpoint === endpoint.value && (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="block"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{renderIconLabel()}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{endpointRequiresUserKey(endpoint.value) && (
|
||||
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
|
||||
)}
|
||||
{isAssistantsNotLoaded && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_click_to_view_var', { 0: endpoint.label })}
|
||||
side="top"
|
||||
render={
|
||||
<span className="flex items-center">
|
||||
<MousePointerClick className="size-4 text-text-secondary" aria-hidden="true" />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{selectedEndpoint === endpoint.value && !isAssistantsNotLoaded && (
|
||||
<CheckCircle2 className="size-4 shrink-0 text-text-primary" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export default function HeaderNewChat() {
|
|||
variant="outline"
|
||||
data-testid="wide-header-new-chat-button"
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
|
||||
className="rounded-xl duration-0 hover:bg-surface-active-alt max-md:hidden"
|
||||
onClick={clickHandler}
|
||||
>
|
||||
<NewChatIcon />
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { startTransition } from 'react';
|
||||
import { TooltipAnchor, Button, Sidebar } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -17,9 +18,13 @@ export default function OpenSidebar({
|
|||
const localize = useLocalize();
|
||||
|
||||
const handleClick = () => {
|
||||
setNavVisible((prev) => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||
return !prev;
|
||||
// Use startTransition to mark this as a non-urgent update
|
||||
// This prevents blocking the main thread during the cascade of re-renders
|
||||
startTransition(() => {
|
||||
setNavVisible((prev) => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||
return !prev;
|
||||
});
|
||||
});
|
||||
// Delay focus until after the sidebar animation completes (200ms)
|
||||
setTimeout(() => {
|
||||
|
|
@ -39,10 +44,7 @@ export default function OpenSidebar({
|
|||
aria-label={localize('com_nav_open_sidebar')}
|
||||
aria-expanded={false}
|
||||
aria-controls="chat-history-nav"
|
||||
className={cn(
|
||||
'rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover',
|
||||
className,
|
||||
)}
|
||||
className={cn('rounded-xl duration-0 hover:bg-surface-active-alt', className)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Sidebar aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -49,16 +49,22 @@ const PresetsMenu: FC = () => {
|
|||
<Trigger asChild>
|
||||
<TooltipAnchor
|
||||
ref={presetsMenuTriggerRef}
|
||||
id="presets-button"
|
||||
aria-label={localize('com_endpoint_examples')}
|
||||
description={localize('com_endpoint_examples')}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
data-testid="presets-button"
|
||||
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
>
|
||||
<BookCopy size={16} aria-hidden="true" />
|
||||
</TooltipAnchor>
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
tabIndex={0}
|
||||
id="presets-button"
|
||||
data-testid="presets-button"
|
||||
aria-label={localize('com_endpoint_examples')}
|
||||
className="rounded-xl p-2 duration-0 hover:bg-surface-active-alt max-md:hidden"
|
||||
// className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
>
|
||||
<BookCopy className="icon-lg" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
></TooltipAnchor>
|
||||
</Trigger>
|
||||
<Portal>
|
||||
<div
|
||||
|
|
@ -74,7 +80,7 @@ const PresetsMenu: FC = () => {
|
|||
<Content
|
||||
side="bottom"
|
||||
align="center"
|
||||
className="mt-2 max-h-[495px] overflow-x-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white md:min-w-[400px]"
|
||||
className="mt-2 max-h-[495px] overflow-x-hidden rounded-lg border border-border-light bg-surface-secondary text-text-primary shadow-lg md:min-w-[400px]"
|
||||
>
|
||||
<PresetItems
|
||||
presets={presetsQuery.data}
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ function FeedbackButtons({
|
|||
|
||||
function buttonClasses(isActive: boolean, isLast: boolean) {
|
||||
return cn(
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
|
||||
'hover:text-text-primary hover:bg-surface-hover',
|
||||
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
|
||||
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ export default function Fork({
|
|||
});
|
||||
|
||||
const buttonStyle = cn(
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
|
||||
'hover:text-text-primary hover:bg-surface-hover',
|
||||
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
|
||||
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ const HoverButton = memo(
|
|||
className = '',
|
||||
}: HoverButtonProps) => {
|
||||
const buttonStyle = cn(
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
|
||||
'hover:text-text-primary hover:bg-surface-hover',
|
||||
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
|
||||
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export default function SiblingSwitch({
|
|||
};
|
||||
|
||||
const buttonStyle = cn(
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
|
||||
'hover:text-text-primary hover:bg-surface-hover',
|
||||
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
|
||||
'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none',
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ export function TemporaryChat() {
|
|||
|
||||
const temporaryBadge = {
|
||||
id: 'temporary',
|
||||
icon: MessageCircleDashed,
|
||||
label: 'com_ui_temporary' as const,
|
||||
atom: store.isTemporary,
|
||||
isAvailable: true,
|
||||
};
|
||||
|
|
@ -37,26 +35,20 @@ export function TemporaryChat() {
|
|||
return (
|
||||
<div className="relative flex flex-wrap items-center gap-2">
|
||||
<TooltipAnchor
|
||||
description={localize(temporaryBadge.label)}
|
||||
description={localize('com_ui_temporary')}
|
||||
render={
|
||||
<button
|
||||
onClick={handleBadgeToggle}
|
||||
aria-label={localize(temporaryBadge.label)}
|
||||
aria-label={localize('com_ui_temporary')}
|
||||
aria-pressed={isTemporary}
|
||||
className={cn(
|
||||
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light text-text-primary transition-all ease-in-out hover:bg-surface-tertiary',
|
||||
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light text-text-primary transition-all ease-in-out',
|
||||
isTemporary
|
||||
? 'bg-surface-active shadow-md'
|
||||
: 'bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md',
|
||||
'active:shadow-inner',
|
||||
? 'bg-surface-active'
|
||||
: 'bg-presentation shadow-sm hover:bg-surface-active-alt',
|
||||
)}
|
||||
>
|
||||
{temporaryBadge.icon && (
|
||||
<temporaryBadge.icon
|
||||
className={cn('relative h-5 w-5 md:h-4 md:w-4', !temporaryBadge.label && 'mx-auto')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<MessageCircleDashed className="icon-lg" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
|
|
@ -6,7 +6,7 @@ import { useToastContext, useMediaQuery } from '@librechat/client';
|
|||
import type { TConversation } from 'librechat-data-provider';
|
||||
import { useUpdateConversationMutation } from '~/data-provider';
|
||||
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
|
||||
import { useNavigateToConvo, useLocalize } from '~/hooks';
|
||||
import { useNavigateToConvo, useLocalize, useShiftKey } from '~/hooks';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { ConvoOptions } from './ConvoOptions';
|
||||
|
|
@ -37,11 +37,14 @@ export default function Conversation({
|
|||
const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? '');
|
||||
const activeConvos = useRecoilValue(store.allConversationsSelector);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const isShiftHeld = useShiftKey();
|
||||
const { conversationId, title = '' } = conversation;
|
||||
|
||||
const [titleInput, setTitleInput] = useState(title || '');
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
// Lazy-load ConvoOptions to avoid running heavy hooks for all conversations
|
||||
const [hasInteracted, setHasInteracted] = useState(false);
|
||||
|
||||
const previousTitle = useRef(title);
|
||||
|
||||
|
|
@ -100,6 +103,12 @@ export default function Conversation({
|
|||
setRenaming(false);
|
||||
};
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (!hasInteracted) {
|
||||
setHasInteracted(true);
|
||||
}
|
||||
}, [hasInteracted]);
|
||||
|
||||
const handleNavigation = (ctrlOrMetaKey: boolean) => {
|
||||
if (ctrlOrMetaKey) {
|
||||
toggleNav();
|
||||
|
|
@ -148,6 +157,8 @@ export default function Conversation({
|
|||
aria-label={localize('com_ui_conversation_label', {
|
||||
title: title || localize('com_ui_untitled'),
|
||||
})}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onFocus={handleMouseEnter}
|
||||
onClick={(e) => {
|
||||
if (renaming) {
|
||||
return;
|
||||
|
|
@ -223,14 +234,16 @@ export default function Conversation({
|
|||
className={cn(
|
||||
'mr-2 flex origin-left',
|
||||
isPopoverActive || isActiveConvo
|
||||
? 'pointer-events-auto max-w-[28px] scale-x-100 opacity-100'
|
||||
: 'pointer-events-none max-w-0 scale-x-0 opacity-0 group-focus-within:pointer-events-auto group-focus-within:max-w-[28px] group-focus-within:scale-x-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:max-w-[28px] group-hover:scale-x-100 group-hover:opacity-100',
|
||||
? 'pointer-events-auto scale-x-100 opacity-100'
|
||||
: 'pointer-events-none max-w-0 scale-x-0 opacity-0 group-focus-within:pointer-events-auto group-focus-within:max-w-[60px] group-focus-within:scale-x-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:max-w-[60px] group-hover:scale-x-100 group-hover:opacity-100',
|
||||
(isPopoverActive || isActiveConvo) && (isShiftHeld ? 'max-w-[60px]' : 'max-w-[28px]'),
|
||||
)}
|
||||
// Removing aria-hidden to fix accessibility issue: ARIA hidden element must not be focusable or contain focusable elements
|
||||
// but not sure what its original purpose was, so leaving the property commented out until it can be cleared safe to delete.
|
||||
// aria-hidden={!(isPopoverActive || isActiveConvo)}
|
||||
>
|
||||
{!renaming && <ConvoOptions {...convoOptionsProps} />}
|
||||
{/* Only render ConvoOptions when user interacts (hover/focus) or for active conversation */}
|
||||
{!renaming && (hasInteracted || isActiveConvo) && <ConvoOptions {...convoOptionsProps} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import { useState, useId, useRef, memo, useCallback, useMemo } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { DropdownPopup, Spinner, useToastContext } from '@librechat/client';
|
||||
import { Ellipsis, Share2, CopyPlus, Archive, Pen, Trash } from 'lucide-react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import {
|
||||
useDuplicateConversationMutation,
|
||||
useDeleteConversationMutation,
|
||||
useGetStartupConfig,
|
||||
useArchiveConvoMutation,
|
||||
} from '~/data-provider';
|
||||
import { useLocalize, useNavigateToConvo, useNewConvo } from '~/hooks';
|
||||
import { useLocalize, useNavigateToConvo, useNewConvo, useShiftKey } from '~/hooks';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import DeleteButton from './DeleteButton';
|
||||
|
|
@ -34,6 +38,8 @@ function ConvoOptions({
|
|||
isActiveConvo: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const isShiftHeld = useShiftKey();
|
||||
const { index } = useChatContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { navigateToConvo } = useNavigateToConvo(index);
|
||||
|
|
@ -52,6 +58,28 @@ function ConvoOptions({
|
|||
|
||||
const archiveConvoMutation = useArchiveConvoMutation();
|
||||
|
||||
const deleteMutation = useDeleteConversationMutation({
|
||||
onSuccess: () => {
|
||||
if (currentConvoId === conversationId || currentConvoId === 'new') {
|
||||
newConversation();
|
||||
navigate('/c/new', { replace: true });
|
||||
}
|
||||
retainView();
|
||||
showToast({
|
||||
message: localize('com_ui_convo_delete_success'),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
showIcon: true,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_convo_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const duplicateConversation = useDuplicateConversationMutation({
|
||||
onSuccess: (data) => {
|
||||
navigateToConvo(data.conversation);
|
||||
|
|
@ -77,6 +105,7 @@ function ConvoOptions({
|
|||
|
||||
const isDuplicateLoading = duplicateConversation.isLoading;
|
||||
const isArchiveLoading = archiveConvoMutation.isLoading;
|
||||
const isDeleteLoading = deleteMutation.isLoading;
|
||||
|
||||
const shareHandler = useCallback(() => {
|
||||
setShowShareDialog(true);
|
||||
|
|
@ -86,47 +115,66 @@ function ConvoOptions({
|
|||
setShowDeleteDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleArchiveClick = useCallback(async () => {
|
||||
const convoId = conversationId ?? '';
|
||||
if (!convoId) {
|
||||
return;
|
||||
}
|
||||
const handleInstantDelete = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const convoId = conversationId ?? '';
|
||||
if (!convoId) {
|
||||
return;
|
||||
}
|
||||
const messages = queryClient.getQueryData<TMessage[]>([QueryKeys.messages, convoId]);
|
||||
const thread_id = messages?.[messages.length - 1]?.thread_id;
|
||||
const endpoint = messages?.[messages.length - 1]?.endpoint;
|
||||
deleteMutation.mutate({ conversationId: convoId, thread_id, endpoint, source: 'button' });
|
||||
},
|
||||
[conversationId, deleteMutation, queryClient],
|
||||
);
|
||||
|
||||
archiveConvoMutation.mutate(
|
||||
{ conversationId: convoId, isArchived: true },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setAnnouncement(localize('com_ui_convo_archived'));
|
||||
setTimeout(() => {
|
||||
setAnnouncement('');
|
||||
}, 10000);
|
||||
if (currentConvoId === convoId || currentConvoId === 'new') {
|
||||
newConversation();
|
||||
navigate('/c/new', { replace: true });
|
||||
}
|
||||
retainView();
|
||||
setIsPopoverActive(false);
|
||||
const handleArchiveClick = useCallback(
|
||||
async (e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
const convoId = conversationId ?? '';
|
||||
if (!convoId) {
|
||||
return;
|
||||
}
|
||||
|
||||
archiveConvoMutation.mutate(
|
||||
{ conversationId: convoId, isArchived: true },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setAnnouncement(localize('com_ui_convo_archived'));
|
||||
setTimeout(() => {
|
||||
setAnnouncement('');
|
||||
}, 10000);
|
||||
if (currentConvoId === convoId || currentConvoId === 'new') {
|
||||
newConversation();
|
||||
navigate('/c/new', { replace: true });
|
||||
}
|
||||
retainView();
|
||||
setIsPopoverActive(false);
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_archive_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_archive_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [
|
||||
conversationId,
|
||||
currentConvoId,
|
||||
archiveConvoMutation,
|
||||
navigate,
|
||||
newConversation,
|
||||
retainView,
|
||||
setIsPopoverActive,
|
||||
showToast,
|
||||
localize,
|
||||
]);
|
||||
);
|
||||
},
|
||||
[
|
||||
conversationId,
|
||||
currentConvoId,
|
||||
archiveConvoMutation,
|
||||
navigate,
|
||||
newConversation,
|
||||
retainView,
|
||||
setIsPopoverActive,
|
||||
showToast,
|
||||
localize,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDuplicateClick = useCallback(() => {
|
||||
duplicateConversation.mutate({
|
||||
|
|
@ -198,6 +246,44 @@ function ConvoOptions({
|
|||
],
|
||||
);
|
||||
|
||||
const buttonClassName = cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center 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
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
|
||||
);
|
||||
|
||||
if (isShiftHeld) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
aria-label={localize('com_ui_archive')}
|
||||
className={cn(buttonClassName, 'hover:bg-surface-hover')}
|
||||
onClick={handleArchiveClick}
|
||||
disabled={isArchiveLoading}
|
||||
>
|
||||
{isArchiveLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<Archive className="icon-md text-text-secondary" aria-hidden={true} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-label={localize('com_ui_delete')}
|
||||
className={cn(buttonClassName, 'hover:bg-surface-hover')}
|
||||
onClick={handleInstantDelete}
|
||||
disabled={isDeleteLoading}
|
||||
>
|
||||
{isDeleteLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<Trash className="icon-md text-text-secondary" aria-hidden={true} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ function AccountSettings() {
|
|||
ref={accountSettingsButtonRef}
|
||||
aria-label={localize('com_nav_account_settings')}
|
||||
data-testid="nav-user"
|
||||
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-hover aria-[expanded=true]:bg-surface-hover"
|
||||
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-active-alt aria-[expanded=true]:bg-surface-active-alt"
|
||||
>
|
||||
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
|
||||
<div className="relative flex">
|
||||
|
|
@ -40,7 +40,7 @@ function AccountSettings() {
|
|||
</div>
|
||||
</Select.Select>
|
||||
<Select.SelectPopover
|
||||
className="popover-ui w-[305px] rounded-lg md:w-[235px]"
|
||||
className="popover-ui w-[305px] rounded-lg md:w-[244px]"
|
||||
style={{
|
||||
transformOrigin: 'bottom',
|
||||
translate: '0 -4px',
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { TooltipAnchor } from '@librechat/client';
|
||||
import { Menu, MenuButton, MenuItems } from '@headlessui/react';
|
||||
import { useState, useId, useMemo, useCallback } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { CrossCircledIcon } from '@radix-ui/react-icons';
|
||||
import { DropdownPopup, TooltipAnchor } from '@librechat/client';
|
||||
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
|
||||
import { BookmarkContext } from '~/Providers/BookmarkContext';
|
||||
import type * as t from '~/common';
|
||||
import type { FC } from 'react';
|
||||
import { useGetConversationTags } from '~/data-provider';
|
||||
import BookmarkNavItems from './BookmarkNavItems';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
|
|
@ -16,56 +16,105 @@ type BookmarkNavProps = {
|
|||
|
||||
const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags }: BookmarkNavProps) => {
|
||||
const localize = useLocalize();
|
||||
const menuId = useId();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { data } = useGetConversationTags();
|
||||
|
||||
const label = useMemo(
|
||||
() => (tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')),
|
||||
[tags, localize],
|
||||
);
|
||||
|
||||
const bookmarks = useMemo(() => data?.filter((tag) => tag.count > 0) ?? [], [data]);
|
||||
|
||||
const handleTagClick = useCallback(
|
||||
(tag: string) => {
|
||||
if (tags.includes(tag)) {
|
||||
setTags(tags.filter((t) => t !== tag));
|
||||
} else {
|
||||
setTags([...tags, tag]);
|
||||
}
|
||||
},
|
||||
[tags, setTags],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setTags([]);
|
||||
}, [setTags]);
|
||||
|
||||
const dropdownItems: t.MenuItemProps[] = useMemo(() => {
|
||||
const items: t.MenuItemProps[] = [
|
||||
{
|
||||
id: 'clear-all',
|
||||
label: localize('com_ui_clear_all'),
|
||||
icon: <CrossCircledIcon className="size-4" />,
|
||||
hideOnClick: false,
|
||||
onClick: handleClear,
|
||||
},
|
||||
];
|
||||
|
||||
if (bookmarks.length === 0) {
|
||||
items.push({
|
||||
id: 'no-bookmarks',
|
||||
label: localize('com_ui_no_bookmarks'),
|
||||
icon: '🤔',
|
||||
disabled: true,
|
||||
});
|
||||
} else {
|
||||
for (const bookmark of bookmarks) {
|
||||
const isSelected = tags.includes(bookmark.tag);
|
||||
items.push({
|
||||
id: bookmark.tag,
|
||||
label: bookmark.tag,
|
||||
hideOnClick: false,
|
||||
icon: isSelected ? (
|
||||
<BookmarkFilledIcon className="size-4" />
|
||||
) : (
|
||||
<BookmarkIcon className="size-4" />
|
||||
),
|
||||
onClick: () => handleTagClick(bookmark.tag),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [bookmarks, tags, localize, handleTagClick, handleClear]);
|
||||
|
||||
return (
|
||||
<Menu as="div" className="group relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<TooltipAnchor
|
||||
description={label}
|
||||
render={
|
||||
<MenuButton
|
||||
id="bookmark-menu-button"
|
||||
aria-label={localize('com_ui_bookmarks')}
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'size-10 border-none text-text-primary hover:bg-accent hover:text-accent-foreground',
|
||||
'rounded-full border-none p-2 hover:bg-surface-hover md:rounded-xl',
|
||||
open ? 'bg-surface-hover' : '',
|
||||
)}
|
||||
data-testid="bookmark-menu"
|
||||
>
|
||||
{tags.length > 0 ? (
|
||||
<BookmarkFilledIcon aria-hidden="true" className="icon-lg text-text-primary" />
|
||||
) : (
|
||||
<BookmarkIcon aria-hidden="true" className="icon-lg text-text-primary" />
|
||||
)}
|
||||
</MenuButton>
|
||||
}
|
||||
/>
|
||||
<MenuItems
|
||||
anchor="bottom"
|
||||
className="absolute left-0 top-full z-[100] mt-1 w-60 translate-y-0 overflow-hidden rounded-lg bg-surface-secondary p-1.5 shadow-lg outline-none"
|
||||
>
|
||||
{data && (
|
||||
<BookmarkContext.Provider value={{ bookmarks: data.filter((tag) => tag.count > 0) }}>
|
||||
<BookmarkNavItems
|
||||
// List of selected tags(string)
|
||||
tags={tags}
|
||||
// When a user selects a tag, this `setTags` function is called to refetch the list of conversations for the selected tag
|
||||
setTags={setTags}
|
||||
/>
|
||||
</BookmarkContext.Provider>
|
||||
)}
|
||||
</MenuItems>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
<DropdownPopup
|
||||
portal={true}
|
||||
menuId={menuId}
|
||||
focusLoop={true}
|
||||
isOpen={isMenuOpen}
|
||||
unmountOnHide={true}
|
||||
setIsOpen={setIsMenuOpen}
|
||||
keyPrefix="bookmark-nav-"
|
||||
trigger={
|
||||
<TooltipAnchor
|
||||
description={label}
|
||||
render={
|
||||
<Ariakit.MenuButton
|
||||
id="bookmark-nav-menu-button"
|
||||
aria-label={localize('com_ui_bookmarks')}
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'size-10 border-none text-text-primary hover:bg-accent hover:text-accent-foreground',
|
||||
'rounded-full border-none p-2 hover:bg-surface-active-alt md:rounded-xl',
|
||||
isMenuOpen ? 'bg-surface-hover' : '',
|
||||
)}
|
||||
data-testid="bookmark-menu"
|
||||
>
|
||||
{tags.length > 0 ? (
|
||||
<BookmarkFilledIcon aria-hidden="true" className="icon-lg text-text-primary" />
|
||||
) : (
|
||||
<BookmarkIcon aria-hidden="true" className="icon-lg text-text-primary" />
|
||||
)}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
/>
|
||||
}
|
||||
items={dropdownItems}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
import { type FC } from 'react';
|
||||
import { CrossCircledIcon } from '@radix-ui/react-icons';
|
||||
import { useBookmarkContext } from '~/Providers/BookmarkContext';
|
||||
import { BookmarkItems, BookmarkItem } from '~/components/Bookmarks';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const BookmarkNavItems: FC<{
|
||||
tags: string[];
|
||||
setTags: (tags: string[]) => void;
|
||||
}> = ({ tags = [], setTags }) => {
|
||||
const { bookmarks } = useBookmarkContext();
|
||||
const localize = useLocalize();
|
||||
|
||||
const getUpdatedSelected = (tag: string) => {
|
||||
if (tags.some((selectedTag) => selectedTag === tag)) {
|
||||
return tags.filter((selectedTag) => selectedTag !== tag);
|
||||
} else {
|
||||
return [...tags, tag];
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (tag?: string) => {
|
||||
if (tag === undefined) {
|
||||
return;
|
||||
}
|
||||
const updatedSelected = getUpdatedSelected(tag);
|
||||
setTags(updatedSelected);
|
||||
return;
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setTags([]);
|
||||
return;
|
||||
};
|
||||
|
||||
if (bookmarks.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<BookmarkItem
|
||||
tag={localize('com_ui_clear_all')}
|
||||
data-testid="bookmark-item-clear"
|
||||
handleSubmit={clear}
|
||||
selected={false}
|
||||
icon={<CrossCircledIcon aria-hidden="true" className="size-4" />}
|
||||
/>
|
||||
<BookmarkItem
|
||||
tag={localize('com_ui_no_bookmarks')}
|
||||
data-testid="bookmark-item-no-bookmarks"
|
||||
handleSubmit={() => Promise.resolve()}
|
||||
selected={false}
|
||||
icon={'🤔'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<BookmarkItems
|
||||
tags={tags}
|
||||
handleSubmit={handleSubmit}
|
||||
header={
|
||||
<BookmarkItem
|
||||
tag={localize('com_ui_clear_all')}
|
||||
data-testid="bookmark-item-clear"
|
||||
handleSubmit={clear}
|
||||
selected={false}
|
||||
icon={<CrossCircledIcon aria-hidden="true" className="size-4" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarkNavItems;
|
||||
|
|
@ -21,7 +21,7 @@ export default function MobileNav({
|
|||
const { title = 'New Chat' } = conversation || {};
|
||||
|
||||
return (
|
||||
<div className="bg-token-main-surface-primary sticky top-0 z-10 flex min-h-[40px] items-center justify-center bg-white pl-1 dark:bg-gray-800 dark:text-white md:hidden">
|
||||
<div className="bg-token-main-surface-primary sticky top-0 z-10 flex min-h-[40px] items-center justify-center bg-presentation pl-1 dark:text-white md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mobile-header-new-chat-button"
|
||||
|
|
@ -29,7 +29,7 @@ export default function MobileNav({
|
|||
navVisible ? localize('com_nav_close_sidebar') : localize('com_nav_open_sidebar')
|
||||
}
|
||||
aria-live="polite"
|
||||
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover"
|
||||
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-active-alt"
|
||||
onClick={() =>
|
||||
setNavVisible((prev) => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||
|
|
@ -62,7 +62,7 @@ export default function MobileNav({
|
|||
<button
|
||||
type="button"
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover"
|
||||
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-active-alt"
|
||||
onClick={() => {
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
memo,
|
||||
lazy,
|
||||
Suspense,
|
||||
useRef,
|
||||
startTransition,
|
||||
} from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Skeleton, useMediaQuery } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
|
||||
|
|
@ -23,8 +33,10 @@ import store from '~/store';
|
|||
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
|
||||
const AccountSettings = lazy(() => import('./AccountSettings'));
|
||||
|
||||
const NAV_WIDTH_DESKTOP = '260px';
|
||||
const NAV_WIDTH_MOBILE = '320px';
|
||||
export const NAV_WIDTH = {
|
||||
MOBILE: 320,
|
||||
DESKTOP: 260,
|
||||
} as const;
|
||||
|
||||
const SearchBarSkeleton = memo(() => (
|
||||
<div className={cn('flex h-10 items-center py-2')}>
|
||||
|
|
@ -66,7 +78,6 @@ const Nav = memo(
|
|||
const { isAuthenticated } = useAuthContext();
|
||||
useTitleGeneration(isAuthenticated);
|
||||
|
||||
const [navWidth, setNavWidth] = useState(NAV_WIDTH_DESKTOP);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const [newUser, setNewUser] = useLocalStorage('newUser', true);
|
||||
const [isChatsExpanded, setIsChatsExpanded] = useLocalStorage('chatsExpanded', true);
|
||||
|
|
@ -122,13 +133,17 @@ const Nav = memo(
|
|||
}, [data]);
|
||||
|
||||
const toggleNavVisible = useCallback(() => {
|
||||
setNavVisible((prev: boolean) => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||
return !prev;
|
||||
// Use startTransition to mark this as a non-urgent update
|
||||
// This prevents blocking the main thread during the cascade of re-renders
|
||||
startTransition(() => {
|
||||
setNavVisible((prev: boolean) => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||
return !prev;
|
||||
});
|
||||
if (newUser) {
|
||||
setNewUser(false);
|
||||
}
|
||||
});
|
||||
if (newUser) {
|
||||
setNewUser(false);
|
||||
}
|
||||
}, [newUser, setNavVisible, setNewUser]);
|
||||
|
||||
const itemToggleNav = useCallback(() => {
|
||||
|
|
@ -143,9 +158,6 @@ const Nav = memo(
|
|||
if (savedNavVisible === null) {
|
||||
toggleNavVisible();
|
||||
}
|
||||
setNavWidth(NAV_WIDTH_MOBILE);
|
||||
} else {
|
||||
setNavWidth(NAV_WIDTH_DESKTOP);
|
||||
}
|
||||
}, [isSmallScreen, toggleNavVisible]);
|
||||
|
||||
|
|
@ -201,61 +213,90 @@ const Nav = memo(
|
|||
}
|
||||
}, [search.query, search.isTyping, isLoading, isFetching]);
|
||||
|
||||
// Always render sidebar to avoid mount/unmount costs
|
||||
// Use transform for GPU-accelerated animation (no layout thrashing)
|
||||
const sidebarWidth = isSmallScreen ? NAV_WIDTH.MOBILE : NAV_WIDTH.DESKTOP;
|
||||
|
||||
// Sidebar content (shared between mobile and desktop)
|
||||
const sidebarContent = (
|
||||
<div className="flex h-full flex-col">
|
||||
<nav
|
||||
id="chat-history-nav"
|
||||
aria-label={localize('com_ui_chat_history')}
|
||||
className="flex h-full flex-col px-2 pb-3.5"
|
||||
aria-hidden={!navVisible}
|
||||
>
|
||||
<div className="flex flex-1 flex-col overflow-hidden" ref={outerContainerRef}>
|
||||
<MemoNewChat
|
||||
subHeaders={subHeaders}
|
||||
toggleNav={toggleNavVisible}
|
||||
headerButtons={headerButtons}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-grow flex-col overflow-hidden">
|
||||
<Conversations
|
||||
conversations={conversations}
|
||||
moveToTop={moveToTop}
|
||||
toggleNav={itemToggleNav}
|
||||
containerRef={conversationsRef}
|
||||
loadMoreConversations={loadMoreConversations}
|
||||
isLoading={isFetchingNextPage || showLoading || isLoading}
|
||||
isSearchLoading={isSearchLoading}
|
||||
isChatsExpanded={isChatsExpanded}
|
||||
setIsChatsExpanded={setIsChatsExpanded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<Skeleton className="mt-1 h-12 w-full rounded-xl" />}>
|
||||
<AccountSettings />
|
||||
</Suspense>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Mobile: Fixed positioned sidebar that slides over content
|
||||
// Uses CSS transitions (not Framer Motion) to sync perfectly with content animation
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-testid="nav"
|
||||
className={cn(
|
||||
'nav fixed left-0 top-0 z-[70] h-full bg-surface-primary-alt',
|
||||
navVisible && 'active',
|
||||
)}
|
||||
style={{
|
||||
width: sidebarWidth,
|
||||
transform: navVisible ? 'translateX(0)' : `translateX(-${sidebarWidth}px)`,
|
||||
transition: 'transform 0.2s ease-out',
|
||||
}}
|
||||
>
|
||||
{sidebarContent}
|
||||
</div>
|
||||
<NavMask navVisible={navVisible} toggleNavVisible={toggleNavVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: Inline sidebar with width transition
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence initial={false}>
|
||||
{navVisible && (
|
||||
<motion.div
|
||||
data-testid="nav"
|
||||
className={cn(
|
||||
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-surface-primary-alt',
|
||||
'md:max-w-[260px]',
|
||||
)}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: navWidth }}
|
||||
exit={{ width: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
key="nav"
|
||||
>
|
||||
<div className="h-full w-[320px] md:w-[260px]">
|
||||
<div className="flex h-full flex-col">
|
||||
<nav
|
||||
id="chat-history-nav"
|
||||
aria-label={localize('com_ui_chat_history')}
|
||||
className="flex h-full flex-col px-2 pb-3.5"
|
||||
>
|
||||
<div className="flex flex-1 flex-col overflow-hidden" ref={outerContainerRef}>
|
||||
<MemoNewChat
|
||||
subHeaders={subHeaders}
|
||||
toggleNav={toggleNavVisible}
|
||||
headerButtons={headerButtons}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-grow flex-col overflow-hidden">
|
||||
<Conversations
|
||||
conversations={conversations}
|
||||
moveToTop={moveToTop}
|
||||
toggleNav={itemToggleNav}
|
||||
containerRef={conversationsRef}
|
||||
loadMoreConversations={loadMoreConversations}
|
||||
isLoading={isFetchingNextPage || showLoading || isLoading}
|
||||
isSearchLoading={isSearchLoading}
|
||||
isChatsExpanded={isChatsExpanded}
|
||||
setIsChatsExpanded={setIsChatsExpanded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<Skeleton className="mt-1 h-12 w-full rounded-xl" />}>
|
||||
<AccountSettings />
|
||||
</Suspense>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{isSmallScreen && <NavMask navVisible={navVisible} toggleNavVisible={toggleNavVisible} />}
|
||||
</>
|
||||
<div
|
||||
className="flex-shrink-0 overflow-hidden"
|
||||
style={{ width: navVisible ? sidebarWidth : 0, transition: 'width 0.2s ease-out' }}
|
||||
>
|
||||
<motion.div
|
||||
data-testid="nav"
|
||||
className={cn('nav h-full bg-surface-primary-alt', navVisible && 'active')}
|
||||
style={{ width: sidebarWidth }}
|
||||
initial={false}
|
||||
animate={{
|
||||
x: navVisible ? 0 : -sidebarWidth,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
{sidebarContent}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -66,13 +66,13 @@ export default function NewChat({
|
|||
data-testid="close-sidebar-button"
|
||||
aria-label={localize('com_nav_close_sidebar')}
|
||||
aria-expanded={true}
|
||||
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
|
||||
className="rounded-full border-none bg-transparent duration-0 hover:bg-surface-active-alt md:rounded-xl"
|
||||
onClick={handleToggleNav}
|
||||
>
|
||||
<Sidebar aria-hidden="true" className="max-md:hidden" />
|
||||
<MobileSidebar
|
||||
aria-hidden="true"
|
||||
className="m-1 inline-flex size-10 items-center justify-center md:hidden"
|
||||
className="icon-lg m-1 inline-flex items-center justify-center md:hidden"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ export default function NewChat({
|
|||
variant="outline"
|
||||
data-testid="nav-new-chat-button"
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
|
||||
className="rounded-full border-none bg-transparent duration-0 hover:bg-surface-active-alt md:rounded-xl"
|
||||
onClick={clickHandler}
|
||||
>
|
||||
<NewChatIcon className="icon-lg text-text-primary" />
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivEleme
|
|||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="group relative my-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-2 border-transparent px-3 py-2 text-text-primary focus-within:border-ring-primary focus-within:bg-surface-hover hover:bg-surface-hover"
|
||||
className="group relative my-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-2 border-transparent px-3 py-2 text-text-primary focus-within:border-ring-primary focus-within:bg-surface-active-alt hover:bg-surface-active-alt"
|
||||
>
|
||||
<Search
|
||||
aria-hidden="true"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * from './ExportConversation';
|
||||
export * from './SettingsTabs/';
|
||||
export { default as MobileNav } from './MobileNav';
|
||||
export { default as Nav } from './Nav';
|
||||
export { default as Nav, NAV_WIDTH } from './Nav';
|
||||
export { default as NavLink } from './NavLink';
|
||||
export { default as NewChat } from './NewChat';
|
||||
export { default as SearchBar } from './SearchBar';
|
||||
|
|
|
|||
|
|
@ -1,74 +1,27 @@
|
|||
import { useMemo, useEffect, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
||||
import {
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
Button,
|
||||
Switch,
|
||||
DropdownPopup,
|
||||
OGDialogTemplate,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
|
||||
import { Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import { OGDialog, OGDialogTemplate, Button, useToastContext } from '@librechat/client';
|
||||
import { AdminSettingsDialog } from '~/components/ui';
|
||||
import { useUpdatePromptPermissionsMutation } from '~/data-provider';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import type { PermissionConfig } from '~/components/ui';
|
||||
|
||||
type FormValues = Record<Permissions, boolean>;
|
||||
|
||||
type LabelControllerProps = {
|
||||
label: string;
|
||||
promptPerm: Permissions;
|
||||
control: Control<FormValues, unknown, FormValues>;
|
||||
setValue: UseFormSetValue<FormValues>;
|
||||
getValues: UseFormGetValues<FormValues>;
|
||||
confirmChange?: (newValue: boolean, onChange: (value: boolean) => void) => void;
|
||||
};
|
||||
|
||||
const LabelController: React.FC<LabelControllerProps> = ({
|
||||
control,
|
||||
promptPerm,
|
||||
label,
|
||||
confirmChange,
|
||||
}) => (
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
{label}
|
||||
<Controller
|
||||
name={promptPerm}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={(val) => {
|
||||
if (val === false && confirmChange) {
|
||||
confirmChange(val, field.onChange);
|
||||
} else {
|
||||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
value={field.value.toString()}
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const permissions: PermissionConfig[] = [
|
||||
{ permission: Permissions.SHARED_GLOBAL, labelKey: 'com_ui_prompts_allow_share' },
|
||||
{ permission: Permissions.CREATE, labelKey: 'com_ui_prompts_allow_create' },
|
||||
{ permission: Permissions.USE, labelKey: 'com_ui_prompts_allow_use' },
|
||||
];
|
||||
|
||||
const AdminSettings = () => {
|
||||
const localize = useLocalize();
|
||||
const { user, roles } = useAuthContext();
|
||||
const { showToast } = useToastContext();
|
||||
const [confirmAdminUseChange, setConfirmAdminUseChange] = useState<{
|
||||
newValue: boolean;
|
||||
callback: (value: boolean) => void;
|
||||
} | null>(null);
|
||||
const { mutate, isLoading } = useUpdatePromptPermissionsMutation({
|
||||
|
||||
const mutation = useUpdatePromptPermissionsMutation({
|
||||
onSuccess: () => {
|
||||
showToast({ status: 'success', message: localize('com_ui_saved') });
|
||||
},
|
||||
|
|
@ -77,188 +30,68 @@ const AdminSettings = () => {
|
|||
},
|
||||
});
|
||||
|
||||
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
if (roles?.[selectedRole]?.permissions) {
|
||||
return roles[selectedRole]?.permissions[PermissionTypes.PROMPTS];
|
||||
}
|
||||
return roleDefaults[selectedRole].permissions[PermissionTypes.PROMPTS];
|
||||
}, [roles, selectedRole]);
|
||||
|
||||
const {
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reset(roles?.[selectedRole]?.permissions?.[PermissionTypes.PROMPTS]);
|
||||
}, [roles, selectedRole, reset]);
|
||||
|
||||
if (user?.role !== SystemRoles.ADMIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelControllerData = [
|
||||
{
|
||||
promptPerm: Permissions.SHARED_GLOBAL,
|
||||
label: localize('com_ui_prompts_allow_share'),
|
||||
},
|
||||
{
|
||||
promptPerm: Permissions.CREATE,
|
||||
label: localize('com_ui_prompts_allow_create'),
|
||||
},
|
||||
{
|
||||
promptPerm: Permissions.USE,
|
||||
label: localize('com_ui_prompts_allow_use'),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
mutate({ roleName: selectedRole, updates: data });
|
||||
const handlePermissionConfirm = (
|
||||
_permission: Permissions,
|
||||
newValue: boolean,
|
||||
onChange: (value: boolean) => void,
|
||||
) => {
|
||||
setConfirmAdminUseChange({ newValue, callback: onChange });
|
||||
};
|
||||
|
||||
const roleDropdownItems = [
|
||||
{
|
||||
label: SystemRoles.USER,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.USER);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: SystemRoles.ADMIN,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.ADMIN);
|
||||
},
|
||||
},
|
||||
];
|
||||
const trigger = (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mr-2 h-10 w-fit gap-1 border transition-all dark:bg-transparent dark:hover:bg-surface-tertiary sm:m-0"
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
||||
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const confirmDialog = (
|
||||
<OGDialog
|
||||
open={confirmAdminUseChange !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setConfirmAdminUseChange(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={true}
|
||||
title={localize('com_ui_confirm_change')}
|
||||
className="w-11/12 max-w-lg"
|
||||
main={<p className="mb-4">{localize('com_ui_confirm_admin_use_change')}</p>}
|
||||
selection={{
|
||||
selectHandler: () => {
|
||||
if (confirmAdminUseChange) {
|
||||
confirmAdminUseChange.callback(confirmAdminUseChange.newValue);
|
||||
}
|
||||
setConfirmAdminUseChange(null);
|
||||
},
|
||||
selectClasses:
|
||||
'bg-surface-destructive hover:bg-surface-destructive-hover text-white transition-colors duration-200',
|
||||
selectText: localize('com_ui_confirm_action'),
|
||||
isLoading: false,
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mr-2 h-10 w-fit gap-1 border transition-all dark:bg-transparent dark:hover:bg-surface-tertiary sm:m-0"
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
||||
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="max-w-lg border-border-light bg-surface-primary text-text-primary lg:w-1/4">
|
||||
<OGDialogTitle>
|
||||
{localize('com_ui_admin_settings_section', { section: localize('com_ui_prompts') })}
|
||||
</OGDialogTitle>
|
||||
<div className="p-2">
|
||||
{/* Role selection dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{localize('com_ui_role_select')}:</span>
|
||||
<DropdownPopup
|
||||
unmountOnHide={true}
|
||||
menuId="prompt-role-dropdown"
|
||||
isOpen={isRoleMenuOpen}
|
||||
setIsOpen={setIsRoleMenuOpen}
|
||||
trigger={
|
||||
<Ariakit.MenuButton className="inline-flex w-1/5 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
|
||||
{selectedRole}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
itemClassName="items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-5">
|
||||
{labelControllerData.map(({ promptPerm, label }) => (
|
||||
<div key={promptPerm}>
|
||||
<LabelController
|
||||
control={control}
|
||||
promptPerm={promptPerm}
|
||||
label={label}
|
||||
getValues={getValues}
|
||||
setValue={setValue}
|
||||
{...(selectedRole === SystemRoles.ADMIN && promptPerm === Permissions.USE
|
||||
? {
|
||||
confirmChange: (
|
||||
newValue: boolean,
|
||||
onChange: (value: boolean) => void,
|
||||
) => setConfirmAdminUseChange({ newValue, callback: onChange }),
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
{selectedRole === SystemRoles.ADMIN && promptPerm === Permissions.USE && (
|
||||
<>
|
||||
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
|
||||
<span>{localize('com_ui_admin_access_warning')}</span>
|
||||
{'\n'}
|
||||
<a
|
||||
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center text-blue-500 underline"
|
||||
>
|
||||
{localize('com_ui_more_info')}
|
||||
<ExternalLink size={16} className="ml-1" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isLoading}
|
||||
variant="submit"
|
||||
aria-label={localize('com_ui_save')}
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
|
||||
<OGDialog
|
||||
open={confirmAdminUseChange !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setConfirmAdminUseChange(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={true}
|
||||
title={localize('com_ui_confirm_change')}
|
||||
className="w-11/12 max-w-lg"
|
||||
main={<p className="mb-4">{localize('com_ui_confirm_admin_use_change')}</p>}
|
||||
selection={{
|
||||
selectHandler: () => {
|
||||
if (confirmAdminUseChange) {
|
||||
confirmAdminUseChange.callback(confirmAdminUseChange.newValue);
|
||||
}
|
||||
setConfirmAdminUseChange(null);
|
||||
},
|
||||
selectClasses:
|
||||
'bg-surface-destructive hover:bg-surface-destructive-hover text-white transition-colors duration-200',
|
||||
selectText: localize('com_ui_confirm_action'),
|
||||
isLoading: false,
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
</>
|
||||
<AdminSettingsDialog
|
||||
permissionType={PermissionTypes.PROMPTS}
|
||||
sectionKey="com_ui_prompts"
|
||||
permissions={permissions}
|
||||
menuId="prompt-role-dropdown"
|
||||
mutation={mutation}
|
||||
trigger={trigger}
|
||||
dialogContentClassName="max-w-lg border-border-light bg-surface-primary text-text-primary lg:w-1/4"
|
||||
onPermissionConfirm={handlePermissionConfirm}
|
||||
confirmPermissions={[Permissions.USE]}
|
||||
extraContent={confirmDialog}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { ListFilter, User, Share2 } from 'lucide-react';
|
||||
import { SystemCategories } from 'librechat-data-provider';
|
||||
import { Dropdown, AnimatedSearchInput } from '@librechat/client';
|
||||
import { Dropdown, FilterInput } from '@librechat/client';
|
||||
import type { Option } from '~/common';
|
||||
import { useLocalize, useCategories, useDebounce } from '~/hooks';
|
||||
import { usePromptGroupsContext } from '~/Providers';
|
||||
|
|
@ -77,8 +77,6 @@ export default function FilterPrompts({ className = '' }: { className?: string }
|
|||
setSearchTerm(e.target.value);
|
||||
}, []);
|
||||
|
||||
const isSearching = searchTerm !== debouncedSearchTerm;
|
||||
|
||||
const resultCount = promptGroups?.length ?? 0;
|
||||
const searchResultsAnnouncement = useMemo(() => {
|
||||
if (!debouncedSearchTerm.trim()) {
|
||||
|
|
@ -102,11 +100,12 @@ export default function FilterPrompts({ className = '' }: { className?: string }
|
|||
ariaLabel={localize('com_ui_filter_prompts')}
|
||||
iconOnly
|
||||
/>
|
||||
<AnimatedSearchInput
|
||||
<FilterInput
|
||||
inputId="prompts-filter"
|
||||
label={localize('com_ui_filter_prompts_name')}
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
isSearching={isSearching}
|
||||
placeholder={localize('com_ui_filter_prompts_name')}
|
||||
containerClassName="flex-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -238,13 +238,16 @@ export default function GenericGrantAccessDialog({
|
|||
})}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="h-full"
|
||||
>
|
||||
<div className="flex min-w-[32px] items-center justify-center gap-2 text-blue-500">
|
||||
<span className="flex h-6 w-6 items-center justify-center">
|
||||
<Share2Icon className="icon-md h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
{totalCurrentShares > 0 && (
|
||||
<Label className="text-sm font-medium text-text-secondary">{totalCurrentShares}</Label>
|
||||
<Label className="cursor-pointer text-sm font-medium text-text-secondary">
|
||||
{totalCurrentShares}
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,73 +1,21 @@
|
|||
import { useMemo, useEffect, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Switch,
|
||||
OGDialog,
|
||||
DropdownPopup,
|
||||
OGDialogTitle,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
|
||||
import { Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { AdminSettingsDialog } from '~/components/ui';
|
||||
import { useUpdateAgentPermissionsMutation } from '~/data-provider';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import type { PermissionConfig } from '~/components/ui';
|
||||
|
||||
type FormValues = Record<Permissions, boolean>;
|
||||
|
||||
type LabelControllerProps = {
|
||||
label: string;
|
||||
agentPerm: Permissions;
|
||||
control: Control<FormValues, unknown, FormValues>;
|
||||
setValue: UseFormSetValue<FormValues>;
|
||||
getValues: UseFormGetValues<FormValues>;
|
||||
};
|
||||
|
||||
const LabelController: React.FC<LabelControllerProps> = ({
|
||||
control,
|
||||
agentPerm,
|
||||
label,
|
||||
getValues,
|
||||
setValue,
|
||||
}) => (
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
<button
|
||||
className="cursor-pointer select-none"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setValue(agentPerm, !getValues(agentPerm), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<Controller
|
||||
name={agentPerm}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
value={field.value.toString()}
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const permissions: PermissionConfig[] = [
|
||||
{ permission: Permissions.SHARED_GLOBAL, labelKey: 'com_ui_agents_allow_share' },
|
||||
{ permission: Permissions.CREATE, labelKey: 'com_ui_agents_allow_create' },
|
||||
{ permission: Permissions.USE, labelKey: 'com_ui_agents_allow_use' },
|
||||
];
|
||||
|
||||
const AdminSettings = () => {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { user, roles } = useAuthContext();
|
||||
const { mutate, isLoading } = useUpdateAgentPermissionsMutation({
|
||||
|
||||
const mutation = useUpdateAgentPermissionsMutation({
|
||||
onSuccess: () => {
|
||||
showToast({ status: 'success', message: localize('com_ui_saved') });
|
||||
},
|
||||
|
|
@ -76,157 +24,14 @@ const AdminSettings = () => {
|
|||
},
|
||||
});
|
||||
|
||||
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const rolePerms = roles?.[selectedRole]?.permissions;
|
||||
if (rolePerms) {
|
||||
return rolePerms[PermissionTypes.AGENTS];
|
||||
}
|
||||
return roleDefaults[selectedRole].permissions[PermissionTypes.AGENTS];
|
||||
}, [roles, selectedRole]);
|
||||
|
||||
const {
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const value = roles?.[selectedRole]?.permissions?.[PermissionTypes.AGENTS];
|
||||
if (value) {
|
||||
reset(value);
|
||||
} else {
|
||||
reset(roleDefaults[selectedRole].permissions[PermissionTypes.AGENTS]);
|
||||
}
|
||||
}, [roles, selectedRole, reset]);
|
||||
|
||||
if (user?.role !== SystemRoles.ADMIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelControllerData = [
|
||||
{
|
||||
agentPerm: Permissions.SHARED_GLOBAL,
|
||||
label: localize('com_ui_agents_allow_share'),
|
||||
},
|
||||
{
|
||||
agentPerm: Permissions.CREATE,
|
||||
label: localize('com_ui_agents_allow_create'),
|
||||
},
|
||||
{
|
||||
agentPerm: Permissions.USE,
|
||||
label: localize('com_ui_agents_allow_use'),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
mutate({ roleName: selectedRole, updates: data });
|
||||
};
|
||||
|
||||
const roleDropdownItems = [
|
||||
{
|
||||
label: SystemRoles.USER,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.USER);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: SystemRoles.ADMIN,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.ADMIN);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'outline'}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-primary"
|
||||
aria-label={localize('com_ui_admin_settings')}
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="border-border-light bg-surface-primary text-text-primary lg:w-1/4">
|
||||
<OGDialogTitle>
|
||||
{localize('com_ui_admin_settings_section', { section: localize('com_ui_agents') })}
|
||||
</OGDialogTitle>
|
||||
<div className="p-2">
|
||||
{/* Role selection dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{localize('com_ui_role_select')}:</span>
|
||||
<DropdownPopup
|
||||
unmountOnHide={true}
|
||||
menuId="role-dropdown"
|
||||
isOpen={isRoleMenuOpen}
|
||||
setIsOpen={setIsRoleMenuOpen}
|
||||
trigger={
|
||||
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
|
||||
{selectedRole}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
itemClassName="items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
{/* Permissions form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-5">
|
||||
{labelControllerData.map(({ agentPerm, label }) => (
|
||||
<div key={agentPerm}>
|
||||
<LabelController
|
||||
control={control}
|
||||
agentPerm={agentPerm}
|
||||
label={label}
|
||||
getValues={getValues}
|
||||
setValue={setValue}
|
||||
/>
|
||||
{selectedRole === SystemRoles.ADMIN && agentPerm === Permissions.USE && (
|
||||
<>
|
||||
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
|
||||
<span>{localize('com_ui_admin_access_warning')}</span>
|
||||
{'\n'}
|
||||
<a
|
||||
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 underline"
|
||||
>
|
||||
{localize('com_ui_more_info')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring-primary"
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
<AdminSettingsDialog
|
||||
permissionType={PermissionTypes.AGENTS}
|
||||
sectionKey="com_ui_agents"
|
||||
permissions={permissions}
|
||||
menuId="agent-role-dropdown"
|
||||
mutation={mutation}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
111
client/src/components/SidePanel/Bookmarks/BookmarkCard.tsx
Normal file
111
client/src/components/SidePanel/Bookmarks/BookmarkCard.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import type { TConversationTag } from 'librechat-data-provider';
|
||||
import { TooltipAnchor, useToastContext } from '@librechat/client';
|
||||
import { useConversationTagMutation } from '~/data-provider';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import BookmarkCardActions from './BookmarkCardActions';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface BookmarkCardProps {
|
||||
bookmark: TConversationTag;
|
||||
position: number;
|
||||
moveRow: (dragIndex: number, hoverIndex: number) => void;
|
||||
}
|
||||
|
||||
interface DragItem {
|
||||
index: number;
|
||||
id: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export default function BookmarkCard({ bookmark, position, moveRow }: BookmarkCardProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const mutation = useConversationTagMutation({
|
||||
context: 'BookmarkCard',
|
||||
tag: bookmark.tag,
|
||||
});
|
||||
|
||||
const handleDrop = (item: DragItem) => {
|
||||
mutation.mutate(
|
||||
{ ...bookmark, position: item.index },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_bookmarks_update_success'),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_bookmarks_update_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const [, drop] = useDrop({
|
||||
accept: 'bookmark',
|
||||
drop: handleDrop,
|
||||
hover(item: DragItem) {
|
||||
if (!ref.current || item.index === position) {
|
||||
return;
|
||||
}
|
||||
moveRow(item.index, position);
|
||||
item.index = position;
|
||||
},
|
||||
});
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: 'bookmark',
|
||||
item: { index: position },
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
|
||||
drag(drop(ref));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-move items-center gap-2 rounded-lg px-3 py-2.5',
|
||||
'border border-border-light bg-transparent',
|
||||
'hover:bg-surface-secondary',
|
||||
isDragging && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<GripVertical className="size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
|
||||
{/* Tag name */}
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-text-primary">
|
||||
{bookmark.tag}
|
||||
</span>
|
||||
|
||||
{/* Count badge */}
|
||||
<TooltipAnchor
|
||||
description={`${bookmark.count} ${localize(bookmark.count === 1 ? 'com_ui_conversation' : 'com_ui_conversations')}`}
|
||||
side="top"
|
||||
render={
|
||||
<span className="shrink-0 rounded-full bg-surface-tertiary px-2 py-0.5 text-xs text-text-secondary">
|
||||
{bookmark.count}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="shrink-0">
|
||||
<BookmarkCardActions bookmark={bookmark} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import type { TConversationTag } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
OGDialog,
|
||||
OGDialogTrigger,
|
||||
OGDialogTemplate,
|
||||
TooltipAnchor,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import { useDeleteConversationTagMutation } from '~/data-provider';
|
||||
import { BookmarkEditDialog } from '~/components/Bookmarks';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface BookmarkCardActionsProps {
|
||||
bookmark: TConversationTag;
|
||||
}
|
||||
|
||||
export default function BookmarkCardActions({ bookmark }: BookmarkCardActionsProps) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const editTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const deleteTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const deleteBookmarkMutation = useDeleteConversationTagMutation({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_bookmarks_delete_success'),
|
||||
});
|
||||
setDeleteOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_bookmarks_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
await deleteBookmarkMutation.mutateAsync(bookmark.tag);
|
||||
}, [bookmark.tag, deleteBookmarkMutation]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Edit button */}
|
||||
<BookmarkEditDialog
|
||||
context="BookmarkCardActions"
|
||||
bookmark={bookmark}
|
||||
open={editOpen}
|
||||
setOpen={setEditOpen}
|
||||
triggerRef={editTriggerRef}
|
||||
>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_edit')}
|
||||
side="top"
|
||||
render={
|
||||
<Button
|
||||
ref={editTriggerRef}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={localize('com_ui_bookmarks_edit')}
|
||||
onClick={() => setEditOpen(true)}
|
||||
>
|
||||
<Pencil className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialogTrigger>
|
||||
</BookmarkEditDialog>
|
||||
|
||||
{/* Delete button */}
|
||||
<OGDialog open={deleteOpen} onOpenChange={setDeleteOpen} triggerRef={deleteTriggerRef}>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
side="top"
|
||||
render={
|
||||
<Button
|
||||
ref={deleteTriggerRef}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={localize('com_ui_bookmarks_delete')}
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_bookmarks_delete')}
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<p className="text-left text-sm text-text-secondary">
|
||||
{localize('com_ui_bookmark_delete_confirm')} <strong>{bookmark.tag}</strong>
|
||||
</p>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirmDelete,
|
||||
selectClasses:
|
||||
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
|
||||
selectText: localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { Bookmark } from 'lucide-react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface BookmarkEmptyStateProps {
|
||||
isFiltered?: boolean;
|
||||
}
|
||||
|
||||
export default function BookmarkEmptyState({ isFiltered = false }: BookmarkEmptyStateProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div className="mb-3 rounded-full bg-surface-secondary p-3">
|
||||
<Bookmark className="size-6 text-text-tertiary" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{isFiltered ? localize('com_ui_no_bookmarks_match') : localize('com_ui_no_bookmarks')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
client/src/components/SidePanel/Bookmarks/BookmarkList.tsx
Normal file
32
client/src/components/SidePanel/Bookmarks/BookmarkList.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { TConversationTag } from 'librechat-data-provider';
|
||||
import BookmarkEmptyState from './BookmarkEmptyState';
|
||||
import BookmarkCard from './BookmarkCard';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookmarks: TConversationTag[];
|
||||
moveRow: (dragIndex: number, hoverIndex: number) => void;
|
||||
isFiltered?: boolean;
|
||||
}
|
||||
|
||||
export default function BookmarkList({
|
||||
bookmarks,
|
||||
moveRow,
|
||||
isFiltered = false,
|
||||
}: BookmarkListProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
if (bookmarks.length === 0) {
|
||||
return <BookmarkEmptyState isFiltered={isFiltered} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" role="list" aria-label={localize('com_ui_bookmarks')}>
|
||||
{bookmarks.map((bookmark) => (
|
||||
<div key={bookmark._id} role="listitem">
|
||||
<BookmarkCard bookmark={bookmark} position={bookmark.position} moveRow={moveRow} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +1,14 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { BookmarkPlusIcon } from 'lucide-react';
|
||||
import {
|
||||
Table,
|
||||
Input,
|
||||
Button,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
OGDialogTrigger,
|
||||
} from '@librechat/client';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Button, FilterInput, OGDialogTrigger, TooltipAnchor } from '@librechat/client';
|
||||
import type { ConversationTagsResponse, TConversationTag } from 'librechat-data-provider';
|
||||
import { BookmarkContext, useBookmarkContext } from '~/Providers/BookmarkContext';
|
||||
import { BookmarkEditDialog } from '~/components/Bookmarks';
|
||||
import BookmarkTableRow from './BookmarkTableRow';
|
||||
import BookmarkList from './BookmarkList';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
const removeDuplicates = (bookmarks: TConversationTag[]) => {
|
||||
const seen = new Set();
|
||||
return bookmarks.filter((bookmark) => {
|
||||
|
|
@ -31,8 +23,7 @@ const BookmarkTable = () => {
|
|||
const [rows, setRows] = useState<ConversationTagsResponse>([]);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const pageSize = 10;
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const { bookmarks = [] } = useBookmarkContext();
|
||||
|
||||
|
|
@ -41,6 +32,11 @@ const BookmarkTable = () => {
|
|||
setRows(_bookmarks);
|
||||
}, [bookmarks]);
|
||||
|
||||
// Reset page when search changes
|
||||
useEffect(() => {
|
||||
setPageIndex(0);
|
||||
}, [searchQuery]);
|
||||
|
||||
const moveRow = useCallback((dragIndex: number, hoverIndex: number) => {
|
||||
setRows((prevTags: TConversationTag[]) => {
|
||||
const updatedRows = [...prevTags];
|
||||
|
|
@ -50,86 +46,60 @@ const BookmarkTable = () => {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const renderRow = useCallback(
|
||||
(row: TConversationTag) => (
|
||||
<BookmarkTableRow key={row._id} moveRow={moveRow} row={row} position={row.position} />
|
||||
),
|
||||
[moveRow],
|
||||
);
|
||||
|
||||
const filteredRows = rows.filter(
|
||||
(row) => row.tag && row.tag.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
const currentRows = filteredRows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
|
||||
const totalPages = Math.ceil(filteredRows.length / pageSize);
|
||||
|
||||
return (
|
||||
<BookmarkContext.Provider value={{ bookmarks }}>
|
||||
<div role="region" aria-label={localize('com_ui_bookmarks')} className="mt-2 space-y-2">
|
||||
<div className="relative flex items-center gap-4">
|
||||
<Input
|
||||
id="bookmarks-filter"
|
||||
placeholder=" "
|
||||
<div role="region" aria-label={localize('com_ui_bookmarks')} className="mt-2 space-y-3">
|
||||
{/* Header: Filter + Create Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterInput
|
||||
inputId="bookmarks-filter"
|
||||
label={localize('com_ui_bookmarks_filter')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
aria-label={localize('com_ui_bookmarks_filter')}
|
||||
className="peer"
|
||||
containerClassName="flex-1"
|
||||
/>
|
||||
<label
|
||||
htmlFor="bookmarks-filter"
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-text-secondary transition-all duration-200 peer-focus:top-0 peer-focus:bg-background peer-focus:px-1 peer-focus:text-xs peer-[:not(:placeholder-shown)]:top-0 peer-[:not(:placeholder-shown)]:bg-background peer-[:not(:placeholder-shown)]:px-1 peer-[:not(:placeholder-shown)]:text-xs"
|
||||
<BookmarkEditDialog context="BookmarkTable" open={createOpen} setOpen={setCreateOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_bookmarks_new')}
|
||||
side="bottom"
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0 bg-transparent"
|
||||
aria-label={localize('com_ui_bookmarks_new')}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialogTrigger>
|
||||
</BookmarkEditDialog>
|
||||
</div>
|
||||
|
||||
{/* Bookmark List */}
|
||||
<BookmarkList
|
||||
bookmarks={currentRows}
|
||||
moveRow={moveRow}
|
||||
isFiltered={searchQuery.length > 0}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{filteredRows.length > pageSize && (
|
||||
<div
|
||||
className="flex items-center justify-end gap-2"
|
||||
role="navigation"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
{localize('com_ui_bookmarks_filter')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border-light bg-transparent shadow-sm transition-colors">
|
||||
<Table className="w-full table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow className="border-b border-border-light">
|
||||
<TableHead className="w-[70%] bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary">
|
||||
<div>{localize('com_ui_bookmarks_title')}</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[30%] bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary">
|
||||
<div>{localize('com_ui_bookmarks_count')}</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[40%] bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary">
|
||||
<div>{localize('com_assistants_actions')}</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentRows.length ? (
|
||||
currentRows.map(renderRow)
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-24 text-center text-sm text-text-secondary">
|
||||
{localize('com_ui_no_bookmarks')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex justify-between gap-2">
|
||||
<BookmarkEditDialog context="BookmarkPanel" open={open} setOpen={setOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full gap-2 text-sm"
|
||||
aria-label={localize('com_ui_bookmarks_new')}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<BookmarkPlusIcon className="size-4" aria-hidden="true" />
|
||||
<div className="break-all">{localize('com_ui_bookmarks_new')}</div>
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
</BookmarkEditDialog>
|
||||
</div>
|
||||
<div className="flex items-center gap-2" role="navigation" aria-label="Pagination">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -139,24 +109,20 @@ const BookmarkTable = () => {
|
|||
>
|
||||
{localize('com_ui_prev')}
|
||||
</Button>
|
||||
<div aria-live="polite" className="text-sm">
|
||||
{`${pageIndex + 1} / ${Math.ceil(filteredRows.length / pageSize)}`}
|
||||
<div className="whitespace-nowrap text-sm" aria-live="polite">
|
||||
{pageIndex + 1} / {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setPageIndex((prev) =>
|
||||
(prev + 1) * pageSize < filteredRows.length ? prev + 1 : prev,
|
||||
)
|
||||
}
|
||||
disabled={(pageIndex + 1) * pageSize >= filteredRows.length}
|
||||
onClick={() => setPageIndex((prev) => (prev + 1 < totalPages ? prev + 1 : prev))}
|
||||
disabled={pageIndex + 1 >= totalPages}
|
||||
aria-label={localize('com_ui_next')}
|
||||
>
|
||||
{localize('com_ui_next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BookmarkContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import type { TConversationTag } from 'librechat-data-provider';
|
||||
import { TableRow, TableCell, useToastContext } from '@librechat/client';
|
||||
import { DeleteBookmarkButton, EditBookmarkButton } from '~/components/Bookmarks';
|
||||
import { useConversationTagMutation } from '~/data-provider';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface BookmarkTableRowProps {
|
||||
row: TConversationTag;
|
||||
moveRow: (dragIndex: number, hoverIndex: number) => void;
|
||||
position: number;
|
||||
}
|
||||
|
||||
interface DragItem {
|
||||
index: number;
|
||||
id: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const BookmarkTableRow: React.FC<BookmarkTableRowProps> = ({ row, moveRow, position }) => {
|
||||
const ref = useRef<HTMLTableRowElement>(null);
|
||||
const mutation = useConversationTagMutation({ context: 'BookmarkTableRow', tag: row.tag });
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const handleDrop = (item: DragItem) => {
|
||||
mutation.mutate(
|
||||
{ ...row, position: item.index },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_bookmarks_update_success'),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_bookmarks_update_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const [, drop] = useDrop({
|
||||
accept: 'bookmark',
|
||||
drop: handleDrop,
|
||||
hover(item: DragItem) {
|
||||
if (!ref.current || item.index === position) {
|
||||
return;
|
||||
}
|
||||
moveRow(item.index, position);
|
||||
item.index = position;
|
||||
},
|
||||
});
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: 'bookmark',
|
||||
item: { index: position },
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
|
||||
drag(drop(ref));
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
ref={ref}
|
||||
className="cursor-move hover:bg-surface-secondary"
|
||||
style={{ opacity: isDragging ? 0.5 : 1 }}
|
||||
>
|
||||
<TableCell className="w-[70%] px-4 py-4">
|
||||
<div className="overflow-hidden text-ellipsis whitespace-nowrap">{row.tag}</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-[10%] px-12 py-4">{row.count}</TableCell>
|
||||
<TableCell className="w-[20%] px-4 py-4">
|
||||
<div className="flex gap-2">
|
||||
<EditBookmarkButton bookmark={row} tabIndex={0} />
|
||||
<DeleteBookmarkButton bookmark={row.tag} tabIndex={0} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarkTableRow;
|
||||
6
client/src/components/SidePanel/Bookmarks/index.ts
Normal file
6
client/src/components/SidePanel/Bookmarks/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { default as BookmarkPanel } from './BookmarkPanel';
|
||||
export { default as BookmarkTable } from './BookmarkTable';
|
||||
export { default as BookmarkList } from './BookmarkList';
|
||||
export { default as BookmarkCard } from './BookmarkCard';
|
||||
export { default as BookmarkCardActions } from './BookmarkCardActions';
|
||||
export { default as BookmarkEmptyState } from './BookmarkEmptyState';
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { ArrowUpLeft } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
Button,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
FilterInput,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import {
|
||||
|
|
@ -182,22 +182,12 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
|
||||
return (
|
||||
<div role="region" aria-label={localize('com_files_table')} className="mt-2 space-y-2">
|
||||
<div className="relative flex items-center gap-4">
|
||||
<Input
|
||||
id="filename-filter"
|
||||
placeholder=" "
|
||||
value={filenameFilter ?? ''}
|
||||
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
|
||||
aria-label={localize('com_files_filter')}
|
||||
className="peer"
|
||||
/>
|
||||
<label
|
||||
htmlFor="filename-filter"
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-text-secondary transition-all duration-200 peer-focus:top-0 peer-focus:bg-background peer-focus:px-1 peer-focus:text-xs peer-[:not(:placeholder-shown)]:top-0 peer-[:not(:placeholder-shown)]:bg-background peer-[:not(:placeholder-shown)]:px-1 peer-[:not(:placeholder-shown)]:text-xs"
|
||||
>
|
||||
{localize('com_files_filter')}
|
||||
</label>
|
||||
</div>
|
||||
<FilterInput
|
||||
inputId="filename-filter"
|
||||
label={localize('com_files_filter')}
|
||||
value={filenameFilter ?? ''}
|
||||
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-border-light bg-transparent shadow-sm transition-colors">
|
||||
<div className="overflow-x-auto">
|
||||
|
|
|
|||
|
|
@ -1,72 +1,21 @@
|
|||
import { useMemo, useEffect, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Switch,
|
||||
OGDialog,
|
||||
DropdownPopup,
|
||||
OGDialogTitle,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
|
||||
import { Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { AdminSettingsDialog } from '~/components/ui';
|
||||
import { useUpdateMCPServersPermissionsMutation } from '~/data-provider';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import type { PermissionConfig } from '~/components/ui';
|
||||
|
||||
type FormValues = Record<Permissions, boolean>;
|
||||
|
||||
type LabelControllerProps = {
|
||||
label: string;
|
||||
mcpServersPerm: Permissions;
|
||||
control: Control<FormValues, unknown, FormValues>;
|
||||
setValue: UseFormSetValue<FormValues>;
|
||||
getValues: UseFormGetValues<FormValues>;
|
||||
};
|
||||
|
||||
const LabelController: React.FC<LabelControllerProps> = ({
|
||||
control,
|
||||
mcpServersPerm,
|
||||
label,
|
||||
getValues,
|
||||
setValue,
|
||||
}) => (
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
<button
|
||||
className="cursor-pointer select-none"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setValue(mcpServersPerm, !getValues(mcpServersPerm), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<Controller
|
||||
name={mcpServersPerm}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
value={field.value?.toString()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const permissions: PermissionConfig[] = [
|
||||
{ permission: Permissions.USE, labelKey: 'com_ui_mcp_servers_allow_use' },
|
||||
{ permission: Permissions.CREATE, labelKey: 'com_ui_mcp_servers_allow_create' },
|
||||
{ permission: Permissions.SHARE, labelKey: 'com_ui_mcp_servers_allow_share' },
|
||||
];
|
||||
|
||||
const MCPAdminSettings = () => {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { user, roles } = useAuthContext();
|
||||
const { mutate, isLoading } = useUpdateMCPServersPermissionsMutation({
|
||||
|
||||
const mutation = useUpdateMCPServersPermissionsMutation({
|
||||
onSuccess: () => {
|
||||
showToast({ status: 'success', message: localize('com_ui_saved') });
|
||||
},
|
||||
|
|
@ -75,157 +24,14 @@ const MCPAdminSettings = () => {
|
|||
},
|
||||
});
|
||||
|
||||
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const rolePerms = roles?.[selectedRole]?.permissions;
|
||||
if (rolePerms) {
|
||||
return rolePerms[PermissionTypes.MCP_SERVERS];
|
||||
}
|
||||
return roleDefaults[selectedRole].permissions[PermissionTypes.MCP_SERVERS];
|
||||
}, [roles, selectedRole]);
|
||||
|
||||
const {
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const value = roles?.[selectedRole]?.permissions?.[PermissionTypes.MCP_SERVERS];
|
||||
if (value) {
|
||||
reset(value);
|
||||
} else {
|
||||
reset(roleDefaults[selectedRole].permissions[PermissionTypes.MCP_SERVERS]);
|
||||
}
|
||||
}, [roles, selectedRole, reset]);
|
||||
|
||||
if (user?.role !== SystemRoles.ADMIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelControllerData = [
|
||||
{
|
||||
mcpServersPerm: Permissions.USE,
|
||||
label: localize('com_ui_mcp_servers_allow_use'),
|
||||
},
|
||||
{
|
||||
mcpServersPerm: Permissions.CREATE,
|
||||
label: localize('com_ui_mcp_servers_allow_create'),
|
||||
},
|
||||
{
|
||||
mcpServersPerm: Permissions.SHARE,
|
||||
label: localize('com_ui_mcp_servers_allow_share'),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
mutate({ roleName: selectedRole, updates: data });
|
||||
};
|
||||
|
||||
const roleDropdownItems = [
|
||||
{
|
||||
label: SystemRoles.USER,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.USER);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: SystemRoles.ADMIN,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.ADMIN);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'outline'}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-primary"
|
||||
aria-label={localize('com_ui_admin_settings')}
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="border-border-light bg-surface-primary text-text-primary lg:w-1/4">
|
||||
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
|
||||
'com_ui_mcp_servers',
|
||||
)}`}</OGDialogTitle>
|
||||
<div className="p-2">
|
||||
{/* Role selection dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{localize('com_ui_role_select')}:</span>
|
||||
<DropdownPopup
|
||||
unmountOnHide={true}
|
||||
menuId="role-dropdown"
|
||||
isOpen={isRoleMenuOpen}
|
||||
setIsOpen={setIsRoleMenuOpen}
|
||||
trigger={
|
||||
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
|
||||
{selectedRole}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
itemClassName="items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
{/* Permissions form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-5">
|
||||
{labelControllerData.map(({ mcpServersPerm, label }) => (
|
||||
<div key={mcpServersPerm}>
|
||||
<LabelController
|
||||
control={control}
|
||||
mcpServersPerm={mcpServersPerm}
|
||||
label={label}
|
||||
getValues={getValues}
|
||||
setValue={setValue}
|
||||
/>
|
||||
{selectedRole === SystemRoles.ADMIN && mcpServersPerm === Permissions.USE && (
|
||||
<>
|
||||
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
|
||||
<span>{localize('com_ui_admin_access_warning')}</span>
|
||||
{'\n'}
|
||||
<a
|
||||
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 underline"
|
||||
>
|
||||
{localize('com_ui_more_info')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-primary"
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
<AdminSettingsDialog
|
||||
permissionType={PermissionTypes.MCP_SERVERS}
|
||||
sectionKey="com_ui_mcp_servers"
|
||||
permissions={permissions}
|
||||
menuId="mcp-role-dropdown"
|
||||
mutation={mutation}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useRef, useMemo } from 'react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { Button, Spinner, OGDialogTrigger, Input } from '@librechat/client';
|
||||
import { Button, Spinner, FilterInput, OGDialogTrigger } from '@librechat/client';
|
||||
import { useLocalize, useMCPServerManager, useHasAccess } from '~/hooks';
|
||||
import MCPServerList from './MCPServerList';
|
||||
import MCPServerDialog from './MCPServerDialog';
|
||||
|
|
@ -42,16 +42,12 @@ export default function MCPBuilderPanel() {
|
|||
<MCPAdminSettings />
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={localize('com_ui_filter_mcp_servers')}
|
||||
className="pl-9"
|
||||
aria-label={localize('com_ui_filter_mcp_servers')}
|
||||
/>
|
||||
</div>
|
||||
<FilterInput
|
||||
inputId="mcp-filter"
|
||||
label={localize('com_ui_filter_mcp_servers')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
{hasCreateAccess && (
|
||||
<MCPServerDialog open={showDialog} onOpenChange={setShowDialog} triggerRef={addButtonRef}>
|
||||
|
|
|
|||
|
|
@ -1,56 +1,23 @@
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
||||
import {
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
Button,
|
||||
Switch,
|
||||
DropdownPopup,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
|
||||
import { Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { AdminSettingsDialog } from '~/components/ui';
|
||||
import { useUpdateMemoryPermissionsMutation } from '~/data-provider';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import type { PermissionConfig } from '~/components/ui';
|
||||
|
||||
type FormValues = Record<Permissions, boolean>;
|
||||
|
||||
type LabelControllerProps = {
|
||||
label: string;
|
||||
memoryPerm: Permissions;
|
||||
control: Control<FormValues, unknown, FormValues>;
|
||||
setValue: UseFormSetValue<FormValues>;
|
||||
getValues: UseFormGetValues<FormValues>;
|
||||
};
|
||||
|
||||
const LabelController: React.FC<LabelControllerProps> = ({ control, memoryPerm, label }) => (
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
{label}
|
||||
<Controller
|
||||
name={memoryPerm}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
value={field.value.toString()}
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const permissions: PermissionConfig[] = [
|
||||
{ permission: Permissions.USE, labelKey: 'com_ui_memories_allow_use' },
|
||||
{ permission: Permissions.CREATE, labelKey: 'com_ui_memories_allow_create' },
|
||||
{ permission: Permissions.UPDATE, labelKey: 'com_ui_memories_allow_update' },
|
||||
{ permission: Permissions.READ, labelKey: 'com_ui_memories_allow_read' },
|
||||
{ permission: Permissions.OPT_OUT, labelKey: 'com_ui_memories_allow_opt_out' },
|
||||
];
|
||||
|
||||
const AdminSettings = () => {
|
||||
const localize = useLocalize();
|
||||
const { user, roles } = useAuthContext();
|
||||
const { showToast } = useToastContext();
|
||||
const { mutate, isLoading } = useUpdateMemoryPermissionsMutation({
|
||||
|
||||
const mutation = useUpdateMemoryPermissionsMutation({
|
||||
onSuccess: () => {
|
||||
showToast({ status: 'success', message: localize('com_ui_saved') });
|
||||
},
|
||||
|
|
@ -59,162 +26,14 @@ const AdminSettings = () => {
|
|||
},
|
||||
});
|
||||
|
||||
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
if (roles?.[selectedRole]?.permissions) {
|
||||
return roles?.[selectedRole]?.permissions?.[PermissionTypes.MEMORIES];
|
||||
}
|
||||
return roleDefaults[selectedRole].permissions[PermissionTypes.MEMORIES];
|
||||
}, [roles, selectedRole]);
|
||||
|
||||
const {
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (roles?.[selectedRole]?.permissions?.[PermissionTypes.MEMORIES]) {
|
||||
reset(roles?.[selectedRole]?.permissions?.[PermissionTypes.MEMORIES]);
|
||||
} else {
|
||||
reset(roleDefaults[selectedRole].permissions[PermissionTypes.MEMORIES]);
|
||||
}
|
||||
}, [roles, selectedRole, reset]);
|
||||
|
||||
if (user?.role !== SystemRoles.ADMIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelControllerData = [
|
||||
{
|
||||
memoryPerm: Permissions.USE,
|
||||
label: localize('com_ui_memories_allow_use'),
|
||||
},
|
||||
{
|
||||
memoryPerm: Permissions.CREATE,
|
||||
label: localize('com_ui_memories_allow_create'),
|
||||
},
|
||||
{
|
||||
memoryPerm: Permissions.UPDATE,
|
||||
label: localize('com_ui_memories_allow_update'),
|
||||
},
|
||||
{
|
||||
memoryPerm: Permissions.READ,
|
||||
label: localize('com_ui_memories_allow_read'),
|
||||
},
|
||||
{
|
||||
memoryPerm: Permissions.OPT_OUT,
|
||||
label: localize('com_ui_memories_allow_opt_out'),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
mutate({ roleName: selectedRole, updates: data });
|
||||
};
|
||||
|
||||
const roleDropdownItems = [
|
||||
{
|
||||
label: SystemRoles.USER,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.USER);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: SystemRoles.ADMIN,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.ADMIN);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'outline'}
|
||||
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-primary"
|
||||
aria-label={localize('com_ui_admin_settings')}
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="border-border-light bg-surface-primary text-text-primary lg:w-1/4">
|
||||
<OGDialogTitle>
|
||||
{localize('com_ui_admin_settings_section', { section: localize('com_ui_memories') })}
|
||||
</OGDialogTitle>
|
||||
<div className="p-2">
|
||||
{/* Role selection dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{localize('com_ui_role_select')}:</span>
|
||||
<DropdownPopup
|
||||
unmountOnHide={true}
|
||||
menuId="memory-role-dropdown"
|
||||
isOpen={isRoleMenuOpen}
|
||||
setIsOpen={setIsRoleMenuOpen}
|
||||
trigger={
|
||||
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
|
||||
{selectedRole}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
itemClassName="items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
{/* Permissions form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-5">
|
||||
{labelControllerData.map(({ memoryPerm, label }) => (
|
||||
<div key={memoryPerm}>
|
||||
<LabelController
|
||||
control={control}
|
||||
memoryPerm={memoryPerm}
|
||||
label={label}
|
||||
getValues={getValues}
|
||||
setValue={setValue}
|
||||
/>
|
||||
{selectedRole === SystemRoles.ADMIN && memoryPerm === Permissions.USE && (
|
||||
<>
|
||||
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
|
||||
<span>{localize('com_ui_admin_access_warning')}</span>
|
||||
{'\n'}
|
||||
<a
|
||||
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 underline"
|
||||
>
|
||||
{localize('com_ui_more_info')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-primary"
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
<AdminSettingsDialog
|
||||
permissionType={PermissionTypes.MEMORIES}
|
||||
sectionKey="com_ui_memories"
|
||||
permissions={permissions}
|
||||
menuId="memory-role-dropdown"
|
||||
mutation={mutation}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
57
client/src/components/SidePanel/Memories/MemoryCard.tsx
Normal file
57
client/src/components/SidePanel/Memories/MemoryCard.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { TUserMemory } from 'librechat-data-provider';
|
||||
import MemoryCardActions from './MemoryCardActions';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface MemoryCardProps {
|
||||
memory: TUserMemory;
|
||||
hasUpdateAccess: boolean;
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
export default function MemoryCard({ memory, hasUpdateAccess }: MemoryCardProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-2.5',
|
||||
'border border-border-light bg-transparent',
|
||||
'hover:bg-surface-secondary',
|
||||
)}
|
||||
>
|
||||
{/* Row 1: Key + Token count + Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-text-primary">{memory.key}</span>
|
||||
{memory.tokenCount !== undefined && (
|
||||
<span className="shrink-0 text-xs text-text-secondary">
|
||||
{memory.tokenCount}{' '}
|
||||
{localize(memory.tokenCount === 1 ? 'com_ui_token' : 'com_ui_tokens')}
|
||||
</span>
|
||||
)}
|
||||
{hasUpdateAccess && (
|
||||
<div className="ml-auto shrink-0">
|
||||
<MemoryCardActions memory={memory} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Value + Date */}
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<p className="min-w-0 flex-1 truncate text-sm text-text-primary" title={memory.value}>
|
||||
{memory.value}
|
||||
</p>
|
||||
<span className="shrink-0 text-xs text-text-secondary">
|
||||
{formatDate(memory.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
client/src/components/SidePanel/Memories/MemoryCardActions.tsx
Normal file
124
client/src/components/SidePanel/Memories/MemoryCardActions.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { Pencil } from 'lucide-react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import {
|
||||
Label,
|
||||
Spinner,
|
||||
OGDialog,
|
||||
TrashIcon,
|
||||
TooltipAnchor,
|
||||
OGDialogTrigger,
|
||||
OGDialogTemplate,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { TUserMemory } from 'librechat-data-provider';
|
||||
import { useDeleteMemoryMutation } from '~/data-provider';
|
||||
import MemoryEditDialog from './MemoryEditDialog';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface MemoryCardActionsProps {
|
||||
memory: TUserMemory;
|
||||
}
|
||||
|
||||
export default function MemoryCardActions({ memory }: MemoryCardActionsProps) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const { mutate: deleteMemory, isLoading: isDeleting } = useDeleteMemoryMutation();
|
||||
|
||||
const buttonBaseClass = cn(
|
||||
'flex size-7 items-center justify-center rounded-md',
|
||||
'transition-colors duration-150',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'hover:bg-surface-tertiary',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
|
||||
);
|
||||
|
||||
const confirmDelete = () => {
|
||||
deleteMemory(memory.key, {
|
||||
onSuccess: () => {
|
||||
showToast({ message: localize('com_ui_deleted'), status: 'success' });
|
||||
setDeleteOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
showToast({ message: localize('com_ui_error'), status: 'error' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Edit Button */}
|
||||
<MemoryEditDialog
|
||||
open={editOpen}
|
||||
memory={memory}
|
||||
onOpenChange={setEditOpen}
|
||||
triggerRef={triggerRef as React.MutableRefObject<HTMLButtonElement | null>}
|
||||
>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_edit_memory')}
|
||||
side="top"
|
||||
render={
|
||||
<button
|
||||
ref={triggerRef}
|
||||
className={buttonBaseClass}
|
||||
aria-label={localize('com_ui_edit')}
|
||||
onClick={() => setEditOpen(true)}
|
||||
>
|
||||
<Pencil className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</OGDialogTrigger>
|
||||
</MemoryEditDialog>
|
||||
|
||||
{/* Delete Button */}
|
||||
<OGDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete_memory')}
|
||||
side="top"
|
||||
render={
|
||||
<button
|
||||
className={buttonBaseClass}
|
||||
aria-label={localize('com_ui_delete')}
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Spinner className="size-3.5" />
|
||||
) : (
|
||||
<TrashIcon className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_memory')}
|
||||
className="w-11/12 max-w-lg"
|
||||
main={
|
||||
<Label className="text-left text-sm font-medium">
|
||||
<Trans
|
||||
i18nKey="com_ui_delete_confirm_strong"
|
||||
values={{ title: memory.key }}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</Label>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirmDelete,
|
||||
selectClasses:
|
||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
|
||||
selectText: localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -108,11 +108,11 @@ export default function MemoryCreateDialog({
|
|||
<OGDialogTemplate
|
||||
title={localize('com_ui_create_memory')}
|
||||
showCloseButton={false}
|
||||
className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-lg"
|
||||
className="w-11/12 md:max-w-lg"
|
||||
main={
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memory-key" className="text-sm font-medium">
|
||||
<Label htmlFor="memory-key" className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_key')}
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -123,9 +123,10 @@ export default function MemoryCreateDialog({
|
|||
placeholder={localize('com_ui_enter_key')}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-text-secondary">{localize('com_ui_memory_key_hint')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memory-value" className="text-sm font-medium">
|
||||
<Label htmlFor="memory-value" className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_value')}
|
||||
</Label>
|
||||
<textarea
|
||||
|
|
@ -134,8 +135,8 @@ export default function MemoryCreateDialog({
|
|||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={localize('com_ui_enter_value')}
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
rows={3}
|
||||
className="min-h-[100px] w-full resize-none rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-heavy"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import {
|
||||
OGDialog,
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import type { TUserMemory } from 'librechat-data-provider';
|
||||
import { useUpdateMemoryMutation, useMemoriesQuery } from '~/data-provider';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import MemoryUsageBadge from './MemoryUsageBadge';
|
||||
|
||||
interface MemoryEditDialogProps {
|
||||
memory: TUserMemory | null;
|
||||
|
|
@ -21,6 +22,16 @@ interface MemoryEditDialogProps {
|
|||
triggerRef?: React.MutableRefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
const formatDateTime = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
export default function MemoryEditDialog({
|
||||
memory,
|
||||
open,
|
||||
|
|
@ -116,6 +127,16 @@ export default function MemoryEditDialog({
|
|||
}
|
||||
};
|
||||
|
||||
// Calculate memory-specific usage: available = tokenLimit - (totalTokens - thisMemoryTokens)
|
||||
const memoryUsage = useMemo(() => {
|
||||
if (!memory?.tokenCount || !memData?.tokenLimit) {
|
||||
return null;
|
||||
}
|
||||
const availableForMemory = memData.tokenLimit - (memData.totalTokens ?? 0) + memory.tokenCount;
|
||||
const percentage = Math.round((memory.tokenCount / availableForMemory) * 100);
|
||||
return { availableForMemory, percentage };
|
||||
}, [memory?.tokenCount, memData?.tokenLimit, memData?.totalTokens]);
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
|
||||
{children}
|
||||
|
|
@ -125,38 +146,41 @@ export default function MemoryEditDialog({
|
|||
className="w-11/12 md:max-w-lg"
|
||||
main={
|
||||
<div className="space-y-4">
|
||||
{/* Memory metadata */}
|
||||
{memory && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-text-secondary">
|
||||
<div>
|
||||
{localize('com_ui_date')}:{' '}
|
||||
{new Date(memory.updated_at).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
{/* Token Information */}
|
||||
{memory.tokenCount !== undefined && (
|
||||
<div>
|
||||
{memory.tokenCount.toLocaleString()}
|
||||
{memData?.tokenLimit && ` / ${memData.tokenLimit.toLocaleString()}`}{' '}
|
||||
{localize(memory.tokenCount === 1 ? 'com_ui_token' : 'com_ui_tokens')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Overall Memory Usage */}
|
||||
{memData?.tokenLimit && memData?.usagePercentage !== null && (
|
||||
<div className="text-xs text-text-secondary">
|
||||
{localize('com_ui_usage')}: {memData.usagePercentage}%{' '}
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border border-border-light bg-surface-secondary px-3 py-2">
|
||||
{/* Token count - Left */}
|
||||
{memory.tokenCount !== undefined ? (
|
||||
<span className="text-xs text-text-secondary">
|
||||
{memory.tokenCount.toLocaleString()}{' '}
|
||||
{localize(memory.tokenCount === 1 ? 'com_ui_token' : 'com_ui_tokens')}
|
||||
</span>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{/* Date - Center */}
|
||||
<span className="text-xs text-text-secondary">
|
||||
{formatDateTime(memory.updated_at)}
|
||||
</span>
|
||||
|
||||
{/* Usage badge - Right (memory-specific) */}
|
||||
{memoryUsage ? (
|
||||
<MemoryUsageBadge
|
||||
percentage={memoryUsage.percentage}
|
||||
tokenLimit={memData?.tokenLimit ?? 0}
|
||||
tooltipCurrent={memory.tokenCount}
|
||||
tooltipMax={memoryUsage.availableForMemory}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memory-key" className="text-sm font-medium">
|
||||
<Label htmlFor="memory-key" className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_key')}
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -169,8 +193,10 @@ export default function MemoryEditDialog({
|
|||
disabled={!hasUpdateAccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Value textarea */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memory-value" className="text-sm font-medium">
|
||||
<Label htmlFor="memory-value" className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_value')}
|
||||
</Label>
|
||||
<textarea
|
||||
|
|
@ -179,8 +205,8 @@ export default function MemoryEditDialog({
|
|||
onChange={(e) => hasUpdateAccess && setValue(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={localize('com_ui_enter_value')}
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
rows={3}
|
||||
className="min-h-[100px] w-full resize-none rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-heavy disabled:cursor-not-allowed disabled:opacity-50"
|
||||
rows={4}
|
||||
disabled={!hasUpdateAccess}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { Brain } from 'lucide-react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface MemoryEmptyStateProps {
|
||||
isFiltered?: boolean;
|
||||
}
|
||||
|
||||
export default function MemoryEmptyState({ isFiltered = false }: MemoryEmptyStateProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-border-light bg-surface-primary p-6 text-center">
|
||||
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-surface-tertiary">
|
||||
<Brain className="size-5 text-text-secondary" aria-hidden="true" />
|
||||
</div>
|
||||
{isFiltered ? (
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_no_memories_match')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_no_memories_title')}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-text-secondary">{localize('com_ui_no_memories')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
client/src/components/SidePanel/Memories/MemoryList.tsx
Normal file
32
client/src/components/SidePanel/Memories/MemoryList.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { TUserMemory } from 'librechat-data-provider';
|
||||
import MemoryEmptyState from './MemoryEmptyState';
|
||||
import MemoryCard from './MemoryCard';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface MemoryListProps {
|
||||
memories: TUserMemory[];
|
||||
hasUpdateAccess: boolean;
|
||||
isFiltered?: boolean;
|
||||
}
|
||||
|
||||
export default function MemoryList({
|
||||
memories,
|
||||
hasUpdateAccess,
|
||||
isFiltered = false,
|
||||
}: MemoryListProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
if (memories.length === 0) {
|
||||
return <MemoryEmptyState isFiltered={isFiltered} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" role="list" aria-label={localize('com_ui_memories')}>
|
||||
{memories.map((memory) => (
|
||||
<div key={memory.key} role="listitem">
|
||||
<MemoryCard memory={memory} hasUpdateAccess={hasUpdateAccess} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
229
client/src/components/SidePanel/Memories/MemoryPanel.tsx
Normal file
229
client/src/components/SidePanel/Memories/MemoryPanel.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Switch,
|
||||
Spinner,
|
||||
FilterInput,
|
||||
TooltipAnchor,
|
||||
OGDialogTrigger,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { TUserMemory } from 'librechat-data-provider';
|
||||
import {
|
||||
useUpdateMemoryPreferencesMutation,
|
||||
useMemoriesQuery,
|
||||
useGetUserQuery,
|
||||
} from '~/data-provider';
|
||||
import { useLocalize, useAuthContext, useHasAccess } from '~/hooks';
|
||||
import MemoryCreateDialog from './MemoryCreateDialog';
|
||||
import MemoryUsageBadge from './MemoryUsageBadge';
|
||||
import AdminSettings from './AdminSettings';
|
||||
import MemoryList from './MemoryList';
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
export default function MemoryPanel() {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const { data: userData } = useGetUserQuery();
|
||||
const { data: memData, isLoading } = useMemoriesQuery();
|
||||
const { showToast } = useToastContext();
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [referenceSavedMemories, setReferenceSavedMemories] = useState(true);
|
||||
|
||||
const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_preferences_updated'),
|
||||
status: 'success',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_error_updating_preferences'),
|
||||
status: 'error',
|
||||
});
|
||||
setReferenceSavedMemories((prev) => !prev);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (userData?.personalization?.memories !== undefined) {
|
||||
setReferenceSavedMemories(userData.personalization.memories);
|
||||
}
|
||||
}, [userData?.personalization?.memories]);
|
||||
|
||||
const handleMemoryToggle = (checked: boolean) => {
|
||||
setReferenceSavedMemories(checked);
|
||||
updateMemoryPreferencesMutation.mutate({ memories: checked });
|
||||
};
|
||||
|
||||
const hasReadAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.READ,
|
||||
});
|
||||
|
||||
const hasUpdateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.UPDATE,
|
||||
});
|
||||
|
||||
const hasCreateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
|
||||
const hasOptOutAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.OPT_OUT,
|
||||
});
|
||||
|
||||
const memories: TUserMemory[] = useMemo(() => memData?.memories ?? [], [memData]);
|
||||
|
||||
const filteredMemories = useMemo(() => {
|
||||
return matchSorter(memories, searchQuery, {
|
||||
keys: ['key', 'value'],
|
||||
});
|
||||
}, [memories, searchQuery]);
|
||||
|
||||
const currentRows = useMemo(() => {
|
||||
return filteredMemories.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
|
||||
}, [filteredMemories, pageIndex]);
|
||||
|
||||
// Reset page when search changes
|
||||
useEffect(() => {
|
||||
setPageIndex(0);
|
||||
}, [searchQuery]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-4">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasReadAccess) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_no_read_access')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(filteredMemories.length / pageSize);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div role="region" aria-label={localize('com_ui_memories')} className="mt-2 space-y-3">
|
||||
{/* Header: Filter + Create Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterInput
|
||||
inputId="memory-search"
|
||||
label={localize('com_ui_memories_filter')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
containerClassName="flex-1"
|
||||
/>
|
||||
{hasCreateAccess && (
|
||||
<MemoryCreateDialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_create_memory')}
|
||||
side="bottom"
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0 bg-transparent"
|
||||
aria-label={localize('com_ui_create_memory')}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialogTrigger>
|
||||
</MemoryCreateDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls: Usage Badge + Memory Toggle */}
|
||||
{(memData?.tokenLimit != null || hasOptOutAccess) && (
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Usage Badge */}
|
||||
{memData?.tokenLimit != null && (
|
||||
<MemoryUsageBadge
|
||||
percentage={memData.usagePercentage ?? 0}
|
||||
tokenLimit={memData.tokenLimit}
|
||||
totalTokens={memData.totalTokens}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Memory Toggle */}
|
||||
{hasOptOutAccess && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-text-secondary">{localize('com_ui_use_memory')}</span>
|
||||
<Switch
|
||||
checked={referenceSavedMemories}
|
||||
onCheckedChange={handleMemoryToggle}
|
||||
aria-label={localize('com_ui_use_memory')}
|
||||
disabled={updateMemoryPreferencesMutation.isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Memory List */}
|
||||
<MemoryList
|
||||
memories={currentRows}
|
||||
hasUpdateAccess={hasUpdateAccess}
|
||||
isFiltered={searchQuery.length > 0}
|
||||
/>
|
||||
|
||||
{/* Footer: Admin Settings + Pagination */}
|
||||
{(user?.role === SystemRoles.ADMIN || filteredMemories.length > pageSize) && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* Admin Settings - Left */}
|
||||
{user?.role === SystemRoles.ADMIN ? <AdminSettings /> : <div />}
|
||||
|
||||
{/* Pagination - Right */}
|
||||
{filteredMemories.length > pageSize && (
|
||||
<div className="flex items-center gap-2" role="navigation" aria-label="Pagination">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageIndex((prev) => Math.max(prev - 1, 0))}
|
||||
disabled={pageIndex === 0}
|
||||
aria-label={localize('com_ui_prev')}
|
||||
>
|
||||
{localize('com_ui_prev')}
|
||||
</Button>
|
||||
<div className="whitespace-nowrap text-sm" aria-live="polite">
|
||||
{pageIndex + 1} / {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageIndex((prev) => (prev + 1 < totalPages ? prev + 1 : prev))}
|
||||
disabled={pageIndex + 1 >= totalPages}
|
||||
aria-label={localize('com_ui_next')}
|
||||
>
|
||||
{localize('com_ui_next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { TooltipAnchor } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface MemoryUsageBadgeProps {
|
||||
percentage: number;
|
||||
tokenLimit: number;
|
||||
totalTokens?: number;
|
||||
/** Custom current value for tooltip (overrides totalTokens) */
|
||||
tooltipCurrent?: number;
|
||||
/** Custom max value for tooltip (overrides tokenLimit) */
|
||||
tooltipMax?: number;
|
||||
}
|
||||
|
||||
const getStatusColor = (pct: number): string => {
|
||||
if (pct > 90) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
|
||||
}
|
||||
if (pct > 75) {
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400';
|
||||
}
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
|
||||
};
|
||||
|
||||
export default function MemoryUsageBadge({
|
||||
percentage,
|
||||
tokenLimit,
|
||||
totalTokens,
|
||||
tooltipCurrent,
|
||||
tooltipMax,
|
||||
}: MemoryUsageBadgeProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const tokenLabel = localize('com_ui_tokens');
|
||||
const current = tooltipCurrent ?? totalTokens;
|
||||
const max = tooltipMax ?? tokenLimit;
|
||||
|
||||
const tooltipText =
|
||||
current !== undefined
|
||||
? `${current.toLocaleString()} / ${max.toLocaleString()} ${tokenLabel}`
|
||||
: `${max.toLocaleString()} ${tokenLabel}`;
|
||||
|
||||
return (
|
||||
<TooltipAnchor
|
||||
description={tooltipText}
|
||||
side="top"
|
||||
render={
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1',
|
||||
'text-xs font-medium',
|
||||
getStatusColor(percentage),
|
||||
)}
|
||||
role="status"
|
||||
aria-label={`${localize('com_ui_usage')}: ${percentage}%`}
|
||||
>
|
||||
<span>{percentage}%</span>
|
||||
<span className="opacity-70">{localize('com_ui_used').toLowerCase()}</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,434 +0,0 @@
|
|||
/* Memories */
|
||||
import { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import {
|
||||
Table,
|
||||
Input,
|
||||
Label,
|
||||
Button,
|
||||
Switch,
|
||||
Spinner,
|
||||
TableRow,
|
||||
OGDialog,
|
||||
EditIcon,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TrashIcon,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TooltipAnchor,
|
||||
useToastContext,
|
||||
OGDialogTrigger,
|
||||
OGDialogTemplate,
|
||||
} from '@librechat/client';
|
||||
import type { TUserMemory } from 'librechat-data-provider';
|
||||
import {
|
||||
useUpdateMemoryPreferencesMutation,
|
||||
useDeleteMemoryMutation,
|
||||
useMemoriesQuery,
|
||||
useGetUserQuery,
|
||||
} from '~/data-provider';
|
||||
import { useLocalize, useAuthContext, useHasAccess } from '~/hooks';
|
||||
import MemoryCreateDialog from './MemoryCreateDialog';
|
||||
import MemoryEditDialog from './MemoryEditDialog';
|
||||
import AdminSettings from './AdminSettings';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const EditMemoryButton = ({ memory }: { memory: TUserMemory }) => {
|
||||
const localize = useLocalize();
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<MemoryEditDialog
|
||||
open={open}
|
||||
memory={memory}
|
||||
onOpenChange={setOpen}
|
||||
triggerRef={triggerRef as React.MutableRefObject<HTMLButtonElement | null>}
|
||||
>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_edit_memory')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={localize('com_ui_bookmarks_edit')}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<EditIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialogTrigger>
|
||||
</MemoryEditDialog>
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteMemoryButton = ({ memory }: { memory: TUserMemory }) => {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { mutate: deleteMemory } = useDeleteMemoryMutation();
|
||||
const [deletingKey, setDeletingKey] = useState<string | null>(null);
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setDeletingKey(memory.key);
|
||||
deleteMemory(memory.key, {
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_deleted'),
|
||||
status: 'success',
|
||||
});
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () =>
|
||||
showToast({
|
||||
message: localize('com_ui_error'),
|
||||
status: 'error',
|
||||
}),
|
||||
onSettled: () => setDeletingKey(null),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={setOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete_memory')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={localize('com_ui_delete')}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{deletingKey === memory.key ? (
|
||||
<Spinner className="size-4 animate-spin" />
|
||||
) : (
|
||||
<TrashIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_memory')}
|
||||
className="w-11/12 max-w-lg"
|
||||
main={
|
||||
<Label className="text-left text-sm font-medium">
|
||||
<Trans
|
||||
i18nKey="com_ui_delete_confirm_strong"
|
||||
values={{ title: memory.key }}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</Label>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirmDelete,
|
||||
selectClasses:
|
||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
|
||||
selectText: localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
||||
const pageSize = 10;
|
||||
export default function MemoryViewer() {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const { data: userData } = useGetUserQuery();
|
||||
const { data: memData, isLoading } = useMemoriesQuery();
|
||||
const { showToast } = useToastContext();
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [referenceSavedMemories, setReferenceSavedMemories] = useState(true);
|
||||
|
||||
const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_preferences_updated'),
|
||||
status: 'success',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_error_updating_preferences'),
|
||||
status: 'error',
|
||||
});
|
||||
setReferenceSavedMemories((prev) => !prev);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (userData?.personalization?.memories !== undefined) {
|
||||
setReferenceSavedMemories(userData.personalization.memories);
|
||||
}
|
||||
}, [userData?.personalization?.memories]);
|
||||
|
||||
const handleMemoryToggle = (checked: boolean) => {
|
||||
setReferenceSavedMemories(checked);
|
||||
updateMemoryPreferencesMutation.mutate({ memories: checked });
|
||||
};
|
||||
|
||||
const hasReadAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.READ,
|
||||
});
|
||||
|
||||
const hasUpdateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.UPDATE,
|
||||
});
|
||||
|
||||
const hasCreateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
|
||||
const hasOptOutAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permission: Permissions.OPT_OUT,
|
||||
});
|
||||
|
||||
const memories: TUserMemory[] = useMemo(() => memData?.memories ?? [], [memData]);
|
||||
|
||||
const filteredMemories = useMemo(() => {
|
||||
return matchSorter(memories, searchQuery, {
|
||||
keys: ['key', 'value'],
|
||||
});
|
||||
}, [memories, searchQuery]);
|
||||
|
||||
const currentRows = useMemo(() => {
|
||||
return filteredMemories.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
|
||||
}, [filteredMemories, pageIndex]);
|
||||
|
||||
const getProgressBarColor = (percentage: number): string => {
|
||||
if (percentage > 90) {
|
||||
return 'stroke-red-500';
|
||||
}
|
||||
if (percentage > 75) {
|
||||
return 'stroke-yellow-500';
|
||||
}
|
||||
return 'stroke-green-500';
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-4">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasReadAccess) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_no_read_access')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div role="region" aria-label={localize('com_ui_memories')} className="mt-2 space-y-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="memory-search"
|
||||
placeholder=" "
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
aria-label={localize('com_ui_memories_filter')}
|
||||
className="peer"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="memory-search"
|
||||
className="pointer-events-none absolute -top-1 left-3 w-auto origin-[0] translate-y-3 scale-100 rounded bg-background px-1 text-base text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-3 peer-placeholder-shown:scale-100 peer-focus:-translate-y-2 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-2 peer-[:not(:placeholder-shown)]:scale-75"
|
||||
>
|
||||
{localize('com_ui_memories_filter')}
|
||||
</Label>
|
||||
</div>
|
||||
{/* Memory Usage and Toggle Display */}
|
||||
{(memData?.tokenLimit || hasOptOutAccess) && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center rounded-lg',
|
||||
memData?.tokenLimit != null && hasOptOutAccess ? 'justify-between' : 'justify-end',
|
||||
)}
|
||||
>
|
||||
{/* Usage Display */}
|
||||
{memData?.tokenLimit && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative size-10">
|
||||
<svg className="size-10 -rotate-90 transform">
|
||||
<circle
|
||||
cx="20"
|
||||
cy="20"
|
||||
r="16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
className="text-gray-200 dark:text-gray-700"
|
||||
/>
|
||||
<circle
|
||||
cx="20"
|
||||
cy="20"
|
||||
r="16"
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
strokeDasharray={`${2 * Math.PI * 16}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 16 * (1 - (memData.usagePercentage ?? 0) / 100)}`}
|
||||
className={`transition-all ${getProgressBarColor(memData.usagePercentage ?? 0)}`}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs font-medium">{memData.usagePercentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-text-secondary">{localize('com_ui_usage')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Memory Toggle */}
|
||||
{hasOptOutAccess && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span>{localize('com_ui_use_memory')}</span>
|
||||
<Switch
|
||||
checked={referenceSavedMemories}
|
||||
onCheckedChange={handleMemoryToggle}
|
||||
aria-label={localize('com_ui_use_memory')}
|
||||
disabled={updateMemoryPreferencesMutation.isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Create Memory Button */}
|
||||
{hasCreateAccess && (
|
||||
<div className="flex w-full justify-end">
|
||||
<MemoryCreateDialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full bg-transparent"
|
||||
aria-label={localize('com_ui_create_memory')}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
{localize('com_ui_create_memory')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
</MemoryCreateDialog>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border border-border-light bg-transparent shadow-sm transition-colors">
|
||||
<Table className="w-full table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow className="border-b border-border-light hover:bg-surface-secondary">
|
||||
<TableHead
|
||||
className={`${
|
||||
hasUpdateAccess ? 'w-[75%]' : 'w-[100%]'
|
||||
} bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary`}
|
||||
>
|
||||
<div>{localize('com_ui_memory')}</div>
|
||||
</TableHead>
|
||||
{hasUpdateAccess && (
|
||||
<TableHead className="w-[25%] bg-surface-secondary py-3 text-center text-sm font-medium text-text-secondary">
|
||||
<div>{localize('com_assistants_actions')}</div>
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentRows.length ? (
|
||||
currentRows.map((memory: TUserMemory, idx: number) => (
|
||||
<TableRow
|
||||
key={idx}
|
||||
className="border-b border-border-light hover:bg-surface-secondary"
|
||||
>
|
||||
<TableCell className={`${hasUpdateAccess ? 'w-[75%]' : 'w-[100%]'} px-4 py-4`}>
|
||||
<div
|
||||
className="overflow-hidden text-ellipsis whitespace-nowrap text-sm text-text-primary"
|
||||
title={memory.value}
|
||||
>
|
||||
{memory.value}
|
||||
</div>
|
||||
</TableCell>
|
||||
{hasUpdateAccess && (
|
||||
<TableCell className="w-[25%] px-4 py-4">
|
||||
<div className="flex justify-center gap-2">
|
||||
<EditMemoryButton memory={memory} />
|
||||
<DeleteMemoryButton memory={memory} />
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={hasUpdateAccess ? 2 : 1}
|
||||
className="h-24 text-center text-sm text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_no_memories')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination controls */}
|
||||
{filteredMemories.length > pageSize && (
|
||||
<div
|
||||
className="flex items-center justify-end gap-2"
|
||||
role="navigation"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageIndex((prev) => Math.max(prev - 1, 0))}
|
||||
disabled={pageIndex === 0}
|
||||
aria-label={localize('com_ui_prev')}
|
||||
>
|
||||
{localize('com_ui_prev')}
|
||||
</Button>
|
||||
<div className="text-sm" aria-live="polite">
|
||||
{`${pageIndex + 1} / ${Math.ceil(filteredMemories.length / pageSize)}`}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setPageIndex((prev) =>
|
||||
(prev + 1) * pageSize < filteredMemories.length ? prev + 1 : prev,
|
||||
)
|
||||
}
|
||||
disabled={(pageIndex + 1) * pageSize >= filteredMemories.length}
|
||||
aria-label={localize('com_ui_next')}
|
||||
>
|
||||
{localize('com_ui_next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Settings */}
|
||||
{user?.role === SystemRoles.ADMIN && (
|
||||
<div className="mt-4">
|
||||
<AdminSettings />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,2 +1,10 @@
|
|||
export { default as MemoryViewer } from './MemoryViewer';
|
||||
export { default as MemoryPanel } from './MemoryPanel';
|
||||
export { default as MemoryViewer } from './MemoryPanel'; // Backward compatibility alias
|
||||
export { default as MemoryList } from './MemoryList';
|
||||
export { default as MemoryCard } from './MemoryCard';
|
||||
export { default as MemoryCardActions } from './MemoryCardActions';
|
||||
export { default as MemoryUsageBadge } from './MemoryUsageBadge';
|
||||
export { default as MemoryEmptyState } from './MemoryEmptyState';
|
||||
export { default as MemoryEditDialog } from './MemoryEditDialog';
|
||||
export { default as MemoryCreateDialog } from './MemoryCreateDialog';
|
||||
export { default as AdminSettings } from './AdminSettings';
|
||||
|
|
|
|||
275
client/src/components/ui/AdminSettingsDialog.tsx
Normal file
275
client/src/components/ui/AdminSettingsDialog.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import { useMemo, useEffect, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
||||
import {
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
Button,
|
||||
Switch,
|
||||
DropdownPopup,
|
||||
} from '@librechat/client';
|
||||
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
|
||||
type FormValues = Record<Permissions, boolean>;
|
||||
|
||||
export interface PermissionConfig {
|
||||
permission: Permissions;
|
||||
labelKey: TranslationKeys;
|
||||
}
|
||||
|
||||
export interface AdminSettingsDialogProps {
|
||||
/** The permission type from PermissionTypes enum */
|
||||
permissionType: PermissionTypes;
|
||||
/** Localization key for the section name (e.g., 'com_ui_memories', 'com_ui_agents') */
|
||||
sectionKey: TranslationKeys;
|
||||
/** Array of permission configurations to display */
|
||||
permissions: PermissionConfig[];
|
||||
/** Unique ID for the role dropdown menu */
|
||||
menuId: string;
|
||||
/** Mutation function and loading state from the permission update hook */
|
||||
mutation: {
|
||||
mutate: (data: { roleName: SystemRoles; updates: Record<Permissions, boolean> }) => void;
|
||||
isLoading: boolean;
|
||||
};
|
||||
/** Whether to show the admin access warning when ADMIN role and USE permission is displayed (default: true) */
|
||||
showAdminWarning?: boolean;
|
||||
/** Custom trigger element. If not provided, uses default button with icon and text */
|
||||
trigger?: React.ReactNode;
|
||||
/** Additional className for the dialog content */
|
||||
dialogContentClassName?: string;
|
||||
/** Custom callback when a permission change requires confirmation */
|
||||
onPermissionConfirm?: (
|
||||
permission: Permissions,
|
||||
newValue: boolean,
|
||||
onChange: (value: boolean) => void,
|
||||
) => void;
|
||||
/** Permissions that require confirmation before changing (only applies when onPermissionConfirm is provided) */
|
||||
confirmPermissions?: Permissions[];
|
||||
/** Custom content to render after the permissions form (e.g., confirmation dialogs) */
|
||||
extraContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
type LabelControllerProps = {
|
||||
label: string;
|
||||
permission: Permissions;
|
||||
control: Control<FormValues, unknown, FormValues>;
|
||||
setValue: UseFormSetValue<FormValues>;
|
||||
getValues: UseFormGetValues<FormValues>;
|
||||
onConfirm?: (newValue: boolean, onChange: (value: boolean) => void) => void;
|
||||
};
|
||||
|
||||
const LabelController: React.FC<LabelControllerProps> = ({
|
||||
control,
|
||||
permission,
|
||||
label,
|
||||
onConfirm,
|
||||
}) => (
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
{label}
|
||||
<Controller
|
||||
name={permission}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={(val) => {
|
||||
if (val === false && onConfirm) {
|
||||
onConfirm(val, field.onChange);
|
||||
} else {
|
||||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
value={field.value?.toString()}
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AdminSettingsDialog: React.FC<AdminSettingsDialogProps> = ({
|
||||
permissionType,
|
||||
sectionKey,
|
||||
permissions,
|
||||
menuId,
|
||||
mutation,
|
||||
showAdminWarning = true,
|
||||
trigger,
|
||||
dialogContentClassName,
|
||||
onPermissionConfirm,
|
||||
confirmPermissions = [],
|
||||
extraContent,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const { user, roles } = useAuthContext();
|
||||
const { mutate, isLoading } = mutation;
|
||||
|
||||
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
if (roles?.[selectedRole]?.permissions) {
|
||||
return roles[selectedRole]?.permissions[permissionType];
|
||||
}
|
||||
return roleDefaults[selectedRole].permissions[permissionType];
|
||||
}, [roles, selectedRole, permissionType]);
|
||||
|
||||
const {
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (roles?.[selectedRole]?.permissions?.[permissionType]) {
|
||||
reset(roles[selectedRole]?.permissions[permissionType]);
|
||||
} else {
|
||||
reset(roleDefaults[selectedRole].permissions[permissionType]);
|
||||
}
|
||||
}, [roles, selectedRole, reset, permissionType]);
|
||||
|
||||
if (user?.role !== SystemRoles.ADMIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
mutate({ roleName: selectedRole, updates: data });
|
||||
};
|
||||
|
||||
const roleDropdownItems = [
|
||||
{
|
||||
label: SystemRoles.USER,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.USER);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: SystemRoles.ADMIN,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.ADMIN);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const defaultTrigger = (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="relative h-9 w-full gap-2 rounded-lg border-border-light font-medium focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-primary"
|
||||
aria-label={localize('com_ui_admin_settings')}
|
||||
>
|
||||
<ShieldEllipsis className="size-5 cursor-pointer" aria-hidden="true" />
|
||||
{localize('com_ui_admin_settings')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>{trigger ?? defaultTrigger}</OGDialogTrigger>
|
||||
<OGDialogContent
|
||||
className={
|
||||
dialogContentClassName ??
|
||||
'border-border-light bg-surface-primary text-text-primary lg:w-1/4'
|
||||
}
|
||||
>
|
||||
<OGDialogTitle>
|
||||
{localize('com_ui_admin_settings_section', { section: localize(sectionKey) })}
|
||||
</OGDialogTitle>
|
||||
<div className="p-2">
|
||||
{/* Role selection dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{localize('com_ui_role_select')}:</span>
|
||||
<DropdownPopup
|
||||
unmountOnHide={true}
|
||||
menuId={menuId}
|
||||
isOpen={isRoleMenuOpen}
|
||||
setIsOpen={setIsRoleMenuOpen}
|
||||
trigger={
|
||||
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
|
||||
{selectedRole}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
itemClassName="items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
{/* Permissions form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-5">
|
||||
{permissions.map(({ permission, labelKey }) => {
|
||||
const label = localize(labelKey);
|
||||
const needsConfirm =
|
||||
selectedRole === SystemRoles.ADMIN &&
|
||||
confirmPermissions.includes(permission) &&
|
||||
onPermissionConfirm;
|
||||
|
||||
return (
|
||||
<div key={permission}>
|
||||
<LabelController
|
||||
control={control}
|
||||
permission={permission}
|
||||
label={label}
|
||||
getValues={getValues}
|
||||
setValue={setValue}
|
||||
onConfirm={
|
||||
needsConfirm
|
||||
? (newValue, onChange) =>
|
||||
onPermissionConfirm(permission, newValue, onChange)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{showAdminWarning &&
|
||||
selectedRole === SystemRoles.ADMIN &&
|
||||
permission === Permissions.USE && (
|
||||
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
|
||||
<span>{localize('com_ui_admin_access_warning')}</span>
|
||||
{'\n'}
|
||||
<a
|
||||
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 underline"
|
||||
>
|
||||
{localize('com_ui_more_info')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="submit"
|
||||
disabled={isSubmitting || isLoading}
|
||||
aria-label={localize('com_ui_save')}
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
{extraContent}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSettingsDialog;
|
||||
|
|
@ -1 +1,3 @@
|
|||
export { default as TermsAndConditionsModal } from './TermsAndConditionsModal';
|
||||
export { default as AdminSettingsDialog } from './AdminSettingsDialog';
|
||||
export type { PermissionConfig, AdminSettingsDialogProps } from './AdminSettingsDialog';
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export * from './useLazyEffect';
|
||||
export { default as useShiftKey } from './useShiftKey';
|
||||
|
|
|
|||
47
client/src/hooks/Generic/useShiftKey.ts
Normal file
47
client/src/hooks/Generic/useShiftKey.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to track whether the shift key is currently being held down.
|
||||
* Ignores shift when Alt is also pressed to avoid conflicts with
|
||||
* accessibility keyboard shortcuts (Shift+Alt is used for screen readers).
|
||||
* @returns boolean indicating if shift key is pressed (without Alt)
|
||||
*/
|
||||
export default function useShiftKey(): boolean {
|
||||
const [isShiftHeld, setIsShiftHeld] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only set shift if Alt is not pressed (Alt+Shift is used for a11y)
|
||||
if (e.key === 'Shift' && !e.altKey) {
|
||||
setIsShiftHeld(true);
|
||||
}
|
||||
// If Alt is pressed while shift is held, reset shift state
|
||||
if (e.key === 'Alt' && e.shiftKey) {
|
||||
setIsShiftHeld(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') {
|
||||
setIsShiftHeld(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset shift state when window loses focus
|
||||
const handleBlur = () => {
|
||||
setIsShiftHeld(false);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isShiftHeld;
|
||||
}
|
||||
|
|
@ -14,10 +14,10 @@ import MCPBuilderPanel from '~/components/SidePanel/MCPBuilder/MCPBuilderPanel';
|
|||
import type { NavLink } from '~/common';
|
||||
import AgentPanelSwitch from '~/components/SidePanel/Agents/AgentPanelSwitch';
|
||||
import BookmarkPanel from '~/components/SidePanel/Bookmarks/BookmarkPanel';
|
||||
import MemoryViewer from '~/components/SidePanel/Memories/MemoryViewer';
|
||||
import PanelSwitch from '~/components/SidePanel/Builder/PanelSwitch';
|
||||
import PromptsAccordion from '~/components/Prompts/PromptsAccordion';
|
||||
import Parameters from '~/components/SidePanel/Parameters/Panel';
|
||||
import { MemoryPanel } from '~/components/SidePanel/Memories';
|
||||
import FilesPanel from '~/components/SidePanel/Files/Panel';
|
||||
import { useHasAccess, useMCPServerManager } from '~/hooks';
|
||||
|
||||
|
|
@ -122,7 +122,7 @@ export default function useSideNavLinks({
|
|||
label: '',
|
||||
icon: Database,
|
||||
id: 'memories',
|
||||
Component: MemoryViewer,
|
||||
Component: MemoryPanel,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -378,7 +378,6 @@
|
|||
"com_files_downloading": "Downloading Files",
|
||||
"com_files_filter": "Filter files...",
|
||||
"com_files_filter_by": "Filter files by...",
|
||||
"com_files_filter_input": "Filter listed files by name...",
|
||||
"com_files_no_results": "No results.",
|
||||
"com_files_number_selected": "{{0}} of {{1}} items(s) selected",
|
||||
"com_files_preparing_download": "Preparing download...",
|
||||
|
|
@ -752,7 +751,6 @@
|
|||
"com_ui_bookmarks": "Bookmarks",
|
||||
"com_ui_bookmarks_add": "Add Bookmarks",
|
||||
"com_ui_bookmarks_add_to_conversation": "Add to current conversation",
|
||||
"com_ui_bookmarks_count": "Count",
|
||||
"com_ui_bookmarks_create_error": "There was an error creating the bookmark",
|
||||
"com_ui_bookmarks_create_exists": "This bookmark already exists",
|
||||
"com_ui_bookmarks_create_success": "Bookmark created successfully",
|
||||
|
|
@ -786,6 +784,7 @@
|
|||
"com_ui_clear_presets": "Clear Presets",
|
||||
"com_ui_clear_search": "Clear search",
|
||||
"com_ui_click_to_close": "Click to close",
|
||||
"com_ui_click_to_view_var": "Click to view {{0}}",
|
||||
"com_ui_client_id": "Client ID",
|
||||
"com_ui_client_secret": "Client Secret",
|
||||
"com_ui_close": "Close",
|
||||
|
|
@ -813,6 +812,8 @@
|
|||
"com_ui_continue_oauth": "Continue with OAuth",
|
||||
"com_ui_control_bar": "Control bar",
|
||||
"com_ui_controls": "Controls",
|
||||
"com_ui_conversation": "conversation",
|
||||
"com_ui_conversations": "conversations",
|
||||
"com_ui_conversation_label": "{{title}} conversation",
|
||||
"com_ui_convo_archived": "Conversation archived",
|
||||
"com_ui_convo_delete_error": "Failed to delete conversation",
|
||||
|
|
@ -923,6 +924,8 @@
|
|||
"com_ui_enter_key": "Enter key",
|
||||
"com_ui_enter_openapi_schema": "Enter your OpenAPI schema here",
|
||||
"com_ui_enter_value": "Enter value",
|
||||
"com_ui_enter_name": "Enter name",
|
||||
"com_ui_enter_description": "Enter description (optional)",
|
||||
"com_ui_error": "Error",
|
||||
"com_ui_error_connection": "Error connecting to server, try refreshing the page.",
|
||||
"com_ui_error_message_prefix": "Error Message:",
|
||||
|
|
@ -958,9 +961,9 @@
|
|||
"com_ui_file_token_limit": "File Token Limit",
|
||||
"com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage",
|
||||
"com_ui_files": "Files",
|
||||
"com_ui_filter_mcp_servers": "Filter MCP servers by name",
|
||||
"com_ui_filter_mcp_servers": "Filter MCP servers...",
|
||||
"com_ui_filter_prompts": "Filter Prompts",
|
||||
"com_ui_filter_prompts_name": "Filter prompts by name",
|
||||
"com_ui_filter_prompts_name": "Filter prompts...",
|
||||
"com_ui_final_touch": "Final touch",
|
||||
"com_ui_finance": "Finance",
|
||||
"com_ui_fork": "Fork",
|
||||
|
|
@ -1100,6 +1103,7 @@
|
|||
"com_ui_memory_error": "Memory Error",
|
||||
"com_ui_memory_key_exists": "A memory with this key already exists. Please use a different key.",
|
||||
"com_ui_memory_key_validation": "Memory key must only contain lowercase letters and underscores.",
|
||||
"com_ui_memory_key_hint": "Use lowercase letters and underscores only",
|
||||
"com_ui_memory_storage_full": "Memory Storage Full",
|
||||
"com_ui_memory_updated": "Updated saved memory",
|
||||
"com_ui_memory_updated_items": "Updated Memories",
|
||||
|
|
@ -1124,7 +1128,8 @@
|
|||
"com_ui_new_conversation_title": "New Conversation Title",
|
||||
"com_ui_next": "Next",
|
||||
"com_ui_no": "No",
|
||||
"com_ui_no_bookmarks": "it seems like you have no bookmarks yet. Click on a chat and add a new one",
|
||||
"com_ui_no_bookmarks": "No bookmarks yet",
|
||||
"com_ui_no_bookmarks_match": "No bookmarks match your search",
|
||||
"com_ui_no_categories": "No categories available",
|
||||
"com_ui_no_category": "No category",
|
||||
"com_ui_no_changes": "No changes were made",
|
||||
|
|
@ -1132,6 +1137,8 @@
|
|||
"com_ui_no_mcp_servers": "No MCP servers yet",
|
||||
"com_ui_no_mcp_servers_match": "No MCP servers match your filter",
|
||||
"com_ui_no_memories": "No memories. Create them manually or prompt the AI to remember something",
|
||||
"com_ui_no_memories_title": "No memories yet",
|
||||
"com_ui_no_memories_match": "No memories match your search",
|
||||
"com_ui_no_personalization_available": "No personalization options are currently available",
|
||||
"com_ui_no_read_access": "You don't have permission to view memories",
|
||||
"com_ui_no_results_found": "No results found",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useMediaQuery } from '@librechat/client';
|
||||
import type { ContextType } from '~/common';
|
||||
import {
|
||||
useSearchEnabled,
|
||||
|
|
@ -16,8 +17,8 @@ import {
|
|||
FileMapContext,
|
||||
} from '~/Providers';
|
||||
import { useUserTermsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import { Nav, MobileNav, NAV_WIDTH } from '~/components/Nav';
|
||||
import { TermsAndConditionsModal } from '~/components/ui';
|
||||
import { Nav, MobileNav } from '~/components/Nav';
|
||||
import { useHealthCheck } from '~/data-provider';
|
||||
import { Banner } from '~/components/Banners';
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ export default function Root() {
|
|||
});
|
||||
|
||||
const { isAuthenticated, logout } = useAuthContext();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
// Global health check - runs once per authenticated session
|
||||
useHealthCheck(isAuthenticated);
|
||||
|
|
@ -74,7 +76,19 @@ export default function Root() {
|
|||
<div className="flex" style={{ height: `calc(100dvh - ${bannerHeight}px)` }}>
|
||||
<div className="relative z-0 flex h-full w-full overflow-hidden">
|
||||
<Nav navVisible={navVisible} setNavVisible={setNavVisible} />
|
||||
<div className="relative flex h-full max-w-full flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className="relative flex h-full max-w-full flex-1 flex-col overflow-hidden"
|
||||
style={
|
||||
isSmallScreen
|
||||
? {
|
||||
transform: navVisible
|
||||
? `translateX(${NAV_WIDTH.MOBILE}px)`
|
||||
: 'translateX(0)',
|
||||
transition: 'transform 0.2s ease-out',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<MobileNav navVisible={navVisible} setNavVisible={setNavVisible} />
|
||||
<Outlet context={{ navVisible, setNavVisible } satisfies ContextType} />
|
||||
</div>
|
||||
|
|
|
|||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -50237,7 +50237,7 @@
|
|||
},
|
||||
"packages/client": {
|
||||
"name": "@librechat/client",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/preset-env": "^7.28.5",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@librechat/client",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"description": "React components for LibreChat",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ const Menu: React.FC<MenuProps> = ({
|
|||
>
|
||||
<Ariakit.MenuButton
|
||||
className={cn(
|
||||
'group flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
|
||||
'group flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
|
||||
itemClassName,
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
|
|
@ -138,7 +138,7 @@ const Menu: React.FC<MenuProps> = ({
|
|||
key={`${keyPrefix ?? ''}${index}-${item.id ?? ''}`}
|
||||
id={item.id}
|
||||
className={cn(
|
||||
'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
|
||||
'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
|
||||
itemClassName,
|
||||
item.className,
|
||||
)}
|
||||
|
|
|
|||
54
packages/client/src/components/FilterInput.tsx
Normal file
54
packages/client/src/components/FilterInput.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import * as React from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export interface FilterInputProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'placeholder'> {
|
||||
/** The label text shown in the floating label */
|
||||
label: string;
|
||||
/** Unique identifier for the input - used to link label */
|
||||
inputId: string;
|
||||
/** Container className for custom styling */
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A standardized filter/search input component with a floating label
|
||||
* that animates up when focused or has a value.
|
||||
*
|
||||
* @example
|
||||
* <FilterInput
|
||||
* inputId="bookmarks-filter"
|
||||
* label={localize('com_ui_bookmarks_filter')}
|
||||
* value={searchQuery}
|
||||
* onChange={(e) => setSearchQuery(e.target.value)}
|
||||
* />
|
||||
*/
|
||||
const FilterInput = React.forwardRef<HTMLInputElement, FilterInputProps>(
|
||||
({ className, label, inputId, containerClassName, ...props }, ref) => {
|
||||
return (
|
||||
<div className={cn('relative', containerClassName)}>
|
||||
<input
|
||||
id={inputId}
|
||||
ref={ref}
|
||||
placeholder=" "
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'peer flex h-10 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-text-secondary transition-all duration-200 peer-focus:top-0 peer-focus:bg-background peer-focus:px-1 peer-focus:text-xs peer-[:not(:placeholder-shown)]:top-0 peer-[:not(:placeholder-shown)]:bg-background peer-[:not(:placeholder-shown)]:px-1 peer-[:not(:placeholder-shown)]:text-xs"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FilterInput.displayName = 'FilterInput';
|
||||
|
||||
export { FilterInput };
|
||||
|
|
@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...pr
|
|||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-10 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className ?? '',
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,19 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
|
|||
import { X } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const DialogDepthContext = React.createContext(0);
|
||||
|
||||
interface OGDialogProps extends DialogPrimitive.DialogProps {
|
||||
triggerRef?: React.RefObject<HTMLButtonElement | HTMLInputElement | HTMLDivElement | null>;
|
||||
triggerRefs?: React.RefObject<HTMLButtonElement | HTMLInputElement | HTMLDivElement | null>[];
|
||||
}
|
||||
|
||||
const Dialog = React.forwardRef<HTMLDivElement, OGDialogProps>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
({ children, triggerRef, triggerRefs, onOpenChange, ...props }, ref) => {
|
||||
const parentDepth = React.useContext(DialogDepthContext);
|
||||
const currentDepth = parentDepth + 1;
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open && triggerRef?.current) {
|
||||
setTimeout(() => {
|
||||
|
|
@ -29,9 +35,11 @@ const Dialog = React.forwardRef<HTMLDivElement, OGDialogProps>(
|
|||
};
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root {...props} onOpenChange={handleOpenChange}>
|
||||
{children}
|
||||
</DialogPrimitive.Root>
|
||||
<DialogDepthContext.Provider value={currentDepth}>
|
||||
<DialogPrimitive.Root {...props} onOpenChange={handleOpenChange}>
|
||||
{children}
|
||||
</DialogPrimitive.Root>
|
||||
</DialogDepthContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -45,16 +53,22 @@ const DialogClose = DialogPrimitive.Close;
|
|||
export const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
>(({ className, style, ...props }, ref) => {
|
||||
const depth = React.useContext(DialogDepthContext);
|
||||
const overlayZIndex = 50 + (depth - 1) * 60;
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
style={{ ...style, zIndex: overlayZIndex }}
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
|
|
@ -73,13 +87,16 @@ const DialogContent = React.forwardRef<
|
|||
overlayClassName,
|
||||
showCloseButton = true,
|
||||
children,
|
||||
style,
|
||||
onEscapeKeyDown: propsOnEscapeKeyDown,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const depth = React.useContext(DialogDepthContext);
|
||||
const contentZIndex = 100 + (depth - 1) * 60;
|
||||
|
||||
/* Handle Escape key to prevent closing dialog if a tooltip or dropdown is open
|
||||
/* Handle Escape key to prevent closing dialog if a tooltip or dropdown is open
|
||||
(this is a workaround in order to achieve WCAG compliance which requires
|
||||
that our tooltips be dismissable with Escape key) */
|
||||
const handleEscapeKeyDown = React.useCallback(
|
||||
|
|
@ -88,11 +105,11 @@ const DialogContent = React.forwardRef<
|
|||
const dropdownMenus = document.querySelectorAll('[role="menu"]');
|
||||
|
||||
for (const tooltip of tooltips) {
|
||||
const style = window.getComputedStyle(tooltip);
|
||||
const computedStyle = window.getComputedStyle(tooltip);
|
||||
if (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
parseFloat(style.opacity) > 0
|
||||
computedStyle.display !== 'none' &&
|
||||
computedStyle.visibility !== 'hidden' &&
|
||||
parseFloat(computedStyle.opacity) > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
|
|
@ -100,11 +117,11 @@ const DialogContent = React.forwardRef<
|
|||
}
|
||||
|
||||
for (const dropdownMenu of dropdownMenus) {
|
||||
const style = window.getComputedStyle(dropdownMenu);
|
||||
const computedStyle = window.getComputedStyle(dropdownMenu);
|
||||
if (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
parseFloat(style.opacity) > 0
|
||||
computedStyle.display !== 'none' &&
|
||||
computedStyle.visibility !== 'hidden' &&
|
||||
parseFloat(computedStyle.opacity) > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
|
|
@ -121,9 +138,10 @@ const DialogContent = React.forwardRef<
|
|||
<DialogOverlay className={overlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
style={{ ...style, zIndex: contentZIndex }}
|
||||
onEscapeKeyDown={handleEscapeKeyDown}
|
||||
className={cn(
|
||||
'max-w-11/12 fixed left-[50%] top-[50%] z-[100] grid max-h-[90vh] w-full translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-2xl bg-background p-6 text-text-primary shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
'max-w-11/12 fixed left-[50%] top-[50%] grid max-h-[90vh] w-full translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-2xl bg-background p-6 text-text-primary shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export * from './DropdownMenu';
|
|||
export * from './HoverCard';
|
||||
export * from './Input';
|
||||
export * from './InputNumber';
|
||||
export * from './FilterInput';
|
||||
export * from './Label';
|
||||
export * from './OriginalDialog';
|
||||
export * from './QuestionMark';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue