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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
.container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 200px;
|
||||
height: 32px;
|
||||
padding: 0 32px 0 12px;
|
||||
color: var(--ifm-font-color-base);
|
||||
font-size: var(--ifm-font-size-base);
|
||||
background: var(--ifm-background-surface-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: var(--ifm-global-radius);
|
||||
outline: none;
|
||||
transition:
|
||||
width 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
width: 280px;
|
||||
border-color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--ifm-color-emphasis-300);
|
||||
border-top-color: var(--ifm-color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: var(--ifm-background-surface-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: var(--ifm-global-radius);
|
||||
box-shadow: var(--ifm-global-shadow-md);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: block;
|
||||
padding: 10px 14px;
|
||||
color: var(--ifm-font-color-base);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
.item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
color: var(--ifm-color-primary);
|
||||
text-decoration: none;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
}
|
||||
|
||||
.selectedItem {
|
||||
color: var(--ifm-color-primary);
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
outline: 2px solid var(--ifm-color-primary-light);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.itemHeading {
|
||||
margin-bottom: 2px;
|
||||
font-weight: var(--ifm-font-weight-semibold);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-weight: normal;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.itemFile {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.itemScore {
|
||||
margin-left: auto;
|
||||
color: var(--ifm-color-emphasis-500);
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.itemContent {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.8rem;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 12px 14px;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 8px 14px;
|
||||
color: var(--ifm-color-info-dark);
|
||||
font-size: 0.8rem;
|
||||
background: var(--ifm-color-info-contrast-background);
|
||||
border-top: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
.errorBadge {
|
||||
padding: 8px 14px;
|
||||
color: var(--ifm-color-warning-dark);
|
||||
font-size: 0.8rem;
|
||||
background: var(--ifm-color-warning-contrast-background);
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
Reference in New Issue
Block a user