mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
Core & UI: Support tag filters in search dialog
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-ui': minor
|
||||
---
|
||||
|
||||
Support tag filters in search dialog
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-core': minor
|
||||
---
|
||||
|
||||
Support `tag` facet field for Algolia Search Integration
|
||||
@@ -1,25 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import algo from 'algoliasearch/lite';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import type { SharedProps } from 'fumadocs-ui/components/dialog/search';
|
||||
import SearchDialog from 'fumadocs-ui/components/dialog/search-algolia';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { modes } from '@/utils/modes';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useMode } from '@/app/layout.client';
|
||||
|
||||
const itemVariants = cva(
|
||||
'rounded-md border px-2 py-0.5 text-xs font-medium text-muted-foreground transition-colors',
|
||||
{
|
||||
variants: {
|
||||
active: {
|
||||
true: 'bg-accent text-accent-foreground',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const appId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID;
|
||||
const apiKey = process.env.NEXT_PUBLIC_ALGOLIA_API_KEY;
|
||||
const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX;
|
||||
@@ -34,43 +20,17 @@ export default function CustomSearchDialog(
|
||||
props: SharedProps,
|
||||
): React.ReactElement {
|
||||
const defaultTag = useMode() ?? 'headless';
|
||||
const [tag, setTag] = useState(defaultTag);
|
||||
|
||||
useEffect(() => {
|
||||
setTag(defaultTag);
|
||||
}, [defaultTag]);
|
||||
|
||||
return (
|
||||
<SearchDialog
|
||||
index={index}
|
||||
{...props}
|
||||
searchOptions={{
|
||||
filters: `tag:${tag}`,
|
||||
}}
|
||||
footer={
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
{modes.map((mode) => (
|
||||
<button
|
||||
key={mode.param}
|
||||
className={cn(itemVariants({ active: tag === mode.param }))}
|
||||
onClick={() => {
|
||||
setTag(mode.param);
|
||||
}}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{mode.name}
|
||||
</button>
|
||||
))}
|
||||
<a
|
||||
href="https://algolia.com"
|
||||
rel="noreferrer noopener"
|
||||
className="ms-auto text-xs text-muted-foreground"
|
||||
>
|
||||
Search powered by Algolia
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
defaultTag={defaultTag}
|
||||
tags={modes.map((mode) => ({
|
||||
name: mode.name,
|
||||
value: mode.param,
|
||||
}))}
|
||||
showAlgolia
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { BaseIndex } from './server';
|
||||
export interface Options extends SearchOptions {
|
||||
/**
|
||||
* Use `empty` as result if query is empty
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
allowEmpty?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { SearchClient, SearchIndex } from 'algoliasearch';
|
||||
import type { StructuredData } from '@/mdx-plugins/remark-structure';
|
||||
|
||||
@@ -15,6 +14,11 @@ interface DocumentRecord {
|
||||
url: string;
|
||||
structured: StructuredData;
|
||||
|
||||
/**
|
||||
* Tag to filter results
|
||||
*/
|
||||
tag?: string;
|
||||
|
||||
/**
|
||||
* Data to be added to each section index
|
||||
*/
|
||||
@@ -22,14 +26,28 @@ interface DocumentRecord {
|
||||
}
|
||||
|
||||
export interface SyncOptions {
|
||||
/**
|
||||
* Index Name for documents
|
||||
*/
|
||||
document?: string;
|
||||
|
||||
/**
|
||||
* Search indexes
|
||||
*/
|
||||
documents: DocumentRecord[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update index settings and replace all objects
|
||||
*
|
||||
* @param client - Algolia Admin Client
|
||||
* @param options - Index Options
|
||||
*/
|
||||
export async function sync(
|
||||
client: SearchClient,
|
||||
{ document = 'document', documents }: SyncOptions,
|
||||
options: SyncOptions,
|
||||
): Promise<void> {
|
||||
const { document = 'document', documents } = options;
|
||||
const index = client.initIndex(document);
|
||||
await setIndexSettings(index);
|
||||
await updateDocuments(index, documents);
|
||||
@@ -46,7 +64,14 @@ export async function setIndexSettings(index: SearchIndex): Promise<void> {
|
||||
}
|
||||
|
||||
interface Section {
|
||||
/**
|
||||
* Heading content
|
||||
*/
|
||||
section?: string;
|
||||
|
||||
/**
|
||||
* The anchor id
|
||||
*/
|
||||
section_id?: string;
|
||||
content: string;
|
||||
}
|
||||
@@ -88,12 +113,13 @@ export async function updateDocuments(
|
||||
): Promise<void> {
|
||||
const objects = documents.flatMap((page) => {
|
||||
return getSections(page).map(
|
||||
(section) =>
|
||||
(section, idx) =>
|
||||
({
|
||||
objectID: `${page._id}-${randomUUID()}`,
|
||||
objectID: `${page._id}-${idx.toString()}`,
|
||||
title: page.title,
|
||||
url: page.url,
|
||||
page_id: page._id,
|
||||
tag: page.tag,
|
||||
...section,
|
||||
...page.extra_data,
|
||||
}) satisfies BaseIndex,
|
||||
@@ -103,22 +129,14 @@ export async function updateDocuments(
|
||||
await index.replaceAllObjects(objects);
|
||||
}
|
||||
|
||||
export interface BaseIndex {
|
||||
export interface BaseIndex extends Section {
|
||||
objectID: string;
|
||||
title: string;
|
||||
url: string;
|
||||
|
||||
section?: string;
|
||||
|
||||
/**
|
||||
* The anchor id
|
||||
*/
|
||||
section_id?: string;
|
||||
tag?: string;
|
||||
|
||||
/**
|
||||
* The id of page, used for distinct
|
||||
*/
|
||||
page_id: string;
|
||||
|
||||
content: string;
|
||||
}
|
||||
|
||||
@@ -5,21 +5,49 @@ import {
|
||||
type Options,
|
||||
useAlgoliaSearch,
|
||||
} from 'fumadocs-core/search-algolia/client';
|
||||
import { type ReactNode } from 'react';
|
||||
import { SearchDialog, type SharedProps } from './search';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import {
|
||||
SearchDialog,
|
||||
type SharedProps,
|
||||
type TagItem,
|
||||
TagsList,
|
||||
} from './search';
|
||||
|
||||
export interface AlgoliaSearchDialogProps extends SharedProps {
|
||||
index: SearchIndex;
|
||||
searchOptions?: Options;
|
||||
footer?: ReactNode;
|
||||
|
||||
defaultTag?: string;
|
||||
tags?: TagItem[];
|
||||
|
||||
/**
|
||||
* Add the "Powered by Algolia" label, this is useful for free tier users
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
showAlgolia?: boolean;
|
||||
}
|
||||
|
||||
export default function AlgoliaSearchDialog({
|
||||
index,
|
||||
searchOptions,
|
||||
tags,
|
||||
defaultTag,
|
||||
showAlgolia = false,
|
||||
...props
|
||||
}: AlgoliaSearchDialogProps): React.ReactElement {
|
||||
const { search, setSearch, query } = useAlgoliaSearch(index, searchOptions);
|
||||
const [tag, setTag] = useState<string>();
|
||||
let filters = searchOptions?.filters;
|
||||
|
||||
if (tag) {
|
||||
filters = filters ? `tag:${tag} AND (${filters})` : `tag:${tag}`;
|
||||
}
|
||||
|
||||
const { search, setSearch, query } = useAlgoliaSearch(index, {
|
||||
...searchOptions,
|
||||
filters,
|
||||
});
|
||||
|
||||
return (
|
||||
<SearchDialog
|
||||
@@ -27,6 +55,28 @@ export default function AlgoliaSearchDialog({
|
||||
onSearchChange={setSearch}
|
||||
results={query.data ?? []}
|
||||
{...props}
|
||||
footer={
|
||||
<>
|
||||
{tags ? (
|
||||
<TagsList tag={tag ?? defaultTag} onTagChange={setTag} items={tags}>
|
||||
{showAlgolia ? <AlgoliaTitle /> : null}
|
||||
</TagsList>
|
||||
) : null}
|
||||
{props.footer}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlgoliaTitle(): React.ReactNode {
|
||||
return (
|
||||
<a
|
||||
href="https://algolia.com"
|
||||
rel="noreferrer noopener"
|
||||
className="ms-auto text-xs text-muted-foreground"
|
||||
>
|
||||
Search powered by Algolia
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useDocsSearch } from 'fumadocs-core/search/client';
|
||||
import { type ReactNode } from 'react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { useI18n } from '@/contexts/i18n';
|
||||
import { SearchDialog, type SharedProps } from './search';
|
||||
import {
|
||||
SearchDialog,
|
||||
type SharedProps,
|
||||
type TagItem,
|
||||
TagsList,
|
||||
} from './search';
|
||||
|
||||
export interface DefaultSearchDialogProps extends SharedProps {
|
||||
/**
|
||||
* Search tag
|
||||
*
|
||||
* @deprecated Use Tags API instead
|
||||
*/
|
||||
tag?: string;
|
||||
|
||||
defaultTag?: string;
|
||||
tags?: TagItem[];
|
||||
|
||||
/**
|
||||
* Search API URL
|
||||
*/
|
||||
@@ -25,12 +35,14 @@ export interface DefaultSearchDialogProps extends SharedProps {
|
||||
}
|
||||
|
||||
export default function DefaultSearchDialog({
|
||||
tag,
|
||||
defaultTag,
|
||||
tags,
|
||||
api,
|
||||
delayMs,
|
||||
...props
|
||||
}: DefaultSearchDialogProps): React.ReactElement {
|
||||
const { locale } = useI18n();
|
||||
const [tag, setTag] = useState<string>();
|
||||
const { search, setSearch, query } = useDocsSearch(locale, tag, api, delayMs);
|
||||
|
||||
return (
|
||||
@@ -39,6 +51,18 @@ export default function DefaultSearchDialog({
|
||||
onSearchChange={setSearch}
|
||||
results={query.data ?? []}
|
||||
{...props}
|
||||
footer={
|
||||
<>
|
||||
{tags ? (
|
||||
<TagsList
|
||||
tag={tag ?? defaultTag}
|
||||
onTagChange={setTag}
|
||||
items={tags}
|
||||
/>
|
||||
) : null}
|
||||
{props.footer}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { FileTextIcon, HashIcon, TextIcon } from 'lucide-react';
|
||||
import { FileText, Hash, Text } from 'lucide-react';
|
||||
import type { SortedResult } from 'fumadocs-core/search/shared';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, type ReactNode, useCallback } from 'react';
|
||||
import {
|
||||
useMemo,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
type HTMLAttributes,
|
||||
} from 'react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { useI18n } from '@/contexts/i18n';
|
||||
import {
|
||||
CommandEmpty,
|
||||
@@ -29,16 +35,16 @@ export interface SharedProps {
|
||||
|
||||
interface SearchDialogProps
|
||||
extends SharedProps,
|
||||
Omit<SearchContentProps, 'defaultItems'> {
|
||||
Omit<SearchContentProps, 'items'> {
|
||||
results: SortedResult[] | 'empty';
|
||||
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
interface SearchContentProps {
|
||||
search: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
results: SortedResult[] | 'empty';
|
||||
|
||||
defaultItems?: SortedResult[];
|
||||
items: SortedResult[];
|
||||
}
|
||||
|
||||
export function SearchDialog({
|
||||
@@ -61,31 +67,30 @@ export function SearchDialog({
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange} footer={footer}>
|
||||
<Search defaultItems={defaultItems} {...props} />
|
||||
<Search
|
||||
{...props}
|
||||
items={props.results === 'empty' ? defaultItems : props.results}
|
||||
/>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const icons = {
|
||||
text: <TextIcon />,
|
||||
heading: <HashIcon />,
|
||||
page: <FileTextIcon />,
|
||||
text: <Text />,
|
||||
heading: <Hash />,
|
||||
page: <FileText />,
|
||||
};
|
||||
|
||||
function Search({
|
||||
search,
|
||||
onSearchChange,
|
||||
defaultItems = [],
|
||||
results,
|
||||
items,
|
||||
}: SearchContentProps): React.ReactElement {
|
||||
const { text } = useI18n();
|
||||
const router = useRouter();
|
||||
const { setOpenSearch } = useSearchContext();
|
||||
const sidebar = useSidebar();
|
||||
|
||||
const items = results === 'empty' ? defaultItems : results;
|
||||
const hideList = results === 'empty' && defaultItems.length === 0;
|
||||
|
||||
const onOpen = (url: string): void => {
|
||||
router.push(url);
|
||||
setOpenSearch(false);
|
||||
@@ -107,7 +112,7 @@ function Search({
|
||||
}, [setOpenSearch])}
|
||||
placeholder={text.search}
|
||||
/>
|
||||
<CommandList className={cn(hideList && 'hidden')}>
|
||||
<CommandList className={cn(items.length === 0 && 'hidden')}>
|
||||
<CommandEmpty>{text.searchNoResult}</CommandEmpty>
|
||||
|
||||
<CommandGroup value="result">
|
||||
@@ -129,3 +134,55 @@ function Search({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
'rounded-md border px-2 py-0.5 text-xs font-medium text-muted-foreground transition-colors',
|
||||
{
|
||||
variants: {
|
||||
active: {
|
||||
true: 'bg-accent text-accent-foreground',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface TagItem {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface TagsListProps extends HTMLAttributes<HTMLDivElement> {
|
||||
tag?: string;
|
||||
onTagChange: (tag: string) => void;
|
||||
|
||||
items: TagItem[];
|
||||
}
|
||||
|
||||
export function TagsList({
|
||||
tag,
|
||||
onTagChange,
|
||||
items,
|
||||
...props
|
||||
}: TagsListProps): ReactNode {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn('flex flex-row items-center gap-1', props.className)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.value}
|
||||
className={cn(itemVariants({ active: tag === item.value }))}
|
||||
onClick={() => {
|
||||
onTagChange(item.value);
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user