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,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>
);
}
@@ -0,0 +1,170 @@
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.heading {
margin-bottom: 1rem;
font-size: 1.5rem;
}
.searchInput {
width: 100%;
height: 40px;
margin-bottom: 1.5rem;
padding: 0 16px;
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: border-color 0.2s ease;
}
.searchInput:focus {
border-color: var(--ifm-color-primary);
}
.loadingText {
padding: 1rem 0;
color: var(--ifm-color-emphasis-600);
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.filterBtn {
padding: 4px 12px;
color: var(--ifm-font-color-base);
font-size: 0.875rem;
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 999px;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.filterBtn:hover {
border-color: var(--ifm-color-primary);
}
.filterBtnActive {
color: var(--ifm-color-primary-contrast-foreground);
background: var(--ifm-color-primary);
border-color: var(--ifm-color-primary);
}
.empty {
padding: 2rem 0;
color: var(--ifm-color-emphasis-600);
font-size: 1rem;
text-align: center;
}
.resultList {
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
}
.resultItem {
display: block;
padding: 12px 16px;
color: var(--ifm-font-color-base);
text-decoration: none;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
transition: background 0.1s ease;
}
.resultItem:last-child {
border-bottom: none;
}
.resultItem:hover {
color: var(--ifm-font-color-base);
text-decoration: none;
background: var(--ifm-color-emphasis-100);
}
.resultTitle {
margin-bottom: 4px;
font-weight: var(--ifm-font-weight-semibold);
font-size: 0.95rem;
}
.resultMeta {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 4px;
}
.resultPath {
color: var(--ifm-color-emphasis-600);
font-size: 0.75rem;
}
.resultAnchor {
color: var(--ifm-color-emphasis-500);
font-size: 0.75rem;
}
.resultScore {
margin-left: auto;
color: var(--ifm-color-emphasis-500);
font-size: 0.75rem;
white-space: nowrap;
}
.resultContent {
display: -webkit-box;
overflow: hidden;
color: var(--ifm-color-emphasis-700);
font-size: 0.85rem;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.chatLink {
display: inline-block;
margin-bottom: 1rem;
padding: 6px 14px;
color: var(--ifm-color-primary);
font-size: 0.875rem;
text-decoration: none;
border: 1px solid var(--ifm-color-primary);
border-radius: var(--ifm-global-radius);
transition:
background 0.15s ease,
color 0.15s ease;
}
.chatLink:hover {
color: var(--ifm-color-primary-contrast-foreground);
text-decoration: none;
background: var(--ifm-color-primary);
}
.badge {
display: inline-block;
padding: 1px 8px;
color: var(--ifm-color-emphasis-700);
font-weight: normal;
font-size: 0.7rem;
letter-spacing: 0.04em;
white-space: nowrap;
text-transform: uppercase;
background: var(--ifm-color-emphasis-200);
border-radius: 999px;
}