Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
202 lines
6.1 KiB
React
202 lines
6.1 KiB
React
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { IconServer } from '@tabler/icons-react';
|
||
|
||
/**
|
||
* Красивый selector серверов с автодополнением.
|
||
* Похож по UX на CommunityAutocompleteInput, но заточен под поля серверов.
|
||
*/
|
||
function ServerAutocompleteInput({
|
||
value,
|
||
onChange,
|
||
servers = [],
|
||
placeholder = '',
|
||
className = 'form-control',
|
||
onSelectMeta,
|
||
maxSuggestions = 12,
|
||
}) {
|
||
const containerRef = useRef(null);
|
||
const inputRef = useRef(null);
|
||
const [open, setOpen] = useState(false);
|
||
const [activeIndex, setActiveIndex] = useState(-1);
|
||
|
||
const suggestions = useMemo(() => {
|
||
const q = String(value || '').toLowerCase();
|
||
|
||
const list = (servers || []).map((s) => {
|
||
const id = s.id || s.ip;
|
||
const labelIp = s.ip || '—';
|
||
const labelDns = s.dns || '';
|
||
const labelProvider = s.provider || '';
|
||
const label =
|
||
labelDns && labelProvider
|
||
? `${labelIp} (${labelDns}) – ${labelProvider}`
|
||
: labelDns
|
||
? `${labelIp} (${labelDns})`
|
||
: labelProvider
|
||
? `${labelIp} – ${labelProvider}`
|
||
: `${labelIp}`;
|
||
|
||
return {
|
||
raw: s,
|
||
value: id,
|
||
label,
|
||
search: [
|
||
id,
|
||
s.ip,
|
||
s.extIp,
|
||
s.internalIp,
|
||
s.dns,
|
||
s.provider,
|
||
s.country,
|
||
s.gateway,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')
|
||
.toLowerCase(),
|
||
};
|
||
});
|
||
|
||
if (!q) return list.slice(0, maxSuggestions);
|
||
const filtered = list.filter((item) => item.search.includes(q));
|
||
return filtered.slice(0, maxSuggestions);
|
||
}, [value, servers, maxSuggestions]);
|
||
|
||
useEffect(() => {
|
||
const handleOutside = (e) => {
|
||
if (!containerRef.current) return;
|
||
if (!containerRef.current.contains(e.target)) setOpen(false);
|
||
};
|
||
document.addEventListener('click', handleOutside);
|
||
return () => document.removeEventListener('click', handleOutside);
|
||
}, []);
|
||
|
||
const selectItem = (item) => {
|
||
onChange(item.value);
|
||
if (onSelectMeta) onSelectMeta(item.raw);
|
||
setOpen(false);
|
||
setActiveIndex(-1);
|
||
if (inputRef.current) inputRef.current.focus();
|
||
};
|
||
|
||
const handleKeyDown = (e) => {
|
||
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||
setOpen(true);
|
||
return;
|
||
}
|
||
if (!open) return;
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
setActiveIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
setActiveIndex((prev) => Math.max(prev - 1, 0));
|
||
} else if (e.key === 'Enter') {
|
||
if (activeIndex >= 0 && activeIndex < suggestions.length) {
|
||
e.preventDefault();
|
||
selectItem(suggestions[activeIndex]);
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
setOpen(false);
|
||
setActiveIndex(-1);
|
||
}
|
||
};
|
||
|
||
// Текст в input: ищем label по выбранному serverId
|
||
const displayValue = useMemo(() => {
|
||
if (!value) return '';
|
||
const found = (servers || []).find((s) => (s.id || s.ip) === value);
|
||
if (!found) return '';
|
||
const ip = found.ip || '—';
|
||
const dns = found.dns || '';
|
||
const provider = found.provider || '';
|
||
if (dns && provider) return `${ip} (${dns}) – ${provider}`;
|
||
if (dns) return `${ip} (${dns})`;
|
||
if (provider) return `${ip} – ${provider}`;
|
||
return ip;
|
||
}, [value, servers]);
|
||
|
||
return (
|
||
<div ref={containerRef} className="position-relative" style={{ width: '100%' }}>
|
||
<div className="input-icon">
|
||
<span className="input-icon-addon">
|
||
<IconServer size={18} />
|
||
</span>
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
className={className}
|
||
placeholder={placeholder}
|
||
value={open ? displayValue || value : displayValue}
|
||
onChange={(e) => {
|
||
onChange(e.target.value);
|
||
setOpen(true);
|
||
}}
|
||
onFocus={() => setOpen(true)}
|
||
onKeyDown={handleKeyDown}
|
||
autoComplete="off"
|
||
/>
|
||
</div>
|
||
|
||
{open && suggestions.length > 0 && (
|
||
<div
|
||
className="dropdown-menu show"
|
||
style={{
|
||
display: 'block',
|
||
width: '100%',
|
||
maxHeight: 280,
|
||
overflowY: 'auto',
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
className={`dropdown-item${activeIndex === -1 ? ' active' : ''}`}
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
onChange('');
|
||
if (onSelectMeta) onSelectMeta(null);
|
||
setOpen(false);
|
||
setActiveIndex(-1);
|
||
}}
|
||
>
|
||
<div className="d-flex align-items-center text-muted small">
|
||
<span className="me-2">Без привязки</span>
|
||
</div>
|
||
</button>
|
||
|
||
{suggestions.map((s, idx) => (
|
||
<button
|
||
type="button"
|
||
key={`${s.value}-${idx}`}
|
||
className={`dropdown-item${idx === activeIndex ? ' active' : ''}`}
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
selectItem(s);
|
||
}}
|
||
onMouseEnter={() => setActiveIndex(idx)}
|
||
>
|
||
<div className="d-flex align-items-start">
|
||
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 24, height: 24 }}>
|
||
<IconServer size={14} />
|
||
</span>
|
||
<div className="flex-fill text-start">
|
||
<div className="fw-medium">{s.label}</div>
|
||
<div className="text-muted small text-truncate" style={{ maxWidth: '100%' }}>
|
||
{s.raw.country && <span className="me-2">{s.raw.country}</span>}
|
||
{s.raw.tunnel && <span className="me-2">{s.raw.tunnel}</span>}
|
||
{s.raw.gateway && <span className="me-2">{s.raw.gateway}</span>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default ServerAutocompleteInput;
|
||
|
||
|
||
|