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 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>
|
||||
</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">
|
||||
{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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 { 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: [],
|
||||
|
|
|
@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -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