From 4438dbbe0c86e959f267ed465ec2aae62f59bb8a Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Thu, 3 Apr 2025 17:49:29 +1000 Subject: [PATCH] remove sortable list and simplify --- ui/src/components/SortableList.tsx | 311 ------------------ ui/src/routes/devices.$id.settings.macros.tsx | 246 ++++++++------ 2 files changed, 147 insertions(+), 410 deletions(-) delete mode 100644 ui/src/components/SortableList.tsx diff --git a/ui/src/components/SortableList.tsx b/ui/src/components/SortableList.tsx deleted file mode 100644 index 9cd3669..0000000 --- a/ui/src/components/SortableList.tsx +++ /dev/null @@ -1,311 +0,0 @@ -import { useState, useCallback, ReactNode } from 'react'; -import clsx from 'clsx'; -import { LuGripVertical } from 'react-icons/lu'; -import { cva } from "@/cva.config"; - -interface SortableListProps { - items: T[]; - keyFn: (item: T) => string; - onSort: (newItems: T[]) => Promise; - 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 }) => ( -
- -
-); - -export function SortableList({ - items, - keyFn, - onSort, - children, - disabled = false, - className = '', - itemClassName = '', - variant = 'list', - size = 'MD', - renderHandle, - hideHandle = false, - handlePosition = 'left', -}: SortableListProps) { - const [dragItem, setDragItem] = useState(null); - const [dragOverItem, setDragOverItem] = useState(null); - const [touchStartY, setTouchStartY] = useState(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 ( -
- {items.map((item, index) => ( -
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)} - > -
- {handlePosition === 'left' && !hideHandle && ( - renderHandle ? - renderHandle(dragItem === index) : - - )} -
- {children(item, index)} -
- {handlePosition === 'right' && !hideHandle && ( - renderHandle ? - renderHandle(dragItem === index) : - - )} -
-
- ))} -
- ); -} \ No newline at end of file diff --git a/ui/src/routes/devices.$id.settings.macros.tsx b/ui/src/routes/devices.$id.settings.macros.tsx index c8213d1..7ba7f20 100644 --- a/ui/src/routes/devices.$id.settings.macros.tsx +++ b/ui/src/routes/devices.$id.settings.macros.tsx @@ -1,12 +1,12 @@ -import { useEffect, Fragment, useMemo } from "react"; +import { useEffect, Fragment, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight } from "react-icons/lu"; +import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown } from "react-icons/lu"; import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores"; import { SettingsPageHeader } from "@/components/SettingsPageheader"; import { Button } from "@/components/Button"; import EmptyCard from "@/components/EmptyCard"; -import { SortableList } from "@/components/SortableList"; +import Card from "@/components/Card"; import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros"; import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings"; import notifications from "@/notifications"; @@ -22,6 +22,7 @@ const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => { export default function SettingsMacrosRoute() { const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore(); const navigate = useNavigate(); + const [actionLoadingId, setActionLoadingId] = useState(null); const isMaxMacrosReached = useMemo(() => macros.length >= MAX_TOTAL_MACROS, @@ -35,11 +36,18 @@ export default function SettingsMacrosRoute() { }, [initialized, loadMacros]); const handleDuplicateMacro = async (macro: KeySequence) => { + if (!macro?.id || !macro?.name) { + notifications.error("Invalid macro data"); + return; + } + if (isMaxMacrosReached) { notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`); return; } + setActionLoadingId(macro.id); + const newMacroCopy: KeySequence = { ...JSON.parse(JSON.stringify(macro)), id: generateMacroId(), @@ -56,9 +64,141 @@ export default function SettingsMacrosRoute() { } else { notifications.error("Failed to duplicate macro"); } + } finally { + setActionLoadingId(null); } }; + const handleMoveMacro = async (index: number, direction: 'up' | 'down', macroId: string) => { + if (!Array.isArray(macros) || macros.length === 0) { + notifications.error("No macros available"); + return; + } + + const newIndex = direction === 'up' ? index - 1 : index + 1; + if (newIndex < 0 || newIndex >= macros.length) return; + + setActionLoadingId(macroId); + + try { + const newMacros = [...macros]; + [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]]; + const updatedMacros = normalizeSortOrders(newMacros); + + await saveMacros(updatedMacros); + notifications.success("Macro order updated successfully"); + } catch (error: unknown) { + if (error instanceof Error) { + notifications.error(`Failed to reorder macros: ${error.message}`); + } else { + notifications.error("Failed to reorder macros"); + } + } finally { + setActionLoadingId(null); + } + }; + + const MacroList = useMemo(() => ( +
+ {macros.map((macro, index) => ( + +
+
+
+ +
+

+ {macro.name} +

+

+ + {macro.steps.map((step, stepIndex) => { + const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight; + + return ( + + + + {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? ( + <> + {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => ( + + + {modifierDisplayMap[modifier] || modifier} + + {idx < step.modifiers.length - 1 && ( + + + )} + + ))} + + {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && ( + + + )} + + {Array.isArray(step.keys) && step.keys.map((key, idx) => ( + + + {keyDisplayMap[key] || key} + + {idx < step.keys.length - 1 && ( + + + )} + + ))} + + ) : ( + Delay only + )} + ({step.delay}ms) + + + ); + })} + +

+
+ +
+
+
+
+ ))} +
+ ), [macros, actionLoadingId]); + return (
navigate("add")} disabled={isMaxMacrosReached} + aria-label="Add new macro" />
- {loading ? ( + {loading && macros.length === 0 ? ( navigate("add")} disabled={isMaxMacrosReached} + aria-label="Add new macro" /> } /> - ) : ( - - 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: unknown) { - if (error instanceof Error) { - notifications.error(`Failed to reorder macros: ${error.message}`); - } else { - notifications.error("Failed to reorder macros"); - } - } - }} - variant="list" - size="XS" - handlePosition="left" - > - {(macro) => ( -
-
-

- {macro.name} -

-

- - {macro.steps.map((step, stepIndex) => { - const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight; - - return ( - - - - {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? ( - <> - {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => ( - - - {modifierDisplayMap[modifier] || modifier} - - {idx < step.modifiers.length - 1 && ( - + - )} - - ))} - - {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && ( - + - )} - - {Array.isArray(step.keys) && step.keys.map((key, idx) => ( - - - {keyDisplayMap[key] || key} - - {idx < step.keys.length - 1 && ( - + - )} - - ))} - - ) : ( - Delay only - )} - ({step.delay}ms) - - - ); - })} - -

-
- -
-
-
- )} - - )} + ) : MacroList}
);