COPY docuservix

This commit is contained in:
2026-06-16 13:58:03 +03:00
parent da37322232
commit f5181ef8a0
10 changed files with 1350 additions and 0 deletions
@@ -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);
}