From 73fe0835cfb59c142e67d029b44120cfa75a7425 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Wed, 5 Feb 2025 17:37:17 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20style:=20Prompt=20UI=20Refresh?= =?UTF-8?q?=20&=20A11Y=20Improvements=20(#5614)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * πŸš€ feat: Add animated search input and improve filtering UI * πŸ„ refactor: Clean up category options and optimize event handlers in ChatGroupItem * πŸš€ refactor: 'Rename Prompt' option and enhance prompt filtering UI Changed the useUpdatePromptGroup mutation in prompts.ts to replace the JSON.parse(JSON.stringify(...)) clones with structuredClone. This avoids errors when data contains non‑JSON values and improves data cloning reliability * πŸ”§ refactor: Update Sharing Prompts UI; fix: Show info message only after updating switch status * πŸ”§ refactor: Simplify condition checks and replace button with custom Button component in SharePrompt * πŸ”§ refactor: Update DashGroupItem styles and improve accessibility with updated aria-label * πŸ”§ refactor: Adjust layout styles in GroupSidePanel and enhance loading skeletons in List component * πŸ”§ refactor: Improve layout and styling of AdvancedSwitch component; adjust DashBreadcrumb margin for better alignment * πŸ”§ refactor: Add new surface colors for destructive actions and update localization strings for confirmation prompts * πŸ”§ refactor: Update PromptForm and PromptName components for improved layout and styling; replace button with custom Button component * πŸ”§ refactor: Enhance styling and layout of DashGroupItem, FilterPrompts, and Label components for improved user experience * πŸ”§ refactor: Update DeleteBookmarkButton and Label components for improved layout and text handling * πŸ”§ refactor: Simplify CategorySelector usage and update destructive surface colors for a11y * πŸ”§ refactor: Update styling and layout of PromptName, SharePrompt, and DashGroupItem components; enhance Dropdown functionality with custom renderValue * πŸ”§ refactor: Improve layout and styling of various components; update button sizes and localization strings for better accessibility and user experience * πŸ”§ refactor: Add useCurrentPromptData hook and enhance RightPanel component; update CategorySelector for improved functionality and accessibility * πŸ”§ refactor: Update input components and styling for Command and Description; enhance layout and accessibility in PromptVariables and PromptForm * πŸ”§ refactor: Remove useCurrentPromptData hook and clean up related components; enhance PromptVersions layout * πŸ”§ refactor: Enhance accessibility by adding aria-labels to buttons and inputs; improve localization for filter prompts * πŸ”§ refactor: Enhance accessibility by adding aria-labels to various components; improve layout and styling in PromptForm and CategorySelector * πŸ”§ refactor: Enhance accessibility by adding aria-labels to buttons and components; improve dialog roles and descriptions in SharePrompt and PromptForm * πŸ”§ refactor: Improve accessibility by adding aria-labels and roles; enhance layout and styling in ChatGroupItem, ListCard, and ManagePrompts components * πŸ”§ refactor: Update UI components for improved styling and accessibility; replace button elements with custom Button component and enhance layout in VariableForm, PromptDetails, and PromptVariables * πŸ”§ refactor: Improve null checks for group and instanceProjectId in SharePrompt component; enhance readability and maintainability * style: Enhance AnimatedSearchInput component with TypeScript types; improve conditional rendering for search states and accessibility --------- Co-authored-by: Danny Avila --- api/models/Categories.js | 4 - .../Bookmarks/DeleteBookmarkButton.tsx | 2 +- .../src/components/Prompts/AdminSettings.tsx | 227 +++++---- .../src/components/Prompts/AdvancedSwitch.tsx | 41 +- client/src/components/Prompts/Command.tsx | 11 +- .../src/components/Prompts/DeleteVersion.tsx | 9 +- client/src/components/Prompts/Description.tsx | 15 +- .../Prompts/Groups/AlwaysMakeProd.tsx | 1 + .../Prompts/Groups/CategorySelector.tsx | 83 ++-- .../Prompts/Groups/ChatGroupItem.tsx | 4 +- .../Prompts/Groups/CreatePromptForm.tsx | 9 +- .../Prompts/Groups/DashGroupItem.tsx | 322 +++++------- .../Prompts/Groups/FilterPrompts.tsx | 193 +++----- .../Prompts/Groups/GroupSidePanel.tsx | 4 +- client/src/components/Prompts/Groups/List.tsx | 16 +- .../components/Prompts/Groups/ListCard.tsx | 19 +- .../Prompts/Groups/PanelNavigation.tsx | 2 +- .../Prompts/Groups/VariableForm.tsx | 9 +- .../src/components/Prompts/ManagePrompts.tsx | 13 +- .../src/components/Prompts/PreviewPrompt.tsx | 2 +- .../src/components/Prompts/PromptDetails.tsx | 15 +- .../src/components/Prompts/PromptEditor.tsx | 60 ++- client/src/components/Prompts/PromptForm.tsx | 466 ++++++++++-------- client/src/components/Prompts/PromptName.tsx | 104 ++-- .../components/Prompts/PromptVariables.tsx | 41 +- .../src/components/Prompts/PromptVersions.tsx | 207 ++++++-- .../components/Prompts/PromptsAccordion.tsx | 4 +- client/src/components/Prompts/PromptsView.tsx | 4 +- client/src/components/Prompts/SharePrompt.tsx | 84 ++-- .../src/components/ui/AnimatedSearchInput.tsx | 81 +-- client/src/components/ui/Button.tsx | 3 +- client/src/components/ui/Dropdown.tsx | 140 ++++-- client/src/components/ui/Input.tsx | 1 + client/src/components/ui/Label.tsx | 2 +- client/src/components/ui/OGDialogTemplate.tsx | 11 +- client/src/data-provider/prompts.ts | 34 +- client/src/hooks/Prompts/useCategories.tsx | 4 +- client/src/localization/languages/Eng.ts | 10 +- client/src/routes/Layouts/DashBreadcrumb.tsx | 7 +- client/src/style.css | 31 +- client/tailwind.config.cjs | 2 + 41 files changed, 1269 insertions(+), 1028 deletions(-) diff --git a/api/models/Categories.js b/api/models/Categories.js index fc2cbdd98b..0f7f29703f 100644 --- a/api/models/Categories.js +++ b/api/models/Categories.js @@ -1,10 +1,6 @@ const { logger } = require('~/config'); // const { Categories } = require('./schema/categories'); const options = [ - { - label: '', - value: '', - }, { label: 'idea', value: 'idea', diff --git a/client/src/components/Bookmarks/DeleteBookmarkButton.tsx b/client/src/components/Bookmarks/DeleteBookmarkButton.tsx index d592c3cd72..e9dcf0e4d1 100644 --- a/client/src/components/Bookmarks/DeleteBookmarkButton.tsx +++ b/client/src/components/Bookmarks/DeleteBookmarkButton.tsx @@ -65,7 +65,7 @@ const DeleteBookmarkButton: FC<{ {localize('com_ui_bookmark_delete_confirm')} {bookmark} diff --git a/client/src/components/Prompts/AdminSettings.tsx b/client/src/components/Prompts/AdminSettings.tsx index b3925ca6d4..11ee14ff58 100644 --- a/client/src/components/Prompts/AdminSettings.tsx +++ b/client/src/components/Prompts/AdminSettings.tsx @@ -1,13 +1,22 @@ import * as Ariakit from '@ariakit/react'; +import { ExternalLink } from 'lucide-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 type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form'; -import { OGDialog, OGDialogTitle, OGDialogContent, OGDialogTrigger } from '~/components/ui'; +import { + OGDialog, + OGDialogTitle, + OGDialogContent, + OGDialogTrigger, + Button, + Switch, + DropdownPopup, +} from '~/components/ui'; import { useUpdatePromptPermissionsMutation } from '~/data-provider'; +import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import { useLocalize, useAuthContext } from '~/hooks'; -import { Button, Switch, DropdownPopup } from '~/components/ui'; import { useToastContext } from '~/Providers'; type FormValues = Record; @@ -18,28 +27,17 @@ type LabelControllerProps = { control: Control; setValue: UseFormSetValue; getValues: UseFormGetValues; + confirmChange?: (newValue: boolean, onChange: (value: boolean) => void) => void; }; const LabelController: React.FC = ({ control, promptPerm, label, - getValues, - setValue, + confirmChange, }) => (
- + {label} = ({ { + if (val === false && confirmChange) { + confirmChange(val, field.onChange); + } else { + field.onChange(val); + } + }} value={field.value.toString()} /> )} @@ -59,6 +63,10 @@ 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({ onSuccess: () => { showToast({ status: 'success', message: localize('com_ui_saved') }); @@ -137,82 +145,117 @@ const AdminSettings = () => { ]; return ( - - - - - - - {`${localize('com_ui_admin_settings')} - ${localize('com_ui_prompts')}`} - -
- {/* Role selection dropdown */} -
- {localize('com_ui_role_select')}: - - {selectedRole} - - } - items={roleDropdownItems} - itemClassName="items-center justify-center" - sameWidth={true} - /> + <> + + + + + + + {`${localize('com_ui_admin_settings')} - ${localize('com_ui_prompts')}`} + +
+ {/* Role selection dropdown */} +
+ {localize('com_ui_role_select')}: + + {selectedRole} + + } + items={roleDropdownItems} + itemClassName="items-center justify-center" + sameWidth={true} + /> +
+
+
+ {labelControllerData.map(({ promptPerm, label }) => ( +
+ void, + ) => setConfirmAdminUseChange({ newValue, callback: onChange }), + } + : {})} + /> + {selectedRole === SystemRoles.ADMIN && promptPerm === Permissions.USE && ( + <> +
+ {localize('com_ui_admin_access_warning')} + {'\n'} + + {localize('com_ui_more_info')} + + +
+ + )} +
+ ))} +
+
+ +
+
-
-
- {labelControllerData.map(({ promptPerm, label }) => ( -
- - {selectedRole === SystemRoles.ADMIN && promptPerm === Permissions.USE && ( - <> -
- {localize('com_ui_admin_access_warning')} - {'\n'} - - {localize('com_ui_more_info')} - -
- - )} -
- ))} -
-
- -
-
-
- - + + + + { + if (!open) { + setConfirmAdminUseChange(null); + } + }} + > + {localize('com_ui_confirm_admin_use_change')}

} + 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, + }} + /> +
+ ); }; diff --git a/client/src/components/Prompts/AdvancedSwitch.tsx b/client/src/components/Prompts/AdvancedSwitch.tsx index c0ee9d273c..d8d6c219fc 100644 --- a/client/src/components/Prompts/AdvancedSwitch.tsx +++ b/client/src/components/Prompts/AdvancedSwitch.tsx @@ -11,32 +11,45 @@ const AdvancedSwitch = () => { const setAlwaysMakeProd = useSetRecoilState(alwaysMakeProd); return ( -
-
+
+
+
+ + {/* Simple Mode Button */} + + {/* Advanced Mode Button */}
diff --git a/client/src/components/Prompts/Command.tsx b/client/src/components/Prompts/Command.tsx index a80dc1d78c..905a57cc87 100644 --- a/client/src/components/Prompts/Command.tsx +++ b/client/src/components/Prompts/Command.tsx @@ -1,6 +1,7 @@ import { SquareSlash } from 'lucide-react'; import { Constants } from 'librechat-data-provider'; import { useState, useEffect } from 'react'; +import { Input } from '~/components/ui'; import { useLocalize } from '~/hooks'; const Command = ({ @@ -43,20 +44,20 @@ const Command = ({ } return ( -
-

+
+

- {disabled !== true && ( - {`${charCount}/${Constants.COMMANDS_MAX_LENGTH}`} + {`${charCount}/${Constants.COMMANDS_MAX_LENGTH}`} )}

diff --git a/client/src/components/Prompts/DeleteVersion.tsx b/client/src/components/Prompts/DeleteVersion.tsx index 9a2800933a..ba54e49f53 100644 --- a/client/src/components/Prompts/DeleteVersion.tsx +++ b/client/src/components/Prompts/DeleteVersion.tsx @@ -18,15 +18,16 @@ const DeleteVersion = ({ diff --git a/client/src/components/Prompts/Description.tsx b/client/src/components/Prompts/Description.tsx index 452c624ce8..6ea3388af4 100644 --- a/client/src/components/Prompts/Description.tsx +++ b/client/src/components/Prompts/Description.tsx @@ -1,8 +1,9 @@ import { useState, useEffect } from 'react'; -import { Info } from 'lucide-react'; +import { Input } from '~/components/ui'; import { useLocalize } from '~/hooks'; +import { Info } from 'lucide-react'; -const MAX_LENGTH = 56; +const MAX_LENGTH = 120; const Description = ({ initialValue, @@ -40,20 +41,20 @@ const Description = ({ } return ( -
-

+
+

- {!disabled && ( - {`${charCount}/${MAX_LENGTH}`} + {`${charCount}/${MAX_LENGTH}`} )}

diff --git a/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx b/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx index 7ecdf739b6..6a3132775e 100644 --- a/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx +++ b/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx @@ -28,6 +28,7 @@ export default function AlwaysMakeProd({ checked={alwaysMakeProd} onCheckedChange={handleCheckedChange} data-testid="alwaysMakeProd" + aria-label="Always make prompt production" />
{localize('com_nav_always_make_prod')}

diff --git a/client/src/components/Prompts/Groups/CategorySelector.tsx b/client/src/components/Prompts/Groups/CategorySelector.tsx index 61c30c6c76..6e2245e8c3 100644 --- a/client/src/components/Prompts/Groups/CategorySelector.tsx +++ b/client/src/components/Prompts/Groups/CategorySelector.tsx @@ -1,59 +1,80 @@ import React, { useMemo } from 'react'; import { useFormContext, Controller } from 'react-hook-form'; import { LocalStorageKeys } from 'librechat-data-provider'; -import { useLocalize, useCategories } from '~/hooks'; -import { cn, createDropdownSetter } from '~/utils'; -import { SelectDropDown } from '~/components/ui'; +import { Dropdown } from '~/components/ui'; +import { useCategories } from '~/hooks'; -const CategorySelector = ({ - currentCategory, - onValueChange, - className = '', - tabIndex, -}: { +interface CategorySelectorProps { currentCategory?: string; onValueChange?: (value: string) => void; className?: string; - tabIndex?: number; +} + +const CategorySelector: React.FC = ({ + currentCategory, + onValueChange, + className = '', }) => { - const localize = useLocalize(); - const { control, watch, setValue } = useFormContext(); + const formContext = useFormContext(); const { categories, emptyCategory } = useCategories(); - const watchedCategory = watch('category'); + const control = formContext.control; + const watch = formContext.watch; + const setValue = formContext.setValue; + + const watchedCategory = watch ? watch('category') : currentCategory; + const categoryOption = useMemo( () => - categories.find((category) => category.value === (watchedCategory ?? currentCategory)) ?? - emptyCategory, + (categories ?? []).find( + (category) => category.value === (watchedCategory ?? currentCategory), + ) ?? emptyCategory, [watchedCategory, categories, currentCategory, emptyCategory], ); - return ( + return formContext ? ( ( - { + { setValue('category', value, { shouldDirty: false }); localStorage.setItem(LocalStorageKeys.LAST_PROMPT_CATEGORY, value); onValueChange?.(value); - })} - availableValues={categories} - showAbove={false} - showLabel={false} - emptyTitle={true} - showOptionIcon={true} - searchPlaceholder={localize('com_ui_search_categories')} - className={cn('h-10 w-56 cursor-pointer', className)} - currentValueClass="text-md gap-2" - optionsListClass="text-sm max-h-72" + }} + aria-labelledby="category-selector-label" + ariaLabel="Prompt's category selector" + className={className} + options={categories || []} + renderValue={(option) => ( +
+ {option.icon != null && {option.icon as React.ReactNode}} + {option.label} +
+ )} /> )} /> + ) : ( + { + localStorage.setItem(LocalStorageKeys.LAST_PROMPT_CATEGORY, value); + onValueChange?.(value); + }} + aria-labelledby="category-selector-label" + ariaLabel="Prompt's category selector" + className={className} + options={categories || []} + renderValue={(option) => ( +
+ {option.icon != null && {option.icon as React.ReactNode}} + {option.label} +
+ )} + /> ); }; diff --git a/client/src/components/Prompts/Groups/ChatGroupItem.tsx b/client/src/components/Prompts/Groups/ChatGroupItem.tsx index b372f8a59b..f9b2850af6 100644 --- a/client/src/components/Prompts/Groups/ChatGroupItem.tsx +++ b/client/src/components/Prompts/Groups/ChatGroupItem.tsx @@ -80,7 +80,7 @@ function ChatGroupItem({ e.stopPropagation(); } }} - className="z-50 inline-flex h-7 w-7 items-center justify-center rounded-md border border-border-medium bg-transparent p-0 text-sm font-medium transition-all duration-300 ease-in-out hover:border-border-heavy hover:bg-surface-secondary focus:border-border-heavy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" + className="z-50 inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border-medium bg-transparent p-0 text-sm font-medium transition-all duration-300 ease-in-out hover:border-border-heavy hover:bg-surface-hover focus:border-border-heavy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" >

)} /> - +
@@ -166,7 +166,12 @@ const CreatePromptForm = ({ /> methods.setValue('command', value)} tabIndex={0} />
-
diff --git a/client/src/components/Prompts/Groups/DashGroupItem.tsx b/client/src/components/Prompts/Groups/DashGroupItem.tsx index 533a33d8c9..7fb46df047 100644 --- a/client/src/components/Prompts/Groups/DashGroupItem.tsx +++ b/client/src/components/Prompts/Groups/DashGroupItem.tsx @@ -1,237 +1,175 @@ -import { useState, useRef, useMemo } from 'react'; -import { MenuIcon, EarthIcon } from 'lucide-react'; +import { memo, useState, useRef, useMemo, useCallback, KeyboardEvent } from 'react'; +import { EarthIcon, Pen } from 'lucide-react'; import { useNavigate, useParams } from 'react-router-dom'; import { SystemRoles, type TPromptGroup } from 'librechat-data-provider'; import { useDeletePromptGroup, useUpdatePromptGroup } from '~/data-provider'; -import { - Input, - Label, - Button, - OGDialog, - DropdownMenu, - OGDialogTrigger, - DropdownMenuGroup, - DropdownMenuContent, - DropdownMenuTrigger, - DropdownMenuItem, -} from '~/components/ui'; +import { Input, Label, Button, OGDialog, OGDialogTrigger } from '~/components/ui'; import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import { useLocalize, useAuthContext } from '~/hooks'; import { TrashIcon } from '~/components/svg'; import { cn } from '~/utils/'; -export default function DashGroupItem({ - group, - instanceProjectId, -}: { +interface DashGroupItemProps { group: TPromptGroup; instanceProjectId?: string; -}) { +} + +function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps) { const params = useParams(); const navigate = useNavigate(); const localize = useLocalize(); - const { user } = useAuthContext(); - const blurTimeoutRef = useRef(); - const [nameEditFlag, setNameEditFlag] = useState(false); - const [nameInputField, setNameInputField] = useState(group.name); - const isOwner = useMemo(() => user?.id === group.author, [user, group]); - const groupIsGlobal = useMemo( - () => instanceProjectId != null && group.projectIds?.includes(instanceProjectId), - [group, instanceProjectId], + + const blurTimeoutRef = useRef(null); + const [nameInputValue, setNameInputValue] = useState(group.name); + + const isOwner = useMemo(() => user?.id === group.author, [user?.id, group.author]); + const isGlobalGroup = useMemo( + () => instanceProjectId && group.projectIds?.includes(instanceProjectId), + [group.projectIds, instanceProjectId], ); const updateGroup = useUpdatePromptGroup({ onMutate: () => { - clearTimeout(blurTimeoutRef.current); - setNameEditFlag(false); + if (blurTimeoutRef.current) { + clearTimeout(blurTimeoutRef.current); + } }, }); - const deletePromptGroupMutation = useDeletePromptGroup({ - onSuccess: (response, variables) => { + + const deleteGroup = useDeletePromptGroup({ + onSuccess: (_response, variables) => { if (variables.id === group._id) { navigate('/d/prompts'); } }, }); - const cancelRename = () => { - setNameEditFlag(false); - }; + const { isLoading } = updateGroup; - const saveRename = () => { - updateGroup.mutate({ payload: { name: nameInputField }, id: group._id ?? '' }); - }; + const handleSaveRename = useCallback(() => { + console.log(group._id ?? '', { name: nameInputValue }); + updateGroup.mutate({ id: group._id ?? '', payload: { name: nameInputValue } }); + }, [group._id, nameInputValue, updateGroup]); - const handleBlur = () => { - blurTimeoutRef.current = setTimeout(() => { - cancelRename(); - }, 100); - }; + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + navigate(`/d/prompts/${group._id}`, { replace: true }); + } + }, + [group._id, navigate], + ); - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - navigate(`/d/prompts/${group._id}`, { replace: true }); - } - }; + const triggerDelete = useCallback(() => { + deleteGroup.mutate({ id: group._id ?? '' }); + }, [group._id, deleteGroup]); - const handleRename = (e: Event) => { - e.stopPropagation(); - setNameEditFlag(true); - }; - - const handleDelete = () => { - deletePromptGroupMutation.mutate({ id: group._id ?? '' }); - }; + const handleContainerClick = useCallback(() => { + navigate(`/d/prompts/${group._id}`, { replace: true }); + }, [group._id, navigate]); return (
{ - if (!nameEditFlag) { - navigate(`/d/prompts/${group._id}`, { replace: true }); - } - }} + onClick={handleContainerClick} onKeyDown={handleKeyDown} role="button" tabIndex={0} aria-label={`${group.name} prompt group`} > -
-
- {nameEditFlag ? ( +
+
+
+ +
+ {isGlobalGroup && ( + + )} + {(isOwner || user?.role === SystemRoles.ADMIN) && ( <> -
- - e.stopPropagation()} - onChange={(e) => setNameInputField(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Escape') { - cancelRename(); - } else if (e.key === 'Enter') { - saveRename(); - } - }} - onBlur={handleBlur} - aria-label={localize('com_ui_rename_group')} - /> - -
-
- {localize('com_ui_renaming_var', group.name)} -
- - ) : ( - <> -
-
-
-
- {groupIsGlobal === true && ( - - )} - {(isOwner || user?.role === SystemRoles.ADMIN) && ( - <> - - - - - - - - {localize('com_ui_rename')} - - - - - - - - - -
-
- -
-
- - } - selection={{ - selectHandler: handleDelete, - selectClasses: - 'bg-red-600 dark:bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white', - selectText: localize('com_ui_delete'), - }} + + + + + +
+ setNameInputValue(e.target.value)} + className="w-full" + aria-label={localize('com_ui_rename_prompt') + ' ' + group.name} /> - - - )} -
-
-
- {group.oneliner ?? '' ? group.oneliner : group.productionPrompt?.prompt ?? ''} -
+
+
+ } + selection={{ + selectHandler: handleSaveRename, + selectClasses: + 'bg-surface-submit hover:bg-surface-submit-hover text-white disabled:hover:bg-surface-submit', + selectText: localize('com_ui_save'), + isLoading, + }} + /> + + + + + + + +
+ +
+
+ } + selection={{ + selectHandler: triggerDelete, + selectClasses: + 'bg-red-600 dark:bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white', + selectText: localize('com_ui_delete'), + }} + /> + )}
@@ -239,3 +177,5 @@ export default function DashGroupItem({
); } + +export default memo(DashGroupItemComponent); diff --git a/client/src/components/Prompts/Groups/FilterPrompts.tsx b/client/src/components/Prompts/Groups/FilterPrompts.tsx index 88893868f2..0d0ab9a2fc 100644 --- a/client/src/components/Prompts/Groups/FilterPrompts.tsx +++ b/client/src/components/Prompts/Groups/FilterPrompts.tsx @@ -1,109 +1,13 @@ -import { ListFilter, User, Share2, Dot } from 'lucide-react'; -import React, { useState, useCallback, useMemo } from 'react'; +import { ListFilter, User, Share2 } from 'lucide-react'; +import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { SystemCategories } from 'librechat-data-provider'; -import type { OptionWithIcon } from '~/common'; import { usePromptGroupsNav, useLocalize, useCategories } from '~/hooks'; -import { - Input, - Button, - DropdownMenu, - DropdownMenuItem, - DropdownMenuGroup, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuSeparator, -} from '~/components/ui'; +import { Dropdown, AnimatedSearchInput } from '~/components/ui'; +import type { Option } from '~/common'; import { cn } from '~/utils'; import store from '~/store'; -export function FilterItem({ - label, - icon, - onClick, - isActive, -}: { - label: string; - icon: React.ReactNode; - onClick?: () => void; - isActive?: boolean; -}) { - return ( - - {icon} - {label} - {isActive === true && ( - - - - )} - - ); -} - -export function FilterMenu({ - onSelect, -}: { - onSelect: (category: string, icon?: React.ReactNode | null) => void; -}) { - const localize = useLocalize(); - const { categories } = useCategories('h-4 w-4'); - const memoizedCategories = useMemo(() => { - const noCategory = { - label: localize('com_ui_no_category'), - value: SystemCategories.NO_CATEGORY, - }; - if (!categories) { - return [noCategory]; - } - - return [noCategory, ...categories]; - }, [categories, localize]); - - const categoryFilter = useRecoilValue(store.promptsCategory); - return ( - - - } - onClick={() => onSelect(SystemCategories.ALL, )} - isActive={categoryFilter === ''} - /> - } - onClick={() => onSelect(SystemCategories.MY_PROMPTS, )} - isActive={categoryFilter === SystemCategories.MY_PROMPTS} - /> - } - onClick={() => onSelect(SystemCategories.SHARED_PROMPTS, )} - isActive={categoryFilter === SystemCategories.SHARED_PROMPTS} - /> - - - - {memoizedCategories - .filter((category) => category.value) - .map((category, i) => ( - onSelect(category.value, (category as OptionWithIcon).icon)} - isActive={category.value === categoryFilter} - /> - ))} - - - ); -} - export default function FilterPrompts({ setName, className = '', @@ -113,46 +17,81 @@ export default function FilterPrompts({ const localize = useLocalize(); const [displayName, setDisplayName] = useState(''); const setCategory = useSetRecoilState(store.promptsCategory); - const [selectedIcon, setSelectedIcon] = useState(); + const categoryFilter = useRecoilValue(store.promptsCategory); + const { categories } = useCategories('h-4 w-4'); + const [isSearching, setIsSearching] = useState(false); + + const filterOptions = useMemo(() => { + const baseOptions: Option[] = [ + { + value: SystemCategories.ALL, + label: localize('com_ui_all_proper'), + icon: , + }, + { + value: SystemCategories.MY_PROMPTS, + label: localize('com_ui_my_prompts'), + icon: , + }, + { + value: SystemCategories.SHARED_PROMPTS, + label: localize('com_ui_shared_prompts'), + icon: , + }, + { divider: true, value: null }, + ]; + + const categoryOptions = categories + ? [...categories] + : [ + { + value: SystemCategories.NO_CATEGORY, + label: localize('com_ui_no_category'), + }, + ]; + + return [...baseOptions, ...categoryOptions]; + }, [categories, localize]); const onSelect = useCallback( - (category: string, icon?: React.ReactNode | null) => { - if (category === SystemCategories.ALL) { - setSelectedIcon(); - return setCategory(''); - } - setCategory(category); - if (icon != null && React.isValidElement(icon)) { - setSelectedIcon(icon); + (value: string) => { + if (value === SystemCategories.ALL) { + setCategory(''); + } else { + setCategory(value); } }, [setCategory], ); + useEffect(() => { + setIsSearching(true); + const timeout = setTimeout(() => { + setIsSearching(false); + }, 500); + return () => clearTimeout(timeout); + }, [displayName]); + return ( -
- - - - - - - + } + label="Filter: " + ariaLabel={localize('com_ui_filter_prompts')} + iconOnly + /> + { setDisplayName(e.target.value); setName(e.target.value); }} - className="w-full border-border-light placeholder:text-text-secondary" + isSearching={isSearching} + placeholder={localize('com_ui_filter_prompts_name')} />
); diff --git a/client/src/components/Prompts/Groups/GroupSidePanel.tsx b/client/src/components/Prompts/Groups/GroupSidePanel.tsx index 23170da54f..74e3de1745 100644 --- a/client/src/components/Prompts/Groups/GroupSidePanel.tsx +++ b/client/src/components/Prompts/Groups/GroupSidePanel.tsx @@ -29,7 +29,7 @@ export default function GroupSidePanel({ return (
)} @@ -43,16 +45,18 @@ export default function List({ {isLoading && isChatRoute && ( )} - {isLoading && !isChatRoute && ( - - )} + {isLoading && + !isChatRoute && + Array.from({ length: 10 }).map((_, index: number) => ( + + ))} {!isLoading && groups.length === 0 && isChatRoute && (
{localize('com_ui_nothing_found')}
)} {!isLoading && groups.length === 0 && !isChatRoute && ( -
+
{localize('com_ui_nothing_found')}
)} diff --git a/client/src/components/Prompts/Groups/ListCard.tsx b/client/src/components/Prompts/Groups/ListCard.tsx index c17faed8bf..4de783caaa 100644 --- a/client/src/components/Prompts/Groups/ListCard.tsx +++ b/client/src/components/Prompts/Groups/ListCard.tsx @@ -1,5 +1,6 @@ import React from 'react'; import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon'; +import { Label } from '~/components/ui'; export default function ListCard({ category, @@ -25,25 +26,31 @@ export default function ListCard({
-
+
{children}
-
+
{snippet}
diff --git a/client/src/components/Prompts/Groups/PanelNavigation.tsx b/client/src/components/Prompts/Groups/PanelNavigation.tsx index 39df43f160..76395e842d 100644 --- a/client/src/components/Prompts/Groups/PanelNavigation.tsx +++ b/client/src/components/Prompts/Groups/PanelNavigation.tsx @@ -19,7 +19,7 @@ function PanelNavigation({ }) { const localize = useLocalize(); return ( -
+
{!isChatRoute && }
diff --git a/client/src/components/Prompts/Groups/VariableForm.tsx b/client/src/components/Prompts/Groups/VariableForm.tsx index eb8b6683ac..096c7416a9 100644 --- a/client/src/components/Prompts/Groups/VariableForm.tsx +++ b/client/src/components/Prompts/Groups/VariableForm.tsx @@ -15,8 +15,8 @@ import { extractVariableInfo, } from '~/utils'; import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown'; +import { TextareaAutosize, InputCombobox, Button } from '~/components/ui'; import { useAuthContext, useLocalize, useSubmitMessage } from '~/hooks'; -import { TextareaAutosize, InputCombobox } from '~/components/ui'; type FieldType = 'text' | 'select'; @@ -202,12 +202,9 @@ export default function VariableForm({ ))}
- +
diff --git a/client/src/components/Prompts/ManagePrompts.tsx b/client/src/components/Prompts/ManagePrompts.tsx index 6c9ce8661b..a05e54cbcb 100644 --- a/client/src/components/Prompts/ManagePrompts.tsx +++ b/client/src/components/Prompts/ManagePrompts.tsx @@ -14,10 +14,19 @@ export default function ManagePrompts({ className }: { className?: string }) { setPromptsCategory(''); }, [setPromptsName, setPromptsCategory]); - const clickHandler = useCustomLink('/d/prompts', clickCallback); + const customLink = useCustomLink('/d/prompts', clickCallback); + const clickHandler = (e: React.MouseEvent) => { + customLink(e as unknown as React.MouseEvent); + }; return ( - ); diff --git a/client/src/components/Prompts/PreviewPrompt.tsx b/client/src/components/Prompts/PreviewPrompt.tsx index 368840d698..1b1efba70c 100644 --- a/client/src/components/Prompts/PreviewPrompt.tsx +++ b/client/src/components/Prompts/PreviewPrompt.tsx @@ -13,7 +13,7 @@ const PreviewPrompt = ({ }) => { return ( - +
diff --git a/client/src/components/Prompts/PromptDetails.tsx b/client/src/components/Prompts/PromptDetails.tsx index 4dec2dd4fe..53aeff3182 100644 --- a/client/src/components/Prompts/PromptDetails.tsx +++ b/client/src/components/Prompts/PromptDetails.tsx @@ -12,6 +12,7 @@ import CategoryIcon from './Groups/CategoryIcon'; import PromptVariables from './PromptVariables'; import { PromptVariableGfm } from './Markdown'; import { replaceSpecialVars } from '~/utils'; +import { Label } from '~/components/ui'; import Description from './Description'; import Command from './Command'; @@ -30,25 +31,25 @@ const PromptDetails = ({ group }: { group?: TPromptGroup }) => { return (
-
+
-
+
{(group.category?.length ?? 0) > 0 ? ( ) : null}
- {group.name} +
-
-
+
+
-

+

{localize('com_ui_prompt_text')}

-
+
= ({ name, isEditing, setIsEditing }) => { ]; return ( -
-

- {localize('com_ui_prompt_text')} -
+
+

+ + {localize('com_ui_prompt_text')} + +
{editorMode === PromptsEditorMode.ADVANCED && ( )} - + )} + { + if (!selectedPrompt || !selectedPrompt._id) { + console.warn('No prompt is selected or prompt _id is missing'); + return; + } + deletePromptMutation.mutate({ + _id: selectedPrompt._id, + groupId, + }); + }} + /> +
+

+ {editorMode === PromptsEditorMode.ADVANCED && + (isLoadingPrompts + ? Array.from({ length: 6 }).map((_, index: number) => ( +
+ +
+ )) + : prompts.length > 0 && ( + + ))} +
+ ); + return ( -
onSave(data.prompt))}> -
-
- {isLoadingGroup ? ( - - ) : ( - { - if (!group) { - return console.warn('Group not found'); - } - updateGroupMutation.mutate({ id: group._id || '', payload: { name: value } }); - }} - /> - )} -
- - updateGroupMutation.mutate({ - id: group._id || '', - payload: { name: group.name || '', category: value }, - }) - } - /> - {hasShareAccess && } + onSave(data.prompt))}> +
+
+
+
+
+ {isLoadingGroup ? ( + + ) : ( + <> + { + if (!group._id) { + return; + } + updateGroupMutation.mutate({ id: group._id, payload: { name: value } }); + }} + /> +
+ +
+ {editorMode === PromptsEditorMode.SIMPLE && } +
+ + )} +
+ {isLoadingPrompts ? ( + + ) : ( +
+ + + + +
+ )} +
+ {editorMode === PromptsEditorMode.ADVANCED && ( - - )} - { - deletePromptMutation.mutate({ - _id: selectedPrompt?._id || '', - groupId: group._id || '', - }); - }} - /> -
-
- {editorMode === PromptsEditorMode.ADVANCED && ( -
- -
- )} -
- {/* Left Section */} -
- {isLoadingPrompts ? ( - - ) : ( -
- - - - +
+
)}
- {/* Right Section */} - {editorMode === PromptsEditorMode.ADVANCED && ( -
- {isLoadingPrompts ? ( - - ) : ( - !!prompts.length && ( - - ) - )} -
+
+ +
diff --git a/client/src/components/Prompts/PromptName.tsx b/client/src/components/Prompts/PromptName.tsx index 77edbe726c..3066d629e6 100644 --- a/client/src/components/Prompts/PromptName.tsx +++ b/client/src/components/Prompts/PromptName.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useRef } from 'react'; -import { EditIcon, SaveIcon } from '~/components/svg'; +import { Button, Label, Input, EditIcon, SaveIcon } from '~/components'; type Props = { name?: string; @@ -31,21 +31,12 @@ const PromptName: React.FC = ({ name, onSave }) => { clearTimeout(blurTimeoutRef.current); }; - const handleBlur = () => { - blurTimeoutRef.current = setTimeout(() => { - if (document.activeElement !== inputRef.current) { - setIsEditing(false); - setNewName(name); - } - }, 200); - }; - const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { setIsEditing(false); setNewName(name); } - if (e.key === 'Enter' || e.key === 'Tab') { + if (e.key === 'Enter') { saveName(); } }; @@ -61,39 +52,64 @@ const PromptName: React.FC = ({ name, onSave }) => { }, [name]); return ( -
- {isEditing ? ( -
- - -
- ) : ( -
- {newName} - -
- )} +
+
+ {isEditing ? ( + <> + + + + + ) : ( + <> + + + + )} +
); }; diff --git a/client/src/components/Prompts/PromptVariables.tsx b/client/src/components/Prompts/PromptVariables.tsx index 8fd25d3095..8b8140e066 100644 --- a/client/src/components/Prompts/PromptVariables.tsx +++ b/client/src/components/Prompts/PromptVariables.tsx @@ -28,18 +28,18 @@ const PromptVariables = ({ }, [promptText]); return ( -
-

+
+

{localize('com_ui_variables')}

-
+
{variables.length ? ( -
+
{variables.map((variable, index) => ( -
) : ( -
- - {/** @ts-ignore */} - - {localize('com_ui_variables_info')} - - +
+ + {localize('com_ui_variables_info')} +
)} - + {showInfo && ( -
+
- + {localize('com_ui_special_variables')} - {'\u00A0'} - - {/** @ts-ignore */} + {localize('com_ui_special_variables_info')}
- + {localize('com_ui_dropdown_variables')} - {'\u00A0'} - - {/** @ts-ignore */} + {localize('com_ui_dropdown_variables_info')} diff --git a/client/src/components/Prompts/PromptVersions.tsx b/client/src/components/Prompts/PromptVersions.tsx index 86415e3c80..2f34a8d2e2 100644 --- a/client/src/components/Prompts/PromptVersions.tsx +++ b/client/src/components/Prompts/PromptVersions.tsx @@ -1,11 +1,140 @@ import React from 'react'; import { format } from 'date-fns'; -import { Layers3 } from 'lucide-react'; +import { Layers3, Crown, Zap } from 'lucide-react'; import type { TPrompt, TPromptGroup } from 'librechat-data-provider'; +import { Tag, TooltipAnchor, Label } from '~/components/ui'; import { useLocalize } from '~/hooks'; -import { Tag } from '~/components/ui'; import { cn } from '~/utils'; +const CombinedStatusIcon = ({ description }: { description: string }) => ( + + +
+ } + > +); + +const VersionTags = ({ tags }: { tags: string[] }) => { + const localize = useLocalize(); + const isLatestAndProduction = tags.includes('latest') && tags.includes('production'); + + if (isLatestAndProduction) { + return ( + + + + ); + } + + return ( + + {tags.map((tag, i) => ( + { + if (tag === 'production') { + return ( +
+ +
+ ); + } + if (tag === 'latest') { + return ( +
+ +
+ ); + } + return null; + })()} + /> + } + >
+ ))} +
+ ); +}; + +const VersionCard = ({ + prompt, + index, + isSelected, + totalVersions, + onClick, + authorName, + tags, +}: { + prompt: TPrompt; + index: number; + isSelected: boolean; + totalVersions: number; + onClick: () => void; + authorName?: string; + tags: string[]; +}) => { + const localize = useLocalize(); + + return ( + + ); +}; + const PromptVersions = ({ prompts, group, @@ -14,19 +143,24 @@ const PromptVersions = ({ }: { prompts: TPrompt[]; group?: TPromptGroup; - selectionIndex: React.SetStateAction; + selectionIndex: number; setSelectionIndex: React.Dispatch>; }) => { const localize = useLocalize(); + return ( - <> -

- - {localize('com_ui_versions')} -

-
    +
    +
    +

    + + {localize('com_ui_versions')} +

    +
    + +
    {prompts.map((prompt: TPrompt, index: number) => { const tags: string[] = []; + if (index === 0) { tags.push('latest'); } @@ -36,53 +170,20 @@ const PromptVersions = ({ } return ( -
  • setSelectionIndex(index)} - > -

    - {localize('com_ui_version_var', `${prompts.length - index}`)} -

    -

    - {format(new Date(prompt.createdAt), 'yyyy-MM-dd HH:mm')} -

    - {tags.length > 0 && ( - - {tags.map((tag, i) => { - return ( - - -
  • - ) : null - } - /> - ); - })} - - )} - {group?.authorName && ( -

    by {group.authorName}

    - )} - + authorName={group?.authorName} + tags={tags} + /> ); })} -
- +
+ ); }; diff --git a/client/src/components/Prompts/PromptsAccordion.tsx b/client/src/components/Prompts/PromptsAccordion.tsx index ef455a80a7..444646c5ee 100644 --- a/client/src/components/Prompts/PromptsAccordion.tsx +++ b/client/src/components/Prompts/PromptsAccordion.tsx @@ -9,11 +9,11 @@ export default function PromptsAccordion() { return (
-
+
- +
); diff --git a/client/src/components/Prompts/PromptsView.tsx b/client/src/components/Prompts/PromptsView.tsx index fcc23bfb7c..9a5b3f4eaa 100644 --- a/client/src/components/Prompts/PromptsView.tsx +++ b/client/src/components/Prompts/PromptsView.tsx @@ -1,7 +1,6 @@ import { useMemo, useEffect } from 'react'; import { Outlet, useParams, useNavigate } from 'react-router-dom'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; -import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt'; import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts'; import DashBreadcrumb from '~/routes/Layouts/DashBreadcrumb'; import { usePromptGroupsNav, useHasAccess } from '~/hooks'; @@ -35,13 +34,12 @@ export default function PromptsView() { } return ( -
+
-
!!(group?.projectIds ?? []).includes(instanceProjectId), + () => ((group?.projectIds ?? []) as string[]).includes(instanceProjectId as string), [group, instanceProjectId], ); const { control, setValue, - getValues, handleSubmit, formState: { isSubmitting }, } = useForm({ @@ -51,19 +51,26 @@ const SharePrompt = ({ group, disabled }: { group?: TPromptGroup; disabled: bool setValue(Permissions.SHARED_GLOBAL, groupIsGlobal); }, [groupIsGlobal, setValue]); - if (!group || !instanceProjectId) { + if (group == null || !instanceProjectId) { return null; } const onSubmit = (data: FormValues) => { const groupId = group._id ?? ''; - if (!groupId || !instanceProjectId) { + if (groupId === '' || !instanceProjectId) { + return; + } + + if (data[Permissions.SHARED_GLOBAL] === true && groupIsGlobal) { + showToast({ + message: localize('com_ui_prompt_already_shared_to_all'), + status: 'info', + }); return; } const payload = {} as TUpdatePromptGroupPayload; - - if (data[Permissions.SHARED_GLOBAL]) { + if (data[Permissions.SHARED_GLOBAL] === true) { payload.projectIds = [startupConfig.instanceProjectId]; } else { payload.removeProjectIds = [startupConfig.instanceProjectId]; @@ -81,81 +88,50 @@ const SharePrompt = ({ group, disabled }: { group?: TPromptGroup; disabled: bool - - + + {localize('com_ui_share_var', `"${group.name}"`)} -
+ +
+ {localize('com_ui_share_form_description')} +
-
- - +
+ {localize('com_ui_share_to_all_users')}
{ - const isValid = !(value && groupIsGlobal); - if (!isValid) { - showToast({ - message: localize('com_ui_prompt_already_shared_to_all'), - status: 'warning', - }); - } - return isValid; - }, - }} + disabled={isFetching === true || updateGroup.isLoading || !instanceProjectId} render={({ field }) => ( )} />
- +
diff --git a/client/src/components/ui/AnimatedSearchInput.tsx b/client/src/components/ui/AnimatedSearchInput.tsx index 422d7f943b..dddfef4231 100644 --- a/client/src/components/ui/AnimatedSearchInput.tsx +++ b/client/src/components/ui/AnimatedSearchInput.tsx @@ -1,59 +1,66 @@ -import React, { useState } from 'react'; +import React from 'react'; import { Search } from 'lucide-react'; +import { cn } from '~/utils'; -const AnimatedSearchInput = ({ value, onChange, isSearching: searching, placeholder }) => { - const [isFocused, setIsFocused] = useState(false); +const AnimatedSearchInput = ({ + value, + onChange, + isSearching: searching, + placeholder, +}: { + value?: string; + onChange: (e: React.ChangeEvent) => void; + isSearching?: boolean; + placeholder: string; +}) => { const isSearching = searching === true; + const hasValue = value != null && value.length > 0; return (
- {/* Background gradient effect */} -
-
-
+ {/* Icon on the left */} +
- {/* Input field with background transitions */} + {/* Input field */} setIsFocused(true)} - onBlur={() => setIsFocused(false)} placeholder={placeholder} className={` - w-full rounded-lg px-10 py-2 - transition-all duration-500 ease-in-out - placeholder:text-gray-400 + peer relative z-20 w-full rounded-lg bg-surface-secondary px-10 + py-2 outline-none ring-0 backdrop-blur-sm transition-all + duration-500 ease-in-out placeholder:text-gray-400 focus:outline-none focus:ring-0 - ${isFocused ? 'bg-white/10' : 'bg-white/5'} - ${isSearching ? 'bg-white/15' : ''} - backdrop-blur-sm + `} + /> + + {/* Gradient overlay */} +
{/* Animated loading indicator */}
@@ -69,7 +76,7 @@ const AnimatedSearchInput = ({ value, onChange, isSearching: searching, placehol className={` absolute -inset-8 -z-10 transition-all duration-700 ease-in-out - ${isSearching ? 'scale-105 opacity-100' : 'scale-100 opacity-0'} + ${isSearching && hasValue ? 'scale-105 opacity-100' : 'scale-100 opacity-0'} `} >
@@ -77,26 +84,24 @@ const AnimatedSearchInput = ({ value, onChange, isSearching: searching, placehol className={` bg-gradient-radial absolute inset-0 from-blue-500/10 to-transparent transition-opacity duration-700 ease-in-out - ${isSearching ? 'animate-pulse-slow opacity-100' : 'opacity-0'} + ${isSearching && hasValue ? 'animate-pulse-slow opacity-100' : 'opacity-0'} `} />
- - {/* Focus state background glow */}
diff --git a/client/src/components/ui/Button.tsx b/client/src/components/ui/Button.tsx index 4687db0fbc..35b2d103b7 100644 --- a/client/src/components/ui/Button.tsx +++ b/client/src/components/ui/Button.tsx @@ -9,7 +9,8 @@ const buttonVariants = cva( variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', - destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/80', + destructive: + 'bg-surface-destructive text-destructive-foreground hover:bg-surface-destructive-hover', outline: 'text-text-primary border border-border-light bg-background hover:bg-accent hover:text-accent-foreground', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', diff --git a/client/src/components/ui/Dropdown.tsx b/client/src/components/ui/Dropdown.tsx index f89ae7dc78..1f3ab1e534 100644 --- a/client/src/components/ui/Dropdown.tsx +++ b/client/src/components/ui/Dropdown.tsx @@ -7,12 +7,22 @@ interface DropdownProps { value?: string; label?: string; onChange: (value: string) => void; - options: string[] | Option[]; + options: (string | Option | { divider: true })[]; className?: string; sizeClasses?: string; testId?: string; + icon?: React.ReactNode; + iconOnly?: boolean; + renderValue?: (option: Option) => React.ReactNode; + ariaLabel?: string; } +const isDivider = (item: string | Option | { divider: true }): item is { divider: true } => + typeof item === 'object' && 'divider' in item; + +const isOption = (item: string | Option | { divider: true }): item is Option => + typeof item === 'object' && 'value' in item && 'label' in item; + const Dropdown: React.FC = ({ value: selectedValue, label = '', @@ -21,6 +31,10 @@ const Dropdown: React.FC = ({ className = '', sizeClasses, testId = 'dropdown-menu', + icon, + iconOnly = false, + renderValue, + ariaLabel, }) => { const handleChange = (value: string) => { onChange(value); @@ -31,63 +45,101 @@ const Dropdown: React.FC = ({ setValue: handleChange, }); + const getOptionObject = (val: string | undefined): Option | undefined => { + if (val == null || val === '') { + return undefined; + } + return options + .filter((o) => !isDivider(o)) + .map((o) => (typeof o === 'string' ? { value: o, label: o } : o)) + .find((o) => isOption(o) && o.value === val) as Option | undefined; + }; + + const getOptionLabel = (currentValue: string | undefined) => { + if (currentValue == null || currentValue === '') { + return ''; + } + const option = getOptionObject(currentValue); + return option ? option.label : currentValue; + }; + return (
-
- - {label} - {options - .map((o) => (typeof o === 'string' ? { value: o, label: o } : o)) - .find((o) => o.value === selectedValue)?.label ?? selectedValue} - - +
+ {icon} + {!iconOnly && ( + + {label} + {(() => { + const matchedOption = getOptionObject(selectedValue); + if (matchedOption && renderValue) { + return renderValue(matchedOption); + } + return getOptionLabel(selectedValue); + })()} + + )}
+ {!iconOnly && } - {options.map((item, index) => ( - -
- - {typeof item === 'string' ? item : (item as Option).label} - - {selectedValue === (typeof item === 'string' ? item : item.value) && ( - - - - - - )} -
-
- ))} + {options.map((item, index) => { + if (isDivider(item)) { + return
; + } + + const option = typeof item === 'string' ? { value: item, label: item } : item; + if (!isOption(option)) { + return null; + } + + return ( + +
+ {option.icon != null && {option.icon as React.ReactNode}} + {option.label} + {selectedValue === option.value && ( + + + + + + )} +
+
+ ); + })}
); diff --git a/client/src/components/ui/Input.tsx b/client/src/components/ui/Input.tsx index adc3b422c1..855567a5b4 100644 --- a/client/src/components/ui/Input.tsx +++ b/client/src/components/ui/Input.tsx @@ -16,6 +16,7 @@ const Input = React.forwardRef(({ className, ...pr /> ); }); + Input.displayName = 'Input'; export { Input }; diff --git a/client/src/components/ui/Label.tsx b/client/src/components/ui/Label.tsx index 9c6697c2e3..2ff6e600da 100644 --- a/client/src/components/ui/Label.tsx +++ b/client/src/components/ui/Label.tsx @@ -10,7 +10,7 @@ const Label = React.forwardRef< void; selectClasses?: string; selectText?: string | ReactNode; + isLoading?: boolean; }; type DialogTemplateProps = { @@ -30,6 +33,7 @@ type DialogTemplateProps = { footerClassName?: string; showCloseButton?: boolean; showCancelButton?: boolean; + onClose?: () => void; }; const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref) => { @@ -49,7 +53,7 @@ const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref - {selectText} + {isLoading === true ? : selectText} ) : null}
diff --git a/client/src/data-provider/prompts.ts b/client/src/data-provider/prompts.ts index 1fcb8ea64b..3f6e4bb89a 100644 --- a/client/src/data-provider/prompts.ts +++ b/client/src/data-provider/prompts.ts @@ -32,29 +32,31 @@ export const useUpdatePromptGroup = ( mutationFn: (variables: t.TUpdatePromptGroupVariables) => dataService.updatePromptGroup(variables), onMutate: (variables: t.TUpdatePromptGroupVariables) => { - const group = JSON.parse( - JSON.stringify( - queryClient.getQueryData([QueryKeys.promptGroup, variables.id]), - ), - ) as t.TPromptGroup; - const groupData = queryClient.getQueryData([ + const groupData = queryClient.getQueryData([ + QueryKeys.promptGroup, + variables.id, + ]); + const group = groupData ? structuredClone(groupData) : undefined; + + const groupListData = queryClient.getQueryData([ QueryKeys.promptGroups, name, category, pageSize, ]); - const previousListData = JSON.parse(JSON.stringify(groupData)) as t.PromptGroupListData; + const previousListData = groupListData ? structuredClone(groupListData) : undefined; + let update = variables.payload; - if (update.removeProjectIds && group.projectIds) { - update = JSON.parse(JSON.stringify(update)); + if (update.removeProjectIds && group?.projectIds) { + update = structuredClone(update); update.projectIds = group.projectIds.filter((id) => !update.removeProjectIds?.includes(id)); delete update.removeProjectIds; } - if (groupData) { + if (groupListData) { const newData = updateGroupFields( /* Paginated Data */ - groupData, + groupListData, /* Update */ { _id: variables.id, ...update }, /* Callback */ @@ -74,12 +76,12 @@ export const useUpdatePromptGroup = ( }, onError: (err, variables, context) => { if (context?.group) { - queryClient.setQueryData([QueryKeys.promptGroups, variables.id], context?.group); + queryClient.setQueryData([QueryKeys.promptGroups, variables.id], context.group); } if (context?.previousListData) { queryClient.setQueryData( [QueryKeys.promptGroups, name, category, pageSize], - context?.previousListData, + context.previousListData, ); } if (onError) { @@ -110,7 +112,7 @@ export const useCreatePrompt = ( onSuccess: (response, variables, context) => { const { prompt, group } = response; queryClient.setQueryData( - [QueryKeys.prompts, variables?.prompt?.groupId], + [QueryKeys.prompts, variables.prompt.groupId], (oldData: t.TPrompt[] | undefined) => { return [prompt, ...(oldData ?? [])]; }, @@ -301,12 +303,12 @@ export const useMakePromptProduction = (options?: t.MakePromptProductionOptions) }, onError: (err, variables, context) => { if (context?.group) { - queryClient.setQueryData([QueryKeys.promptGroups, variables.groupId], context?.group); + queryClient.setQueryData([QueryKeys.promptGroups, variables.groupId], context.group); } if (context?.previousListData) { queryClient.setQueryData( [QueryKeys.promptGroups, name, category, pageSize], - context?.previousListData, + context.previousListData, ); } if (onError) { diff --git a/client/src/hooks/Prompts/useCategories.tsx b/client/src/hooks/Prompts/useCategories.tsx index 7a74862950..6bdaa5272f 100644 --- a/client/src/hooks/Prompts/useCategories.tsx +++ b/client/src/hooks/Prompts/useCategories.tsx @@ -19,9 +19,7 @@ const useCategories = (className = '') => { const { data: categories = loadingCategories } = useGetCategories({ select: (data) => data.map((category) => ({ - label: category.label - ? localize(`com_ui_${category.label}`) || category.label - : localize('com_ui_select_a_category'), + label: localize(`com_ui_${category.label}`) || category.label, value: category.value, icon: category.value ? ( diff --git a/client/src/localization/languages/Eng.ts b/client/src/localization/languages/Eng.ts index 5da1f46d6c..338609101a 100644 --- a/client/src/localization/languages/Eng.ts +++ b/client/src/localization/languages/Eng.ts @@ -214,6 +214,7 @@ export default { com_ui_prompt: 'Prompt', com_ui_prompts: 'Prompts', com_ui_prompt_name: 'Prompt Name', + com_ui_rename_prompt: 'Rename Prompt', com_ui_delete_prompt: 'Delete Prompt?', com_ui_admin: 'Admin', com_ui_simple: 'Simple', @@ -229,9 +230,12 @@ export default { com_ui_prompt_name_required: 'Prompt Name is required', com_ui_prompt_text_required: 'Text is required', com_ui_prompt_text: 'Text', + com_ui_currently_production: 'Currently in production', + com_ui_latest_version: 'Latest version', com_ui_back_to_chat: 'Back to Chat', com_ui_back_to_prompts: 'Back to Prompts', com_ui_categories: 'Categories', + com_ui_filter_prompts: 'Filter Prompts', com_ui_filter_prompts_name: 'Filter prompts by name', com_ui_search_categories: 'Search Categories', com_ui_manage: 'Manage', @@ -373,9 +377,13 @@ export default { com_ui_agent_duplicate_error: 'There was an error duplicating the agent', com_ui_prompt_already_shared_to_all: 'This prompt is already shared to all users', com_ui_description_placeholder: 'Optional: Enter a description to display for the prompt', - com_ui_command_placeholder: 'Optional: Enter a command for the prompt or name will be used.', + com_ui_command_placeholder: 'Optional: Enter a command for the prompt or name will be used', com_ui_command_usage_placeholder: 'Select a Prompt by command or name', com_ui_no_prompt_description: 'No description found.', + com_ui_latest_production_version: 'Latest production version', + com_ui_confirm_change: 'Confirm Change', + com_ui_confirm_admin_use_change: + 'Changing this setting will block access for admins, including yourself. Are you sure you want to proceed?', com_ui_share_link_to_chat: 'Share link to chat', com_ui_share_error: 'There was an error sharing the chat link', com_ui_share_retrieve_error: 'There was an error retrieving the shared links', diff --git a/client/src/routes/Layouts/DashBreadcrumb.tsx b/client/src/routes/Layouts/DashBreadcrumb.tsx index f031689e94..60a6eb9a82 100644 --- a/client/src/routes/Layouts/DashBreadcrumb.tsx +++ b/client/src/routes/Layouts/DashBreadcrumb.tsx @@ -1,7 +1,7 @@ -import { useSetRecoilState } from 'recoil'; import { useMemo, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { SystemRoles } from 'librechat-data-provider'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { ArrowLeft, MessageSquareQuote } from 'lucide-react'; import { Breadcrumb, @@ -17,8 +17,10 @@ import { } from '~/components/ui'; import { useLocalize, useCustomLink, useAuthContext } from '~/hooks'; import AdvancedSwitch from '~/components/Prompts/AdvancedSwitch'; +import { RightPanel } from '../../components/Prompts/RightPanel'; import AdminSettings from '~/components/Prompts/AdminSettings'; import { useDashboardContext } from '~/Providers'; +import { PromptsEditorMode } from '~/common'; import store from '~/store'; const promptsPathPattern = /prompts\/(?!new(?:\/|$)).*$/; @@ -40,6 +42,7 @@ export default function DashBreadcrumb() { const setPromptsName = useSetRecoilState(store.promptsName); const setPromptsCategory = useSetRecoilState(store.promptsCategory); + const editorMode = useRecoilValue(store.promptsEditorMode); const clickCallback = useCallback(() => { setPromptsName(''); @@ -55,7 +58,7 @@ export default function DashBreadcrumb() { ); return ( -
+
diff --git a/client/src/style.css b/client/src/style.css index f632f8eb03..d1b7790030 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -29,6 +29,17 @@ --green-800: #065f46; --green-900: #064e3b; --green-950: #022c22; + --red-50: #fef2f2; + --red-100: #fee2e2; + --red-200: #fecaca; + --red-300: #fca5a5; + --red-400: #f87171; + --red-500: #ef4444; + --red-600: #dc2626; + --red-700: #b91c1c; + --red-800: #991b1b; + --red-900: #7f1d1d; + --red-950: #450a0a; --gizmo-gray-500: #999; --gizmo-gray-600: #666; --gizmo-gray-950: #0f0f0f; @@ -62,6 +73,8 @@ html { --surface-dialog: var(--white); --surface-submit: var(--green-700); --surface-submit-hover: var(--green-800); + --surface-destructive: var(--red-700); + --surface-destructive-hover: var(--red-800); --border-light: var(--gray-200); --border-medium-alt: var(--gray-300); --border-medium: var(--gray-300); @@ -117,6 +130,8 @@ html { --surface-dialog: var(--gray-850); --surface-submit: var(--green-700); --surface-submit-hover: var(--green-800); + --surface-destructive: var(--red-800); + --surface-destructive-hover: var(--red-900); --border-light: var(--gray-700); --border-medium-alt: var(--gray-600); --border-medium: var(--gray-600); @@ -2347,7 +2362,7 @@ button.scroll-convo { .popover-ui { z-index: 1000; display: flex; - max-height: min(var(--popover-available-height, 300px), 300px); + max-height: min(var(--popover-available-height, 1700px), 1700px); flex-direction: column; overflow: auto; overscroll-behavior: contain; @@ -2418,9 +2433,15 @@ button.scroll-convo { /** AnimatedSearchInput style */ @keyframes gradient-x { - 0% { background-position: 0% 50%; } - 50% { background-position: 100% 50%; } - 100% { background-position: 0% 50%; } + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } } .animate-gradient-x { @@ -2452,4 +2473,4 @@ button.scroll-convo { .scale-98 { transform: scale(0.98); -} \ No newline at end of file +} diff --git a/client/tailwind.config.cjs b/client/tailwind.config.cjs index ac867e8568..45f6533fb5 100644 --- a/client/tailwind.config.cjs +++ b/client/tailwind.config.cjs @@ -85,6 +85,8 @@ module.exports = { 'surface-dialog': 'var(--surface-dialog)', 'surface-submit': 'var(--surface-submit)', 'surface-submit-hover': 'var(--surface-submit-hover)', + 'surface-destructive': 'var(--surface-destructive)', + 'surface-destructive-hover': 'var(--surface-destructive-hover)', 'border-light': 'var(--border-light)', 'border-medium': 'var(--border-medium)', 'border-medium-alt': 'var(--border-medium-alt)',