From 7b8725892de712d595cc4fbd262ceeed512ba4f2 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Thu, 3 Apr 2025 16:01:22 +1000 Subject: [PATCH] split up macro routes --- ui/src/components/Combobox.tsx | 47 +- ui/src/components/MacroBar.tsx | 1 - ui/src/components/MacroForm.tsx | 362 +++++ ui/src/components/MacroStepCard.tsx | 246 ++++ ui/src/constants/macros.ts | 5 + ui/src/hooks/stores.ts | 8 +- ui/src/main.tsx | 34 +- .../devices.$id.settings.macros.add.tsx | 63 + .../devices.$id.settings.macros.edit.tsx | 108 ++ ui/src/routes/devices.$id.settings.macros.tsx | 1181 ++--------------- 10 files changed, 959 insertions(+), 1096 deletions(-) create mode 100644 ui/src/components/MacroForm.tsx create mode 100644 ui/src/components/MacroStepCard.tsx create mode 100644 ui/src/constants/macros.ts create mode 100644 ui/src/routes/devices.$id.settings.macros.add.tsx create mode 100644 ui/src/routes/devices.$id.settings.macros.edit.tsx diff --git a/ui/src/components/Combobox.tsx b/ui/src/components/Combobox.tsx index f1342d4..383d88a 100644 --- a/ui/src/components/Combobox.tsx +++ b/ui/src/components/Combobox.tsx @@ -1,9 +1,7 @@ import { useRef } from "react"; import clsx from "clsx"; - -import { Combobox as HeadlessCombobox, ComboboxProps as HeadlessComboboxProps, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"; +import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"; import { cva } from "@/cva.config"; - import Card from "./Card"; export interface ComboboxOption { @@ -16,15 +14,15 @@ const sizes = { SM: "h-[32px] pl-3 pr-8 text-[13px]", MD: "h-[40px] pl-4 pr-10 text-sm", LG: "h-[48px] pl-4 pr-10 px-5 text-base", -}; +} as const; const comboboxVariants = cva({ variants: { size: sizes }, }); -interface ComboboxProps<T> extends HeadlessComboboxProps<T, boolean, React.ExoticComponent<{ - children?: React.ReactNode; -}>> { +type BaseProps = React.ComponentProps<typeof HeadlessCombobox>; + +interface ComboboxProps extends Omit<BaseProps, 'displayValue'> { displayValue: (option: ComboboxOption) => string; onInputChange: (option: string) => void; options: () => ComboboxOption[]; @@ -34,7 +32,7 @@ interface ComboboxProps<T> extends HeadlessComboboxProps<T, boolean, React.Exoti disabledMessage?: string; } -export function Combobox<T>({ +export function Combobox({ onInputChange, displayValue, options, @@ -45,11 +43,11 @@ export function Combobox<T>({ onChange, disabledMessage = "Input disabled", ...otherProps -}: ComboboxProps<T>) { +}: ComboboxProps) { const inputRef = useRef<HTMLInputElement>(null); const classes = comboboxVariants({ size }); - const handleChange = (value: T) => { + const handleChange = (value: unknown) => { if (onChange) { onChange(value); inputRef.current?.blur(); @@ -57,14 +55,13 @@ export function Combobox<T>({ }; return ( - <HeadlessCombobox<T, boolean, React.ExoticComponent<{ children?: React.ReactNode;}>> - immediate + <HeadlessCombobox onChange={handleChange} {...otherProps} > - {() => ( + {() => ( <> - <Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30"> + <Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30"> <ComboboxInput ref={inputRef} className={clsx( @@ -90,11 +87,11 @@ export function Combobox<T>({ onChange={(event) => onInputChange(event.target.value)} disabled={disabled} /> - </Card> - - {options().length > 0 && ( + </Card> + + {options().length > 0 && ( <ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar"> - {options().map((option) => ( + {options().map((option) => ( <ComboboxOption key={option.value} value={option} @@ -109,21 +106,21 @@ export function Combobox<T>({ "dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200" )} > - {option.label} + {option.label} </ComboboxOption> - ))} + ))} </ComboboxOptions> - )} - - {options().length === 0 && inputRef.current?.value && ( + )} + + {options().length === 0 && inputRef.current?.value && ( <div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white dark:bg-slate-800 py-2 px-4 text-sm shadow-lg ring-1 ring-black/5 dark:ring-slate-700"> <div className="text-slate-500 dark:text-slate-400"> {emptyMessage} </div> </div> - )} + )} </> - )} + )} </HeadlessCombobox> ); } \ No newline at end of file diff --git a/ui/src/components/MacroBar.tsx b/ui/src/components/MacroBar.tsx index a0a7275..107d8de 100644 --- a/ui/src/components/MacroBar.tsx +++ b/ui/src/components/MacroBar.tsx @@ -12,7 +12,6 @@ export default function MacroBar() { const { executeMacro } = useKeyboard(); const [send] = useJsonRpc(); - // Set up sendFn and initialize macros if needed useEffect(() => { setSendFn(send); diff --git a/ui/src/components/MacroForm.tsx b/ui/src/components/MacroForm.tsx new file mode 100644 index 0000000..c482f7b --- /dev/null +++ b/ui/src/components/MacroForm.tsx @@ -0,0 +1,362 @@ +import { useState } from "react"; + +import { LuPlus, LuInfo } from "react-icons/lu"; + +import { KeySequence } from "@/hooks/stores"; +import { Button } from "@/components/Button"; +import { InputFieldWithLabel, FieldError } from "@/components/InputField"; +import Fieldset from "@/components/Fieldset"; +import { MacroStepCard } from "@/components/MacroStepCard"; +import Modal from "@/components/Modal"; +import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros"; + +interface ValidationErrors { + name?: string; + steps?: Record<number, { + keys?: string; + modifiers?: string; + delay?: string; + }>; +} + +interface MacroFormProps { + initialData: Partial<KeySequence>; + onSubmit: (macro: Partial<KeySequence>) => Promise<void>; + onCancel: () => void; + isSubmitting?: boolean; + submitText?: string; + showCancelConfirm?: boolean; + onCancelConfirm?: () => void; + showDelete?: boolean; + onDelete?: () => void; + isDeleting?: boolean; +} + +export function MacroForm({ + initialData, + onSubmit, + onCancel, + isSubmitting = false, + submitText = "Save Macro", + showCancelConfirm = false, + onCancelConfirm, + showDelete = false, + onDelete, + isDeleting = false +}: MacroFormProps) { + const [macro, setMacro] = useState<Partial<KeySequence>>(initialData); + const [keyQueries, setKeyQueries] = useState<Record<number, string>>({}); + const [errors, setErrors] = useState<ValidationErrors>({}); + const [errorMessage, setErrorMessage] = useState<string | null>(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const showTemporaryError = (message: string) => { + setErrorMessage(message); + setTimeout(() => setErrorMessage(null), 3000); + }; + + const validateForm = (): boolean => { + const newErrors: ValidationErrors = {}; + + // Name validation + if (!macro.name?.trim()) { + newErrors.name = "Name is required"; + } else if (macro.name.trim().length > 50) { + newErrors.name = "Name must be less than 50 characters"; + } + + if (!macro.steps?.length) { + newErrors.steps = { 0: { keys: "At least one step is required" } }; + } else { + const hasKeyOrModifier = macro.steps.some(step => + (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0 + ); + + if (!hasKeyOrModifier) { + newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } }; + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async () => { + if (!validateForm()) { + showTemporaryError("Please fix the validation errors"); + return; + } + + try { + await onSubmit(macro); + } catch (error) { + if (error instanceof Error) { + showTemporaryError(error.message); + } else { + showTemporaryError("An error occurred while saving"); + } + } + }; + + const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => { + const newSteps = [...(macro.steps || [])]; + if (!newSteps[stepIndex]) return; + + if (option.keys) { + newSteps[stepIndex].keys = option.keys; + } else if (option.value) { + if (!newSteps[stepIndex].keys) { + newSteps[stepIndex].keys = []; + } + const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : []; + if (keysArray.length >= MAX_KEYS_PER_STEP) { + showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`); + return; + } + newSteps[stepIndex].keys = [...keysArray, option.value]; + } + setMacro({ ...macro, steps: newSteps }); + + if (errors.steps?.[stepIndex]?.keys) { + const newErrors = { ...errors }; + delete newErrors.steps?.[stepIndex].keys; + if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) { + delete newErrors.steps?.[stepIndex]; + } + if (Object.keys(newErrors.steps || {}).length === 0) { + delete newErrors.steps; + } + setErrors(newErrors); + } + }; + + const handleKeyQueryChange = (stepIndex: number, query: string) => { + setKeyQueries(prev => ({ ...prev, [stepIndex]: query })); + }; + + const handleModifierChange = (stepIndex: number, modifiers: string[]) => { + const newSteps = [...(macro.steps || [])]; + newSteps[stepIndex].modifiers = modifiers; + setMacro({ ...macro, steps: newSteps }); + + // Clear step errors when modifiers are added + if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) { + const newErrors = { ...errors }; + delete newErrors.steps?.[stepIndex].keys; + if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) { + delete newErrors.steps?.[stepIndex]; + } + if (Object.keys(newErrors.steps || {}).length === 0) { + delete newErrors.steps; + } + setErrors(newErrors); + } + }; + + const handleDelayChange = (stepIndex: number, delay: number) => { + const newSteps = [...(macro.steps || [])]; + newSteps[stepIndex].delay = delay; + setMacro({ ...macro, steps: newSteps }); + }; + + const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => { + const newSteps = [...(macro.steps || [])]; + const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1; + [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]]; + setMacro({ ...macro, steps: newSteps }); + }; + + const isMaxStepsReached = (macro.steps?.length || 0) >= MAX_STEPS_PER_MACRO; + + return ( + <> + <div className="space-y-4"> + <Fieldset> + <InputFieldWithLabel + type="text" + label="Macro Name" + placeholder="Macro Name" + value={macro.name} + error={errors.name} + onChange={e => { + setMacro(prev => ({ ...prev, name: e.target.value })); + if (errors.name) { + const newErrors = { ...errors }; + delete newErrors.name; + setErrors(newErrors); + } + }} + /> + </Fieldset> + + <div> + <div className="flex items-center justify-between text-sm"> + <div className="flex items-center gap-1"> + <label className="font-medium text-slate-700 dark:text-slate-200"> + Steps + </label> + <div className="group relative cursor-pointer"> + <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" /> + <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700"> + <p>Each step is a collection of keys and/or modifiers that will be executed in order. You can add up to a maximum of {MAX_STEPS_PER_MACRO} steps per macro.</p> + </div> + </div> + </div> + <span className="text-slate-500 dark:text-slate-400"> + {macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps + </span> + </div> + {errors.steps && errors.steps[0]?.keys && ( + <div className="mt-2"> + <FieldError error={errors.steps[0].keys} /> + </div> + )} + <Fieldset> + <div className="mt-2 space-y-4"> + {(macro.steps || []).map((step, stepIndex) => ( + <MacroStepCard + key={stepIndex} + step={step} + stepIndex={stepIndex} + onDelete={macro.steps && macro.steps.length > 1 ? () => { + const newSteps = [...(macro.steps || [])]; + newSteps.splice(stepIndex, 1); + setMacro(prev => ({ ...prev, steps: newSteps })); + } : undefined} + onMoveUp={() => handleStepMove(stepIndex, 'up')} + onMoveDown={() => handleStepMove(stepIndex, 'down')} + onKeySelect={(option) => handleKeySelect(stepIndex, option)} + onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)} + keyQuery={keyQueries[stepIndex] || ''} + onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)} + onDelayChange={(delay) => handleDelayChange(stepIndex, delay)} + isLastStep={stepIndex === (macro.steps?.length || 0) - 1} + /> + ))} + </div> + </Fieldset> + + <div className="mt-4"> + <Button + size="MD" + theme="light" + fullWidth + LeadingIcon={LuPlus} + text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`} + onClick={() => { + if (isMaxStepsReached) { + showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`); + return; + } + + setMacro(prev => ({ + ...prev, + steps: [ + ...(prev.steps || []), + { keys: [], modifiers: [], delay: DEFAULT_DELAY } + ], + })); + setErrors({}); + }} + disabled={isMaxStepsReached} + /> + </div> + + {errorMessage && ( + <div className="mt-4"> + <FieldError error={errorMessage} /> + </div> + )} + + <div className="mt-6 flex items-center justify-between"> + {showCancelConfirm ? ( + <div className="flex items-center gap-2"> + <span className="text-sm text-slate-600 dark:text-slate-400"> + Cancel changes? + </span> + <Button + size="SM" + theme="danger" + text="Yes" + onClick={onCancelConfirm} + /> + <Button + size="SM" + theme="light" + text="No" + onClick={() => onCancel()} + /> + </div> + ) : ( + <> + <div className="flex gap-x-2"> + <Button + size="SM" + theme="primary" + text={isSubmitting ? "Saving..." : submitText} + onClick={handleSubmit} + disabled={isSubmitting} + /> + <Button + size="SM" + theme="light" + text="Cancel" + onClick={onCancel} + /> + </div> + {showDelete && ( + <Button + size="SM" + theme="danger" + text={isDeleting ? "Deleting..." : "Delete Macro"} + onClick={() => setShowDeleteConfirm(true)} + disabled={isDeleting} + /> + )} + </> + )} + </div> + </div> + </div> + + <Modal + open={showDeleteConfirm} + onClose={() => setShowDeleteConfirm(false)} + > + <div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out"> + <div className="relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800 pointer-events-auto"> + <div className="space-y-4"> + <div className="space-y-0"> + <h2 className="text-lg font-bold leading-tight text-black dark:text-white"> + Delete Macro + </h2> + <div className="text-sm leading-snug text-slate-600 dark:text-slate-400"> + Are you sure you want to delete this macro? This action cannot be undone. + </div> + </div> + + <div className="flex justify-end gap-x-2"> + <Button + size="SM" + theme="danger" + text={isDeleting ? "Deleting..." : "Delete"} + onClick={() => { + onDelete?.(); + setShowDeleteConfirm(false); + }} + disabled={isDeleting} + /> + <Button + size="SM" + theme="light" + text="Cancel" + onClick={() => setShowDeleteConfirm(false)} + /> + </div> + </div> + </div> + </div> + </Modal> + </> + ); +} \ No newline at end of file diff --git a/ui/src/components/MacroStepCard.tsx b/ui/src/components/MacroStepCard.tsx new file mode 100644 index 0000000..1bd6329 --- /dev/null +++ b/ui/src/components/MacroStepCard.tsx @@ -0,0 +1,246 @@ +import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu"; + +import { Button } from "@/components/Button"; +import { Combobox } from "@/components/Combobox"; +import { SelectMenuBasic } from "@/components/SelectMenuBasic"; +import Card from "@/components/Card"; +import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings"; +import { MAX_KEYS_PER_STEP } from "@/constants/macros"; + +// Filter out modifier keys since they're handled in the modifiers section +const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta']; + +const keyOptions = Object.keys(keys) + .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix))) + .map(key => ({ + value: key, + label: keyDisplayMap[key] || key, + })); + +const modifierOptions = Object.keys(modifiers).map(modifier => ({ + value: modifier, + label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"), +})); + +const groupedModifiers: Record<string, typeof modifierOptions> = { + Control: modifierOptions.filter(mod => mod.value.startsWith('Control')), + Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')), + Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')), + Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')), +}; + +const PRESET_DELAYS = [ + { value: "50", label: "50ms" }, + { value: "100", label: "100ms" }, + { value: "200", label: "200ms" }, + { value: "300", label: "300ms" }, + { value: "500", label: "500ms" }, + { value: "750", label: "750ms" }, + { value: "1000", label: "1000ms" }, + { value: "1500", label: "1500ms" }, + { value: "2000", label: "2000ms" }, +]; + +interface MacroStep { + keys: string[]; + modifiers: string[]; + delay: number; +} + +interface MacroStepCardProps { + step: MacroStep; + stepIndex: number; + onDelete?: () => void; + onMoveUp?: () => void; + onMoveDown?: () => void; + onKeySelect: (option: { value: string | null; keys?: string[] }) => void; + onKeyQueryChange: (query: string) => void; + keyQuery: string; + onModifierChange: (modifiers: string[]) => void; + onDelayChange: (delay: number) => void; + isLastStep: boolean; +} + +const ensureArray = <T,>(arr: T[] | null | undefined): T[] => { + return Array.isArray(arr) ? arr : []; +}; + +export function MacroStepCard({ + step, + stepIndex, + onDelete, + onMoveUp, + onMoveDown, + onKeySelect, + onKeyQueryChange, + keyQuery, + onModifierChange, + onDelayChange, + isLastStep +}: MacroStepCardProps) { + const getFilteredKeys = () => { + const selectedKeys = ensureArray(step.keys); + const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value)); + + if (keyQuery === '') { + return availableKeys; + } else { + return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase())); + } + }; + + return ( + <Card className="p-4"> + <div className="mb-2 flex items-center justify-between"> + <div className="flex items-center gap-1.5"> + <div className="flex items-center gap-1"> + <Button + size="XS" + theme="light" + onClick={onMoveUp} + disabled={stepIndex === 0} + LeadingIcon={LuArrowUp} + /> + <Button + size="XS" + theme="light" + onClick={onMoveDown} + disabled={isLastStep} + LeadingIcon={LuArrowDown} + /> + </div> + <span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"> + {stepIndex + 1} + </span> + </div> + + <div className="flex items-center space-x-2"> + {onDelete && ( + <Button + size="XS" + theme="danger" + text="Delete" + onClick={onDelete} + /> + )} + </div> + </div> + + <div className="space-y-4 mt-2"> + <div className="w-full flex flex-col gap-2"> + <label className="text-sm font-medium text-slate-700 dark:text-slate-300"> + Modifiers + </label> + <div className="inline-flex flex-wrap gap-3"> + {Object.entries(groupedModifiers).map(([group, mods]) => ( + <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2"> + <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400"> + {group} + </span> + <div className="flex flex-wrap gap-1"> + {mods.map(option => ( + <label + key={option.value} + className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${ + ensureArray(step.modifiers).includes(option.value) + ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' + : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700' + }`} + > + <input + type="checkbox" + className="sr-only" + checked={ensureArray(step.modifiers).includes(option.value)} + onChange={e => { + const modifiersArray = ensureArray(step.modifiers); + const newModifiers = e.target.checked + ? [...modifiersArray, option.value] + : modifiersArray.filter(m => m !== option.value); + onModifierChange(newModifiers); + }} + /> + {option.label.split(' ')[1] || option.label} + </label> + ))} + </div> + </div> + ))} + </div> + </div> + + <div className="w-full flex flex-col gap-1"> + <div className="flex items-center gap-1"> + <label className="text-sm font-medium text-slate-700 dark:text-slate-300"> + Keys + </label> + <div className="group relative cursor-pointer"> + <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" /> + <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700"> + <p>You can add up to a maximum of {MAX_KEYS_PER_STEP} keys to press per step.</p> + </div> + </div> + </div> + <div className="flex flex-wrap gap-1 pb-2"> + {ensureArray(step.keys).map((key, keyIndex) => ( + <span + key={keyIndex} + className="inline-flex items-center rounded-md bg-blue-100 px-1 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200" + > + <span className="px-1"> + {keyDisplayMap[key] || key} + </span> + <Button + size="XS" + className="" + theme="blank" + onClick={() => { + const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex); + onKeySelect({ value: null, keys: newKeys }); + }} + LeadingIcon={LuX} + /> + </span> + ))} + </div> + <div className="relative w-full"> + <Combobox + onChange={(value: { value: string; label: string }) => onKeySelect(value)} + displayValue={() => keyQuery} + onInputChange={onKeyQueryChange} + options={getFilteredKeys} + disabledMessage="Max keys reached" + size="SM" + immediate + disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP} + placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."} + emptyMessage="No matching keys found" + /> + </div> + </div> + + <div className="w-full flex flex-col gap-1"> + <div className="flex items-center gap-1"> + <label className="text-sm font-medium text-slate-700 dark:text-slate-300"> + Step Duration + </label> + <div className="group relative cursor-pointer"> + <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" /> + <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700"> + <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p> + </div> + </div> + </div> + <div className="flex items-center gap-3"> + <SelectMenuBasic + size="SM" + fullWidth + value={step.delay.toString()} + onChange={(e) => onDelayChange(parseInt(e.target.value, 10))} + options={PRESET_DELAYS} + /> + </div> + </div> + </div> + </Card> + ); +} \ No newline at end of file diff --git a/ui/src/constants/macros.ts b/ui/src/constants/macros.ts new file mode 100644 index 0000000..853cfe9 --- /dev/null +++ b/ui/src/constants/macros.ts @@ -0,0 +1,5 @@ +export const DEFAULT_DELAY = 50; +export const MAX_STEPS_PER_MACRO = 10; +export const MAX_KEYS_PER_STEP = 10; +export const MAX_TOTAL_MACROS = 25; +export const COPY_SUFFIX = "(copy)"; \ No newline at end of file diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 05bfdf0..0fa4121 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { MAX_STEPS_PER_MACRO, MAX_TOTAL_MACROS, MAX_KEYS_PER_STEP } from "@/constants/macros"; // Define the JsonRpc types for better type checking interface JsonRpcResponse { @@ -671,7 +672,6 @@ export interface KeySequenceStep { export interface KeySequence { id: string; name: string; - description?: string; steps: KeySequenceStep[]; sortOrder?: number; } @@ -686,9 +686,9 @@ export interface MacrosState { setSendFn: (sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void)) => void; } -const MAX_STEPS_PER_MACRO = 10; -const MAX_TOTAL_MACROS = 25; -const MAX_KEYS_PER_STEP = 10; +export const generateMacroId = () => { + return Math.random().toString(36).substring(2, 9); +}; export const useMacrosStore = create<MacrosState>((set, get) => ({ macros: [], diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 4b29129..e09a2a9 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -40,10 +40,12 @@ import * as SettingsAccessIndexRoute from "./routes/devices.$id.settings.access. import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware"; import SettingsVideoRoute from "./routes/devices.$id.settings.video"; import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance"; -import SettingsMacrosRoute from "./routes/devices.$id.settings.macros"; import * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index"; import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update"; import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth"; +import SettingsMacrosRoute from "./routes/devices.$id.settings.macros"; +import SettingsMacrosAddRoute from "./routes/devices.$id.settings.macros.add"; +import SettingsMacrosEditRoute from "./routes/devices.$id.settings.macros.edit"; export const isOnDevice = import.meta.env.MODE === "device"; export const isInCloud = !isOnDevice; @@ -178,7 +180,20 @@ if (isOnDevice) { }, { path: "macros", - element: <SettingsMacrosRoute />, + children: [ + { + index: true, + element: <SettingsMacrosRoute />, + }, + { + path: "add", + element: <SettingsMacrosAddRoute />, + }, + { + path: ":macroId/edit", + element: <SettingsMacrosEditRoute />, + }, + ], }, ], }, @@ -290,7 +305,20 @@ if (isOnDevice) { }, { path: "macros", - element: <SettingsMacrosRoute />, + children: [ + { + index: true, + element: <SettingsMacrosRoute />, + }, + { + path: "add", + element: <SettingsMacrosAddRoute />, + }, + { + path: ":macroId/edit", + element: <SettingsMacrosEditRoute />, + }, + ], }, ], }, diff --git a/ui/src/routes/devices.$id.settings.macros.add.tsx b/ui/src/routes/devices.$id.settings.macros.add.tsx new file mode 100644 index 0000000..1b3ce30 --- /dev/null +++ b/ui/src/routes/devices.$id.settings.macros.add.tsx @@ -0,0 +1,63 @@ +import { useNavigate } from "react-router-dom"; +import { useState } from "react"; + +import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores"; +import { SettingsPageHeader } from "@/components/SettingsPageheader"; +import { MacroForm } from "@/components/MacroForm"; +import { DEFAULT_DELAY } from "@/constants/macros"; +import notifications from "@/notifications"; + +export default function SettingsMacrosAddRoute() { + const { macros, saveMacros } = useMacrosStore(); + const [isSaving, setIsSaving] = useState(false); + const navigate = useNavigate(); + + const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => { + return macros.map((macro, index) => ({ + ...macro, + sortOrder: index + 1, + })); + }; + + const handleAddMacro = async (macro: Partial<KeySequence>) => { + setIsSaving(true); + try { + const newMacro: KeySequence = { + id: generateMacroId(), + name: macro.name!.trim(), + steps: macro.steps || [], + sortOrder: macros.length + 1, + }; + + await saveMacros(normalizeSortOrders([...macros, newMacro])); + notifications.success(`Macro "${newMacro.name}" created successfully`); + navigate("../"); + } catch (error: unknown) { + if (error instanceof Error) { + notifications.error(`Failed to create macro: ${error.message}`); + } else { + notifications.error("Failed to create macro"); + } + } finally { + setIsSaving(false); + } + }; + + return ( + <div className="space-y-4"> + <SettingsPageHeader + title="Add New Macro" + description="Create a new keyboard macro" + /> + <MacroForm + initialData={{ + name: "", + steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }], + }} + onSubmit={handleAddMacro} + onCancel={() => navigate("../")} + isSubmitting={isSaving} + /> + </div> + ); +} \ No newline at end of file diff --git a/ui/src/routes/devices.$id.settings.macros.edit.tsx b/ui/src/routes/devices.$id.settings.macros.edit.tsx new file mode 100644 index 0000000..6d92420 --- /dev/null +++ b/ui/src/routes/devices.$id.settings.macros.edit.tsx @@ -0,0 +1,108 @@ +import { useNavigate, useParams } from "react-router-dom"; +import { useState, useEffect } from "react"; + +import { KeySequence, useMacrosStore } from "@/hooks/stores"; +import { SettingsPageHeader } from "@/components/SettingsPageheader"; +import { MacroForm } from "@/components/MacroForm"; +import notifications from "@/notifications"; + +const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => { + return macros.map((macro, index) => ({ + ...macro, + sortOrder: index + 1, + })); +}; + +export default function SettingsMacrosEditRoute() { + const { macros, saveMacros } = useMacrosStore(); + const [isUpdating, setIsUpdating] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const navigate = useNavigate(); + const { macroId } = useParams<{ macroId: string }>(); + const [macro, setMacro] = useState<KeySequence | null>(null); + + useEffect(() => { + const foundMacro = macros.find(m => m.id === macroId); + if (foundMacro) { + setMacro({ + ...foundMacro, + steps: foundMacro.steps.map(step => ({ + ...step, + keys: Array.isArray(step.keys) ? step.keys : [], + modifiers: Array.isArray(step.modifiers) ? step.modifiers : [], + delay: typeof step.delay === 'number' ? step.delay : 0 + })) + }); + } else { + navigate("../"); + } + }, [macroId, macros, navigate]); + + const handleUpdateMacro = async (updatedMacro: Partial<KeySequence>) => { + if (!macro) return; + + setIsUpdating(true); + try { + const newMacros = macros.map(m => + m.id === macro.id ? { + ...macro, + name: updatedMacro.name!.trim(), + steps: updatedMacro.steps || [], + } : m + ); + + await saveMacros(normalizeSortOrders(newMacros)); + notifications.success(`Macro "${updatedMacro.name}" updated successfully`); + navigate("../"); + } catch (error: unknown) { + if (error instanceof Error) { + notifications.error(`Failed to update macro: ${error.message}`); + } else { + notifications.error("Failed to update macro"); + } + } finally { + setIsUpdating(false); + } + }; + + const handleDeleteMacro = async () => { + if (!macro) return; + + setIsDeleting(true); + try { + const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macro.id)); + await saveMacros(updatedMacros); + notifications.success(`Macro "${macro.name}" deleted successfully`); + navigate("../macros"); + } catch (error: unknown) { + if (error instanceof Error) { + notifications.error(`Failed to delete macro: ${error.message}`); + } else { + notifications.error("Failed to delete macro"); + } + } finally { + setIsDeleting(false); + } + }; + + if (!macro) return null; + + return ( + <div className="space-y-4"> + <SettingsPageHeader + title="Edit Macro" + description="Modify your keyboard macro" + /> + <MacroForm + initialData={macro} + onSubmit={handleUpdateMacro} + onCancel={() => navigate("../")} + isSubmitting={isUpdating} + submitText="Save Changes" + showDelete + onDelete={handleDeleteMacro} + isDeleting={isDeleting} + /> + </div> + ); +} \ 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 6dc4a93..c8213d1 100644 --- a/ui/src/routes/devices.$id.settings.macros.tsx +++ b/ui/src/routes/devices.$id.settings.macros.tsx @@ -1,90 +1,17 @@ -import { useState, useEffect, useCallback, Fragment } from "react"; -import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuInfo, LuCopy, LuArrowUp, LuArrowDown, LuMoveRight, LuCornerDownRight } from "react-icons/lu"; +import { useEffect, Fragment, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight } from "react-icons/lu"; -import { KeySequence, useMacrosStore } from "@/hooks/stores"; +import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores"; import { SettingsPageHeader } from "@/components/SettingsPageheader"; import { Button } from "@/components/Button"; -import Checkbox from "@/components/Checkbox"; -import { keys, modifiers, keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings"; -import { useJsonRpc } from "@/hooks/useJsonRpc"; +import EmptyCard from "@/components/EmptyCard"; +import { SortableList } from "@/components/SortableList"; +import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros"; +import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings"; import notifications from "@/notifications"; import { SettingsItem } from "@/routes/devices.$id.settings"; -import { InputFieldWithLabel, FieldError } from "@/components/InputField"; -import Fieldset from "@/components/Fieldset"; -import { SelectMenuBasic } from "@/components/SelectMenuBasic"; -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; - -interface MacroStep { - keys: string[]; - modifiers: string[]; - delay: number; -} - -interface KeyOption { - value: string; - label: string; -} - -interface KeyOptionData { - value: string | null; - keys?: string[]; - label?: string; -} - -const generateId = () => { - return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; -}; - -// Filter out modifier keys since they're handled in the modifiers section -const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta']; - -const keyOptions = Object.keys(keys) - .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix))) - .map(key => ({ - value: key, - label: keyDisplayMap[key] || key, - })); - -const modifierOptions = Object.keys(modifiers).map(modifier => ({ - value: modifier, - label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"), -})); - -const groupedModifiers = { - Control: modifierOptions.filter(mod => mod.value.startsWith('Control')), - Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')), - Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')), - Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')), -}; - - -const PRESET_DELAYS = [ - { value: "50", label: "50ms" }, - { value: "100", label: "100ms" }, - { value: "200", label: "200ms" }, - { value: "300", label: "300ms" }, - { value: "500", label: "500ms" }, - { value: "750", label: "750ms" }, - { value: "1000", label: "1000ms" }, - { value: "1500", label: "1500ms" }, - { value: "2000", label: "2000ms" }, -]; - -const MAX_STEPS_PER_MACRO = 10; -const MAX_TOTAL_MACROS = 25; -const MAX_KEYS_PER_STEP = 10; - -const ensureArray = <T,>(arr: T[] | null | undefined): T[] => { - return Array.isArray(arr) ? arr : []; -}; - -// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => { return macros.map((macro, index) => ({ ...macro, @@ -92,805 +19,93 @@ const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => { })); }; -interface MacroStepCardProps { - step: MacroStep; - stepIndex: number; - onDelete?: () => void; - onMoveUp?: () => void; - onMoveDown?: () => void; - isDesktop: boolean; - onKeySelect: (option: KeyOptionData) => void; - onKeyQueryChange: (query: string) => void; - keyQuery: string; - getFilteredKeys: () => KeyOption[]; - onModifierChange: (modifiers: string[]) => void; - onDelayChange: (delay: number) => void; - isLastStep: boolean; -} - -function MacroStepCard({ - step, - stepIndex, - onDelete, - onMoveUp, - onMoveDown, - onKeySelect, - onKeyQueryChange, - keyQuery, - getFilteredKeys, - onModifierChange, - onDelayChange, - isLastStep -}: MacroStepCardProps) { - return ( - <div className="macro-step-card rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-4 shadow-sm"> - <div className="mb-2 flex items-center justify-between"> - <div className="flex items-center gap-1.5"> - <div className="flex items-center gap-1"> - <Button - size="XS" - theme="light" - onClick={onMoveUp} - disabled={stepIndex === 0} - LeadingIcon={LuArrowUp} - /> - <Button - size="XS" - theme="light" - onClick={onMoveDown} - disabled={isLastStep} - LeadingIcon={LuArrowDown} - /> - </div> - <span className="macro-step-number flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"> - {stepIndex + 1} - </span> - </div> - - <div className="flex items-center space-x-2"> - {onDelete && ( - <Button - size="XS" - theme="danger" - text="Delete" - onClick={onDelete} - LeadingIcon={LuTrash} - /> - )} - </div> - </div> - - <div className="space-y-4 mt-2"> - <div className="w-full flex flex-col gap-2"> - <label className="text-sm font-medium text-slate-700 dark:text-slate-300"> - Modifiers: - </label> - <div className="macro-modifiers-container inline-flex flex-wrap gap-3"> - {Object.entries(groupedModifiers).map(([group, mods]) => ( - <div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2"> - <span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400"> - {group} - </span> - <div className="flex flex-wrap gap-1"> - {mods.map(option => ( - <label - key={option.value} - className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${ - ensureArray(step.modifiers).includes(option.value) - ? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200' - : 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700' - }`} - > - <Checkbox - className="sr-only" - size="SM" - checked={ensureArray(step.modifiers).includes(option.value)} - onChange={e => { - const modifiersArray = ensureArray(step.modifiers); - const newModifiers = e.target.checked - ? [...modifiersArray, option.value] - : modifiersArray.filter(m => m !== option.value); - onModifierChange(newModifiers); - }} - /> - {option.label.split(' ')[1] || option.label} - </label> - ))} - </div> - </div> - ))} - </div> - </div> - - <div className="w-full flex flex-col gap-1"> - <label className="text-sm font-medium text-slate-700 dark:text-slate-300"> - Keys: - </label> - - <div className="macro-key-group flex flex-wrap gap-1 pb-2"> - {ensureArray(step.keys).map((key, keyIndex) => ( - <span - key={keyIndex} - className="inline-flex items-center rounded-md bg-blue-100 px-1 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200" - > - <span className="px-1"> - {keyDisplayMap[key] || key} - </span> - <Button - size="XS" - className="" - theme="blank" - onClick={() => { - const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex); - onKeySelect({ value: null, keys: newKeys }); - }} - LeadingIcon={LuX} - /> - </span> - ))} - </div> - <div className="relative w-full"> - <Combobox<KeyOption> - onChange={(value: KeyOption) => onKeySelect(value)} - displayValue={() => keyQuery} - onInputChange={onKeyQueryChange} - options={getFilteredKeys} - disabledMessage="Max keys reached" - size="SM" - disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP} - placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."} - emptyMessage="No matching keys found" - /> - </div> - </div> - - <div className="w-full flex flex-col gap-1"> - <div className="flex items-center gap-1"> - <label className="text-sm font-medium text-slate-700 dark:text-slate-300"> - Step Duration: - </label> - <div className="group relative"> - <LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" /> - <div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700"> - <p>The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.</p> - </div> - </div> - </div> - <div className="flex items-center gap-3"> - <SelectMenuBasic - size="SM" - fullWidth - value={step.delay.toString()} - onChange={(e) => onDelayChange(parseInt(e.target.value, 10))} - options={PRESET_DELAYS} - /> - </div> - </div> - </div> - </div> - ); -} - -// Helper to update step keys used by both new and edit flows -const updateStepKeys = ( - steps: MacroStep[], - stepIndex: number, - keyOption: { value: string | null; keys?: string[] }, - showTemporaryError: (msg: string) => void -) => { - const newSteps = [...steps]; - - // Check if the step at stepIndex exists - if (!newSteps[stepIndex]) { - console.error(`Step at index ${stepIndex} does not exist`); - return steps; // Return original steps to avoid mutation - } - - if (keyOption.keys) { - newSteps[stepIndex].keys = keyOption.keys; - } else if (keyOption.value) { - // Initialize keys array if it doesn't exist - if (!newSteps[stepIndex].keys) { - newSteps[stepIndex].keys = []; - } - const keysArray = ensureArray(newSteps[stepIndex].keys); - if (keysArray.length >= MAX_KEYS_PER_STEP) { - showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`); - return newSteps; - } - newSteps[stepIndex].keys = [...keysArray, keyOption.value]; - } - return newSteps; -}; - -interface StepError { - keys?: string; - modifiers?: string; - delay?: string; -} - -interface ValidationErrors { - name?: string; - description?: string; - steps?: Record<number, StepError>; -} - export default function SettingsMacrosRoute() { - const { macros, loading, initialized, loadMacros, saveMacros, setSendFn } = useMacrosStore(); - const [editingMacro, setEditingMacro] = useState<KeySequence | null>(null); - const [newMacro, setNewMacro] = useState<Partial<KeySequence>>({ - name: "", - description: "", - steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }], - }); - const [macroToDelete, setMacroToDelete] = useState<string | null>(null); + const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore(); + const navigate = useNavigate(); - const [keyQueries, setKeyQueries] = useState<Record<number, string>>({}); - const [editKeyQueries, setEditKeyQueries] = useState<Record<number, string>>({}); - - const [errorMessage, setErrorMessage] = useState<string | null>(null); - const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 768); - - const [send] = useJsonRpc(); + const isMaxMacrosReached = useMemo(() => + macros.length >= MAX_TOTAL_MACROS, + [macros.length] + ); - const isMaxMacrosReached = macros.length >= MAX_TOTAL_MACROS; - const isMaxStepsReachedForNewMacro = (newMacro.steps?.length || 0) >= MAX_STEPS_PER_MACRO; - - const showTemporaryError = useCallback((message: string) => { - setErrorMessage(message); - setTimeout(() => setErrorMessage(null), 3000); - }, []); - - // Helper for both new and edit key select - const handleKeySelectUpdate = (stepIndex: number, option: KeyOptionData, isEditing = false) => { - if (isEditing && editingMacro) { - const updatedSteps = updateStepKeys(editingMacro.steps, stepIndex, option, showTemporaryError); - setEditingMacro({ ...editingMacro, steps: updatedSteps }); - } else { - const updatedSteps = updateStepKeys(newMacro.steps || [], stepIndex, option, showTemporaryError); - setNewMacro({ ...newMacro, steps: updatedSteps }); - } - }; - - const handleKeySelect = (stepIndex: number, option: KeyOptionData) => { - handleKeySelectUpdate(stepIndex, option, false); - }; - - const handleEditKeySelect = (stepIndex: number, option: KeyOptionData) => { - handleKeySelectUpdate(stepIndex, option, true); - }; - - const handleKeyQueryChange = (stepIndex: number, query: string) => { - setKeyQueries(prev => ({ ...prev, [stepIndex]: query })); - }; - - const handleEditKeyQueryChange = (stepIndex: number, query: string) => { - setEditKeyQueries(prev => ({ ...prev, [stepIndex]: query })); - }; - - const getFilteredKeys = (stepIndex: number, isEditing = false) => { - const query = isEditing - ? (editKeyQueries[stepIndex] || '') - : (keyQueries[stepIndex] || ''); - - const currentStep = isEditing - ? editingMacro?.steps[stepIndex] - : newMacro.steps?.[stepIndex]; - - const selectedKeys = ensureArray(currentStep?.keys); - - const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value)); - - if (query === '') { - return availableKeys; - } else { - return availableKeys.filter(option => option.label.toLowerCase().includes(query.toLowerCase())); - } - }; - useEffect(() => { - setSendFn(send); if (!initialized) { loadMacros(); } - }, [initialized, loadMacros, setSendFn, send]); - - const [errors, setErrors] = useState<ValidationErrors>({}); - - const clearErrors = useCallback(() => { - setErrors({}); - }, []); - - const validateMacro = (macro: Partial<KeySequence>): ValidationErrors => { - const errors: ValidationErrors = {}; - - // Name validation - if (!macro.name?.trim()) { - errors.name = "Name is required"; - } else if (macro.name.trim().length > 50) { - errors.name = "Name must be less than 50 characters"; - } - - // Description validation (optional) - if (macro.description && macro.description.trim().length > 200) { - errors.description = "Description must be less than 200 characters"; - } - - // Steps validation - if (!macro.steps?.length) { - errors.steps = { 0: { keys: "At least one step is required" } }; - return errors; - } - - // Check if at least one step has keys or modifiers - const hasKeyOrModifier = macro.steps.some(step => - (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0 - ); - - if (!hasKeyOrModifier) { - errors.steps = { 0: { keys: "At least one step must have keys or modifiers" } }; - return errors; - } - - const stepErrors: Record<number, StepError> = {}; - - macro.steps.forEach((step, index) => { - const stepError: StepError = {}; - - // Keys validation (only if keys are present) - if (step.keys?.length && step.keys.length > MAX_KEYS_PER_STEP) { - stepError.keys = `Maximum ${MAX_KEYS_PER_STEP} keys allowed`; - } - - // Delay validation - if (typeof step.delay !== 'number' || step.delay < 0) { - stepError.delay = "Invalid delay value"; - } - - if (Object.keys(stepError).length > 0) { - stepErrors[index] = stepError; - } - }); - - if (Object.keys(stepErrors).length > 0) { - errors.steps = stepErrors; - } - - return errors; - }; - - const resetNewMacro = () => { - setNewMacro({ - name: "", - description: "", - steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }], - }); - setKeyQueries({}); - setErrors({}); - }; - - const [isSaving, setIsSaving] = useState(false); - const [isUpdating, setIsUpdating] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - - const handleAddMacro = useCallback(async () => { - if (isMaxMacrosReached) { - showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`); - return; - } - - const validationErrors = validateMacro(newMacro); - if (Object.keys(validationErrors).length > 0) { - setErrors(validationErrors); - return; - } - - setIsSaving(true); - try { - const macro: KeySequence = { - id: generateId(), - name: newMacro.name!.trim(), - description: newMacro.description?.trim() || "", - steps: newMacro.steps || [], - sortOrder: macros.length + 1, - }; - - await saveMacros(normalizeSortOrders([...macros, macro])); - resetNewMacro(); - setShowAddMacro(false); - notifications.success(`Macro "${macro.name}" created successfully`); - } catch (error) { - if (error instanceof Error) { - notifications.error(`Failed to create macro: ${error.message}`); - showTemporaryError(error.message); - } else { - notifications.error("Failed to create macro"); - showTemporaryError("Failed to save macro"); - } - } finally { - setIsSaving(false); - } - }, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]); - - const handleEditMacro = (macro: KeySequence) => { - setEditingMacro({ - ...macro, - description: macro.description || "", - steps: macro.steps.map(step => ({ - ...step, - keys: ensureArray(step.keys), - modifiers: ensureArray(step.modifiers), - delay: typeof step.delay === 'number' ? step.delay : DEFAULT_DELAY - })) - }); - clearErrors(); - setEditKeyQueries({}); - }; - - const handleDeleteMacro = async (id: string) => { - const macroToBeDeleted = macros.find(m => m.id === id); - if (!macroToBeDeleted) return; - - setIsDeleting(true); - try { - const updatedMacros = normalizeSortOrders(macros.filter(macro => macro.id !== id)); - await saveMacros(updatedMacros); - if (editingMacro?.id === id) { - setEditingMacro(null); - } - setMacroToDelete(null); - notifications.success(`Macro "${macroToBeDeleted.name}" deleted successfully`); - } catch (error) { - if (error instanceof Error) { - notifications.error(`Failed to delete macro: ${error.message}`); - showTemporaryError(error.message); - } else { - notifications.error("Failed to delete macro"); - showTemporaryError("Failed to delete macro"); - } - } finally { - setIsDeleting(false); - } - }; + }, [initialized, loadMacros]); const handleDuplicateMacro = async (macro: KeySequence) => { if (isMaxMacrosReached) { - showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`); + notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`); return; } const newMacroCopy: KeySequence = { ...JSON.parse(JSON.stringify(macro)), - id: generateId(), - name: `${macro.name} (copy)`, + id: generateMacroId(), + name: `${macro.name} ${COPY_SUFFIX}`, sortOrder: macros.length + 1, }; - newMacroCopy.steps = newMacroCopy.steps.map(step => ({ - ...step, - keys: ensureArray(step.keys), - modifiers: ensureArray(step.modifiers), - delay: step.delay || DEFAULT_DELAY - })); - try { await saveMacros(normalizeSortOrders([...macros, newMacroCopy])); notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`); - } catch (error) { + } catch (error: unknown) { if (error instanceof Error) { notifications.error(`Failed to duplicate macro: ${error.message}`); - showTemporaryError(error.message); } else { notifications.error("Failed to duplicate macro"); - showTemporaryError("Failed to duplicate macro"); } } }; - const handleStepMove = (stepIndex: number, direction: 'up' | 'down', steps: MacroStep[]) => { - const newSteps = [...steps]; - const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1; - [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]]; - return newSteps; - }; - - useEffect(() => { - const handleResize = () => { - setIsDesktop(window.innerWidth >= 768); - }; - - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); - - 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) { - setEditingMacro(null); - setErrors({}); - } - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { - if (editingMacro) { - handleUpdateMacro(); - } else if (!isMaxMacrosReached) { - handleAddMacro(); - } - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [editingMacro, isMaxMacrosReached, handleAddMacro, handleUpdateMacro]); - - const handleModifierChange = (stepIndex: number, modifiers: string[]) => { - if (editingMacro) { - const newSteps = [...editingMacro.steps]; - newSteps[stepIndex].modifiers = modifiers; - setEditingMacro({ ...editingMacro, steps: newSteps }); - } else { - const newSteps = [...(newMacro.steps || [])]; - newSteps[stepIndex].modifiers = modifiers; - setNewMacro({ ...newMacro, steps: newSteps }); - } - }; - - const handleDelayChange = (stepIndex: number, delay: number) => { - if (editingMacro) { - const newSteps = [...editingMacro.steps]; - newSteps[stepIndex].delay = delay; - setEditingMacro({ ...editingMacro, steps: newSteps }); - } else { - const newSteps = [...(newMacro.steps || [])]; - newSteps[stepIndex].delay = delay; - setNewMacro({ ...newMacro, steps: newSteps }); - } - }; - - const ErrorMessage = ({ error }: { error?: string }) => { - if (!error) return null; - return ( - <FieldError error={error} /> - ); - }; return ( <div className="space-y-4"> - <SettingsPageHeader - title="Keyboard Macros" - description="Create and manage keyboard macros for quick actions" - /> - {macros.length > 0 && ( - <div className="flex items-center justify-between mb-4"> - <SettingsItem - title="Macros" - description={`${macros.length}/${MAX_TOTAL_MACROS}`} - > - <div className="flex items-center gap-2"> - {!showAddMacro && ( - <Button - size="SM" - theme="primary" - text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"} - onClick={() => setShowAddMacro(true)} - disabled={isMaxMacrosReached} - /> - )} - </div> - </SettingsItem> - </div> - )} - - {errorMessage && (<FieldError error={errorMessage} />)} - - {loading && ( - <div className="flex items-center justify-center p-8"> - <LuLoader className="h-6 w-6 animate-spin text-blue-500" /> - </div> - )} - <div className={`space-y-4 ${loading ? 'hidden' : ''}`}> - {showAddMacro && ( - <Card className="p-3"> - <CardHeader - headline="Add New Macro" + <SettingsPageHeader + title="Keyboard Macros" + description="Create and manage keyboard macros for quick actions" + /> + <div className="flex items-center justify-between mb-4"> + <SettingsItem + title="Macros" + description={`${loading ? '?' : macros.length}/${MAX_TOTAL_MACROS}`} + > + <div className="flex items-center gap-2"> + <Button + size="SM" + theme="primary" + text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"} + onClick={() => navigate("add")} + disabled={isMaxMacrosReached} /> - <Fieldset className="mt-4"> - <InputFieldWithLabel - type="text" - label="Macro Name" - placeholder="Macro Name" - value={newMacro.name} - error={errors.name} - onChange={e => { - setNewMacro(prev => ({ ...prev, name: e.target.value })); - if (errors.name) { - const newErrors = { ...errors }; - delete newErrors.name; - setErrors(newErrors); - } - }} - /> - </Fieldset> - - <div className="mt-4"> - <div className="macro-section-header"> - <label className="macro-section-title"> - Steps: - </label> - <span className="macro-section-subtitle"> - {newMacro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps - </span> - </div> - {errors.steps && errors.steps[0]?.keys && ( - <div className="mt-2"> - <ErrorMessage error={errors.steps[0].keys} /> - </div> - )} - <div className="mt-2 text-xs text-slate-500 dark:text-slate-400"> - You can add up to {MAX_STEPS_PER_MACRO} steps per macro - </div> - <Fieldset> - <div className="mt-2 space-y-4"> - {(newMacro.steps || []).map((step, stepIndex) => ( - <MacroStepCard - key={stepIndex} - step={step} - stepIndex={stepIndex} - onDelete={newMacro.steps && newMacro.steps.length > 1 ? () => { - const newSteps = [...(newMacro.steps || [])]; - newSteps.splice(stepIndex, 1); - setNewMacro(prev => ({ ...prev, steps: newSteps })); - } : undefined} - onMoveUp={() => { - const newSteps = handleStepMove(stepIndex, 'up', newMacro.steps || []); - setNewMacro(prev => ({ ...prev, steps: newSteps })); - }} - onMoveDown={() => { - const newSteps = handleStepMove(stepIndex, 'down', newMacro.steps || []); - setNewMacro(prev => ({ ...prev, steps: newSteps })); - }} - isDesktop={isDesktop} - onKeySelect={(option) => handleKeySelect(stepIndex, option)} - onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)} - keyQuery={keyQueries[stepIndex] || ''} - getFilteredKeys={() => getFilteredKeys(stepIndex)} - onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)} - onDelayChange={(delay) => handleDelayChange(stepIndex, delay)} - isLastStep={stepIndex === (newMacro.steps?.length || 0) - 1} - /> - ))} - </div> - </Fieldset> + </div> + </SettingsItem> + </div> - <div className="mt-4 border-t border-slate-200 pt-4 dark:border-slate-700"> - <Button - size="MD" - theme="light" - fullWidth - LeadingIcon={LuPlus} - text={`Add Step ${isMaxStepsReachedForNewMacro ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`} - onClick={() => { - if (isMaxStepsReachedForNewMacro) { - showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`); - return; - } - - setNewMacro(prev => ({ - ...prev, - steps: [ - ...(prev.steps || []), - { keys: [], modifiers: [], delay: DEFAULT_DELAY } - ], - })); - clearErrors(); - }} - disabled={isMaxStepsReachedForNewMacro} - /> - </div> - - <div className="mt-6 flex items-center justify-between border-t border-slate-200 pt-4 dark:border-slate-700"> - {showClearConfirm ? ( - <div className="flex items-center gap-2"> - <span className="text-sm text-slate-600 dark:text-slate-400"> - Cancel changes? - </span> - <Button - size="SM" - theme="danger" - text="Yes" - onClick={() => { - resetNewMacro(); - setShowAddMacro(false); - setShowClearConfirm(false); - }} - /> - <Button - size="SM" - theme="light" - text="No" - onClick={() => setShowClearConfirm(false)} - /> - </div> - ) : ( - <div className="flex gap-x-2"> - <Button - size="SM" - theme="primary" - text={isSaving ? "Saving..." : "Save Macro"} - onClick={handleAddMacro} - disabled={isSaving} - /> - <Button - size="SM" - theme="light" - text="Cancel" - onClick={() => { - if (newMacro.name || newMacro.description || newMacro.steps?.some(s => s.keys?.length || s.modifiers?.length)) { - setShowClearConfirm(true); - } else { - resetNewMacro(); - setShowAddMacro(false); - } - }} - /> - </div> - )} - </div> - </div> - </Card> - )} - {macros.length === 0 && !showAddMacro && ( + <div className="space-y-4"> + {loading ? ( + <EmptyCard + headline="Loading macros..." + description="Please wait while we fetch your macros" + BtnElm={ + <LuLoader className="h-6 w-6 animate-spin text-blue-500" /> + } + /> + ) : macros.length === 0 ? ( <EmptyCard headline="No macros created yet" + description="Create keyboard macros to automate repetitive tasks" BtnElm={ <Button size="SM" theme="primary" text="Add New Macro" - onClick={() => setShowAddMacro(true)} + onClick={() => navigate("add")} disabled={isMaxMacrosReached} /> } /> - )} - {macros.length > 0 && ( + ) : ( <SortableList<KeySequence> keyFn={(macro) => macro.id} items={macros} @@ -900,248 +115,88 @@ export default function SettingsMacrosRoute() { try { await saveMacros(updatedMacros); notifications.success("Macro order updated successfully"); - } catch (error) { + } catch (error: unknown) { 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 className="border-blue-300 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/20"> - <CardHeader - headline="Edit Macro" - /> - <Fieldset className="mt-4"> - <InputFieldWithLabel - type="text" - label="Macro Name" - placeholder="Macro Name" - value={editingMacro.name} - error={errors.name} - onChange={e => { - setEditingMacro({ ...editingMacro, name: e.target.value }); - if (errors.name) { - const newErrors = { ...errors }; - delete newErrors.name; - setErrors(newErrors); - } - }} - /> - </Fieldset> - - <div className="mt-4"> - <div className="flex items-center justify-between"> - <label className="text-sm font-medium text-slate-700 dark:text-slate-300"> - Steps: - </label> - <span className="text-sm text-slate-500 dark:text-slate-400"> - {editingMacro.steps.length}/{MAX_STEPS_PER_MACRO} steps - </span> - </div> - {errors.steps && errors.steps[0]?.keys && ( - <div className="mt-2"> - <ErrorMessage error={errors.steps[0].keys} /> - </div> - )} - <div className="mt-2 text-xs text-slate-500 dark:text-slate-400"> - You can add up to {MAX_STEPS_PER_MACRO} steps per macro - </div> - <Fieldset> - <div className="mt-2 space-y-4"> - {editingMacro.steps.map((step, stepIndex) => ( - <MacroStepCard - key={stepIndex} - step={step} - stepIndex={stepIndex} - onDelete={editingMacro.steps.length > 1 ? () => { - const newSteps = [...editingMacro.steps]; - newSteps.splice(stepIndex, 1); - setEditingMacro({ ...editingMacro, steps: newSteps }); - } : undefined} - onMoveUp={() => { - const newSteps = handleStepMove(stepIndex, 'up', editingMacro.steps); - setEditingMacro({ ...editingMacro, steps: newSteps }); - }} - onMoveDown={() => { - const newSteps = handleStepMove(stepIndex, 'down', editingMacro.steps); - setEditingMacro({ ...editingMacro, steps: newSteps }); - }} - isDesktop={isDesktop} - onKeySelect={(option) => handleEditKeySelect(stepIndex, option)} - onKeyQueryChange={(query) => handleEditKeyQueryChange(stepIndex, query)} - keyQuery={editKeyQueries[stepIndex] || ''} - getFilteredKeys={() => getFilteredKeys(stepIndex, true)} - onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)} - onDelayChange={(delay) => handleDelayChange(stepIndex, delay)} - isLastStep={stepIndex === editingMacro.steps.length - 1} - /> - ))} - </div> - </Fieldset> - - <div className="mt-4 border-t border-slate-200 pt-4 dark:border-slate-700"> - <Button - size="MD" - theme="light" - fullWidth - LeadingIcon={LuPlus} - text={`Add Step ${editingMacro.steps.length >= MAX_STEPS_PER_MACRO ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`} - onClick={() => { - if (editingMacro.steps.length >= MAX_STEPS_PER_MACRO) { - showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`); - return; - } - - setEditingMacro({ - ...editingMacro, - steps: [ - ...editingMacro.steps, - { keys: [], modifiers: [], delay: DEFAULT_DELAY } - ], - }); - clearErrors(); - }} - disabled={editingMacro.steps.length >= MAX_STEPS_PER_MACRO} - /> - </div> + <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> + <p className="mt-1 ml-2 text-xs text-slate-500 dark:text-slate-400 overflow-hidden"> + <span className="flex flex-col items-start gap-1"> + {macro.steps.map((step, stepIndex) => { + const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight; - <div className="mt-4 flex items-center justify-between border-t border-slate-200 pt-4 dark:border-slate-700"> - <div className="flex gap-x-2"> - <Button - size="SM" - theme="primary" - text={isUpdating ? "Saving..." : "Save Changes"} - onClick={handleUpdateMacro} - disabled={isUpdating} - /> - <Button - size="SM" - theme="light" - text="Cancel" - onClick={() => { - setEditingMacro(null); - setErrors({}); - }} - /> - </div> - </div> - </div> - </Card> - ) : ( - <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> - <p className="mt-1 ml-2 text-xs text-slate-500 dark:text-slate-400 overflow-hidden"> - <span className="flex flex-col items-start gap-1"> - {macro.steps.map((step, stepIndex) => { - const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight; - - return ( - <span key={stepIndex} className="inline-flex items-center"> - <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" /> - <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50"> - {(ensureArray(step.modifiers).length > 0 || ensureArray(step.keys).length > 0) ? ( - <> - {ensureArray(step.modifiers).map((modifier, idx) => ( - <Fragment key={`mod-${idx}`}> - <span className="font-medium text-slate-600 dark:text-slate-200"> - {modifierDisplayMap[modifier] || modifier} - </span> - {idx < ensureArray(step.modifiers).length - 1 && ( - <span className="text-slate-400 dark:text-slate-600"> + </span> - )} - </Fragment> - ))} - - {ensureArray(step.modifiers).length > 0 && ensureArray(step.keys).length > 0 && ( - <span className="text-slate-400 dark:text-slate-600"> + </span> - )} - - {ensureArray(step.keys).map((key, idx) => ( - <Fragment key={`key-${idx}`}> - <span className="font-medium text-blue-600 dark:text-blue-200"> - {keyDisplayMap[key] || key} - </span> - {idx < ensureArray(step.keys).length - 1 && ( - <span className="text-slate-400 dark:text-slate-600"> + </span> - )} - </Fragment> - ))} - </> - ) : ( - <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span> - )} - <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span> - </span> + return ( + <span key={stepIndex} className="inline-flex items-center"> + <StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" /> + <span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50"> + {(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) => ( + <Fragment key={`mod-${idx}`}> + <span className="font-medium text-slate-600 dark:text-slate-200"> + {modifierDisplayMap[modifier] || modifier} + </span> + {idx < step.modifiers.length - 1 && ( + <span className="text-slate-400 dark:text-slate-600"> + </span> + )} + </Fragment> + ))} + + {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && ( + <span className="text-slate-400 dark:text-slate-600"> + </span> + )} + + {Array.isArray(step.keys) && step.keys.map((key, idx) => ( + <Fragment key={`key-${idx}`}> + <span className="font-medium text-blue-600 dark:text-blue-200"> + {keyDisplayMap[key] || key} + </span> + {idx < step.keys.length - 1 && ( + <span className="text-slate-400 dark:text-slate-600"> + </span> + )} + </Fragment> + ))} + </> + ) : ( + <span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span> + )} + <span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span> </span> - ); - })} - </span> - </p> - </div> - - <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"> - Delete macro? - </span> - <div className="flex items-center gap-x-2"> - <Button - size="XS" - theme="danger" - text="Yes" - onClick={() => { - handleDeleteMacro(macro.id); - }} - disabled={isDeleting} - /> - <Button - size="XS" - theme="light" - text="No" - onClick={() => setMacroToDelete(null)} - /> - </div> - </div> - ) : ( - <> - <Button - size="XS" - theme="light" - LeadingIcon={LuPenLine} - onClick={() => handleEditMacro(macro)} - /> - <Button - size="XS" - theme="light" - LeadingIcon={LuCopy} - onClick={() => handleDuplicateMacro(macro)} - /> - <Button - size="XS" - theme="light" - LeadingIcon={LuTrash} - onClick={() => setMacroToDelete(macro.id)} - className="text-red-500 dark:text-red-400" - /> - </> - )} - </div> + </span> + ); + })} + </span> + </p> </div> - ) + + <div className="flex items-center gap-1 ml-4"> + <Button + size="XS" + theme="light" + LeadingIcon={LuCopy} + onClick={() => handleDuplicateMacro(macro)} + /> + <Button + size="XS" + theme="light" + LeadingIcon={LuPenLine} + onClick={() => navigate(`${macro.id}/edit`)} + /> + </div> + </div> )} </SortableList> )}