mirror of https://github.com/jetkvm/kvm.git
				
				
				
			create sortable list component
This commit is contained in:
		
							parent
							
								
									0206d798a3
								
							
						
					
					
						commit
						48d8523122
					
				|  | @ -0,0 +1,311 @@ | |||
| import { useState, useCallback, ReactNode } from 'react'; | ||||
| import clsx from 'clsx'; | ||||
| import { LuGripVertical } from 'react-icons/lu'; | ||||
| import { cva } from "@/cva.config"; | ||||
| 
 | ||||
| interface SortableListProps<T> { | ||||
|   items: T[]; | ||||
|   keyFn: (item: T) => string; | ||||
|   onSort: (newItems: T[]) => Promise<void>; | ||||
|   children: (item: T, index: number) => ReactNode; | ||||
|   disabled?: boolean; | ||||
|   className?: string; | ||||
|   itemClassName?: string; | ||||
|   variant?: 'list' | 'grid'; | ||||
|   size?: keyof typeof sizes; | ||||
|   renderHandle?: (isDragging: boolean) => ReactNode; | ||||
|   hideHandle?: boolean; | ||||
|   handlePosition?: 'left' | 'right'; | ||||
| } | ||||
| 
 | ||||
| const sizes = { | ||||
|   XS: "min-h-[24.5px] py-1 px-3 text-xs", | ||||
|   SM: "min-h-[32px] py-1.5 px-3 text-[13px]", | ||||
|   MD: "min-h-[40px] py-2 px-4 text-sm", | ||||
|   LG: "min-h-[48px] py-2.5 px-4 text-base", | ||||
| }; | ||||
| 
 | ||||
| const containerVariants = { | ||||
|   list: { | ||||
|     XS: 'space-y-1', | ||||
|     SM: 'space-y-2', | ||||
|     MD: 'space-y-3', | ||||
|     LG: 'space-y-4' | ||||
|   }, | ||||
|   grid: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4', | ||||
| }; | ||||
| 
 | ||||
| const sortableItemVariants = cva({ | ||||
|   base: 'transition-all duration-300 ease-out rounded', | ||||
|   variants: { | ||||
|     size: sizes, | ||||
|     isDragging: { | ||||
|       true: 'shadow-lg bg-blue-100/80 dark:bg-blue-900/40 border-blue-200 dark:border-blue-800 z-50', | ||||
|       false: '' | ||||
|     }, | ||||
|     isDropTarget: { | ||||
|       true: 'border border-dashed border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/10', | ||||
|       false: '' | ||||
|     }, | ||||
|     handlePosition: { | ||||
|       left: 'flex-row', | ||||
|       right: 'flex-row-reverse' | ||||
|     }, | ||||
|     disabled: { | ||||
|       true: 'pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80', | ||||
|       false: 'hover:bg-blue-50/80 active:bg-blue-100/60 dark:hover:bg-slate-700 dark:active:bg-slate-800/60' | ||||
|     } | ||||
|   }, | ||||
|   defaultVariants: { | ||||
|     size: 'MD', | ||||
|     isDragging: false, | ||||
|     isDropTarget: false, | ||||
|     handlePosition: 'left', | ||||
|     disabled: false | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| const DefaultHandle = ({ isDragging, disabled }: { isDragging: boolean; disabled?: boolean }) => ( | ||||
|   <div className={clsx( | ||||
|     disabled ? 'cursor-not-allowed' : 'cursor-grab active:cursor-grabbing touch-none', | ||||
|     'p-1', | ||||
|     'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300', | ||||
|     isDragging && 'text-slate-700 dark:text-slate-300', | ||||
|     disabled && 'opacity-50' | ||||
|   )}> | ||||
|     <LuGripVertical className="h-4 w-4" /> | ||||
|   </div> | ||||
| ); | ||||
| 
 | ||||
| export function SortableList<T>({ | ||||
|   items, | ||||
|   keyFn, | ||||
|   onSort, | ||||
|   children, | ||||
|   disabled = false, | ||||
|   className = '', | ||||
|   itemClassName = '', | ||||
|   variant = 'list', | ||||
|   size = 'MD', | ||||
|   renderHandle, | ||||
|   hideHandle = false, | ||||
|   handlePosition = 'left', | ||||
| }: SortableListProps<T>) { | ||||
|   const [dragItem, setDragItem] = useState<number | null>(null); | ||||
|   const [dragOverItem, setDragOverItem] = useState<number | null>(null); | ||||
|   const [touchStartY, setTouchStartY] = useState<number | null>(null); | ||||
| 
 | ||||
|   const containerClasses = clsx( | ||||
|     'sortable-list', | ||||
|     variant === 'grid' ? containerVariants.grid : containerVariants.list[size], | ||||
|     className | ||||
|   ); | ||||
| 
 | ||||
|   const getItemClasses = (index: number) => clsx( | ||||
|     'relative flex items-center', | ||||
|     sortableItemVariants({  | ||||
|       size, | ||||
|       isDragging: dragItem === index, | ||||
|       isDropTarget: dragOverItem === index && dragItem !== index, | ||||
|       handlePosition, | ||||
|       disabled | ||||
|     }), | ||||
|     itemClassName | ||||
|   ); | ||||
| 
 | ||||
|   const handleDragStart = useCallback((index: number) => { | ||||
|     if (disabled) return; | ||||
|     setDragItem(index); | ||||
|      | ||||
|     const allItems = document.querySelectorAll('[data-sortable-item]'); | ||||
|     const draggedElement = allItems[index]; | ||||
|     if (draggedElement) { | ||||
|       draggedElement.classList.add('dragging'); | ||||
|     } | ||||
|   }, [disabled]); | ||||
|    | ||||
|   const handleDragOver = useCallback((e: React.DragEvent, index: number) => { | ||||
|     if (disabled) return; | ||||
|     e.preventDefault(); | ||||
|     setDragOverItem(index); | ||||
|      | ||||
|     const allItems = document.querySelectorAll('[data-sortable-item]'); | ||||
|     allItems.forEach(el => el.classList.remove('drop-target')); | ||||
|      | ||||
|     const targetElement = allItems[index]; | ||||
|     if (targetElement) { | ||||
|       targetElement.classList.add('drop-target'); | ||||
|     } | ||||
|   }, [disabled]); | ||||
|    | ||||
|   const handleDrop = useCallback(async (e: React.DragEvent) => { | ||||
|     if (disabled) return; | ||||
|     e.preventDefault(); | ||||
|     if (dragItem === null || dragOverItem === null) return; | ||||
|      | ||||
|     const itemsCopy = [...items]; | ||||
|     const draggedItem = itemsCopy.splice(dragItem, 1)[0]; | ||||
|     itemsCopy.splice(dragOverItem, 0, draggedItem); | ||||
|      | ||||
|     await onSort(itemsCopy); | ||||
|      | ||||
|     const allItems = document.querySelectorAll('[data-sortable-item]'); | ||||
|     allItems.forEach(el => { | ||||
|       el.classList.remove('drop-target'); | ||||
|       el.classList.remove('dragging'); | ||||
|     }); | ||||
|      | ||||
|     setDragItem(null); | ||||
|     setDragOverItem(null); | ||||
|   }, [disabled, dragItem, dragOverItem, items, onSort]); | ||||
| 
 | ||||
|   const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => { | ||||
|     if (disabled) return; | ||||
|     const touch = e.touches[0]; | ||||
|     setTouchStartY(touch.clientY); | ||||
|     setDragItem(index); | ||||
|      | ||||
|     const element = e.currentTarget as HTMLElement; | ||||
|     const rect = element.getBoundingClientRect(); | ||||
|      | ||||
|     // Create ghost element
 | ||||
|     const ghost = element.cloneNode(true) as HTMLElement; | ||||
|     ghost.id = 'ghost-item'; | ||||
|     ghost.className = 'sortable-ghost'; | ||||
|     ghost.style.height = `${rect.height}px`; | ||||
|     element.parentNode?.insertBefore(ghost, element); | ||||
|      | ||||
|     // Set up dragged element
 | ||||
|     element.style.position = 'fixed'; | ||||
|     element.style.left = `${rect.left}px`; | ||||
|     element.style.top = `${rect.top}px`; | ||||
|     element.style.width = `${rect.width}px`; | ||||
|     element.style.zIndex = '50'; | ||||
|   }, [disabled]); | ||||
| 
 | ||||
|   const handleTouchMove = useCallback((e: React.TouchEvent) => { | ||||
|     if (disabled || touchStartY === null || dragItem === null) return; | ||||
|      | ||||
|     const touch = e.touches[0]; | ||||
|     const deltaY = touch.clientY - touchStartY; | ||||
|     const element = e.currentTarget as HTMLElement; | ||||
|      | ||||
|     element.style.transform = `translateY(${deltaY}px)`; | ||||
|      | ||||
|     const sortableElements = document.querySelectorAll('[data-sortable-item]'); | ||||
|     const draggedRect = element.getBoundingClientRect(); | ||||
|     const draggedMiddle = draggedRect.top + draggedRect.height / 2; | ||||
|      | ||||
|     sortableElements.forEach((el, i) => { | ||||
|       if (i === dragItem) return; | ||||
|        | ||||
|       const rect = el.getBoundingClientRect(); | ||||
|       const elementMiddle = rect.top + rect.height / 2; | ||||
|       const distance = Math.abs(draggedMiddle - elementMiddle); | ||||
|        | ||||
|       if (distance < rect.height) { | ||||
|         const direction = draggedMiddle > elementMiddle ? -1 : 1; | ||||
|         (el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`; | ||||
|         (el as HTMLElement).style.transition = 'transform 0.15s ease-out'; | ||||
|       } else { | ||||
|         (el as HTMLElement).style.transform = ''; | ||||
|         (el as HTMLElement).style.transition = 'transform 0.15s ease-out'; | ||||
|       } | ||||
|     }); | ||||
|   }, [disabled, touchStartY, dragItem]); | ||||
| 
 | ||||
|   const handleTouchEnd = useCallback(async (e: React.TouchEvent) => { | ||||
|     if (disabled || dragItem === null) return; | ||||
|      | ||||
|     const element = e.currentTarget as HTMLElement; | ||||
|     const touch = e.changedTouches[0]; | ||||
|      | ||||
|     // Remove ghost element
 | ||||
|     const ghost = document.getElementById('ghost-item'); | ||||
|     ghost?.parentNode?.removeChild(ghost); | ||||
|      | ||||
|     // Reset dragged element styles
 | ||||
|     element.style.position = ''; | ||||
|     element.style.left = ''; | ||||
|     element.style.top = ''; | ||||
|     element.style.width = ''; | ||||
|     element.style.zIndex = ''; | ||||
|     element.style.transform = ''; | ||||
|     element.style.transition = ''; | ||||
|      | ||||
|     const sortableElements = document.querySelectorAll('[data-sortable-item]'); | ||||
|     let targetIndex = dragItem; | ||||
|      | ||||
|     // Find the closest element to the final touch position
 | ||||
|     const finalY = touch.clientY; | ||||
|     let closestDistance = Infinity; | ||||
|      | ||||
|     sortableElements.forEach((el, i) => { | ||||
|       if (i === dragItem) return; | ||||
|        | ||||
|       const rect = el.getBoundingClientRect(); | ||||
|       const distance = Math.abs(finalY - (rect.top + rect.height / 2)); | ||||
|        | ||||
|       if (distance < closestDistance) { | ||||
|         closestDistance = distance; | ||||
|         targetIndex = i; | ||||
|       } | ||||
|        | ||||
|       // Reset other elements
 | ||||
|       (el as HTMLElement).style.transform = ''; | ||||
|       (el as HTMLElement).style.transition = ''; | ||||
|     }); | ||||
|      | ||||
|     if (targetIndex !== dragItem && closestDistance < 50) { | ||||
|       const itemsCopy = [...items]; | ||||
|       const [draggedItem] = itemsCopy.splice(dragItem, 1); | ||||
|       itemsCopy.splice(targetIndex, 0, draggedItem); | ||||
|       await onSort(itemsCopy); | ||||
|     } | ||||
|      | ||||
|     setTouchStartY(null); | ||||
|     setDragItem(null); | ||||
|   }, [disabled, dragItem, items, onSort]); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={containerClasses}> | ||||
|       {items.map((item, index) => ( | ||||
|         <div | ||||
|           key={keyFn(item)} | ||||
|           data-sortable-item={index} | ||||
|           draggable={!disabled} | ||||
|           onDragStart={() => handleDragStart(index)} | ||||
|           onDragOver={e => handleDragOver(e, index)} | ||||
|           onDragEnd={() => { | ||||
|             const allItems = document.querySelectorAll('[data-sortable-item]'); | ||||
|             allItems.forEach(el => { | ||||
|               el.classList.remove('drop-target'); | ||||
|               el.classList.remove('dragging'); | ||||
|             }); | ||||
|           }} | ||||
|           onDrop={handleDrop} | ||||
|           onTouchStart={e => handleTouchStart(e, index)} | ||||
|           onTouchMove={handleTouchMove} | ||||
|           onTouchEnd={handleTouchEnd} | ||||
|           className={getItemClasses(index)} | ||||
|         > | ||||
|           <div className="flex-1 min-w-0 flex items-center gap-2"> | ||||
|             {handlePosition === 'left' && !hideHandle && ( | ||||
|               renderHandle ?  | ||||
|                 renderHandle(dragItem === index) :  | ||||
|                 <DefaultHandle isDragging={dragItem === index} disabled={disabled} /> | ||||
|             )} | ||||
|             <div className="flex-1 min-w-0"> | ||||
|               {children(item, index)} | ||||
|             </div> | ||||
|             {handlePosition === 'right' && !hideHandle && ( | ||||
|               renderHandle ?  | ||||
|                 renderHandle(dragItem === index) :  | ||||
|                 <DefaultHandle isDragging={dragItem === index} disabled={disabled} /> | ||||
|             )} | ||||
|           </div> | ||||
|         </div> | ||||
|       ))} | ||||
|     </div> | ||||
|   ); | ||||
| }  | ||||
|  | @ -201,46 +201,3 @@ video::-webkit-media-controls { | |||
| .hide-scrollbar::-webkit-scrollbar { | ||||
|   display: none; | ||||
| } | ||||
| 
 | ||||
| /* Macro list sortable */ | ||||
| .macro-sortable, [data-macro-item] { | ||||
|   transition: box-shadow 0.15s ease-out, background-color 0.2s ease-out, transform 0.1s, border-color 0.2s; | ||||
|   position: relative; | ||||
|   touch-action: none; | ||||
| } | ||||
| 
 | ||||
| .macro-sortable.dragging, [data-macro-item].dragging { | ||||
|   z-index: 10; | ||||
|   opacity: 0.8; | ||||
|   box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); | ||||
|   @apply bg-blue-500/10; | ||||
| } | ||||
| 
 | ||||
| .macro-sortable.drop-target, [data-macro-item].drop-target { | ||||
|   @apply border-2 border-dashed border-blue-500 bg-blue-500/5; | ||||
| } | ||||
| 
 | ||||
| .macro-sortable.ghost { | ||||
|   position: static; | ||||
|   opacity: 0.3; | ||||
|   pointer-events: none; | ||||
|   background-color: transparent; | ||||
|   border: 2px dashed rgb(148 163 184); | ||||
|   transform: none; | ||||
| } | ||||
| 
 | ||||
| .drag-handle, .macro-sortable-handle { | ||||
|   cursor: grab; | ||||
|   touch-action: none; | ||||
|   @apply flex items-center p-1 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300; | ||||
| } | ||||
| 
 | ||||
| .drag-handle:active, .macro-sortable-handle:active { | ||||
|   cursor: grabbing; | ||||
| } | ||||
| 
 | ||||
| @media (hover: none) { | ||||
|   .macro-sortable, [data-macro-item] { | ||||
|     user-select: none; | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import { useState, useEffect, useRef, useCallback, Fragment } from "react"; | ||||
| import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo, LuCopy, LuArrowUp, LuArrowDown, LuMoveRight, LuCornerDownRight } from "react-icons/lu"; | ||||
| import { useState, useEffect, useCallback, Fragment } from "react"; | ||||
| import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuInfo, LuCopy, LuArrowUp, LuArrowDown, LuMoveRight, LuCornerDownRight } from "react-icons/lu"; | ||||
| 
 | ||||
| import { KeySequence, useMacrosStore } from "@/hooks/stores"; | ||||
| import { SettingsPageHeader } from "@/components/SettingsPageheader"; | ||||
|  | @ -16,6 +16,7 @@ import EmptyCard from "@/components/EmptyCard"; | |||
| import { Combobox } from "@/components/Combobox"; | ||||
| import { CardHeader } from "@/components/CardHeader"; | ||||
| import Card from "@/components/Card"; | ||||
| import { SortableList } from "@/components/SortableList"; | ||||
| 
 | ||||
| const DEFAULT_DELAY = 50; | ||||
| 
 | ||||
|  | @ -302,122 +303,6 @@ const updateStepKeys = ( | |||
|   return newSteps; | ||||
| }; | ||||
| 
 | ||||
| const useTouchSort = (items: KeySequence[], onSort: (newItems: KeySequence[]) => void) => { | ||||
|   const [touchStartY, setTouchStartY] = useState<number | null>(null); | ||||
|   const [touchedIndex, setTouchedIndex] = useState<number | null>(null); | ||||
| 
 | ||||
|   const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => { | ||||
|     const touch = e.touches[0]; | ||||
|     setTouchStartY(touch.clientY); | ||||
|     setTouchedIndex(index); | ||||
|      | ||||
|     const element = e.currentTarget as HTMLElement; | ||||
|     const rect = element.getBoundingClientRect(); | ||||
|      | ||||
|     // Create ghost element
 | ||||
|     const ghost = element.cloneNode(true) as HTMLElement; | ||||
|     ghost.id = 'ghost-macro'; | ||||
|     ghost.className = 'macro-sortable ghost'; | ||||
|     ghost.style.height = `${rect.height}px`; | ||||
|     element.parentNode?.insertBefore(ghost, element); | ||||
|      | ||||
|     // Set up dragged element
 | ||||
|     element.style.position = 'fixed'; | ||||
|     element.style.left = `${rect.left}px`; | ||||
|     element.style.top = `${rect.top}px`; | ||||
|     element.style.width = `${rect.width}px`; | ||||
|     element.style.zIndex = '50'; | ||||
|   }, []); | ||||
| 
 | ||||
|   const handleTouchMove = useCallback((e: React.TouchEvent) => { | ||||
|     if (touchStartY === null || touchedIndex === null) return; | ||||
|      | ||||
|     const touch = e.touches[0]; | ||||
|     const deltaY = touch.clientY - touchStartY; | ||||
|     const element = e.currentTarget as HTMLElement; | ||||
|      | ||||
|     // Smooth movement of dragged element
 | ||||
|     element.style.transform = `translateY(${deltaY}px)`; | ||||
|      | ||||
|     const macroElements = document.querySelectorAll('[data-macro-item]'); | ||||
|     const draggedRect = element.getBoundingClientRect(); | ||||
|     const draggedMiddle = draggedRect.top + draggedRect.height / 2; | ||||
|      | ||||
|     macroElements.forEach((el, i) => { | ||||
|       if (i === touchedIndex) return; | ||||
|        | ||||
|       const rect = el.getBoundingClientRect(); | ||||
|       const elementMiddle = rect.top + rect.height / 2; | ||||
|       const distance = Math.abs(draggedMiddle - elementMiddle); | ||||
|        | ||||
|       if (distance < rect.height) { | ||||
|         const direction = draggedMiddle > elementMiddle ? -1 : 1; | ||||
|         (el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`; | ||||
|         (el as HTMLElement).style.transition = 'transform 0.15s ease-out'; | ||||
|       } else { | ||||
|         (el as HTMLElement).style.transform = ''; | ||||
|         (el as HTMLElement).style.transition = 'transform 0.15s ease-out'; | ||||
|       } | ||||
|     }); | ||||
|   }, [touchStartY, touchedIndex]); | ||||
| 
 | ||||
|   const handleTouchEnd = useCallback(async (e: React.TouchEvent) => { | ||||
|     if (touchedIndex === null) return; | ||||
|      | ||||
|     const element = e.currentTarget as HTMLElement; | ||||
|     const touch = e.changedTouches[0]; | ||||
|      | ||||
|     // Remove ghost element
 | ||||
|     const ghost = document.getElementById('ghost-macro'); | ||||
|     ghost?.parentNode?.removeChild(ghost); | ||||
|      | ||||
|     // Reset dragged element styles
 | ||||
|     element.style.position = ''; | ||||
|     element.style.left = ''; | ||||
|     element.style.top = ''; | ||||
|     element.style.width = ''; | ||||
|     element.style.zIndex = ''; | ||||
|     element.style.transform = ''; | ||||
|     element.style.boxShadow = ''; | ||||
|     element.style.transition = ''; | ||||
|      | ||||
|     const macroElements = document.querySelectorAll('[data-macro-item]'); | ||||
|     let targetIndex = touchedIndex; | ||||
|      | ||||
|     // Find the closest element to the final touch position
 | ||||
|     const finalY = touch.clientY; | ||||
|     let closestDistance = Infinity; | ||||
|      | ||||
|     macroElements.forEach((el, i) => { | ||||
|       if (i === touchedIndex) return; | ||||
|        | ||||
|       const rect = el.getBoundingClientRect(); | ||||
|       const distance = Math.abs(finalY - (rect.top + rect.height / 2)); | ||||
|        | ||||
|       if (distance < closestDistance) { | ||||
|         closestDistance = distance; | ||||
|         targetIndex = i; | ||||
|       } | ||||
|        | ||||
|       // Reset other elements
 | ||||
|       (el as HTMLElement).style.transform = ''; | ||||
|       (el as HTMLElement).style.transition = ''; | ||||
|     }); | ||||
|      | ||||
|     if (targetIndex !== touchedIndex && closestDistance < 50) { | ||||
|       const itemsCopy = [...items]; | ||||
|       const [draggedItem] = itemsCopy.splice(touchedIndex, 1); | ||||
|       itemsCopy.splice(targetIndex, 0, draggedItem); | ||||
|       onSort(itemsCopy); | ||||
|     } | ||||
|      | ||||
|     setTouchStartY(null); | ||||
|     setTouchedIndex(null); | ||||
|   }, [touchedIndex, items, onSort]); | ||||
| 
 | ||||
|   return { handleTouchStart, handleTouchMove, handleTouchEnd }; | ||||
| }; | ||||
| 
 | ||||
| interface StepError { | ||||
|   keys?: string; | ||||
|   modifiers?: string; | ||||
|  | @ -438,10 +323,6 @@ export default function SettingsMacrosRoute() { | |||
|     description: "", | ||||
|     steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }], | ||||
|   }); | ||||
|   const [isDragging, setIsDragging] = useState(false); | ||||
|   const dragItem = useRef<number | null>(null); | ||||
|   const dragOverItem = useRef<number | null>(null); | ||||
|    | ||||
|   const [macroToDelete, setMacroToDelete] = useState<string | null>(null); | ||||
|    | ||||
|   const [keyQueries, setKeyQueries] = useState<Record<number, string>>({}); | ||||
|  | @ -631,99 +512,6 @@ export default function SettingsMacrosRoute() { | |||
|     } | ||||
|   }, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]); | ||||
| 
 | ||||
|   const handleDragStart = (index: number) => { | ||||
|     dragItem.current = index; | ||||
|     setIsDragging(true); | ||||
|      | ||||
|     const allItems = document.querySelectorAll('[data-macro-item]'); | ||||
|     const draggedElement = allItems[index]; | ||||
|     if (draggedElement) { | ||||
|       draggedElement.classList.add('dragging'); | ||||
|     } | ||||
|   }; | ||||
|    | ||||
|   const handleDragOver = (e: React.DragEvent, index: number) => { | ||||
|     e.preventDefault(); | ||||
|     dragOverItem.current = index; | ||||
|      | ||||
|     const allItems = document.querySelectorAll('[data-macro-item]'); | ||||
|     allItems.forEach(el => el.classList.remove('drop-target')); | ||||
|      | ||||
|     const targetElement = allItems[index]; | ||||
|     if (targetElement) { | ||||
|       targetElement.classList.add('drop-target'); | ||||
|     } | ||||
|   }; | ||||
|    | ||||
|   const handleDrop = async (e: React.DragEvent) => { | ||||
|     e.preventDefault(); | ||||
|     if (dragItem.current === null || dragOverItem.current === null) return; | ||||
|      | ||||
|     const macroCopy = [...macros]; | ||||
|     const draggedItem = macroCopy.splice(dragItem.current, 1)[0]; | ||||
|     macroCopy.splice(dragOverItem.current, 0, draggedItem); | ||||
|     const updatedMacros = normalizeSortOrders(macroCopy); | ||||
| 
 | ||||
|     try { | ||||
|       await saveMacros(updatedMacros); | ||||
|       notifications.success("Macro order updated successfully"); | ||||
|     } catch (error) { | ||||
|       if (error instanceof Error) { | ||||
|         notifications.error(`Failed to reorder macros: ${error.message}`); | ||||
|         showTemporaryError(error.message); | ||||
|       } else { | ||||
|         notifications.error("Failed to reorder macros"); | ||||
|         showTemporaryError("Failed to save reordered macros"); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     const allItems = document.querySelectorAll('[data-macro-item]'); | ||||
|     allItems.forEach(el => { | ||||
|       el.classList.remove('drop-target'); | ||||
|       el.classList.remove('dragging'); | ||||
|     }); | ||||
|      | ||||
|     dragItem.current = null; | ||||
|     dragOverItem.current = null; | ||||
|     setIsDragging(false); | ||||
|   }; | ||||
| 
 | ||||
|   const handleUpdateMacro = useCallback(async () => { | ||||
|     if (!editingMacro) return; | ||||
| 
 | ||||
|     const validationErrors = validateMacro(editingMacro); | ||||
|     if (Object.keys(validationErrors).length > 0) { | ||||
|       setErrors(validationErrors); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     setIsUpdating(true); | ||||
|     try { | ||||
|       const newMacros = macros.map(m =>  | ||||
|         m.id === editingMacro.id ? { | ||||
|           ...editingMacro, | ||||
|           name: editingMacro.name.trim(), | ||||
|           description: editingMacro.description?.trim() || "", | ||||
|         } : m | ||||
|       ); | ||||
| 
 | ||||
|       await saveMacros(normalizeSortOrders(newMacros)); | ||||
|       setEditingMacro(null); | ||||
|       clearErrors(); | ||||
|       notifications.success(`Macro "${editingMacro.name}" updated successfully`); | ||||
|     } catch (error) { | ||||
|       if (error instanceof Error) { | ||||
|         notifications.error(`Failed to update macro: ${error.message}`); | ||||
|         showTemporaryError(error.message); | ||||
|       } else { | ||||
|         notifications.error("Failed to update macro"); | ||||
|         showTemporaryError("Failed to update macro"); | ||||
|       } | ||||
|     } finally { | ||||
|       setIsUpdating(false); | ||||
|     } | ||||
|   }, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]); | ||||
| 
 | ||||
|   const handleEditMacro = (macro: KeySequence) => { | ||||
|     setEditingMacro({ | ||||
|       ...macro, | ||||
|  | @ -815,28 +603,45 @@ export default function SettingsMacrosRoute() { | |||
|     return () => window.removeEventListener('resize', handleResize); | ||||
|   }, []); | ||||
| 
 | ||||
|   const { handleTouchStart, handleTouchMove, handleTouchEnd } = useTouchSort( | ||||
|     macros, | ||||
|     async (newMacros) => { | ||||
|       const updatedMacros = normalizeSortOrders(newMacros); | ||||
|       try { | ||||
|         await saveMacros(updatedMacros); | ||||
|         notifications.success("Macro order updated successfully"); | ||||
|       } catch (error) { | ||||
|         if (error instanceof Error) { | ||||
|           notifications.error(`Failed to reorder macros: ${error.message}`); | ||||
|           showTemporaryError(error.message); | ||||
|         } else { | ||||
|           notifications.error("Failed to reorder macros"); | ||||
|           showTemporaryError("Failed to save reordered macros"); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   ); | ||||
| 
 | ||||
|   const [showClearConfirm, setShowClearConfirm] = useState(false); | ||||
|   const [showAddMacro, setShowAddMacro] = useState(false); | ||||
| 
 | ||||
|   const handleUpdateMacro = useCallback(async () => { | ||||
|     if (!editingMacro) return; | ||||
| 
 | ||||
|     const validationErrors = validateMacro(editingMacro); | ||||
|     if (Object.keys(validationErrors).length > 0) { | ||||
|       setErrors(validationErrors); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     setIsUpdating(true); | ||||
|     try { | ||||
|       const newMacros = macros.map(m =>  | ||||
|         m.id === editingMacro.id ? { | ||||
|           ...editingMacro, | ||||
|           name: editingMacro.name.trim(), | ||||
|           description: editingMacro.description?.trim() || "", | ||||
|         } : m | ||||
|       ); | ||||
| 
 | ||||
|       await saveMacros(normalizeSortOrders(newMacros)); | ||||
|       setEditingMacro(null); | ||||
|       clearErrors(); | ||||
|       notifications.success(`Macro "${editingMacro.name}" updated successfully`); | ||||
|     } catch (error) { | ||||
|       if (error instanceof Error) { | ||||
|         notifications.error(`Failed to update macro: ${error.message}`); | ||||
|         showTemporaryError(error.message); | ||||
|       } else { | ||||
|         notifications.error("Failed to update macro"); | ||||
|         showTemporaryError("Failed to update macro"); | ||||
|       } | ||||
|     } finally { | ||||
|       setIsUpdating(false); | ||||
|     } | ||||
|   }, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     const handleKeyDown = (e: KeyboardEvent) => { | ||||
|       if (e.key === 'Escape' && editingMacro) { | ||||
|  | @ -886,7 +691,6 @@ export default function SettingsMacrosRoute() { | |||
|       <FieldError error={error} /> | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="space-y-4"> | ||||
|           <SettingsPageHeader | ||||
|  | @ -1087,10 +891,33 @@ export default function SettingsMacrosRoute() { | |||
|           /> | ||||
|         )} | ||||
|         {macros.length > 0 && ( | ||||
|           <div className="space-y-1"> | ||||
|             {macros.map((macro, index) =>  | ||||
|           <SortableList<KeySequence> | ||||
|             keyFn={(macro) => macro.id} | ||||
|             items={macros} | ||||
|             itemClassName="rounded-md border border-slate-200 p-2 dark:border-slate-700 bg-white dark:bg-slate-800" | ||||
|             onSort={async (newMacros) => { | ||||
|               const updatedMacros = normalizeSortOrders(newMacros); | ||||
|               try { | ||||
|                 await saveMacros(updatedMacros); | ||||
|                 notifications.success("Macro order updated successfully"); | ||||
|               } catch (error) { | ||||
|                 if (error instanceof Error) { | ||||
|                   notifications.error(`Failed to reorder macros: ${error.message}`); | ||||
|                   showTemporaryError(error.message); | ||||
|                 } else { | ||||
|                   notifications.error("Failed to reorder macros"); | ||||
|                   showTemporaryError("Failed to save reordered macros"); | ||||
|                 } | ||||
|               } | ||||
|             }} | ||||
|             disabled={!!editingMacro} | ||||
|             variant="list" | ||||
|             size="XS" | ||||
|             handlePosition="left" | ||||
|           > | ||||
|             {(macro) => ( | ||||
|               editingMacro && editingMacro.id === macro.id ? ( | ||||
|                 <Card key={macro.id} className="border-blue-300 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/20"> | ||||
|                 <Card className="border-blue-300 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/20"> | ||||
|                   <CardHeader | ||||
|                     headline="Edit Macro" | ||||
|                   /> | ||||
|  | @ -1211,35 +1038,8 @@ export default function SettingsMacrosRoute() { | |||
|                   </div> | ||||
|                 </Card> | ||||
|               ) : ( | ||||
|                 <div | ||||
|                   key={macro.id} | ||||
|                   data-macro-item={index} | ||||
|                   draggable={!editingMacro} | ||||
|                   onDragStart={() => handleDragStart(index)} | ||||
|                   onDragOver={e => handleDragOver(e, index)} | ||||
|                   onDragEnd={() => { | ||||
|                     const allItems = document.querySelectorAll('[data-macro-item]'); | ||||
|                     allItems.forEach(el => { | ||||
|                       el.classList.remove('drop-target'); | ||||
|                       el.classList.remove('dragging'); | ||||
|                     }); | ||||
|                     setIsDragging(false); | ||||
|                   }} | ||||
|                   onDrop={handleDrop} | ||||
|                   onTouchStart={(e) => handleTouchStart(e, index)} | ||||
|                   onTouchMove={handleTouchMove} | ||||
|                   onTouchEnd={handleTouchEnd} | ||||
|                   className={`macro-sortable flex items-center justify-between rounded-md border border-slate-200 p-2 dark:border-slate-700 ${ | ||||
|                     isDragging && dragItem.current === index | ||||
|                       ? "bg-blue-50 dark:bg-blue-900/20" | ||||
|                       : "bg-white dark:bg-slate-800" | ||||
|                   }`}
 | ||||
|                 > | ||||
|                   <div className="macro-sortable-handle"> | ||||
|                     <LuGripVertical className="h-4 w-4" /> | ||||
|                   </div> | ||||
|                    | ||||
|                   <div className="pl-4 flex-1 overflow-hidden"> | ||||
|                 <div className="flex items-center justify-between"> | ||||
|                   <div className="flex-1 min-w-0 flex flex-col justify-center"> | ||||
|                     <h3 className="truncate text-sm font-semibold text-black dark:text-white"> | ||||
|                       {macro.name} | ||||
|                     </h3> | ||||
|  | @ -1292,7 +1092,7 @@ export default function SettingsMacrosRoute() { | |||
|                     </p> | ||||
|                   </div> | ||||
|                    | ||||
|                   <div className="flex gap-1 ml-2 flex-shrink-0"> | ||||
|                   <div className="flex items-center gap-1 ml-4"> | ||||
|                     {macroToDelete === macro.id ? ( | ||||
|                       <div className="flex items-center gap-2"> | ||||
|                         <span className="text-sm text-slate-600 dark:text-slate-400"> | ||||
|  | @ -1343,7 +1143,7 @@ export default function SettingsMacrosRoute() { | |||
|                 </div> | ||||
|               ) | ||||
|             )} | ||||
|           </div> | ||||
|           </SortableList> | ||||
|         )} | ||||
|       </div> | ||||
|     </div> | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue