import { useEffect, Fragment, useMemo, useState, useCallback, useRef } from "react"; import { useNavigate } from "react-router"; import { LuPenLine, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown, LuTrash2, LuCommand, LuDownload, } from "react-icons/lu"; import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores"; import { SettingsPageHeader } from "@/components/SettingsPageheader"; import { Button } from "@/components/Button"; import EmptyCard from "@/components/EmptyCard"; import Card from "@/components/Card"; import { MAX_TOTAL_MACROS, COPY_SUFFIX, DEFAULT_DELAY } from "@/constants/macros"; import notifications from "@/notifications"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import LoadingSpinner from "@/components/LoadingSpinner"; import useKeyboardLayout from "@/hooks/useKeyboardLayout"; const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => macros.map((m, i) => ({ ...m, sortOrder: i + 1 })); const pad2 = (n: number) => String(n).padStart(2, "0"); const buildMacroDownloadFilename = (macro: KeySequence) => { const safeName = (macro.name || macro.id).replace(/[^a-z0-9-_]+/gi, "-").toLowerCase(); const now = new Date(); const ts = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`; return `jetkvm-macro-${safeName}-${ts}.json`; }; const sanitizeImportedStep = (raw: any) => ({ keys: Array.isArray(raw?.keys) ? raw.keys.filter((k: any) => typeof k === "string") : [], modifiers: Array.isArray(raw?.modifiers) ? raw.modifiers.filter((m: any) => typeof m === "string") : [], delay: typeof raw?.delay === "number" ? raw.delay : DEFAULT_DELAY, text: typeof raw?.text === "string" ? raw.text : undefined, wait: typeof raw?.wait === "boolean" ? raw.wait : false, }); const sanitizeImportedMacro = (raw: any, sortOrder: number): KeySequence => ({ id: generateMacroId(), name: (typeof raw?.name === "string" && raw.name.trim() ? raw.name : "Imported Macro").slice(0, 50), steps: Array.isArray(raw?.steps) ? raw.steps.map(sanitizeImportedStep) : [], sortOrder, }); export default function SettingsMacrosRoute() { const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore(); const navigate = useNavigate(); const [actionLoadingId, setActionLoadingId] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [macroToDelete, setMacroToDelete] = useState(null); const { selectedKeyboard } = useKeyboardLayout(); const fileInputRef = useRef(null); const isMaxMacrosReached = useMemo( () => macros.length >= MAX_TOTAL_MACROS, [macros.length], ); useEffect(() => { if (!initialized) { loadMacros(); } }, [initialized, loadMacros]); const handleDuplicateMacro = useCallback(async (macro: KeySequence) => { if (!macro?.id || !macro?.name) { notifications.error("Invalid macro data"); return; } if (isMaxMacrosReached) { notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`); return; } setActionLoadingId(macro.id); const newMacroCopy: KeySequence = { ...JSON.parse(JSON.stringify(macro)), id: generateMacroId(), name: `${macro.name} ${COPY_SUFFIX}`, sortOrder: macros.length + 1, }; try { await saveMacros(normalizeSortOrders([...macros, newMacroCopy])); notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`); } catch (e: any) { notifications.error(`Failed to duplicate macro: ${e?.message || 'error'}`); } finally { setActionLoadingId(null); } }, [macros, saveMacros, isMaxMacrosReached]); const handleMoveMacro = useCallback(async (index: number, direction: "up" | "down", macroId: string) => { if (!Array.isArray(macros) || macros.length === 0) { notifications.error("No macros available"); return; } const newIndex = direction === "up" ? index - 1 : index + 1; if (newIndex < 0 || newIndex >= macros.length) return; setActionLoadingId(macroId); try { const newMacros = [...macros]; [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]]; const updatedMacros = normalizeSortOrders(newMacros); await saveMacros(updatedMacros); notifications.success("Macro order updated successfully"); } catch (e: any) { notifications.error(`Failed to reorder macros: ${e?.message || 'error'}`); } finally { setActionLoadingId(null); } }, [macros, saveMacros]); const handleDeleteMacro = useCallback(async () => { if (!macroToDelete?.id) return; setActionLoadingId(macroToDelete.id); try { const updatedMacros = normalizeSortOrders( macros.filter(m => m.id !== macroToDelete.id), ); await saveMacros(updatedMacros); notifications.success(`Macro "${macroToDelete.name}" deleted successfully`); setShowDeleteConfirm(false); setMacroToDelete(null); } catch (error: unknown) { if (error instanceof Error) { notifications.error(`Failed to delete macro: ${error.message}`); } else { notifications.error("Failed to delete macro"); } } finally { setActionLoadingId(null); } }, [macroToDelete, macros, saveMacros]); const handleDownloadMacro = useCallback((macro: KeySequence) => { const data = JSON.stringify(macro, null, 2); const blob = new Blob([data], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = buildMacroDownloadFilename(macro); a.click(); URL.revokeObjectURL(url); }, []); const MacroList = useMemo( () => (
{macros.map((macro, index) => (

{macro.name}

{macro.steps.map((step, stepIndex) => { const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight; return ( {step.text && step.text.length > 0 ? ( Text: "{step.text}" ) : step.wait ? ( Wait ) : (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) => ( {selectedKeyboard.modifierDisplayMap[modifier] || modifier} {idx < step.modifiers.length - 1 && ( {" "} +{" "} )} ))} {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && ( {" "} +{" "} )} {Array.isArray(step.keys) && step.keys.map((key, idx) => ( {selectedKeyboard.keyDisplayMap[key] || key} {idx < step.keys.length - 1 && ( {" "} +{" "} )} ))} ) : ( Pause only )} {step.delay !== DEFAULT_DELAY && ( ({step.delay}ms) )} ); })}

))} { setShowDeleteConfirm(false); setMacroToDelete(null); }} title="Delete Macro" description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`} variant="danger" confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"} onConfirm={handleDeleteMacro} isConfirming={actionLoadingId === macroToDelete?.id} />
), [macros, showDeleteConfirm, macroToDelete?.name, macroToDelete?.id, actionLoadingId, handleDeleteMacro, handleMoveMacro, selectedKeyboard.modifierDisplayMap, selectedKeyboard.keyDisplayMap, handleDuplicateMacro, navigate, handleDownloadMacro], ); return (
{loading && macros.length === 0 ? (
} /> ) : macros.length === 0 ? ( navigate("add")} disabled={isMaxMacrosReached} aria-label="Add new macro" /> } /> ) : ( MacroList )} ); }