diff --git a/packages/react/src/components/header/searchbar/hooks/useAutocomplete.tsx b/packages/react/src/components/header/searchbar/hooks/useAutocomplete.tsx index 9499a976c..0d8b4dd66 100644 --- a/packages/react/src/components/header/searchbar/hooks/useAutocomplete.tsx +++ b/packages/react/src/components/header/searchbar/hooks/useAutocomplete.tsx @@ -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 = { + 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 = { + 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([]); 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 => { + if ( + !searchCategory || + searchCategory === "topic" || + searchCategory === "vtuber" + ) { + const response = await client.get( + `/api/v3/search/autocomplete`, + { + params: { + q: query, + ...(searchCat && { t: searchCat, n: 10 }), + }, + }, + ); + + const vtuberItems = + response.vtuber?.map((x) => ({ + type: "vtuber", + value: x.id, + text: x.name, // Note: Removed langPrefs handling for simplicity + _raw: x, + })) || []; + + const topicItems = + response.topic?.map((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; - 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 = {}; + 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 = [ - "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, + }; } diff --git a/packages/react/src/components/header/searchbar/hooks/useClientAutocomplete.tsx b/packages/react/src/components/header/searchbar/hooks/useClientAutocomplete.tsx deleted file mode 100644 index d20e336c9..000000000 --- a/packages/react/src/components/header/searchbar/hooks/useClientAutocomplete.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useMemo } from "react"; -import { QueryItem, VideoQueryModel } from "../types"; -import { useOrgs } from "@/services/orgs.service"; -import { FIRST_SEARCH } from "../helper"; -import type { TFunction } from "i18next"; -import { CLIPPER_LANGS } from "@/lib/consts"; - -/** - * Provides client-side autocomplete suggestions based on the search category and search string. - * - * @param {keyof VideoQueryModel | undefined} searchCategory - The category to search in, which can be one of the keys in VideoQueryModel or undefined for a broader search. - * @param {string} searchString - The search string to use for filtering autocomplete suggestions. - * @param {TFunction<"translation", undefined>} t - Translation function for localizing suggestion text. - * @returns {Record<"org" | "from" | "to" | "search" | "description" | "type" | "lang" | "has_song" | "other", QueryItem[]>} - An object containing categorized autocomplete suggestions. - */ -export function useClientAutocomplete( - searchCategory: keyof VideoQueryModel | undefined, - searchString: string, - t: TFunction<"translation", undefined>, -): Record< - | "org" - | "from" - | "to" - | "search" - | "description" - | "type" - | "lang" - | "has_song" - | "other", - QueryItem[] -> { - const { data: orgs } = useOrgs({ enabled: !!searchString }); - return useMemo(() => { - const ac_opts: Record< - Exclude, - QueryItem[] - > = { - org: [], - type: [], - search: [], - lang: [], - to: [], - description: [], - from: [], - has_song: [], - other: [], - }; - if ( - searchCategory === "org" || - (searchCategory === undefined && searchString) - ) { - const _squery = searchString.toLowerCase(); - ac_opts.org = - orgs - ?.filter( - (x) => - !searchString || - x.name.toLowerCase().includes(_squery) || - x.name_jp?.toLowerCase().includes(_squery), - ) - ?.slice(0, searchCategory === "org" ? 20 : 5) // only give 5 suggestions when searching broadly. - ?.map((x) => ({ - type: "org", - value: x.name, - text: /*langPrefs.preferredLocaleFn(x.name, x.name_jp) ||*/ x.name, - })) || []; - } - - if (searchCategory === undefined) { - const categoryAutofill = FIRST_SEARCH.filter( - (x) => - !searchString || - t(`search.class.${x.type}`, x.type).startsWith(searchString), - ); - ac_opts.other = categoryAutofill; - - if (!categoryAutofill.find((x) => x.type == "search")) { - ac_opts.search = [ - { - type: "search", - value: searchString, - text: searchString, - }, - ]; - } - - return ac_opts; - } - - // everything else only gets autocompleted when needed: - switch (searchCategory) { - case "has_song": - ac_opts.has_song = ["none", "non-zero", "one", "many"].map((value) => ({ - type: "has_song", - value, - text: "$t", - })); - break; - - case "lang": - ac_opts.lang = CLIPPER_LANGS.map((x) => ({ ...x, type: "lang" })); - break; - - case "type": - ac_opts.type = ["clip", "stream", "placeholder"].map((value) => ({ - type: "type", - value, - text: "$t", - })); - break; - - case "org": - case "topic": - case "vtuber": - break; - - default: - ac_opts.other = [ - { - type: searchCategory, - value: searchString, - text: searchString, - }, - ]; - } - - return ac_opts; - }, [orgs, searchCategory, searchString, t]); -} diff --git a/packages/react/src/components/header/searchbar/hooks/useServerAutocomplete.tsx b/packages/react/src/components/header/searchbar/hooks/useServerAutocomplete.tsx deleted file mode 100644 index 960ca828d..000000000 --- a/packages/react/src/components/header/searchbar/hooks/useServerAutocomplete.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { AC_Response, QueryItem, VideoQueryModel } from "../types"; -import { useQuery } from "@tanstack/react-query"; -import { useClient } from "@/hooks/useClient"; - -/** - * Executes a server-side autocomplete search based on the provided search category and search string. - * - * @param {keyof VideoQueryModel | undefined} searchCategory - The category of the search, which can be one of the keys in VideoQueryModel or undefined. - * @param {string} searchString - The string to search for. - * @return The result of the autocomplete search. - */ -export function useServerAutocomplete( - searchCategory: keyof VideoQueryModel | undefined, - searchString: string, -) { - const client = useClient(); - return useQuery({ - queryKey: ["autocomplete", searchCategory, searchString] as const, - queryFn: async ({ - queryKey: [_, searchCat, query], - }): Promise> => { - if ( - !searchCategory || - searchCategory == "topic" || - searchCategory == "vtuber" - ) { - const acr = await client.get( - `/api/v3/search/autocomplete`, - { - params: { - q: query, - ...(searchCat && { t: searchCat }), - ...(searchCat && { n: 10 }), - }, - }, - ); - - console.log(acr); - - return { - vtuber: - acr.vtuber?.map((x) => ({ - type: "vtuber", - value: x.id, - text: /*langPrefs.preferredLocaleFn(x.english_name, x.name) || */ x.name, - _raw: x, - })) || [], - topic: - acr.topic?.map((x) => ({ - type: "topic", - value: x.id, - text: x.id, - })) || [], - }; - } - return { - vtuber: [], - topic: [], - }; - }, - staleTime: 30000, - enabled: !!searchString, - }); -}