File transfer
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Autocomplete Dropdown
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Suggestion {
|
||||
command: string;
|
||||
description: string;
|
||||
plugin: string;
|
||||
dangerous: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
suggestions: Suggestion[];
|
||||
selectedIndex: number;
|
||||
onSelect: (sug: Suggestion) => void;
|
||||
}
|
||||
|
||||
export default function AutocompleteDropdown({ suggestions, selectedIndex, onSelect }: Props) {
|
||||
return (
|
||||
<ul className='addon-autocomplete-dropdown' role='listbox'>
|
||||
{suggestions.map((sug, i) => (
|
||||
<li
|
||||
key={sug.command}
|
||||
className={`addon-autocomplete-item ${i === selectedIndex ? 'active' : ''} ${sug.dangerous ? 'danger' : ''}`}
|
||||
role='option'
|
||||
aria-selected={i === selectedIndex}
|
||||
onClick={() => onSelect(sug)}
|
||||
onMouseDown={e => e.preventDefault()} // prevent blur
|
||||
>
|
||||
<span className='addon-ac-command'>/{sug.command}</span>
|
||||
{sug.dangerous && <span className='addon-ac-danger-badge'>⚠ Danger</span>}
|
||||
<span className='addon-ac-desc'>{sug.description}</span>
|
||||
<span className='addon-ac-plugin'>{sug.plugin}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
158
resources/scripts/addons/advanced-admin/console/CommandInput.tsx
Normal file
158
resources/scripts/addons/advanced-admin/console/CommandInput.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Danger Command Warning Modal
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Suggestion { command: string; danger_reason?: string; }
|
||||
interface Props { suggestion: Suggestion; onConfirm: () => void; onCancel: () => void; }
|
||||
|
||||
export default function DangerCommandWarning({ suggestion, onConfirm, onCancel }: Props) {
|
||||
return (
|
||||
<div className='addon-modal-overlay' role='dialog' aria-modal='true' aria-label='Danger warning'>
|
||||
<div className='addon-modal addon-modal-danger'>
|
||||
<div className='addon-modal-icon'>⚠️</div>
|
||||
<h3 className='addon-modal-title'>Potentially Dangerous Command</h3>
|
||||
<p className='addon-modal-body'>
|
||||
<code>/{suggestion.command}</code> may have destructive effects.
|
||||
{suggestion.danger_reason && <><br />{suggestion.danger_reason}</>}
|
||||
</p>
|
||||
<div className='addon-modal-actions'>
|
||||
<button className='addon-btn addon-btn-secondary' onClick={onCancel}>Cancel</button>
|
||||
<button className='addon-btn addon-btn-danger' onClick={onConfirm}>Send Anyway</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Syntax Helper — shows command signature below the input
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Suggestion { syntax: string; examples: string[]; plugin: string; }
|
||||
interface Props { suggestion: Suggestion; }
|
||||
|
||||
export default function SyntaxHelper({ suggestion }: Props) {
|
||||
return (
|
||||
<div className='addon-syntax-helper'>
|
||||
<code className='addon-syntax'>{suggestion.syntax}</code>
|
||||
{suggestion.examples.length > 0 && (
|
||||
<span className='addon-syntax-example'>e.g. {suggestion.examples[0]}</span>
|
||||
)}
|
||||
{suggestion.plugin && (
|
||||
<span className='addon-syntax-plugin'>[{suggestion.plugin}]</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user