From 84f62eb70ca133b7ecb84d261a20fad15e8a789f Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 2 Jun 2025 23:32:09 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Refactor=20FilterPrompts=20?= =?UTF-8?q?and=20List=20components=20for=20improved=20search=20handling=20?= =?UTF-8?q?and=20sorting=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Prompts/Groups/FilterPrompts.tsx | 38 +--- client/src/components/Prompts/Groups/List.tsx | 118 ++++++---- .../Prompts/Groups/RankingComponent.tsx | 211 +++++++++--------- 3 files changed, 192 insertions(+), 175 deletions(-) diff --git a/client/src/components/Prompts/Groups/FilterPrompts.tsx b/client/src/components/Prompts/Groups/FilterPrompts.tsx index 3cc5e60142..ba67e61c62 100644 --- a/client/src/components/Prompts/Groups/FilterPrompts.tsx +++ b/client/src/components/Prompts/Groups/FilterPrompts.tsx @@ -1,5 +1,5 @@ import { ListFilter, User, Share2 } from 'lucide-react'; -import React, { useState, useCallback, useMemo, useEffect } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { SystemCategories } from 'librechat-data-provider'; import { usePromptGroupsNav, useLocalize, useCategories } from '~/hooks'; @@ -19,7 +19,6 @@ export default function FilterPrompts({ const setCategory = useSetRecoilState(store.promptsCategory); const categoryFilter = useRecoilValue(store.promptsCategory); const { categories } = useCategories('h-4 w-4'); - const [isSearching, setIsSearching] = useState(false); const filterOptions = useMemo(() => { const baseOptions: Option[] = [ @@ -41,36 +40,27 @@ export default function FilterPrompts({ { divider: true, value: null }, ]; - const categoryOptions = categories + const categoryOptions = categories?.length ? [...categories] - : [ - { - value: SystemCategories.NO_CATEGORY, - label: localize('com_ui_no_category'), - }, - ]; + : [{ value: SystemCategories.NO_CATEGORY, label: localize('com_ui_no_category') }]; return [...baseOptions, ...categoryOptions]; }, [categories, localize]); const onSelect = useCallback( (value: string) => { - if (value === SystemCategories.ALL) { - setCategory(''); - } else { - setCategory(value); - } + setCategory(value === SystemCategories.ALL ? '' : value); }, [setCategory], ); - useEffect(() => { - setIsSearching(true); - const timeout = setTimeout(() => { - setIsSearching(false); - }, 500); - return () => clearTimeout(timeout); - }, [displayName]); + const handleSearchChange = useCallback( + (e: React.ChangeEvent) => { + setDisplayName(e.target.value); + setName(e.target.value); + }, + [setName], + ); return (
@@ -86,11 +76,7 @@ export default function FilterPrompts({ /> { - setDisplayName(e.target.value); - setName(e.target.value); - }} - isSearching={isSearching} + onChange={handleSearchChange} placeholder={localize('com_ui_filter_prompts_name')} />
diff --git a/client/src/components/Prompts/Groups/List.tsx b/client/src/components/Prompts/Groups/List.tsx index 8ad9183044..78ae4e3f9d 100644 --- a/client/src/components/Prompts/Groups/List.tsx +++ b/client/src/components/Prompts/Groups/List.tsx @@ -1,65 +1,105 @@ import type { TPromptGroup, TStartupConfig } from 'librechat-data-provider'; -import { RankablePromptList, RankingProvider } from '~/components/Prompts/Groups/RankingComponent'; +import { + RankablePromptList, + SortedPromptList, + RankingProvider, +} from '~/components/Prompts/Groups/RankingComponent'; import DashGroupItem from '~/components/Prompts/Groups/DashGroupItem'; import ChatGroupItem from '~/components/Prompts/Groups/ChatGroupItem'; import { useGetStartupConfig } from '~/data-provider'; -import { Button, Skeleton } from '~/components/ui'; +import { Skeleton } from '~/components/ui'; import { useLocalize } from '~/hooks'; +import { useMemo } from 'react'; + +interface ListProps { + groups?: TPromptGroup[]; + isChatRoute: boolean; + isLoading: boolean; + enableRanking?: boolean; +} export default function List({ groups = [], isChatRoute, isLoading, enableRanking = true, -}: { - groups?: TPromptGroup[]; - isChatRoute: boolean; - isLoading: boolean; - enableRanking?: boolean; -}) { +}: ListProps) { const localize = useLocalize(); const { data: startupConfig = {} as Partial } = useGetStartupConfig(); const { instanceProjectId } = startupConfig; - const renderGroupItem = (group: TPromptGroup) => { + const renderGroupItem = useMemo( + () => (group: TPromptGroup) => { + const Component = isChatRoute ? ChatGroupItem : DashGroupItem; + return ; + }, + [isChatRoute, instanceProjectId], + ); + + const emptyMessage = localize('com_ui_nothing_found'); + + if (isLoading) { + return ( + +
+
+
+ {isChatRoute ? ( + + ) : ( + Array.from({ length: 10 }).map((_, index) => ( + + )) + )} +
+
+
+
+ ); + } + + if (groups.length === 0) { + return ( + +
+
+
+ {isChatRoute ? ( +
+ {emptyMessage} +
+ ) : ( +
+ {emptyMessage} +
+ )} +
+
+
+
+ ); + } + + const shouldUseRanking = !isChatRoute && enableRanking; + + const renderContent = () => { if (isChatRoute) { - return ; + return ; } - return ; + if (shouldUseRanking) { + return ; + } + return groups.map((group) => renderGroupItem(group)); }; return (
-
- {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')} -
- )} - {!isLoading && groups.length > 0 && !isChatRoute && enableRanking ? ( - - ) : ( - !isLoading && groups.map((group) => renderGroupItem(group)) - )} -
+
{renderContent()}
diff --git a/client/src/components/Prompts/Groups/RankingComponent.tsx b/client/src/components/Prompts/Groups/RankingComponent.tsx index abb2f99287..c7c86a51a1 100644 --- a/client/src/components/Prompts/Groups/RankingComponent.tsx +++ b/client/src/components/Prompts/Groups/RankingComponent.tsx @@ -1,9 +1,10 @@ import React, { useCallback, useEffect, useState, useRef, ReactNode } from 'react'; -import { DndProvider, useDrag, useDrop, useDragLayer } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; import { GripVertical } from 'lucide-react'; -import { useUpdatePromptRankings, useGetUserPromptPreferences } from '~/data-provider'; +import { useDrag, useDrop, useDragLayer } from 'react-dnd'; import type { TPromptGroup } from 'librechat-data-provider'; +import { useUpdatePromptRankings, useGetUserPromptPreferences } from '~/data-provider'; +import CategoryIcon from './CategoryIcon'; +import { Label } from '~/components'; import { cn } from '~/utils'; const ITEM_TYPE = 'PROMPT_GROUP'; @@ -20,8 +21,31 @@ interface DragItem { index: number; id: string; type: string; + group: TPromptGroup; } +const sortGroups = (groups: TPromptGroup[], rankings: any[], favorites: string[]) => { + const rankingMap = new Map(rankings.map((ranking) => [ranking.promptGroupId, ranking.order])); + + return [...groups].sort((a, b) => { + const aId = a._id ?? ''; + const bId = b._id ?? ''; + const aIsFavorite = favorites.includes(aId); + const bIsFavorite = favorites.includes(bId); + + if (aIsFavorite && !bIsFavorite) return -1; + if (!aIsFavorite && bIsFavorite) return 1; + + const aRank = rankingMap.get(aId); + const bRank = rankingMap.get(bId); + if (aRank !== undefined && bRank !== undefined) return aRank - bRank; + if (aRank !== undefined) return -1; + if (bRank !== undefined) return 1; + + return a.name.localeCompare(b.name); + }); +}; + function DraggablePromptItem({ group, index, @@ -30,46 +54,33 @@ function DraggablePromptItem({ children, }: DraggablePromptItemProps) { const ref = useRef(null); - const [isOver, setIsOver] = useState(false); const [{ isDragging }, drag, preview] = useDrag({ type: ITEM_TYPE, - item: () => ({ type: ITEM_TYPE, index, id: group._id }), - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), + item: { type: ITEM_TYPE, index, id: group._id, group }, + collect: (monitor) => ({ isDragging: monitor.isDragging() }), }); - const [{ isOverCurrent }, drop] = useDrop({ + const [{ isOver }, drop] = useDrop({ accept: ITEM_TYPE, hover: (item, monitor) => { - if (!ref.current) return; - - const dragIndex = item.index; - const hoverIndex = index; - if (dragIndex === hoverIndex) return; + if (!ref.current || item.index === index) return; const hoverBoundingRect = ref.current.getBoundingClientRect(); - const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const hoverMiddleY = hoverBoundingRect.height / 2; const clientOffset = monitor.getClientOffset(); if (!clientOffset) return; + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + if (item.index < index && hoverClientY < hoverMiddleY * 0.8) return; + if (item.index > index && hoverClientY > hoverMiddleY * 1.2) return; - if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY * 0.8) return; - if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY * 1.2) return; - - moveItem(dragIndex, hoverIndex); - item.index = hoverIndex; + moveItem(item.index, index); + item.index = index; }, - collect: (monitor) => ({ - isOverCurrent: monitor.isOver({ shallow: true }), - }), + collect: (monitor) => ({ isOver: monitor.isOver({ shallow: true }) }), }); - useEffect(() => { - setIsOver(isOverCurrent); - }, [isOverCurrent]); - drag(drop(ref)); useEffect(() => { @@ -85,15 +96,11 @@ function DraggablePromptItem({ isAnyDragging && !isDragging && 'transition-transform', isOver && !isDragging && 'scale-[1.02]', )} - style={{ - transform: isDragging ? 'scale(1.05)' : 'scale(1)', - cursor: isDragging ? 'grabbing' : 'grab', - }} + style={{ cursor: isDragging ? 'grabbing' : 'grab' }} >
@@ -105,35 +112,14 @@ function DraggablePromptItem({ } function CustomDragLayer() { - const { item, itemType, currentOffset, isDragging } = useDragLayer((monitor) => ({ - item: monitor.getItem(), + const { itemType, item, currentOffset, isDragging } = useDragLayer((monitor) => ({ itemType: monitor.getItemType(), + item: monitor.getItem() as DragItem, currentOffset: monitor.getSourceClientOffset(), isDragging: monitor.isDragging(), })); - if (!isDragging || !currentOffset || itemType !== ITEM_TYPE) { - return null; - } - - const renderPreview = () => { - if (item && typeof item.id === 'string') { - return ( -
- {`Moving: ${item.id}`} -
- ); - } - return
Dragging...
; - }; + if (!isDragging || !currentOffset || itemType !== ITEM_TYPE || !item?.group) return null; return (
- {renderPreview()} +
+
+
+
); } +function SortedPromptList({ + groups, + renderItem, +}: { + groups: TPromptGroup[]; + renderItem: (group: TPromptGroup) => ReactNode; +}) { + const { data: preferences } = useGetUserPromptPreferences(); + const [sortedGroups, setSortedGroups] = useState([]); + + useEffect(() => { + if (!groups?.length) { + setSortedGroups([]); + return; + } + + const rankings = preferences?.rankings || []; + const favorites = preferences?.favorites || []; + setSortedGroups(sortGroups(groups, rankings, favorites)); + }, [groups, preferences]); + + return ( +
+ {sortedGroups.map((group) => ( +
+ {renderItem(group)} +
+ ))} +
+ ); +} + interface RankablePromptListProps { groups: TPromptGroup[]; renderItem: (group: TPromptGroup) => ReactNode; @@ -167,7 +197,6 @@ interface RankablePromptListProps { function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePromptListProps) { const { data: preferences } = useGetUserPromptPreferences(); const updateRankings = useUpdatePromptRankings(); - const [sortedGroups, setSortedGroups] = useState([]); const [isDragging, setIsDragging] = useState(false); const saveTimeoutRef = useRef(null); @@ -177,54 +206,29 @@ function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePro setSortedGroups([]); return; } + const rankings = preferences?.rankings || []; const favorites = preferences?.favorites || []; - const rankingMap = new Map(rankings.map((ranking) => [ranking.promptGroupId, ranking.order])); - - const sorted = [...groups].sort((a, b) => { - const aId = a._id ?? ''; - const bId = b._id ?? ''; - const aIsFavorite = favorites.includes(aId); - const bIsFavorite = favorites.includes(bId); - - if (aIsFavorite && !bIsFavorite) return -1; - if (!aIsFavorite && bIsFavorite) return 1; - - const aRank = rankingMap.get(aId); - const bRank = rankingMap.get(bId); - if (aRank !== undefined && bRank !== undefined) { - return aRank - bRank; - } - if (aRank !== undefined) return -1; - if (bRank !== undefined) return 1; - - return a.name.localeCompare(b.name); - }); - - setSortedGroups(sorted); + setSortedGroups(sortGroups(groups, rankings, favorites)); }, [groups, preferences]); const moveItem = useCallback( (dragIndex: number, hoverIndex: number) => { if (dragIndex === hoverIndex) return; + setSortedGroups((prevGroups) => { const newGroups = [...prevGroups]; - const draggedItem = newGroups[dragIndex]; - newGroups.splice(dragIndex, 1); + const [draggedItem] = newGroups.splice(dragIndex, 1); newGroups.splice(hoverIndex, 0, draggedItem); return newGroups; }); - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - } + if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = setTimeout(() => { setSortedGroups((currentGroups) => { const newRankings = currentGroups - .map((group, index) => - typeof group._id === 'string' ? { promptGroupId: group._id, order: index } : null, - ) + .map((group, index) => (group._id ? { promptGroupId: group._id, order: index } : null)) .filter( (ranking): ranking is { promptGroupId: string; order: number } => ranking !== null, ); @@ -232,12 +236,8 @@ function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePro if (newRankings.length > 0) { updateRankings .mutateAsync({ rankings: newRankings }) - .then(() => { - onRankingChange?.(newRankings.map((r) => r.promptGroupId)); - }) - .catch((error) => { - console.error('Failed to update rankings:', error); - }); + .then(() => onRankingChange?.(newRankings.map((r) => r.promptGroupId))) + .catch(console.error); } return currentGroups; }); @@ -246,14 +246,6 @@ function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePro [updateRankings, onRankingChange], ); - useEffect(() => { - return () => { - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - } - }; - }, []); - useEffect(() => { const handleDragStart = () => setIsDragging(true); const handleDragEnd = () => setIsDragging(false); @@ -264,6 +256,7 @@ function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePro return () => { document.removeEventListener('dragstart', handleDragStart); document.removeEventListener('dragend', handleDragEnd); + if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); }; }, []); @@ -273,9 +266,7 @@ function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePro
+
{children} - +
); } -export { RankablePromptList, RankingProvider }; +export { RankablePromptList, SortedPromptList, RankingProvider };