From db0a8c5fd1ae43a2e1d887bb5fb56627be40818c Mon Sep 17 00:00:00 2001 From: Flo Wolfe Date: Mon, 24 Jun 2024 12:38:42 -0400 Subject: [PATCH 1/5] remove empty string props (#261) --- app/(footer)/contact/contact.tsx | 2 +- app/marathon/show-marathon.tsx | 2 +- app/races/race-faq.tsx | 4 ++-- app/races/stats/[game]/category-stats-list.tsx | 2 +- src/components/user/userform.tsx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) 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/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/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/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/src/components/user/userform.tsx b/src/components/user/userform.tsx index 88da8e76..1a5c8dd6 100644 --- a/src/components/user/userform.tsx +++ b/src/components/user/userform.tsx @@ -213,7 +213,7 @@ const Edit = ({ username, form, setForm }) => { }) } > - + {Array.from( Object.entries(countries()), ).map(([key, value]) => { From 586f61c12e10c4f21c208bd06c783eedf04116ad Mon Sep 17 00:00:00 2001 From: "therun.gg" Date: Mon, 1 Jul 2024 23:25:59 +0200 Subject: [PATCH 2/5] allow editing race password (#264) --- app/races/[race]/edit/edit-race.tsx | 16 ++++++++++++++++ app/races/races.types.ts | 1 + src/actions/races/edit-race.action.ts | 2 ++ 3 files changed, 19 insertions(+) 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/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/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); From 1993063425eb121b7644639ae48a8bb1693ddce8 Mon Sep 17 00:00:00 2001 From: Flo Wolfe Date: Mon, 1 Jul 2024 17:32:49 -0400 Subject: [PATCH 3/5] small type updates (#263) --- src/lib/races.ts | 4 +++- src/vendor/timer/src/components/Timer/Timer.tsx | 2 ++ src/vendor/timer/src/hook/useTimer.ts | 1 + src/vendor/timer/src/lib/helpers/getTimeParts.test.ts | 1 + src/vendor/timer/src/lib/helpers/now.test.ts | 1 + src/vendor/timer/src/lib/models/TimerModel.ts | 1 + tsconfig.json | 2 +- 7 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/lib/races.ts b/src/lib/races.ts index ad40280d..1e0fc9f3 100644 --- a/src/lib/races.ts +++ b/src/lib/races.ts @@ -42,7 +42,9 @@ export const getPaginatedFinishedRacesByGame: PaginationFetcher = async ( params, ): Promise => { const races = await fetch( - `${racesApiUrl}?page=${page}&pageSize=${pageSize}&game=${params.game}`, + `${racesApiUrl}?page=${page}&pageSize=${pageSize}&game=${ + params?.game ?? "" + }`, { next: { revalidate: 0 }, }, diff --git a/src/vendor/timer/src/components/Timer/Timer.tsx b/src/vendor/timer/src/components/Timer/Timer.tsx index 8fee9f84..03b8f03d 100644 --- a/src/vendor/timer/src/components/Timer/Timer.tsx +++ b/src/vendor/timer/src/components/Timer/Timer.tsx @@ -1,3 +1,5 @@ +// @ts-nocheck + import React from 'react'; import { TimerStateValues, TimeParts, Unit } from '../../types'; diff --git a/src/vendor/timer/src/hook/useTimer.ts b/src/vendor/timer/src/hook/useTimer.ts index 2976395d..c4812fc9 100644 --- a/src/vendor/timer/src/hook/useTimer.ts +++ b/src/vendor/timer/src/hook/useTimer.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { useState, useMemo, useCallback, useEffect } from "react"; import { Unit, diff --git a/src/vendor/timer/src/lib/helpers/getTimeParts.test.ts b/src/vendor/timer/src/lib/helpers/getTimeParts.test.ts index c692fbb9..dd692a7e 100644 --- a/src/vendor/timer/src/lib/helpers/getTimeParts.test.ts +++ b/src/vendor/timer/src/lib/helpers/getTimeParts.test.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { TimeParts, Unit } from '../../types'; import getTimeParts from './getTimeParts'; diff --git a/src/vendor/timer/src/lib/helpers/now.test.ts b/src/vendor/timer/src/lib/helpers/now.test.ts index f599943c..b3e96478 100644 --- a/src/vendor/timer/src/lib/helpers/now.test.ts +++ b/src/vendor/timer/src/lib/helpers/now.test.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import now from './now'; declare const global; diff --git a/src/vendor/timer/src/lib/models/TimerModel.ts b/src/vendor/timer/src/lib/models/TimerModel.ts index 90213865..a4245d56 100644 --- a/src/vendor/timer/src/lib/models/TimerModel.ts +++ b/src/vendor/timer/src/lib/models/TimerModel.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import getTimeParts from '../helpers/getTimeParts'; import now from '../helpers/now'; diff --git a/tsconfig.json b/tsconfig.json index edc831de..cf4e2b46 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,6 @@ "~app/*": ["app/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "exclude": ["node_modules"] } From 8347b9511ed9d79674d5c064b0aa7f42939e2f0e Mon Sep 17 00:00:00 2001 From: Flo Wolfe Date: Mon, 8 Jul 2024 12:59:26 -0400 Subject: [PATCH 4/5] Rewrite global search component (see #266) * Rewrite global search component 40a86b8a89c907d2b785678a140703f332700c5f * Restyle search 3370d1dbbe217917972030bdbf5d49b5117e68eb * Add min-width for search spinner 48e33241acda9dfb95fc8d5d30d86242ceccbb61 * Consider `no-results` & hide `more-search` 3e67c13b6ecbe13464b83185e1a64cf7f762f308 * Drop unused constant 241c66d45b4a95ec929a66726bee605a86f7e3fe * fix race condition with empty state 9195de6e7e97a24a567a8cd0dfe5c18829e8a553 * update conditionals for empty states 7a6200bf0a7f94e6654e99dfa8e23f0a70481d4b * Tweak search styles yet again c70a96908062d98b4ec4e263dc39113ef68a9018 * Tweak search styles one more time 1cfe42b53c71a50e631b153319ee22cabcdb6557 * add searching... state 9aefcdfae5a2cf911c277539cb4ade8244dbf1bb * update search styles for empty and searching states bd139c100d81a965430fcc9a6cada2c7b8b1b6a6 * Delete unnecessary styles 1e13cccd787c9319e192fe5ee93fdfed93f5ecd0 --------- Co-authored-by: Sebastian Zoglowek <55794780+zoglo@users.noreply.github.com> --- package-lock.json | 10 + package.json | 1 + src/components/css/Search.module.scss | 51 ---- src/components/search/autocompletion.tsx | 237 ------------------ src/components/search/find-user-or-run.ts | 2 +- .../search/fuzzy-match-highlight.tsx | 65 +++++ src/components/search/global-search.tsx | 223 ++++++++++++++++ .../search/use-aggregated-results.tsx | 53 ++++ src/components/search/use-fuzzy-search.tsx | 63 +++++ src/components/topbar.tsx | 4 +- src/styles/bootstrap/_utilities.scss | 6 +- 11 files changed, 422 insertions(+), 293 deletions(-) delete mode 100644 src/components/css/Search.module.scss delete mode 100644 src/components/search/autocompletion.tsx create mode 100644 src/components/search/fuzzy-match-highlight.tsx create mode 100644 src/components/search/global-search.tsx create mode 100644 src/components/search/use-aggregated-results.tsx create mode 100644 src/components/search/use-fuzzy-search.tsx 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/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/search/autocompletion.tsx b/src/components/search/autocompletion.tsx deleted file mode 100644 index 186eb5d7..00000000 --- a/src/components/search/autocompletion.tsx +++ /dev/null @@ -1,237 +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); - const searchRef = React.createRef(); - 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); - } - }} - > -
    - { - if ( - searchRef.current && - document.activeElement !== searchRef.current - ) { - searchRef.current.focus(); - } - }} - > - - - 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
    - -
    - ); -} - -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/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/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";