244 lines
9.0 KiB
TypeScript
244 lines
9.0 KiB
TypeScript
import { useHistory, useLocation } from '@docusaurus/router';
|
|
import searchConfig from '@docuservix-search/config';
|
|
import Link from '@docusaurus/Link';
|
|
import Layout from '@theme/Layout';
|
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
import type { SearchResult } from '../../types';
|
|
|
|
import styles from './styles.module.css';
|
|
|
|
const MAX_RESULTS = 25;
|
|
|
|
function useQuery(): string {
|
|
const location = useLocation();
|
|
const params = new URLSearchParams(location.search);
|
|
|
|
return params.get('q') ?? '';
|
|
}
|
|
|
|
export default function SearchPage(): JSX.Element {
|
|
const urlQuery = useQuery();
|
|
const history = useHistory();
|
|
|
|
const [inputValue, setInputValue] = useState(urlQuery);
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [notices, setNotices] = useState<string[]>([]);
|
|
const [errors, setErrors] = useState<string[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [activeFilter, setActiveFilter] = useState<string>('all');
|
|
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
|
|
const runSearch = useCallback(async (q: string) => {
|
|
if (!q.trim()) {
|
|
setResults([]);
|
|
setNotices([]);
|
|
setErrors([]);
|
|
|
|
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);
|
|
setErrors([]);
|
|
|
|
try {
|
|
const settled = await Promise.allSettled(
|
|
searchConfig.providers.map((provider) => provider.search(q, controller.signal)),
|
|
);
|
|
|
|
if (controller.signal.aborted) {
|
|
return;
|
|
}
|
|
|
|
const allResults: SearchResult[] = [];
|
|
const allNotices: string[] = [];
|
|
const allErrors: string[] = [];
|
|
|
|
for (let i = 0; i < settled.length; i++) {
|
|
const outcome = settled[i];
|
|
const provider = searchConfig.providers[i];
|
|
|
|
if (outcome.status === 'fulfilled') {
|
|
allResults.push(...outcome.value.results);
|
|
|
|
if (outcome.value.notice) {
|
|
allNotices.push(outcome.value.notice);
|
|
}
|
|
} else {
|
|
allErrors.push(`${provider.name}: недоступен`);
|
|
}
|
|
}
|
|
|
|
allResults.sort((a, b) => b.relevance - a.relevance);
|
|
|
|
setResults(allResults.slice(0, MAX_RESULTS));
|
|
setNotices(allNotices);
|
|
setErrors(allErrors);
|
|
setActiveFilter('all');
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setInputValue(urlQuery);
|
|
runSearch(urlQuery);
|
|
}, [urlQuery, runSearch]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (abortRef.current) {
|
|
abortRef.current.abort();
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setInputValue(e.target.value);
|
|
};
|
|
|
|
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter' && inputValue.trim()) {
|
|
history.push(`/search?q=${encodeURIComponent(inputValue.trim())}`);
|
|
}
|
|
};
|
|
|
|
const sourceTypes = ['all', ...Array.from(new Set(results.map((r) => r.type)))];
|
|
|
|
const filteredResults =
|
|
activeFilter === 'all' ? results : results.filter((r) => r.type === activeFilter);
|
|
|
|
const typeLabel: Record<string, string> = {
|
|
all: 'Все',
|
|
docs: 'Docs',
|
|
};
|
|
|
|
return (
|
|
<Layout title={urlQuery ? `Поиск: ${urlQuery}` : 'Поиск'}>
|
|
<div className={styles.container}>
|
|
<h1 className={styles.heading}>Поиск</h1>
|
|
<input
|
|
className={styles.searchInput}
|
|
type="search"
|
|
value={inputValue}
|
|
onChange={handleInputChange}
|
|
onKeyDown={handleInputKeyDown}
|
|
placeholder="Введите запрос и нажмите Enter..."
|
|
aria-label="Поисковый запрос"
|
|
/>
|
|
|
|
{urlQuery && (
|
|
<Link
|
|
to={`/chat?q=${encodeURIComponent(urlQuery)}`}
|
|
className={styles.chatLink}
|
|
>
|
|
Спросить ИИ →
|
|
</Link>
|
|
)}
|
|
|
|
{errors.length > 0 && (
|
|
<div
|
|
className="alert alert--warning"
|
|
role="alert"
|
|
>
|
|
{errors.map((err, i) => (
|
|
<div key={i}>{err}</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{notices.length > 0 && (
|
|
<div
|
|
className="alert alert--info"
|
|
role="note"
|
|
>
|
|
{notices.map((notice, i) => (
|
|
<div key={i}>{notice}</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{loading && <div className={styles.loadingText}>Поиск...</div>}
|
|
|
|
{!loading && urlQuery && (
|
|
<>
|
|
{sourceTypes.length > 2 && (
|
|
<div
|
|
className={styles.filters}
|
|
role="tablist"
|
|
>
|
|
{sourceTypes.map((type) => (
|
|
<button
|
|
key={type}
|
|
role="tab"
|
|
aria-selected={activeFilter === type}
|
|
className={
|
|
activeFilter === type
|
|
? `${styles.filterBtn} ${styles.filterBtnActive}`
|
|
: styles.filterBtn
|
|
}
|
|
onClick={() => setActiveFilter(type)}
|
|
>
|
|
{typeLabel[type] ?? type}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{filteredResults.length === 0 ? (
|
|
<div className={styles.empty}>Ничего не найдено</div>
|
|
) : (
|
|
<div className={styles.resultList}>
|
|
{filteredResults.map((result, i) => (
|
|
<a
|
|
key={`${result.url}-${i}`}
|
|
href={result.url}
|
|
className={styles.resultItem}
|
|
>
|
|
<div className={styles.resultTitle}>{result.title}</div>
|
|
<div className={styles.resultMeta}>
|
|
<span
|
|
className={styles.badge}
|
|
data-type={result.type}
|
|
>
|
|
{typeLabel[result.type] ?? result.type}
|
|
</span>
|
|
<span className={styles.resultPath}>{result.path}</span>
|
|
{result.anchor && (
|
|
<span className={styles.resultAnchor}>
|
|
#{result.anchor}
|
|
</span>
|
|
)}
|
|
<span className={styles.resultScore}>
|
|
{Math.round(result.relevance * 100)}%
|
|
</span>
|
|
</div>
|
|
{result.content && (
|
|
<div className={styles.resultContent}>
|
|
{result.content}
|
|
</div>
|
|
)}
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|