COPY docuservix
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useHistory } from '@docusaurus/router';
|
||||
import searchConfig from '@docuservix-search/config';
|
||||
import type { SearchResult } from '../../types';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
const MAX_DROPDOWN_RESULTS = 10;
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
export default function SearchBar(): JSX.Element {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [notices, setNotices] = useState<string[]>([]);
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const history = useHistory();
|
||||
|
||||
const runSearch = useCallback(async (q: string) => {
|
||||
if (q.trim().length < 2) {
|
||||
setResults([]);
|
||||
setNotices([]);
|
||||
setHasErrors(false);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const timeout = searchConfig.timeout ?? 5000;
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
setLoading(true);
|
||||
setHasErrors(false);
|
||||
|
||||
try {
|
||||
const settled = await Promise.allSettled(
|
||||
searchConfig.providers.map((provider) => {
|
||||
const signal = controller.signal;
|
||||
return provider.search(q, signal);
|
||||
}),
|
||||
);
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
const allResults: SearchResult[] = [];
|
||||
const allNotices: string[] = [];
|
||||
let anyError = false;
|
||||
|
||||
for (const outcome of settled) {
|
||||
if (outcome.status === 'fulfilled') {
|
||||
allResults.push(...outcome.value.results);
|
||||
if (outcome.value.notice) {
|
||||
allNotices.push(outcome.value.notice);
|
||||
}
|
||||
} else {
|
||||
anyError = true;
|
||||
}
|
||||
}
|
||||
|
||||
allResults.sort((a, b) => b.relevance - a.relevance);
|
||||
const top = allResults.slice(0, MAX_DROPDOWN_RESULTS);
|
||||
|
||||
setResults(top);
|
||||
setNotices(allNotices);
|
||||
setHasErrors(anyError);
|
||||
setOpen(true);
|
||||
setSelectedIndex(-1);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setQuery(value);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => runSearch(value), DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!open) {
|
||||
if (e.key === 'Enter' && query.trim()) {
|
||||
history.push(`/search?q=${encodeURIComponent(query.trim())}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, -1));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && results[selectedIndex]) {
|
||||
window.location.href = results[selectedIndex].url;
|
||||
} else if (query.trim()) {
|
||||
history.push(`/search?q=${encodeURIComponent(query.trim())}`);
|
||||
}
|
||||
setOpen(false);
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.container}
|
||||
>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="search"
|
||||
placeholder="Поиск..."
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label="Поиск по документации"
|
||||
aria-expanded={open}
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
/>
|
||||
{loading && (
|
||||
<span
|
||||
className={styles.spinner}
|
||||
aria-label="Загрузка..."
|
||||
/>
|
||||
)}
|
||||
{open && (
|
||||
<div
|
||||
className={styles.dropdown}
|
||||
role="listbox"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{hasErrors && (
|
||||
<div className={styles.errorBadge}>Некоторые источники недоступны</div>
|
||||
)}
|
||||
{results.length === 0 && !loading && (
|
||||
<div className={styles.empty}>Ничего не найдено</div>
|
||||
)}
|
||||
{results.map((result, i) => (
|
||||
<a
|
||||
key={`${result.url}-${i}`}
|
||||
href={result.url}
|
||||
role="option"
|
||||
aria-selected={i === selectedIndex}
|
||||
className={
|
||||
i === selectedIndex
|
||||
? `${styles.item} ${styles.selectedItem}`
|
||||
: styles.item
|
||||
}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className={styles.itemHeading}>{result.title}</div>
|
||||
<div className={styles.itemFile}>
|
||||
<span
|
||||
className={styles.badge}
|
||||
data-type={result.type}
|
||||
>
|
||||
{result.type}
|
||||
</span>
|
||||
{result.path}
|
||||
<span className={styles.itemScore}>
|
||||
{Math.round(result.relevance * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
{result.content && (
|
||||
<div className={styles.itemContent}>{result.content}</div>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
{notices.map((notice, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={styles.notice}
|
||||
>
|
||||
{notice}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user