159 lines
5.4 KiB
TypeScript
159 lines
5.4 KiB
TypeScript
/**
|
|
* Console Command Input with Autocomplete
|
|
* Ball Studios <https://git.balls.studio>
|
|
*/
|
|
|
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import http from '@/api/http';
|
|
import AutocompleteDropdown from './AutocompleteDropdown';
|
|
import SyntaxHelper from './SyntaxHelper';
|
|
import DangerCommandWarning from './DangerCommandWarning';
|
|
|
|
interface Suggestion {
|
|
command: string;
|
|
description: string;
|
|
syntax: string;
|
|
dangerous: boolean;
|
|
danger_reason?: string;
|
|
plugin: string;
|
|
examples: string[];
|
|
}
|
|
|
|
interface Props {
|
|
onSend: (command: string) => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
const DANGEROUS = ['stop','restart','ban','ban-ip','op','deop','whitelist off','save-off','kill @e','kill @a'];
|
|
|
|
export default function CommandInput({ onSend, disabled = false }: Props) {
|
|
const { id: serverUuid } = useParams<{ id: string }>();
|
|
const [value, setValue] = useState('');
|
|
const [suggestions, setSugs]= useState<Suggestion[]>([]);
|
|
const [selected, setSelected]= useState(-1);
|
|
const [showDanger, setDanger]= useState<Suggestion | null>(null);
|
|
const [activeSug, setActiveSug] = useState<Suggestion | null>(null);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const isDanger = (cmd: string) =>
|
|
DANGEROUS.some(d => cmd.trim().toLowerCase().startsWith(d));
|
|
|
|
const fetchSuggestions = useCallback(async (q: string) => {
|
|
if (!q.trim() || !serverUuid) { setSugs([]); return; }
|
|
try {
|
|
const { data } = await http.get(
|
|
`/api/client/servers/${serverUuid}/addons/console/autocomplete`,
|
|
{ params: { q } }
|
|
);
|
|
setSugs(data.data ?? []);
|
|
setSelected(-1);
|
|
} catch {
|
|
setSugs([]);
|
|
}
|
|
}, [serverUuid]);
|
|
|
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const v = e.target.value;
|
|
setValue(v);
|
|
clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => fetchSuggestions(v), 150);
|
|
|
|
// Update active suggestion for syntax helper
|
|
const match = suggestions.find(s => s.command === v.split(' ')[0]);
|
|
setActiveSug(match ?? null);
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(s => Math.min(s + 1, suggestions.length - 1)); }
|
|
if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(s => Math.max(s - 1, -1)); }
|
|
if (e.key === 'Tab' && suggestions.length > 0) {
|
|
e.preventDefault();
|
|
const idx = selected >= 0 ? selected : 0;
|
|
applySelection(suggestions[idx]);
|
|
}
|
|
if (e.key === 'Escape') { setSugs([]); setSelected(-1); }
|
|
if (e.key === 'Enter') { e.preventDefault(); submit(); }
|
|
};
|
|
|
|
const applySelection = (sug: Suggestion) => {
|
|
setValue(sug.command + ' ');
|
|
setSugs([]);
|
|
setSelected(-1);
|
|
setActiveSug(sug);
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
const submit = () => {
|
|
const cmd = value.trim();
|
|
if (!cmd) return;
|
|
if (isDanger(cmd)) {
|
|
const sug = suggestions.find(s => cmd.startsWith(s.command)) ?? {
|
|
command: cmd, description: '', syntax: '', dangerous: true, danger_reason: 'Potentially destructive command.', plugin: '', examples: [],
|
|
};
|
|
setDanger(sug);
|
|
return;
|
|
}
|
|
send(cmd);
|
|
};
|
|
|
|
const send = (cmd: string) => {
|
|
onSend(cmd);
|
|
setValue('');
|
|
setSugs([]);
|
|
setActiveSug(null);
|
|
setDanger(null);
|
|
};
|
|
|
|
useEffect(() => () => clearTimeout(debounceRef.current), []);
|
|
|
|
return (
|
|
<div className='addon-console-input-wrap'>
|
|
<div className='addon-console-input-row'>
|
|
<input
|
|
ref={inputRef}
|
|
type='text'
|
|
className='addon-console-input'
|
|
value={value}
|
|
onChange={handleChange}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder='Enter command…'
|
|
disabled={disabled}
|
|
autoComplete='off'
|
|
spellCheck={false}
|
|
aria-label='Console command input'
|
|
aria-autocomplete='list'
|
|
/>
|
|
<button
|
|
className='addon-console-send'
|
|
onClick={submit}
|
|
disabled={disabled || !value.trim()}
|
|
aria-label='Send command'
|
|
>▶</button>
|
|
</div>
|
|
|
|
{/* Syntax helper */}
|
|
{activeSug && <SyntaxHelper suggestion={activeSug} />}
|
|
|
|
{/* Dropdown */}
|
|
{suggestions.length > 0 && (
|
|
<AutocompleteDropdown
|
|
suggestions={suggestions}
|
|
selectedIndex={selected}
|
|
onSelect={applySelection}
|
|
/>
|
|
)}
|
|
|
|
{/* Danger modal */}
|
|
{showDanger && (
|
|
<DangerCommandWarning
|
|
suggestion={showDanger}
|
|
onConfirm={() => send(value.trim())}
|
|
onCancel={() => { setDanger(null); inputRef.current?.focus(); }}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|