-
Notifications
You must be signed in to change notification settings - Fork 92
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
merge this horribly complicated hook
- Loading branch information
1 parent
8952e39
commit 80e15b9
Showing
3 changed files
with
199 additions
and
250 deletions.
There are no files selected for viewing
256 changes: 199 additions & 57 deletions
256
packages/react/src/components/header/searchbar/hooks/useAutocomplete.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,84 +1,226 @@ | ||
import { useQuery } from "@tanstack/react-query"; | ||
import { atom, useAtomValue } from "jotai"; | ||
import { splitAtom } from "jotai/utils"; | ||
import { useMemo, useState } from "react"; | ||
import type { TFunction } from "i18next"; | ||
import { useTranslation } from "react-i18next"; | ||
import { splitSearchClassTerms } from "../helper"; | ||
import { JSON_SCHEMA, QueryItem } from "../types"; | ||
import { useClientAutocomplete } from "./useClientAutocomplete"; | ||
import { useServerAutocomplete } from "./useServerAutocomplete"; | ||
import { useClient } from "@/hooks/useClient"; | ||
import { useOrgs } from "@/services/orgs.service"; | ||
import { CLIPPER_LANGS } from "@/lib/consts"; | ||
import { FIRST_SEARCH, splitSearchClassTerms } from "../helper"; | ||
import { JSON_SCHEMA, QueryItem, SearchableCategory } from "../types"; | ||
|
||
/** | ||
* The current searchbar query content. | ||
*/ | ||
export const queryAtom = atom([] as QueryItem[]); | ||
interface ServerAutocompleteResponse { | ||
vtuber?: Array<{ id: string; name: string; english_name?: string }>; | ||
topic?: Array<{ id: string }>; | ||
} | ||
|
||
// Constants | ||
const CATEGORY_PRIORITY: Record<SearchableCategory, number> = { | ||
org: 0, | ||
vtuber: 1, | ||
topic: 2, | ||
type: 3, | ||
lang: 4, | ||
from: 5, | ||
to: 6, | ||
search: 7, | ||
description: 8, | ||
has_song: 9, | ||
}; | ||
|
||
const STATIC_SUGGESTIONS: Record<string, QueryItem[]> = { | ||
has_song: ["none", "non-zero", "one", "many"].map((value) => ({ | ||
type: "has_song", | ||
value, | ||
text: "$t", | ||
})), | ||
type: ["clip", "stream", "placeholder"].map((value) => ({ | ||
type: "type", | ||
value, | ||
text: "$t", | ||
})), | ||
lang: CLIPPER_LANGS.map((x) => ({ ...x, type: "lang" })), | ||
}; | ||
|
||
// Atoms | ||
export const queryAtom = atom<QueryItem[]>([]); | ||
export const splitQueryAtom = splitAtom(queryAtom); | ||
|
||
// Server Autocomplete Logic | ||
function useServerSuggestions( | ||
searchCategory: SearchableCategory | undefined, | ||
searchString: string, | ||
) { | ||
const client = useClient(); | ||
|
||
return useQuery({ | ||
queryKey: ["autocomplete", searchCategory, searchString] as const, | ||
queryFn: async ({ | ||
queryKey: [_, searchCat, query], | ||
}): Promise<QueryItem[]> => { | ||
if ( | ||
!searchCategory || | ||
searchCategory === "topic" || | ||
searchCategory === "vtuber" | ||
) { | ||
const response = await client.get<ServerAutocompleteResponse>( | ||
`/api/v3/search/autocomplete`, | ||
{ | ||
params: { | ||
q: query, | ||
...(searchCat && { t: searchCat, n: 10 }), | ||
}, | ||
}, | ||
); | ||
|
||
const vtuberItems = | ||
response.vtuber?.map<QueryItem>((x) => ({ | ||
type: "vtuber", | ||
value: x.id, | ||
text: x.name, // Note: Removed langPrefs handling for simplicity | ||
_raw: x, | ||
})) || []; | ||
|
||
const topicItems = | ||
response.topic?.map<QueryItem>((x) => ({ | ||
type: "topic", | ||
value: x.id, | ||
text: x.id, | ||
})) || []; | ||
|
||
return [...vtuberItems, ...topicItems]; | ||
} | ||
return []; | ||
}, | ||
staleTime: 30000, | ||
enabled: !!searchString, | ||
}); | ||
} | ||
|
||
// Client Autocomplete Logic | ||
function useClientSuggestions( | ||
searchCategory: SearchableCategory | undefined, | ||
searchString: string, | ||
t: TFunction<"translation", undefined>, | ||
): QueryItem[] { | ||
const { data: orgs } = useOrgs({ enabled: !!searchString }); | ||
|
||
return useMemo(() => { | ||
const suggestions: QueryItem[] = []; | ||
const searchLower = searchString.toLowerCase(); | ||
|
||
// Handle organization suggestions | ||
if ( | ||
searchCategory === "org" || | ||
(searchCategory === undefined && searchString) | ||
) { | ||
const orgSuggestions = | ||
orgs | ||
?.filter( | ||
(org) => | ||
!searchString || | ||
org.name.toLowerCase().includes(searchLower) || | ||
org.name_jp?.toLowerCase().includes(searchLower), | ||
) | ||
?.slice(0, searchCategory === "org" ? 20 : 5) | ||
?.map((org) => ({ | ||
type: "org" as const, | ||
value: org.name, | ||
text: org.name, | ||
})) || []; | ||
|
||
suggestions.push(...orgSuggestions); | ||
} | ||
|
||
// Handle category-specific static suggestions that only show up when a category is specified | ||
if (searchCategory && STATIC_SUGGESTIONS[searchCategory]) { | ||
suggestions.push(...STATIC_SUGGESTIONS[searchCategory]); | ||
} | ||
|
||
// Handle general search when no category is specified | ||
if (searchCategory === undefined) { | ||
if (searchString) { | ||
suggestions.push({ | ||
type: "search", | ||
value: searchString, | ||
text: searchString, | ||
}); | ||
} | ||
|
||
// Add category suggestions | ||
const categoryAutofill = FIRST_SEARCH.filter( | ||
(x) => | ||
!searchString || | ||
t(`search.class.${x.type}`, x.type).startsWith(searchString), | ||
); | ||
suggestions.push(...categoryAutofill); | ||
} | ||
|
||
return suggestions; | ||
}, [orgs, searchCategory, searchString, t]); | ||
} | ||
|
||
// Main Hook | ||
export function useSearchboxAutocomplete() { | ||
const { t } = useTranslation(); | ||
|
||
const query = useAtomValue(queryAtom); | ||
// query: (what's in the search bar) <-- this is an atom. | ||
|
||
// search: (the content typed into the input bar) | ||
const [search, updateSearch] = useState(""); | ||
|
||
const langCategoryReversemapClass = useMemo(() => { | ||
const out = {} as Record<string, keyof typeof JSON_SCHEMA>; | ||
let x: keyof typeof JSON_SCHEMA; | ||
for (x in JSON_SCHEMA) { | ||
out[t("search.class." + x)] = x; | ||
// Build category reverse map for translation | ||
const langCategoryReversemap = useMemo(() => { | ||
const map: Record<string, SearchableCategory> = {}; | ||
for (const key in JSON_SCHEMA) { | ||
map[t(`search.class.${key}`)] = key as SearchableCategory; | ||
} | ||
return out; | ||
return map; | ||
}, [t]); | ||
|
||
// [lang, Eng...] | ||
// Split search into category and search string | ||
const [searchCategory, searchString] = useMemo( | ||
() => splitSearchClassTerms(search, langCategoryReversemapClass), | ||
[search, langCategoryReversemapClass], | ||
() => splitSearchClassTerms(search, langCategoryReversemap), | ||
[search, langCategoryReversemap], | ||
); | ||
|
||
// server autocomplete: (async response fetched from serverside autocomplete depending on search) | ||
const { data: serverAC, ...autocompleteQueryState } = useServerAutocomplete( | ||
// Get suggestions from server and client | ||
const { data: serverSuggestions, ...serverQueryState } = useServerSuggestions( | ||
searchCategory, | ||
searchString, | ||
); | ||
const clientSuggestions = useClientSuggestions( | ||
searchCategory, | ||
searchString, | ||
t, | ||
); | ||
|
||
// Combine and sort suggestions | ||
const autocomplete = useMemo(() => { | ||
const allSuggestions = [...(serverSuggestions || []), ...clientSuggestions]; | ||
|
||
const clientAC = useClientAutocomplete(searchCategory, searchString, t); | ||
|
||
// client autocomplete: (sync response from client autocomplete depending on search and inverted_lang_index) | ||
const autocomplete: QueryItem[] = useMemo(() => { | ||
const out = { ...serverAC, ...clientAC }; | ||
|
||
// order them by category: | ||
const categoryOrder: Array<keyof typeof out> = [ | ||
"org", | ||
"vtuber", | ||
"topic", | ||
"type", | ||
"lang", | ||
"from", | ||
"to", | ||
"search", | ||
"description", | ||
"has_song", | ||
"other", | ||
]; | ||
|
||
const autocompleteList = categoryOrder | ||
.flatMap((x) => out[x]) | ||
.map((x) => { | ||
if (!x) return null; | ||
const ok = JSON_SCHEMA[x.type].suggestionOK?.(query); | ||
if (ok === undefined || ok == "ok") return x; | ||
else if (ok == "replace") return { ...x, replace: true }; | ||
else if (ok === false) return null; | ||
return allSuggestions | ||
.sort((a, b) => { | ||
const aIndex = CATEGORY_PRIORITY[a.type]; | ||
const bIndex = CATEGORY_PRIORITY[b.type]; | ||
return aIndex - bIndex; | ||
}) | ||
.filter((x): x is QueryItem => !!x); | ||
.map((suggestion) => { | ||
const validationResult = | ||
JSON_SCHEMA[suggestion.type].suggestionOK?.(query); | ||
|
||
return autocompleteList; | ||
}, [clientAC, query, serverAC]); | ||
// autocomplete_items: (merged autocomplete options, but grouped by category, also depending on query to remove selected items from dropdown) | ||
if (validationResult === undefined || validationResult === "ok") { | ||
return suggestion; | ||
} else if (validationResult === "replace") { | ||
return { ...suggestion, replace: true }; | ||
} | ||
return null; | ||
}) | ||
.filter((x): x is QueryItem => x !== null); | ||
}, [serverSuggestions, clientSuggestions, query]); | ||
|
||
return { search, updateSearch, autocompleteQueryState, autocomplete }; | ||
return { | ||
search, | ||
updateSearch, | ||
autocompleteQueryState: serverQueryState, | ||
autocomplete, | ||
}; | ||
} |
Oops, something went wrong.