mirror of https://github.com/jetkvm/kvm.git
split up macro routes
This commit is contained in:
parent
48d8523122
commit
7b8725892d
|
@ -1,9 +1,7 @@
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
|
||||||
import { Combobox as HeadlessCombobox, ComboboxProps as HeadlessComboboxProps, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
|
|
||||||
import { cva } from "@/cva.config";
|
import { cva } from "@/cva.config";
|
||||||
|
|
||||||
import Card from "./Card";
|
import Card from "./Card";
|
||||||
|
|
||||||
export interface ComboboxOption {
|
export interface ComboboxOption {
|
||||||
|
@ -16,15 +14,15 @@ const sizes = {
|
||||||
SM: "h-[32px] pl-3 pr-8 text-[13px]",
|
SM: "h-[32px] pl-3 pr-8 text-[13px]",
|
||||||
MD: "h-[40px] pl-4 pr-10 text-sm",
|
MD: "h-[40px] pl-4 pr-10 text-sm",
|
||||||
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
|
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
const comboboxVariants = cva({
|
const comboboxVariants = cva({
|
||||||
variants: { size: sizes },
|
variants: { size: sizes },
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ComboboxProps<T> extends HeadlessComboboxProps<T, boolean, React.ExoticComponent<{
|
type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
|
||||||
children?: React.ReactNode;
|
|
||||||
}>> {
|
interface ComboboxProps extends Omit<BaseProps, 'displayValue'> {
|
||||||
displayValue: (option: ComboboxOption) => string;
|
displayValue: (option: ComboboxOption) => string;
|
||||||
onInputChange: (option: string) => void;
|
onInputChange: (option: string) => void;
|
||||||
options: () => ComboboxOption[];
|
options: () => ComboboxOption[];
|
||||||
|
@ -34,7 +32,7 @@ interface ComboboxProps<T> extends HeadlessComboboxProps<T, boolean, React.Exoti
|
||||||
disabledMessage?: string;
|
disabledMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Combobox<T>({
|
export function Combobox({
|
||||||
onInputChange,
|
onInputChange,
|
||||||
displayValue,
|
displayValue,
|
||||||
options,
|
options,
|
||||||
|
@ -45,11 +43,11 @@ export function Combobox<T>({
|
||||||
onChange,
|
onChange,
|
||||||
disabledMessage = "Input disabled",
|
disabledMessage = "Input disabled",
|
||||||
...otherProps
|
...otherProps
|
||||||
}: ComboboxProps<T>) {
|
}: ComboboxProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const classes = comboboxVariants({ size });
|
const classes = comboboxVariants({ size });
|
||||||
|
|
||||||
const handleChange = (value: T) => {
|
const handleChange = (value: unknown) => {
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange(value);
|
onChange(value);
|
||||||
inputRef.current?.blur();
|
inputRef.current?.blur();
|
||||||
|
@ -57,14 +55,13 @@ export function Combobox<T>({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeadlessCombobox<T, boolean, React.ExoticComponent<{ children?: React.ReactNode;}>>
|
<HeadlessCombobox
|
||||||
immediate
|
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
{...otherProps}
|
{...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
|
<ComboboxInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
@ -90,11 +87,11 @@ export function Combobox<T>({
|
||||||
onChange={(event) => onInputChange(event.target.value)}
|
onChange={(event) => onInputChange(event.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{options().length > 0 && (
|
{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">
|
<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
|
<ComboboxOption
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option}
|
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"
|
"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>
|
</ComboboxOption>
|
||||||
))}
|
))}
|
||||||
</ComboboxOptions>
|
</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="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">
|
<div className="text-slate-500 dark:text-slate-400">
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</HeadlessCombobox>
|
</HeadlessCombobox>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -12,7 +12,6 @@ export default function MacroBar() {
|
||||||
const { executeMacro } = useKeyboard();
|
const { executeMacro } = useKeyboard();
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
|
|
||||||
// Set up sendFn and initialize macros if needed
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSendFn(send);
|
setSendFn(send);
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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)";
|
|
@ -1,5 +1,6 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { createJSONStorage, persist } from "zustand/middleware";
|
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
|
// Define the JsonRpc types for better type checking
|
||||||
interface JsonRpcResponse {
|
interface JsonRpcResponse {
|
||||||
|
@ -671,7 +672,6 @@ export interface KeySequenceStep {
|
||||||
export interface KeySequence {
|
export interface KeySequence {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
|
||||||
steps: KeySequenceStep[];
|
steps: KeySequenceStep[];
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
}
|
}
|
||||||
|
@ -686,9 +686,9 @@ export interface MacrosState {
|
||||||
setSendFn: (sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void)) => void;
|
setSendFn: (sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void)) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_STEPS_PER_MACRO = 10;
|
export const generateMacroId = () => {
|
||||||
const MAX_TOTAL_MACROS = 25;
|
return Math.random().toString(36).substring(2, 9);
|
||||||
const MAX_KEYS_PER_STEP = 10;
|
};
|
||||||
|
|
||||||
export const useMacrosStore = create<MacrosState>((set, get) => ({
|
export const useMacrosStore = create<MacrosState>((set, get) => ({
|
||||||
macros: [],
|
macros: [],
|
||||||
|
|
|
@ -40,10 +40,12 @@ import * as SettingsAccessIndexRoute from "./routes/devices.$id.settings.access.
|
||||||
import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware";
|
import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware";
|
||||||
import SettingsVideoRoute from "./routes/devices.$id.settings.video";
|
import SettingsVideoRoute from "./routes/devices.$id.settings.video";
|
||||||
import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance";
|
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 * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index";
|
||||||
import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update";
|
import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update";
|
||||||
import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth";
|
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 isOnDevice = import.meta.env.MODE === "device";
|
||||||
export const isInCloud = !isOnDevice;
|
export const isInCloud = !isOnDevice;
|
||||||
|
@ -178,7 +180,20 @@ if (isOnDevice) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "macros",
|
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",
|
path: "macros",
|
||||||
element: <SettingsMacrosRoute />,
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: <SettingsMacrosRoute />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "add",
|
||||||
|
element: <SettingsMacrosAddRoute />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ":macroId/edit",
|
||||||
|
element: <SettingsMacrosEditRoute />,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue