mirror of https://github.com/jetkvm/kvm.git
Compare commits
6 Commits
4d437ccf56
...
dde0f01545
| Author | SHA1 | Date |
|---|---|---|
|
|
dde0f01545 | |
|
|
12d31a3d8e | |
|
|
531f6bf65b | |
|
|
25e476e715 | |
|
|
8dd004ab54 | |
|
|
9f27a5d5c3 |
|
|
@ -24,7 +24,7 @@ const (
|
|||
MaxMacrosPerDevice = 25
|
||||
MaxStepsPerMacro = 10
|
||||
MaxKeysPerStep = 10
|
||||
MinStepDelay = 50
|
||||
MinStepDelay = 10
|
||||
MaxStepDelay = 2000
|
||||
)
|
||||
|
||||
|
|
@ -32,6 +32,10 @@ type KeyboardMacroStep struct {
|
|||
Keys []string `json:"keys"`
|
||||
Modifiers []string `json:"modifiers"`
|
||||
Delay int `json:"delay"`
|
||||
// Optional: when set, this step types the given text using the configured keyboard layout.
|
||||
// The delay value is treated as the per-character delay.
|
||||
Text string `json:"text,omitempty"`
|
||||
Wait bool `json:"wait,omitempty"`
|
||||
}
|
||||
|
||||
func (s *KeyboardMacroStep) Validate() error {
|
||||
|
|
|
|||
|
|
@ -1038,6 +1038,15 @@ func setKeyboardMacros(params KeyboardMacrosParams) (any, error) {
|
|||
step.Delay = int(delay)
|
||||
}
|
||||
|
||||
// Optional text field for advanced steps
|
||||
if txt, ok := stepMap["text"].(string); ok {
|
||||
step.Text = txt
|
||||
}
|
||||
|
||||
if wv, ok := stepMap["wait"].(bool); ok {
|
||||
step.Wait = wv
|
||||
}
|
||||
|
||||
steps = append(steps, step)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export function MacroForm({
|
|||
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,
|
||||
step => (step.text && step.text.length > 0) || (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0,
|
||||
);
|
||||
|
||||
if (!hasKeyOrModifier) {
|
||||
|
|
@ -163,6 +163,40 @@ export function MacroForm({
|
|||
setMacro({ ...macro, steps: newSteps });
|
||||
};
|
||||
|
||||
const handleStepTypeChange = (stepIndex: number, type: "keys" | "text" | "wait") => {
|
||||
const newSteps = [...(macro.steps || [])];
|
||||
const prev = newSteps[stepIndex] || { keys: [], modifiers: [], delay: DEFAULT_DELAY };
|
||||
if (type === "text") {
|
||||
newSteps[stepIndex] = { keys: [], modifiers: [], delay: prev.delay, text: prev.text || "" } as any;
|
||||
} else if (type === "wait") {
|
||||
newSteps[stepIndex] = { keys: [], modifiers: [], delay: prev.delay, wait: true } as any;
|
||||
} else {
|
||||
// switch back to keys; drop text
|
||||
const { text, wait, ...rest } = prev as any;
|
||||
newSteps[stepIndex] = { ...rest } as any;
|
||||
}
|
||||
setMacro({ ...macro, steps: newSteps });
|
||||
};
|
||||
|
||||
const handleTextChange = (stepIndex: number, text: string) => {
|
||||
const newSteps = [...(macro.steps || [])];
|
||||
// Ensure this step is of text type
|
||||
newSteps[stepIndex] = { ...(newSteps[stepIndex] || { keys: [], modifiers: [], delay: DEFAULT_DELAY }), text } as any;
|
||||
setMacro({ ...macro, steps: newSteps });
|
||||
};
|
||||
|
||||
const insertStepAfter = (index: number) => {
|
||||
if (isMaxStepsReached) {
|
||||
showTemporaryError(
|
||||
`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const newSteps = [...(macro.steps || [])];
|
||||
newSteps.splice(index + 1, 0, { keys: [], modifiers: [], delay: DEFAULT_DELAY });
|
||||
setMacro(prev => ({ ...prev, steps: newSteps }));
|
||||
};
|
||||
|
||||
const handleStepMove = (stepIndex: number, direction: "up" | "down") => {
|
||||
const newSteps = [...(macro.steps || [])];
|
||||
const newIndex = direction === "up" ? stepIndex - 1 : stepIndex + 1;
|
||||
|
|
@ -213,31 +247,46 @@ export function MacroForm({
|
|||
<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}
|
||||
keyboard={selectedKeyboard}
|
||||
/>
|
||||
<div key={stepIndex} className="space-y-3">
|
||||
<MacroStepCard
|
||||
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}
|
||||
keyboard={selectedKeyboard}
|
||||
onStepTypeChange={type => handleStepTypeChange(stepIndex, type)}
|
||||
onTextChange={text => handleTextChange(stepIndex, text)}
|
||||
/>
|
||||
{stepIndex < (macro.steps?.length || 0) - 1 && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
LeadingIcon={LuPlus}
|
||||
text="Insert step here"
|
||||
onClick={() => insertStepAfter(stepIndex)}
|
||||
disabled={isMaxStepsReached}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Fieldset>
|
||||
|
|
|
|||
|
|
@ -38,16 +38,22 @@ const basePresetDelays = [
|
|||
];
|
||||
|
||||
const PRESET_DELAYS = basePresetDelays.map(delay => {
|
||||
if (parseInt(delay.value, 10) === DEFAULT_DELAY) {
|
||||
return { ...delay, label: "Default" };
|
||||
}
|
||||
if (parseInt(delay.value, 10) === DEFAULT_DELAY) return { ...delay, label: "Default" };
|
||||
return delay;
|
||||
});
|
||||
|
||||
const TEXT_EXTRA_DELAYS = [
|
||||
{ value: "10", label: "10ms" },
|
||||
{ value: "20", label: "20ms" },
|
||||
{ value: "30", label: "30ms" },
|
||||
];
|
||||
|
||||
interface MacroStep {
|
||||
keys: string[];
|
||||
modifiers: string[];
|
||||
delay: number;
|
||||
text?: string;
|
||||
wait?: boolean;
|
||||
}
|
||||
|
||||
interface MacroStepCardProps {
|
||||
|
|
@ -62,7 +68,9 @@ interface MacroStepCardProps {
|
|||
onModifierChange: (modifiers: string[]) => void;
|
||||
onDelayChange: (delay: number) => void;
|
||||
isLastStep: boolean;
|
||||
keyboard: KeyboardLayout
|
||||
keyboard: KeyboardLayout;
|
||||
onStepTypeChange: (type: "keys" | "text" | "wait") => void;
|
||||
onTextChange: (text: string) => void;
|
||||
}
|
||||
|
||||
const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
|
||||
|
|
@ -81,7 +89,9 @@ export function MacroStepCard({
|
|||
onModifierChange,
|
||||
onDelayChange,
|
||||
isLastStep,
|
||||
keyboard
|
||||
keyboard,
|
||||
onStepTypeChange,
|
||||
onTextChange,
|
||||
}: MacroStepCardProps) {
|
||||
const { keyDisplayMap } = keyboard;
|
||||
|
||||
|
|
@ -106,6 +116,8 @@ export function MacroStepCard({
|
|||
}
|
||||
}, [keyOptions, keyQuery, step.keys]);
|
||||
|
||||
const stepType: "keys" | "text" | "wait" = step.wait ? "wait" : (step.text !== undefined ? "text" : "keys");
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
|
|
@ -146,6 +158,46 @@ export function MacroStepCard({
|
|||
</div>
|
||||
|
||||
<div className="space-y-4 mt-2">
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<FieldLabel label="Step Type" />
|
||||
<div className="inline-flex gap-2">
|
||||
<Button
|
||||
size="XS"
|
||||
theme={stepType === "keys" ? "primary" : "light"}
|
||||
text="Keys/Modifiers"
|
||||
onClick={() => onStepTypeChange("keys")}
|
||||
/>
|
||||
<Button
|
||||
size="XS"
|
||||
theme={stepType === "text" ? "primary" : "light"}
|
||||
text="Text"
|
||||
onClick={() => onStepTypeChange("text")}
|
||||
/>
|
||||
<Button
|
||||
size="XS"
|
||||
theme={stepType === "wait" ? "primary" : "light"}
|
||||
text="Wait"
|
||||
onClick={() => onStepTypeChange("wait")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{stepType === "text" ? (
|
||||
<div className="w-full flex flex-col gap-1">
|
||||
<FieldLabel label="Text to type" description="Will be typed with this step's delay per character" />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-md border border-slate-200 px-2 py-1 text-sm dark:border-slate-700 dark:bg-slate-800"
|
||||
value={step.text || ""}
|
||||
onChange={e => onTextChange(e.target.value)}
|
||||
placeholder="Enter text..."
|
||||
/>
|
||||
</div>
|
||||
) : stepType === "wait" ? (
|
||||
<div className="w-full flex flex-col gap-1">
|
||||
<FieldLabel label="Wait" description="Pause execution for the specified duration." />
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">This step waits for the configured duration, no keys are sent.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<FieldLabel label="Modifiers" />
|
||||
<div className="inline-flex flex-wrap gap-3">
|
||||
|
|
@ -176,7 +228,8 @@ export function MacroStepCard({
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
{stepType === "keys" && (
|
||||
<div className="w-full flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<FieldLabel label="Keys" description={`Maximum ${MAX_KEYS_PER_STEP} keys per step.`} />
|
||||
|
|
@ -223,10 +276,10 @@ export function MacroStepCard({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
<div className="w-full flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<FieldLabel label="Step Duration" description="Time to wait before executing the next step." />
|
||||
<FieldLabel label="Step Duration" description={stepType === "text" ? "Delay per character when typing text" : stepType === "wait" ? "How long to pause before the next step" : "Time to wait before executing the next step."} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SelectMenuBasic
|
||||
|
|
@ -234,10 +287,11 @@ export function MacroStepCard({
|
|||
fullWidth
|
||||
value={step.delay.toString()}
|
||||
onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
|
||||
options={PRESET_DELAYS}
|
||||
options={stepType === 'text' ? [...TEXT_EXTRA_DELAYS, ...PRESET_DELAYS] : PRESET_DELAYS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -763,6 +763,8 @@ export interface KeySequenceStep {
|
|||
keys: string[];
|
||||
modifiers: string[];
|
||||
delay: number;
|
||||
text?: string; // optional: when set, type this text with per-character delay
|
||||
wait?: boolean; // optional: when true, this is a pure wait step (pause for delay ms)
|
||||
}
|
||||
|
||||
export interface KeySequence {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { useHidRpc } from "@/hooks/useHidRpc";
|
||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings";
|
||||
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
||||
|
||||
const MACRO_RESET_KEYBOARD_STATE = {
|
||||
keys: new Array(hidKeyBufferSize).fill(0),
|
||||
|
|
@ -27,6 +28,8 @@ export interface MacroStep {
|
|||
keys: string[] | null;
|
||||
modifiers: string[] | null;
|
||||
delay: number;
|
||||
text?: string | undefined;
|
||||
wait?: boolean | undefined;
|
||||
}
|
||||
|
||||
export type MacroSteps = MacroStep[];
|
||||
|
|
@ -34,6 +37,7 @@ export type MacroSteps = MacroStep[];
|
|||
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export default function useKeyboard() {
|
||||
const { selectedKeyboard } = useKeyboardLayout();
|
||||
const { send } = useJsonRpc();
|
||||
const { rpcDataChannel } = useRTCStore();
|
||||
const { keysDownState, setKeysDownState, setKeyboardLedState, setPasteModeEnabled } =
|
||||
|
|
@ -284,9 +288,38 @@ export default function useKeyboard() {
|
|||
// After the delay, the keys and modifiers are released and the next step is executed.
|
||||
// If a step has no keys or modifiers, it is treated as a delay-only step.
|
||||
// A small pause is added between steps to ensure that the device can process the events.
|
||||
const expandTextSteps = useCallback((steps: MacroSteps): MacroSteps => {
|
||||
const expanded: MacroSteps = [];
|
||||
for (const step of steps) {
|
||||
if (step.text && step.text.length > 0 && selectedKeyboard) {
|
||||
for (const char of step.text) {
|
||||
const keyprops = selectedKeyboard.chars[char];
|
||||
if (!keyprops) continue;
|
||||
const { key, shift, altRight, deadKey, accentKey } = keyprops;
|
||||
if (!key) continue;
|
||||
if (accentKey) {
|
||||
const accentModifiers: string[] = [];
|
||||
if (accentKey.shift) accentModifiers.push("ShiftLeft");
|
||||
if (accentKey.altRight) accentModifiers.push("AltRight");
|
||||
expanded.push({ keys: [String(accentKey.key)], modifiers: accentModifiers, delay: step.delay });
|
||||
}
|
||||
const mods: string[] = [];
|
||||
if (shift) mods.push("ShiftLeft");
|
||||
if (altRight) mods.push("AltRight");
|
||||
expanded.push({ keys: [String(key)], modifiers: mods, delay: step.delay });
|
||||
if (deadKey) expanded.push({ keys: ["Space"], modifiers: null, delay: step.delay });
|
||||
}
|
||||
} else {
|
||||
expanded.push(step);
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}, [selectedKeyboard]);
|
||||
|
||||
const executeMacroRemote = useCallback(async (
|
||||
steps: MacroSteps,
|
||||
stepsIn: MacroSteps,
|
||||
) => {
|
||||
const steps = expandTextSteps(stepsIn);
|
||||
const macro: KeyboardMacroStep[] = [];
|
||||
|
||||
for (const [_, step] of steps.entries()) {
|
||||
|
|
@ -297,16 +330,22 @@ export default function useKeyboard() {
|
|||
|
||||
.reduce((acc, val) => acc + val, 0);
|
||||
|
||||
// If the step has keys and/or modifiers, press them and hold for the delay
|
||||
if (keyValues.length > 0 || modifierMask > 0) {
|
||||
if (step.wait) {
|
||||
// pure wait: send a no-op clear state with desired delay
|
||||
macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 });
|
||||
} else if (keyValues.length > 0 || modifierMask > 0) {
|
||||
macro.push({ keys: keyValues, modifier: modifierMask, delay: 20 });
|
||||
macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 });
|
||||
} else {
|
||||
// empty step (pause only)
|
||||
macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 });
|
||||
}
|
||||
}
|
||||
|
||||
sendKeyboardMacroEventHidRpc(macro);
|
||||
}, [sendKeyboardMacroEventHidRpc]);
|
||||
const executeMacroClientSide = useCallback(async (steps: MacroSteps) => {
|
||||
}, [sendKeyboardMacroEventHidRpc, expandTextSteps]);
|
||||
const executeMacroClientSide = useCallback(async (stepsIn: MacroSteps) => {
|
||||
const steps = expandTextSteps(stepsIn);
|
||||
const promises: (() => Promise<void>)[] = [];
|
||||
|
||||
const ac = new AbortController();
|
||||
|
|
@ -318,11 +357,14 @@ export default function useKeyboard() {
|
|||
.map(mod => modifiers[mod])
|
||||
.reduce((acc, val) => acc + val, 0);
|
||||
|
||||
// If the step has keys and/or modifiers, press them and hold for the delay
|
||||
if (keyValues.length > 0 || modifierMask > 0) {
|
||||
if (step.wait) {
|
||||
promises.push(() => sleep(step.delay || 100));
|
||||
} else if (keyValues.length > 0 || modifierMask > 0) {
|
||||
promises.push(() => sendKeystrokeLegacy(keyValues, modifierMask, ac));
|
||||
promises.push(() => resetKeyboardState());
|
||||
promises.push(() => sleep(step.delay || 100));
|
||||
} else {
|
||||
promises.push(() => sleep(step.delay || 100));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -354,7 +396,7 @@ export default function useKeyboard() {
|
|||
reject(error);
|
||||
});
|
||||
});
|
||||
}, [sendKeystrokeLegacy, resetKeyboardState, setAbortController]);
|
||||
}, [sendKeystrokeLegacy, resetKeyboardState, setAbortController, expandTextSteps]);
|
||||
const executeMacro = useCallback(async (steps: MacroSteps) => {
|
||||
if (rpcHidReady) {
|
||||
return executeMacroRemote(steps);
|
||||
|
|
|
|||
|
|
@ -27,5 +27,6 @@ import { fr_FR } from "@/keyboardLayouts/fr_FR"
|
|||
import { it_IT } from "@/keyboardLayouts/it_IT"
|
||||
import { nb_NO } from "@/keyboardLayouts/nb_NO"
|
||||
import { sv_SE } from "@/keyboardLayouts/sv_SE"
|
||||
import { da_DK } from "@/keyboardLayouts/da_DK"
|
||||
|
||||
export const keyboards: KeyboardLayout[] = [ cs_CZ, de_CH, de_DE, en_UK, en_US, es_ES, fr_BE, fr_CH, fr_FR, it_IT, nb_NO, sv_SE ];
|
||||
export const keyboards: KeyboardLayout[] = [ cs_CZ, de_CH, de_DE, en_UK, en_US, es_ES, fr_BE, fr_CH, fr_FR, it_IT, nb_NO, sv_SE, da_DK ];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"
|
||||
|
||||
import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard
|
||||
|
||||
export const name = "Dansk";
|
||||
const isoCode = "da-DK";
|
||||
|
||||
const keyTrema = { key: "BracketRight" }
|
||||
const keyAcute = { key: "Equal", altRight: true }
|
||||
const keyHat = { key: "BracketRight", shift: true }
|
||||
const keyGrave = { key: "Equal", shift: true }
|
||||
const keyTilde = { key: "BracketRight", altRight: true }
|
||||
|
||||
export const chars = {
|
||||
A: { key: "KeyA", shift: true },
|
||||
"Ä": { key: "KeyA", shift: true, accentKey: keyTrema },
|
||||
"Á": { key: "KeyA", shift: true, accentKey: keyAcute },
|
||||
"Â": { key: "KeyA", shift: true, accentKey: keyHat },
|
||||
"À": { key: "KeyA", shift: true, accentKey: keyGrave },
|
||||
"Ã": { key: "KeyA", shift: true, accentKey: keyTilde },
|
||||
B: { key: "KeyB", shift: true },
|
||||
C: { key: "KeyC", shift: true },
|
||||
D: { key: "KeyD", shift: true },
|
||||
E: { key: "KeyE", shift: true },
|
||||
"Ë": { key: "KeyE", shift: true, accentKey: keyTrema },
|
||||
"É": { key: "KeyE", shift: true, accentKey: keyAcute },
|
||||
"Ê": { key: "KeyE", shift: true, accentKey: keyHat },
|
||||
"È": { key: "KeyE", shift: true, accentKey: keyGrave },
|
||||
"Ẽ": { key: "KeyE", shift: true, accentKey: keyTilde },
|
||||
F: { key: "KeyF", shift: true },
|
||||
G: { key: "KeyG", shift: true },
|
||||
H: { key: "KeyH", shift: true },
|
||||
I: { key: "KeyI", shift: true },
|
||||
"Ï": { key: "KeyI", shift: true, accentKey: keyTrema },
|
||||
"Í": { key: "KeyI", shift: true, accentKey: keyAcute },
|
||||
"Î": { key: "KeyI", shift: true, accentKey: keyHat },
|
||||
"Ì": { key: "KeyI", shift: true, accentKey: keyGrave },
|
||||
"Ĩ": { key: "KeyI", shift: true, accentKey: keyTilde },
|
||||
J: { key: "KeyJ", shift: true },
|
||||
K: { key: "KeyK", shift: true },
|
||||
L: { key: "KeyL", shift: true },
|
||||
M: { key: "KeyM", shift: true },
|
||||
N: { key: "KeyN", shift: true },
|
||||
O: { key: "KeyO", shift: true },
|
||||
"Ö": { key: "KeyO", shift: true, accentKey: keyTrema },
|
||||
"Ó": { key: "KeyO", shift: true, accentKey: keyAcute },
|
||||
"Ô": { key: "KeyO", shift: true, accentKey: keyHat },
|
||||
"Ò": { key: "KeyO", shift: true, accentKey: keyGrave },
|
||||
"Õ": { key: "KeyO", shift: true, accentKey: keyTilde },
|
||||
P: { key: "KeyP", shift: true },
|
||||
Q: { key: "KeyQ", shift: true },
|
||||
R: { key: "KeyR", shift: true },
|
||||
S: { key: "KeyS", shift: true },
|
||||
T: { key: "KeyT", shift: true },
|
||||
U: { key: "KeyU", shift: true },
|
||||
"Ü": { key: "KeyU", shift: true, accentKey: keyTrema },
|
||||
"Ú": { key: "KeyU", shift: true, accentKey: keyAcute },
|
||||
"Û": { key: "KeyU", shift: true, accentKey: keyHat },
|
||||
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
|
||||
"Ũ": { key: "KeyU", shift: true, accentKey: keyTilde },
|
||||
V: { key: "KeyV", shift: true },
|
||||
W: { key: "KeyW", shift: true },
|
||||
X: { key: "KeyX", shift: true },
|
||||
Y: { key: "KeyY", shift: true },
|
||||
Z: { key: "KeyZ", shift: true },
|
||||
a: { key: "KeyA" },
|
||||
"ä": { key: "KeyA", accentKey: keyTrema },
|
||||
"á": { key: "KeyA", accentKey: keyAcute },
|
||||
"â": { key: "KeyA", accentKey: keyHat },
|
||||
"à": { key: "KeyA", accentKey: keyGrave },
|
||||
"ã": { key: "KeyA", accentKey: keyTilde },
|
||||
b: { key: "KeyB" },
|
||||
c: { key: "KeyC" },
|
||||
d: { key: "KeyD" },
|
||||
e: { key: "KeyE" },
|
||||
"ë": { key: "KeyE", accentKey: keyTrema },
|
||||
"é": { key: "KeyE", accentKey: keyAcute },
|
||||
"ê": { key: "KeyE", accentKey: keyHat },
|
||||
"è": { key: "KeyE", accentKey: keyGrave },
|
||||
"ẽ": { key: "KeyE", accentKey: keyTilde },
|
||||
"€": { key: "KeyE", altRight: true },
|
||||
f: { key: "KeyF" },
|
||||
g: { key: "KeyG" },
|
||||
h: { key: "KeyH" },
|
||||
i: { key: "KeyI" },
|
||||
"ï": { key: "KeyI", accentKey: keyTrema },
|
||||
"í": { key: "KeyI", accentKey: keyAcute },
|
||||
"î": { key: "KeyI", accentKey: keyHat },
|
||||
"ì": { key: "KeyI", accentKey: keyGrave },
|
||||
"ĩ": { key: "KeyI", accentKey: keyTilde },
|
||||
j: { key: "KeyJ" },
|
||||
k: { key: "KeyK" },
|
||||
l: { key: "KeyL" },
|
||||
m: { key: "KeyM" },
|
||||
n: { key: "KeyN" },
|
||||
o: { key: "KeyO" },
|
||||
"ö": { key: "KeyO", accentKey: keyTrema },
|
||||
"ó": { key: "KeyO", accentKey: keyAcute },
|
||||
"ô": { key: "KeyO", accentKey: keyHat },
|
||||
"ò": { key: "KeyO", accentKey: keyGrave },
|
||||
"õ": { key: "KeyO", accentKey: keyTilde },
|
||||
p: { key: "KeyP" },
|
||||
q: { key: "KeyQ" },
|
||||
r: { key: "KeyR" },
|
||||
s: { key: "KeyS" },
|
||||
t: { key: "KeyT" },
|
||||
u: { key: "KeyU" },
|
||||
"ü": { key: "KeyU", accentKey: keyTrema },
|
||||
"ú": { key: "KeyU", accentKey: keyAcute },
|
||||
"û": { key: "KeyU", accentKey: keyHat },
|
||||
"ù": { key: "KeyU", accentKey: keyGrave },
|
||||
"ũ": { key: "KeyU", accentKey: keyTilde },
|
||||
v: { key: "KeyV" },
|
||||
w: { key: "KeyW" },
|
||||
x: { key: "KeyX" },
|
||||
y: { key: "KeyY" }, // <-- corrected
|
||||
z: { key: "KeyZ" }, // <-- corrected
|
||||
"½": { key: "Backquote" },
|
||||
"§": { key: "Backquote", shift: true },
|
||||
1: { key: "Digit1" },
|
||||
"!": { key: "Digit1", shift: true },
|
||||
2: { key: "Digit2" },
|
||||
"\"": { key: "Digit2", shift: true },
|
||||
"@": { key: "Digit2", altRight: true },
|
||||
3: { key: "Digit3" },
|
||||
"#": { key: "Digit3", shift: true },
|
||||
"£": { key: "Digit3", altRight: true },
|
||||
4: { key: "Digit4" },
|
||||
"¤": { key: "Digit4", shift: true },
|
||||
"$": { key: "Digit4", altRight: true },
|
||||
5: { key: "Digit5" },
|
||||
"%": { key: "Digit5", shift: true },
|
||||
6: { key: "Digit6" },
|
||||
"&": { key: "Digit6", shift: true },
|
||||
7: { key: "Digit7" },
|
||||
"/": { key: "Digit7", shift: true },
|
||||
"{": { key: "Digit7", altRight: true },
|
||||
8: { key: "Digit8" },
|
||||
"(": { key: "Digit8", shift: true },
|
||||
"[": { key: "Digit8", altRight: true },
|
||||
9: { key: "Digit9" },
|
||||
")": { key: "Digit9", shift: true },
|
||||
"]": { key: "Digit9", altRight: true },
|
||||
0: { key: "Digit0" },
|
||||
"=": { key: "Digit0", shift: true },
|
||||
"}": { key: "Digit0", altRight: true },
|
||||
"+": { key: "Minus" },
|
||||
"?": { key: "Minus", shift: true },
|
||||
"\\": { key: "Equal" },
|
||||
"å": { key: "BracketLeft" },
|
||||
"Å": { key: "BracketLeft", shift: true },
|
||||
"ø": { key: "Semicolon" },
|
||||
"Ø": { key: "Semicolon", shift: true },
|
||||
"æ": { key: "Quote" },
|
||||
"Æ": { key: "Quote", shift: true },
|
||||
"'": { key: "Backslash" },
|
||||
"*": { key: "Backslash", shift: true },
|
||||
",": { key: "Comma" },
|
||||
";": { key: "Comma", shift: true },
|
||||
".": { key: "Period" },
|
||||
":": { key: "Period", shift: true },
|
||||
"-": { key: "Slash" },
|
||||
"_": { key: "Slash", shift: true },
|
||||
"<": { key: "IntlBackslash" },
|
||||
">": { key: "IntlBackslash", shift: true },
|
||||
"~": { key: "BracketRight", deadKey: true, altRight: true },
|
||||
"^": { key: "BracketRight", deadKey: true, shift: true },
|
||||
"¨": { key: "BracketRight", deadKey: true, },
|
||||
"|": { key: "Equal", deadKey: true, altRight: true},
|
||||
"`": { key: "Equal", deadKey: true, shift: true, },
|
||||
"´": { key: "Equal", deadKey: true, },
|
||||
" ": { key: "Space" },
|
||||
"\n": { key: "Enter" },
|
||||
Enter: { key: "Enter" },
|
||||
Tab: { key: "Tab" },
|
||||
|
||||
} as Record<string, KeyCombo>;
|
||||
|
||||
export const da_DK: KeyboardLayout = {
|
||||
isoCode: isoCode,
|
||||
name: name,
|
||||
chars: chars,
|
||||
// TODO need to localize these maps and layouts
|
||||
keyDisplayMap: en_US.keyDisplayMap,
|
||||
modifierDisplayMap: en_US.modifierDisplayMap,
|
||||
virtualKeyboard: en_US.virtualKeyboard
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, Fragment, useMemo, useState, useCallback } from "react";
|
||||
import { useEffect, Fragment, useMemo, useState, useCallback, useRef } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
LuPenLine,
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
LuArrowDown,
|
||||
LuTrash2,
|
||||
LuCommand,
|
||||
LuDownload,
|
||||
} from "react-icons/lu";
|
||||
|
||||
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
|
||||
|
|
@ -22,13 +23,32 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
|
|||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
||||
|
||||
const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
|
||||
return macros.map((macro, index) => ({
|
||||
...macro,
|
||||
sortOrder: index + 1,
|
||||
}));
|
||||
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();
|
||||
|
|
@ -36,6 +56,7 @@ export default function SettingsMacrosRoute() {
|
|||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null);
|
||||
const { selectedKeyboard } = useKeyboardLayout();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isMaxMacrosReached = useMemo(
|
||||
() => macros.length >= MAX_TOTAL_MACROS,
|
||||
|
|
@ -48,74 +69,52 @@ export default function SettingsMacrosRoute() {
|
|||
}
|
||||
}, [initialized, loadMacros]);
|
||||
|
||||
const handleDuplicateMacro = useCallback(
|
||||
async (macro: KeySequence) => {
|
||||
if (!macro?.id || !macro?.name) {
|
||||
notifications.error("Invalid macro data");
|
||||
return;
|
||||
}
|
||||
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]);
|
||||
|
||||
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 (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
notifications.error(`Failed to duplicate macro: ${error.message}`);
|
||||
} else {
|
||||
notifications.error("Failed to duplicate macro");
|
||||
}
|
||||
} finally {
|
||||
setActionLoadingId(null);
|
||||
}
|
||||
},
|
||||
[isMaxMacrosReached, macros, saveMacros, setActionLoadingId],
|
||||
);
|
||||
|
||||
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 (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
notifications.error(`Failed to reorder macros: ${error.message}`);
|
||||
} else {
|
||||
notifications.error("Failed to reorder macros");
|
||||
}
|
||||
} finally {
|
||||
setActionLoadingId(null);
|
||||
}
|
||||
},
|
||||
[macros, saveMacros, setActionLoadingId],
|
||||
);
|
||||
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;
|
||||
|
|
@ -140,6 +139,17 @@ export default function SettingsMacrosRoute() {
|
|||
}
|
||||
}, [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(
|
||||
() => (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -178,9 +188,12 @@ export default function SettingsMacrosRoute() {
|
|||
<span key={stepIndex} className="inline-flex items-center">
|
||||
<StepIcon className="mr-1 h-3 w-3 shrink-0 text-slate-400 dark:text-slate-500" />
|
||||
<span className="rounded-md border border-slate-200/50 bg-slate-50 px-2 py-0.5 dark:border-slate-700/50 dark:bg-slate-800">
|
||||
{(Array.isArray(step.modifiers) &&
|
||||
step.modifiers.length > 0) ||
|
||||
(Array.isArray(step.keys) && step.keys.length > 0) ? (
|
||||
{step.text && step.text.length > 0 ? (
|
||||
<span className="font-medium text-emerald-700 dark:text-emerald-300">Text: "{step.text}"</span>
|
||||
) : step.wait ? (
|
||||
<span className="font-medium text-amber-600 dark:text-amber-300">Wait</span>
|
||||
) : (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) => (
|
||||
|
|
@ -224,7 +237,7 @@ export default function SettingsMacrosRoute() {
|
|||
</>
|
||||
) : (
|
||||
<span className="font-medium text-slate-500 dark:text-slate-400">
|
||||
Delay only
|
||||
Pause only
|
||||
</span>
|
||||
)}
|
||||
{step.delay !== DEFAULT_DELAY && (
|
||||
|
|
@ -261,6 +274,7 @@ export default function SettingsMacrosRoute() {
|
|||
disabled={actionLoadingId === macro.id}
|
||||
aria-label={`Duplicate macro ${macro.name}`}
|
||||
/>
|
||||
<Button size="XS" theme="light" LeadingIcon={LuDownload} onClick={() => handleDownloadMacro(macro)} aria-label={`Download macro ${macro.name}`} disabled={actionLoadingId === macro.id} />
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
|
|
@ -290,19 +304,7 @@ export default function SettingsMacrosRoute() {
|
|||
/>
|
||||
</div>
|
||||
),
|
||||
[
|
||||
macros,
|
||||
showDeleteConfirm,
|
||||
macroToDelete?.name,
|
||||
macroToDelete?.id,
|
||||
actionLoadingId,
|
||||
handleDeleteMacro,
|
||||
handleMoveMacro,
|
||||
selectedKeyboard.modifierDisplayMap,
|
||||
selectedKeyboard.keyDisplayMap,
|
||||
handleDuplicateMacro,
|
||||
navigate
|
||||
],
|
||||
[macros, showDeleteConfirm, macroToDelete?.name, macroToDelete?.id, actionLoadingId, handleDeleteMacro, handleMoveMacro, selectedKeyboard.modifierDisplayMap, selectedKeyboard.keyDisplayMap, handleDuplicateMacro, navigate, handleDownloadMacro],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -312,18 +314,57 @@ export default function SettingsMacrosRoute() {
|
|||
title="Keyboard Macros"
|
||||
description={`Combine keystrokes into a single action for faster workflows.`}
|
||||
/>
|
||||
{macros.length > 0 && (
|
||||
<div className="flex items-center pl-2">
|
||||
<div className="flex items-center pl-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
|
||||
onClick={() => navigate("add")}
|
||||
disabled={isMaxMacrosReached}
|
||||
aria-label="Add new macro"
|
||||
/>
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
<input ref={fileInputRef} type="file" accept="application/json" multiple className="hidden" onChange={async e => {
|
||||
const fl = e.target.files;
|
||||
if (!fl || fl.length === 0) return;
|
||||
let working = [...macros];
|
||||
const imported: string[] = [];
|
||||
let errors = 0;
|
||||
let skipped = 0;
|
||||
for (const f of Array.from(fl)) {
|
||||
if (working.length >= MAX_TOTAL_MACROS) { skipped++; continue; }
|
||||
try {
|
||||
const raw = await f.text();
|
||||
const parsed = JSON.parse(raw);
|
||||
const candidates = Array.isArray(parsed) ? parsed : [parsed];
|
||||
for (const c of candidates) {
|
||||
if (working.length >= MAX_TOTAL_MACROS) { skipped += (candidates.length - candidates.indexOf(c)); break; }
|
||||
if (!c || typeof c !== "object") { errors++; continue; }
|
||||
const sanitized = sanitizeImportedMacro(c, working.length + 1);
|
||||
working.push(sanitized);
|
||||
imported.push(sanitized.name);
|
||||
}
|
||||
} catch { errors++; }
|
||||
}
|
||||
try {
|
||||
if (imported.length) {
|
||||
await saveMacros(normalizeSortOrders(working));
|
||||
notifications.success(`Imported ${imported.length} macro${imported.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (errors) notifications.error(`${errors} file${errors === 1 ? '' : 's'} failed`);
|
||||
if (skipped) notifications.error(`${skipped} macro${skipped === 1 ? '' : 's'} skipped (limit ${MAX_TOTAL_MACROS})`);
|
||||
} finally {
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
}} />
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
|
||||
onClick={() => navigate("add")}
|
||||
disabled={isMaxMacrosReached}
|
||||
aria-label="Add new macro"
|
||||
theme="light"
|
||||
text="Import Macro"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
|
|
|||
Loading…
Reference in New Issue