diff --git a/locale/en/translation.json b/locale/en/translation.json index c1faed08..8d2f165f 100644 --- a/locale/en/translation.json +++ b/locale/en/translation.json @@ -117,7 +117,8 @@ "content1": "Try changing your keywords and search again.", "content2": "If you can't find what you're looking for, seek help from our <0>Discord channels." }, - "searchTip": "Use double quotes for exact match. For example, \"time_zone\"." + "searchTip": "Use double quotes for exact match. For example, \"time_zone\".", + "filters": "Filter search results:" }, "tools": { "tidbOperator": { diff --git a/locale/zh/translation.json b/locale/zh/translation.json index 0b7fbebb..88730bc5 100644 --- a/locale/zh/translation.json +++ b/locale/zh/translation.json @@ -115,7 +115,8 @@ "content1": "请尝试使用其他关键词再次搜索。", "content2": "如果您无法找到需要的内容,请尝试前往 <0>AskTUG (TiDB User Group) 进行提问。" }, - "searchTip": "使用半角双引号进行精确搜索。例如,\"time_zone\"。" + "searchTip": "使用半角双引号进行精确搜索。例如,\"time_zone\"。", + "filters": "过滤搜索结果:" }, "tools": { "tidbOperator": { diff --git a/src/components/Search/Results.tsx b/src/components/Search/Results.tsx index 54bae09b..15569b7d 100644 --- a/src/components/Search/Results.tsx +++ b/src/components/Search/Results.tsx @@ -10,14 +10,16 @@ import Chip from "@mui/material/Chip"; import { getSearchCategoryLabelKey, resolveSearchCategory, + type SearchCategory, } from "shared/utils/searchCategory"; export default function SearchResults(props: { loading: boolean; className?: string; data: any[]; + onFilterChange?: (category: SearchCategory | null) => void; }) { - const { data, loading } = props; + const { data, loading, onFilterChange } = props; const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(10); @@ -101,7 +103,11 @@ export default function SearchResults(props: { */} {filteredDataMemo.map((item) => ( - + ))} {data.length === 0 && !loading && ( @@ -177,8 +183,11 @@ function SearchItemSkeleton() { ); } -function SearchItem(props: { data: any }) { - const { data } = props; +function SearchItem(props: { + data: any; + onFilterChange?: (category: SearchCategory | null) => void; +}) { + const { data, onFilterChange } = props; const { t } = useI18next(); const category = React.useMemo( () => resolveSearchCategory(data.url), @@ -214,12 +223,27 @@ function SearchItem(props: { data: any }) { size="small" variant="outlined" label={categoryLabel} + clickable={!!onFilterChange} + onClick={ + onFilterChange + ? (e) => { + e.preventDefault(); + onFilterChange(category); + } + : undefined + } sx={{ height: "20px", fontSize: "12px", borderRadius: "10px", backgroundColor: "carbon.100", color: "carbon.800", + ...(onFilterChange && { + cursor: "pointer", + "&:hover": { + backgroundColor: "carbon.200", + }, + }), }} /> )} @@ -267,3 +291,74 @@ function SearchItem(props: { data: any }) { ); } + +export function SearchFilterBar(props: { + categoryCountMap: Map; + activeFilter: SearchCategory | null; + onFilterChange: (category: SearchCategory | null) => void; + visible: boolean; +}) { + const { categoryCountMap, activeFilter, onFilterChange, visible } = props; + const { t } = useI18next(); + + const entries = Array.from(categoryCountMap.entries()).filter( + ([category]) => { + const labelKey = getSearchCategoryLabelKey(category); + return labelKey && t(labelKey); + } + ); + + if (!visible || entries.length <= 1) { + return null; + } + + return ( + + + + + {entries.map(([category, count]) => { + const label = t(getSearchCategoryLabelKey(category)); + const isActive = activeFilter === category; + return ( + onFilterChange(category)} + sx={{ + height: "24px", + fontSize: "12px", + borderRadius: "12px", + cursor: "pointer", + ...(isActive + ? { + backgroundColor: "carbon.800", + color: "white", + "&:hover": { + backgroundColor: "carbon.700", + }, + } + : { + backgroundColor: "carbon.100", + color: "carbon.800", + "&:hover": { + backgroundColor: "carbon.200", + }, + }), + }} + /> + ); + })} + + ); +} diff --git a/src/templates/DocSearchTemplate.tsx b/src/templates/DocSearchTemplate.tsx index 30c915a6..b51dbd69 100644 --- a/src/templates/DocSearchTemplate.tsx +++ b/src/templates/DocSearchTemplate.tsx @@ -12,6 +12,7 @@ import "styles/docTemplate.css"; import Layout from "components/Layout"; import SearchResults from "components/Search/Results"; +import { SearchFilterBar } from "components/Search/Results"; import SearchInput from "components/Search"; import { Tip } from "components/MDXComponents"; import Seo from "components/Seo"; @@ -20,6 +21,10 @@ import { Locale, TOCNamespace } from "shared/interface"; import { FeedbackSurveyCampaign } from "components/Campaign/FeedbackSurvey"; import ScrollToTopBtn from "components/Button/ScrollToTopBtn"; import { useIsAutoTranslation } from "shared/useIsAutoTranslation"; +import { + resolveSearchCategory, + type SearchCategory, +} from "shared/utils/searchCategory"; const SEARCH_INDEX_BY_LANGUAGE: Partial> = { [Locale.en]: "en-tidb-all-stable", @@ -40,15 +45,41 @@ export default function DocSearchTemplate({ }: DocSearchTemplateProps) { const [isLoading, setIsLoading] = React.useState(false); const [results, setResults] = React.useState([]); + const [activeFilter, setActiveFilter] = + React.useState(null); const { language } = useI18next(); const { search } = useLocation(); + const categoryCountMap = React.useMemo(() => { + const map = new Map(); + for (const item of results) { + const cat = resolveSearchCategory(item.url); + if (cat) map.set(cat, (map.get(cat) || 0) + 1); + } + return map; + }, [results]); + + const filteredResults = React.useMemo(() => { + if (!activeFilter) return results; + return results.filter( + (item) => resolveSearchCategory(item.url) === activeFilter + ); + }, [results, activeFilter]); + + const handleFilterChange = React.useCallback( + (category: SearchCategory | null) => { + setActiveFilter((prev) => (prev === category ? null : category)); + }, + [] + ); + const execSearch = React.useCallback( (query: string) => { const trimmedQuery = query.trim(); if (!trimmedQuery) { setResults([]); + setActiveFilter(null); setIsLoading(false); return; } @@ -56,6 +87,7 @@ export default function DocSearchTemplate({ const indexName = SEARCH_INDEX_BY_LANGUAGE[language as Locale]; if (!indexName) { setResults([]); + setActiveFilter(null); setIsLoading(false); return; } @@ -69,11 +101,13 @@ export default function DocSearchTemplate({ }) .then(({ hits }) => { setResults(hits); + setActiveFilter(null); setIsLoading(false); }) .catch((reason: any) => { console.error(reason); setResults([]); + setActiveFilter(null); setIsLoading(false); }); }, @@ -85,12 +119,14 @@ export default function DocSearchTemplate({ const query = searchParams.get("q") || ""; if (language === Locale.ja) { setResults([]); + setActiveFilter(null); setIsLoading(false); return; } if (!query.trim()) { setResults([]); + setActiveFilter(null); setIsLoading(false); return; } @@ -127,10 +163,20 @@ export default function DocSearchTemplate({ }} /> + 0} + /> - +