diff --git a/app/(footer)/contact/contact.tsx b/app/(footer)/contact/contact.tsx index 8f0a5273..1eaf8d21 100644 --- a/app/(footer)/contact/contact.tsx +++ b/app/(footer)/contact/contact.tsx @@ -162,7 +162,7 @@ export const Contact = () => { }) } > - + diff --git a/app/homepage.tsx b/app/home/homepage.tsx similarity index 67% rename from app/homepage.tsx rename to app/home/homepage.tsx index d1678876..d057403b 100644 --- a/app/homepage.tsx +++ b/app/home/homepage.tsx @@ -1,24 +1,14 @@ -"use client"; - import Link from "next/link"; -import { Button, Col, Row } from "react-bootstrap"; import { PatreonBunnySvgWithoutLink } from "~app/patron/patreon-info"; -import { Run } from "~src/common/types"; -import { Game } from "~app/games/games.types"; +import React from "react"; +import { Col, Row, Button } from "react-bootstrap"; import { DataHolder } from "~src/components/frontpage/data-holder"; import { SkeletonPersonalBests } from "~src/components/skeleton/index/skeleton-personal-bests"; import { PopularGames } from "~src/components/game/popular-games"; import { SkeletonPopularGames } from "~src/components/skeleton/index/skeleton-popular-games"; -import React from "react"; import { useTranslations } from "next-intl"; -export default function Homepage({ - runs, - gamestats, -}: { - runs: Run[]; - gamestats: Game[]; -}) { +export const Homepage = () => { const t = useTranslations("homepage"); return ( @@ -57,32 +47,22 @@ export default function Homepage({ - - - ); -} - -const DataSection = ({ - runs, - gamestats, -}: { - runs: Run[]; - gamestats: Game[]; -}) => { - return ( -
- - -

Recent Personal Bests

- {runs && } - {!runs && } - - -

Popular Games

- {gamestats && } - {!gamestats && } - -
+
+ + +

Recent Personal Bests

+ }> + + + + +

Popular Games

+ }> + + + +
+
); }; diff --git a/app/layout.tsx b/app/layout.tsx index 9722f147..5f949c04 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -23,12 +23,14 @@ export default async function RootLayout({ }: { children: React.ReactNode; }) { - const session = await getSession(); + const [ + session, + locale, + // Providing all messages to the client + // side is the easiest way to get started + messages, + ] = await Promise.all([getSession(), getLocale(), getMessages()]); const sessionError = session.sessionError; - const locale = await getLocale(); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); return ( diff --git a/app/marathon/show-marathon.tsx b/app/marathon/show-marathon.tsx index 01b0c6a1..86b34006 100644 --- a/app/marathon/show-marathon.tsx +++ b/app/marathon/show-marathon.tsx @@ -128,7 +128,7 @@ const BasePage: React.FunctionComponent = ({ setSelectedUser(e.target.value); }} > - + {Object.keys(updatedLiveDataMap).map((key) => { return ; })} diff --git a/app/page.tsx b/app/page.tsx index 3fbc5f9c..2d41be1b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,18 +1,8 @@ import React from "react"; -import { getPersonalBestRuns } from "~src/lib/get-personal-best-runs"; -import { getTabulatedGameStatsPopular } from "~src/components/game/get-tabulated-game-stats"; -import Homepage from "~app/homepage"; +import { Homepage } from "~app/home/homepage"; export const revalidate = 60; export default async function Page() { - const runsPromise = getPersonalBestRuns(); - const gamestatsPromise = getTabulatedGameStatsPopular(); - - const [runs, gamestats] = await Promise.all([ - runsPromise, - gamestatsPromise, - ]); - - return ; + return ; } diff --git a/app/races/[race]/edit/edit-race.tsx b/app/races/[race]/edit/edit-race.tsx index d704a781..2ea169bf 100644 --- a/app/races/[race]/edit/edit-race.tsx +++ b/app/races/[race]/edit/edit-race.tsx @@ -12,6 +12,7 @@ import { BreadcrumbItem, } from "~src/components/breadcrumbs/breadcrumb"; import { UnderlineTooltip } from "~src/components/tooltip"; +import React from "react"; export const EditRace = ({ race, user }: { race: Race; user: User }) => { const [state, formAction] = useFormState(editRace, { message: "" }); @@ -85,6 +86,21 @@ export const EditRace = ({ race, user }: { race: Race; user: User }) => { defaultValue={race.forceStream} /> + + + + + +
diff --git a/app/races/race-faq.tsx b/app/races/race-faq.tsx index 0bb283f8..3560fb28 100644 --- a/app/races/race-faq.tsx +++ b/app/races/race-faq.tsx @@ -37,11 +37,11 @@ export const RaceFaq = () => { const RaceFaqBody = () => { return ( -
+

This is an attempt to modernize speedrun racing.

-

+

It has a modern ELO-based rating system, live tracking of the race with live-standings as the race happens with full LiveSplit Integration. It is extremely easy to start, join and participate diff --git a/app/races/races.types.ts b/app/races/races.types.ts index 6ad61fd2..0b5bd9dd 100644 --- a/app/races/races.types.ts +++ b/app/races/races.types.ts @@ -140,6 +140,7 @@ export interface EditRaceInput { description?: string; customName?: string; forceStream?: string; + password?: string; } export type WebsocketRaceMessageType = diff --git a/app/races/stats/[game]/category-stats-list.tsx b/app/races/stats/[game]/category-stats-list.tsx index b5437e77..0b49bde5 100644 --- a/app/races/stats/[game]/category-stats-list.tsx +++ b/app/races/stats/[game]/category-stats-list.tsx @@ -34,7 +34,7 @@ export const CategoryStatsListDisplay = ({ ); return ( -

+
{pagination.data.map((gameStats) => { diff --git a/package-lock.json b/package-lock.json index 323e0528..65273bb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "cookies-next": "^4.1.0", "country-flag-icons": "^1.5.9", "d3": "^7.8.5", + "fuse.js": "^7.0.0", "globby": "^14.0.0", "joi": "^17.11.0", "jquery": "^3.7.1", @@ -6099,6 +6100,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz", + "integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/package.json b/package.json index 19f479da..828bdd8f 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "cookies-next": "^4.1.0", "country-flag-icons": "^1.5.9", "d3": "^7.8.5", + "fuse.js": "^7.0.0", "globby": "^14.0.0", "joi": "^17.11.0", "jquery": "^3.7.1", diff --git a/src/actions/races/edit-race.action.ts b/src/actions/races/edit-race.action.ts index c094c721..2fb59d77 100644 --- a/src/actions/races/edit-race.action.ts +++ b/src/actions/races/edit-race.action.ts @@ -13,6 +13,7 @@ export async function editRace(_prevState: unknown, raceInput: FormData) { description: raceInput.get("description") as string, customName: raceInput.get("customName") as string, forceStream: raceInput.get("forceStream") as string, + password: raceInput.get("password") as string, }; const raceId = raceInput.get("raceId") as string; @@ -54,6 +55,7 @@ export const validateInput = ( customName: Joi.string().min(0).max(40).optional(), description: Joi.string().min(0).max(1000).optional(), forceStream: Joi.string().min(0).max(100).optional(), + password: Joi.string().min(0).max(40).optional(), }); return raceSchema.validate(input); diff --git a/src/components/css/Search.module.scss b/src/components/css/Search.module.scss deleted file mode 100644 index 12e25760..00000000 --- a/src/components/css/Search.module.scss +++ /dev/null @@ -1,51 +0,0 @@ -.suggestions { - border-color: rgba(0, 128, 0, .5); - right: -70px; - - li { - font-size: 0.9rem; - padding: 0.05rem; - font-weight: 500; - } - - &Active, - li:hover { - --bs-link-color: #fff; - --bs-link-color-rgb: #fff; - --bs-link-hover-color-rgb: #fff; - background: linear-gradient(90deg, green 0%, hsla(0, 0%, 98%, 1) 200%); - color: #fff; - cursor: pointer; - font-weight: 700; - } -} - -@media screen and (min-width: 992px) { - - .suggestions { - max-height: 800px; - width: 900px; - background-color: var(--bs-secondary-bg); - top: 105%; - } - - .suggestionLeft { - border-left: thin solid rgba(0, 128, 0, 0.3); - } -} - -@media screen and (max-width: 1199.98px) { - .suggestions { - width: 600px; - right: 0; - } -} - -@media screen and (max-width: 991.98px) { - - .suggestions { - width: revert; - margin: 1rem 0; - background-color: var(--bs-tertiary-bg); - } -} diff --git a/src/components/frontpage/data-holder.tsx b/src/components/frontpage/data-holder.tsx index ff8f91e6..4aa29818 100644 --- a/src/components/frontpage/data-holder.tsx +++ b/src/components/frontpage/data-holder.tsx @@ -1,11 +1,11 @@ -"use client"; - import { Col, Row, Table } from "react-bootstrap"; import { RunPreview } from "./run-preview"; import React from "react"; import { type Run } from "../../common/types"; +import { getPersonalBestRuns } from "~src/lib/get-personal-best-runs"; -export const DataHolder = ({ runs }: { runs: Run[] }) => { +export const DataHolder = async () => { + const runs = await getPersonalBestRuns(); return ( diff --git a/src/components/game/get-tabulated-game-stats.ts b/src/components/game/get-tabulated-game-stats.ts index 59804b47..f6a0a64b 100644 --- a/src/components/game/get-tabulated-game-stats.ts +++ b/src/components/game/get-tabulated-game-stats.ts @@ -1,4 +1,4 @@ -import { PaginatedGameResult } from "~app/games/games.types"; +import { Game, PaginatedGameResult } from "~app/games/games.types"; import { getApiKey } from "~src/actions/api-key.action"; const fetchData = async (url: string) => { @@ -43,7 +43,7 @@ export const getTabulatedGameStats = async () => { return fetchData(url); }; -export const getTabulatedGameStatsPopular = async () => { +export const getTabulatedGameStatsPopular = async (): Promise => { const url = `${process.env.NEXT_PUBLIC_DATA_URL}/games/stats/`; return fetchData(url); diff --git a/src/components/game/popular-games.tsx b/src/components/game/popular-games.tsx index 04cb40d5..a8abb028 100644 --- a/src/components/game/popular-games.tsx +++ b/src/components/game/popular-games.tsx @@ -1,17 +1,12 @@ -"use client"; - -import { Game } from "~app/games/games.types"; import { Row, Table } from "react-bootstrap"; import { GameLink, UserLink } from "../links/links"; import { DurationToFormatted } from "../util/datetime"; import React from "react"; import { GameImage } from "~src/components/image/gameimage"; +import { getTabulatedGameStatsPopular } from "./get-tabulated-game-stats"; -interface PopularGamesProps { - gamestats: Game[]; -} - -export const PopularGames: React.FC = ({ gamestats }) => { +export const PopularGames = async () => { + const gamestats = await getTabulatedGameStatsPopular(); return (
diff --git a/src/components/links/links.tsx b/src/components/links/links.tsx index 9c197d9e..bb3081be 100644 --- a/src/components/links/links.tsx +++ b/src/components/links/links.tsx @@ -1,3 +1,4 @@ +"use client"; import Link from "next/link"; import { ReactNode } from "react"; import { usePatreons } from "../patreon/use-patreons"; diff --git a/src/components/patreon/patreon-name.tsx b/src/components/patreon/patreon-name.tsx index fc8be680..e28ec76b 100644 --- a/src/components/patreon/patreon-name.tsx +++ b/src/components/patreon/patreon-name.tsx @@ -1,3 +1,4 @@ +"use client"; import { useEffect, useState } from "react"; import patreonStyles from "./patreon-styles"; import { PatreonBunnySvgWithoutLink } from "~app/patron/patreon-info"; diff --git a/src/components/patreon/use-patreons.ts b/src/components/patreon/use-patreons.ts index 7cf9c74c..0bb6901c 100644 --- a/src/components/patreon/use-patreons.ts +++ b/src/components/patreon/use-patreons.ts @@ -1,3 +1,4 @@ +"use client"; import useSWR from "swr"; import { fetcher } from "../../utils/fetcher"; import { PatronList } from "../../../types/patreon.types"; diff --git a/src/components/search/autocompletion.tsx b/src/components/search/autocompletion.tsx deleted file mode 100644 index 2fef5134..00000000 --- a/src/components/search/autocompletion.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import React, { useState } from "react"; -import { Col, Row } from "react-bootstrap"; -import { getFormattedString } from "../util/datetime"; -import styles from "../css/Search.module.scss"; -import { safeEncodeURI } from "~src/utils/uri"; -import { Search as SearchIcon } from "react-bootstrap-icons"; -import { RunData, SearchResults } from "./find-user-or-run"; - -// This page was one of the first I ever wrote for the site and is fully outdated and terrible. -// The entire search view needs to be refactored -//TODO:: FIX -export const AutoCompletion = () => { - const [filteredSuggestions, setFilteredSuggestions] = - useState({ - users: {}, - games: {}, - categories: {}, - }); - const [showSuggestions, setShowSuggestions] = useState(false); - const [input, setInput] = useState(""); - const [loading, setLoading] = useState(false); - let suggestions = { users: {}, games: {}, categories: {} } as SearchResults; - - const onChange: React.ChangeEventHandler = async (e) => { - e.preventDefault(); - const userInput = e.target.value; - setInput(userInput); - - if (!userInput || userInput.length < 2) { - setFilteredSuggestions({ users: {}, games: {}, categories: {} }); - return; - } - - // TODO:: Only search after user has not input anything for some time (300ms or something) - setLoading(true); - suggestions = await (await fetch(`/api/search?q=${userInput}`)).json(); - setLoading(false); - - setFilteredSuggestions(suggestions); - setShowSuggestions(true); - }; - - const Results = ({ - results, - type, - }: { - results: { [key: string]: RunData[] }; - type: string; - }) => { - return ( - <> - {Object.keys(results) - .slice(0, 5) - .map((result) => { - return transformResult( - type, - result, - results[result][0], - ); - }) - .filter((result) => !!result)} - - ); - }; - - const transformResult = ( - type: string, - result: string, - results: RunData, - ) => { - if (type == "runs") { - const split = result.split("//"); - - if (split.length !== 3) return null; - - const pb = results.pbgt ? results.pbgt : results.pb; - - const username = split[0]; - const game = split[1]; - const category = split[2]; - let value = `${game} - ${category} by ${username} in ${getFormattedString( - pb, - )}`; - if (results.pbgt) value += " (IGT)"; - const url = `/${username}/${safeEncodeURI(game)}/${safeEncodeURI( - category, - )}`; - return ( -
  • {}}> - - {value} - -
  • - ); - } - - const url = type == "users" ? `/${result}` : `/games/${result}`; - return ( -
  • - - {result} - -
  • - ); - }; - - const Suggestions = () => { - return ( - <> - -
    -
    Users
    -
      - -
    - - -
    Games
    -
      - -
    - - -
    - - -
    Runs
    -
      - -
    - - - - ); - }; - - const SuggestionsListComponent = () => { - let hasSuggestions = - filteredSuggestions && Object.keys(filteredSuggestions).length > 0; - - if (!hasSuggestions) return <>; - - if (hasSuggestions) { - hasSuggestions = - Object.values(filteredSuggestions).filter((obj) => { - return Object.keys(obj).length > 0; - }).length > 0; - } - - return ( -
    - {hasSuggestions ? ( - Suggestions() - ) : ( -
      -
    • - {input.length < 2 - ? "Please input at least 2 characters" - : !loading - ? `No results for ${input}` - : "Loading..."} -
    • -
    - )} -
    - ); - }; - - return ( -
    { - if (e.code === "Escape") { - setShowSuggestions(false); - } - }} - > -
    - - await onChange(e)} - value={input} - id="searchBox" - /> -
    - {showSuggestions && input && filteredSuggestions && ( - - )} -
    - ); -}; diff --git a/src/components/search/find-user-or-run.ts b/src/components/search/find-user-or-run.ts index 12a896cd..43c844d5 100644 --- a/src/components/search/find-user-or-run.ts +++ b/src/components/search/find-user-or-run.ts @@ -19,7 +19,7 @@ export interface SearchResults { } // Same as API on empty result -const DEFAULT_SEARCH_RESULTS = { +export const DEFAULT_SEARCH_RESULTS = { categories: {}, games: {}, users: {}, diff --git a/src/components/search/fuzzy-match-highlight.tsx b/src/components/search/fuzzy-match-highlight.tsx new file mode 100644 index 00000000..8f81e462 --- /dev/null +++ b/src/components/search/fuzzy-match-highlight.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { FuseResultMatch } from "fuse.js"; + +interface FuzzyMatchHighlightProps { + result: string; + highlights: FuseResultMatch[]; +} + +export const FuzzyMatchHighlight: React.FunctionComponent< + FuzzyMatchHighlightProps +> = ({ result, highlights }) => { + const highlightIndices = React.useMemo( + () => + highlights.reduce( + (result, match) => { + if (match.indices?.length) { + match.indices.forEach(([start, end]) => { + // end + 1 because end is inclusive in Fuse.js indices + result.push({ start, end: end + 1 }); + }); + } + + return result; + }, + [] as { start: number; end: number }[], + ), + [highlights], + ); + + const parts = React.useMemo(() => { + return result.split("").reduce((results, character, index) => { + const isHighlighted = highlightIndices.some( + ({ start, end }) => index >= start && index < end, + ); + if (isHighlighted) { + const highlightedCharacter = ( + + ); + + results.push(highlightedCharacter); + } else { + results.push(character); + } + + return results; + }, [] as React.ReactNode[]); + }, [highlightIndices, result]); + + return <>{parts}; +}; + +const HighlightCharacter = ({ + key, + character, +}: { + key: string; + character: string; +}) => ( + + {character} + +); diff --git a/src/components/search/global-search.tsx b/src/components/search/global-search.tsx new file mode 100644 index 00000000..7be4df1d --- /dev/null +++ b/src/components/search/global-search.tsx @@ -0,0 +1,223 @@ +"use client"; +import React, { useState } from "react"; +import { Search as SearchIcon } from "react-bootstrap-icons"; +import { SearchResults } from "./find-user-or-run"; +import useSWR from "swr"; +import { useDebounce } from "usehooks-ts"; +import { FuzzyMatchHighlight } from "./fuzzy-match-highlight"; +import { useAggregatedResults } from "./use-aggregated-results"; +import { useFilteredFuzzySearch, useFuseSearch } from "./use-fuzzy-search"; +import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +// import { getFormattedString } from "../util/datetime"; + +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +const toTitleCase = (text: string) => + text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(); + +const MAX_SEARCH_RESULTS = 15; + +// TODO: Split apart the results from the search +// If the input is its own component and continues to put the search term in the queryparams +// then we can make the results a server component by reading from the queryparams +export const GlobalSearch = () => { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const router = useRouter(); + + const [query, setQuery] = useState(searchParams.get("q") ?? ""); + const [isResultsPanelOpen, setIsResultsPanelOpen] = useState(false); + const searchRef = React.useRef(null); + const resultsPanelRef = React.useRef(null); + const debouncedQuery = useDebounce(query, 300); + const { + data: searchResults, + error: _error, + isLoading, + } = useSWR( + debouncedQuery ? `/api/search?q=${debouncedQuery}` : null, + fetcher, + // This avoids duplicate searches for the same key + // _but_ won't act as a debounce. + { dedupingInterval: 500 }, + ); + const aggregatedResults = useAggregatedResults(searchResults); + React.useEffect(() => { + const params = new URLSearchParams(searchParams.toString()); + if (!query) { + params.delete("q"); + } else { + params.set("q", query); + } + router.push(`${pathname}?${params.toString()}`); + }, [pathname, query, router, searchParams]); + + // Will be useful for correctly displaying a run in the search results + // const getFormattedRunData = React.useCallback( + // (result: string, runData: RunData) => { + // const run = result.split("//"); + // if (run.length !== 3) return null; + // const [username, game, category] = run; + // const pb = runData.pbgt ? runData.pbgt : runData.pb; + + // return `${game} - ${category} by ${username} in ${getFormattedString( + // pb, + // )}`; + // }, + // [], + // ); + + const fuse = useFuseSearch(aggregatedResults); + const filteredResults = useFilteredFuzzySearch(fuse, query); + const searchResultEntries = React.useMemo( + () => Object.entries(filteredResults), + [filteredResults], + ); + //const resultsLength = fuse._docs?.length; Unsure about this right now + const onChange: React.ChangeEventHandler = + React.useCallback((e) => { + e.preventDefault(); + const userInput = e.target.value; + setQuery(userInput); + setIsResultsPanelOpen(!!userInput); + }, []); + + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + resultsPanelRef.current && + !resultsPanelRef.current.contains(event.target as Node) && + searchRef.current && + !searchRef.current.contains(event.target as Node) + ) { + setIsResultsPanelOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + const isSearching = React.useMemo(() => { + if (!query) return false; + /* + If the query is different from the debounced query then we know + that when they match that it'll trigger a new API call with useSWR. + In other words, this is a signal that a search is _coming_. + */ + if (query !== debouncedQuery) return true; + /* + If isLoading from SWR then we know we have a network request in flight. + We are quite literally searching. + */ + // eslint-disable-next-line sonarjs/prefer-single-boolean-return + if (isLoading) return true; + + return false; + }, [debouncedQuery, isLoading, query]); + + return ( +
    +
    +
    + ); +}; diff --git a/src/components/search/use-aggregated-results.tsx b/src/components/search/use-aggregated-results.tsx new file mode 100644 index 00000000..624c9984 --- /dev/null +++ b/src/components/search/use-aggregated-results.tsx @@ -0,0 +1,53 @@ +"use client"; +import React from "react"; +import { SearchResults } from "./find-user-or-run"; + +export type AggregatedResults = Omit; +export const STORAGE_KEY = "globalSearchResults"; + +const DEFAULT_AGGREGATED_RESULTS: AggregatedResults = { + users: {}, + games: {}, + // categories: {}, +}; + +export const useAggregatedResults = ( + searchResults: SearchResults | undefined, +): AggregatedResults => { + const initialResults = React.useMemo(() => { + const storedResults = sessionStorage.getItem(STORAGE_KEY); + if (!storedResults) return DEFAULT_AGGREGATED_RESULTS; + + try { + return JSON.parse(storedResults) as AggregatedResults; + } catch (_err) { + return DEFAULT_AGGREGATED_RESULTS; + } + }, []); + + // eslint-disable-next-line sonarjs/prefer-immediate-return + const aggregatedResults = React.useMemo(() => { + if (!searchResults) return initialResults; + const newAggregatedResults: AggregatedResults = { ...initialResults }; + + ( + Object.keys(newAggregatedResults) as Array + ).forEach((key) => { + Object.entries(searchResults[key]).forEach(([item, data]) => { + if (!newAggregatedResults[key][item]) { + newAggregatedResults[key][item] = data; + } + }); + }); + + // Persist to sessionStorage + sessionStorage.setItem( + STORAGE_KEY, + JSON.stringify(newAggregatedResults), + ); + + return newAggregatedResults; + }, [initialResults, searchResults]); + + return aggregatedResults; +}; diff --git a/src/components/search/use-fuzzy-search.tsx b/src/components/search/use-fuzzy-search.tsx new file mode 100644 index 00000000..fe7ae5dd --- /dev/null +++ b/src/components/search/use-fuzzy-search.tsx @@ -0,0 +1,63 @@ +import Fuse, { FuseResultMatch } from "fuse.js"; +import React from "react"; +import { AggregatedResults } from "./use-aggregated-results"; +import { RunData } from "./find-user-or-run"; + +interface SearchItem { + key: string; + type: "user" | "game"; + data: RunData[]; + matches: FuseResultMatch[]; +} + +// TODO: Add categories and other types in the future +export const useFuseSearch = (aggregatedResults: AggregatedResults) => { + return React.useMemo(() => { + const combinedResults = [ + ...Object.entries(aggregatedResults.users).map(([key, data]) => ({ + type: "user" as const, + key, + data, + })), + ...Object.entries(aggregatedResults.games).map(([key, data]) => ({ + type: "game" as const, + key, + data, + })), + ]; + + return new Fuse(combinedResults, { + keys: ["key", "game", "user"], + includeScore: true, + includeMatches: true, + threshold: 0.45, + }); + }, [aggregatedResults]); +}; + +export const useFilteredFuzzySearch = ( + fuse: Fuse<{ + type: "user" | "game"; + key: string; + data: RunData[]; + }>, + query: string, +) => { + return React.useMemo(() => { + if (!query) return []; + const fuzzyResults = fuse.search(query); + return fuzzyResults.reduce( + (result, { item, matches }) => { + if (!result[item.type]) result[item.type] = []; + result[item.type].push({ + ...item, + matches: (matches || []) as FuseResultMatch[], + }); + return result; + }, + {} as { + [key: string]: SearchItem[]; + }, + ); + }, [query, fuse]); +}; diff --git a/src/components/topbar.tsx b/src/components/topbar.tsx index fd1c8f1e..0f7f8ef1 100644 --- a/src/components/topbar.tsx +++ b/src/components/topbar.tsx @@ -8,7 +8,7 @@ import { TwitchUser } from "./twitch/TwitchUser"; import { TwitchLoginButton } from "./twitch/TwitchLoginButton"; import { getColorMode } from "~src/utils/colormode"; import { Upload } from "react-bootstrap-icons"; -import { AutoCompletion } from "~src/components/search/autocompletion"; +import { GlobalSearch } from "~src/components/search/global-search"; import { resetSession } from "~src/actions/reset-session.action"; const DarkModeSlider = dynamic(() => import("./dark-mode-slider"), { @@ -107,7 +107,7 @@ const Topbar = ({ username, picture, sessionError }: Partial) => { Games