COPY docuservix
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user