diff --git a/src/hooks/useGames.ts b/src/hooks/useGames.ts index 6e49003..b6397ba 100644 --- a/src/hooks/useGames.ts +++ b/src/hooks/useGames.ts @@ -5,11 +5,14 @@ import { Game } from 'types' import { log, LogType } from 'utils' import gameFiles from '../utils/games' +let cachedGamesData: Game[] | null = null + export const useGames = () => { const hasProcessedFiles = useRef(false) - const [games, setGames] = useState([]) + const [games, setGames] = useState(cachedGamesData || []) useEffect(() => { + if (cachedGamesData) return if (hasProcessedFiles.current) return hasProcessedFiles.current = true @@ -52,6 +55,7 @@ export const useGames = () => { }) await Promise.all(promises) + cachedGamesData = uniqueGames setGames(uniqueGames) } catch (err) { log( diff --git a/src/pages/blogs/index.tsx b/src/pages/blogs/index.tsx index d4b4fab..64815d7 100644 --- a/src/pages/blogs/index.tsx +++ b/src/pages/blogs/index.tsx @@ -9,7 +9,7 @@ import '../../styles/pagination.css' import '../../styles/search.css' import '../../styles/styles.css' import { PaginationWithPageNumbers } from 'components/Pagination' -import { scrollIntoView } from 'utils' +import { normalizeSearchString, scrollIntoView } from 'utils' import { LoadingSpinner } from 'components/LoadingSpinner' import { Filter } from 'components/Filters' import { Dropdown } from 'components/Filters/Dropdown' @@ -63,15 +63,17 @@ export const BlogsPage = () => { } let filtered = blogs?.filter(filterNsfwFn) || [] - const lowerCaseSearchTerm = searchTerm.toLowerCase() + const normalizedSearchTerm = normalizeSearchString(searchTerm) - if (searchTerm !== '') { + if (normalizedSearchTerm !== '') { const filterSearchTermFn = (blog: Partial) => - (blog.title || '').toLowerCase().includes(lowerCaseSearchTerm) || - (blog.summary || '').toLowerCase().includes(lowerCaseSearchTerm) || - (blog.content || '').toLowerCase().includes(lowerCaseSearchTerm) || + normalizeSearchString(blog.title || '').includes( + normalizedSearchTerm + ) || + (blog.summary || '').toLowerCase().includes(normalizedSearchTerm) || + (blog.content || '').toLowerCase().includes(normalizedSearchTerm) || (blog.tTags || []).findIndex((tag) => - tag.toLowerCase().includes(lowerCaseSearchTerm) + tag.toLowerCase().includes(normalizedSearchTerm) ) > -1 filtered = filtered.filter(filterSearchTermFn) } diff --git a/src/pages/search.tsx b/src/pages/search.tsx index b294ce9..018e2fb 100644 --- a/src/pages/search.tsx +++ b/src/pages/search.tsx @@ -36,6 +36,9 @@ import { DEFAULT_FILTER_OPTIONS, extractModData, isModDataComplete, + memoizedNormalizeSearchString, + normalizeSearchString, + normalizeUserSearchString, scrollIntoView } from 'utils' import { useCuratedSet } from 'hooks/useCuratedSet' @@ -293,18 +296,17 @@ const ModsResult = ({ }, [searchTerm]) const filteredMods = useMemo(() => { + const normalizedSearchTerm = normalizeSearchString(searchTerm) // Search page requires search term - if (searchTerm === '') return [] - - const lowerCaseSearchTerm = searchTerm.toLowerCase() + if (normalizedSearchTerm === '') return [] const filterFn = (mod: ModDetails) => - mod.title.toLowerCase().includes(lowerCaseSearchTerm) || - mod.game.toLowerCase().includes(lowerCaseSearchTerm) || - mod.summary.toLowerCase().includes(lowerCaseSearchTerm) || - mod.body.toLowerCase().includes(lowerCaseSearchTerm) || + normalizeSearchString(mod.title).includes(normalizedSearchTerm) || + memoizedNormalizeSearchString(mod.game).includes(normalizedSearchTerm) || + mod.summary.toLowerCase().includes(normalizedSearchTerm) || + mod.body.toLowerCase().includes(normalizedSearchTerm) || mod.tags.findIndex((tag) => - tag.toLowerCase().includes(lowerCaseSearchTerm) + tag.toLowerCase().includes(normalizedSearchTerm) ) > -1 const filterSourceFn = (mod: ModDetails) => { @@ -377,13 +379,14 @@ const UsersResult = ({ const userState = useAppSelector((state) => state.user) useEffect(() => { - if (searchTerm === '') { + const normalizedSearchTerm = normalizeUserSearchString(searchTerm) + if (normalizedSearchTerm === '') { setProfiles([]) } else { const sub = ndk.subscribe( { kinds: [NDKKind.Metadata], - search: searchTerm + search: normalizedSearchTerm }, { closeOnEose: true, @@ -395,7 +398,7 @@ const UsersResult = ({ // Stop the sub after 10 seconds if we are still searching the same term as before window.setTimeout(() => { - if (sub.filter.search === searchTerm) { + if (sub.filter.search === normalizedSearchTerm) { sub.stop() } }, 10000) @@ -500,12 +503,13 @@ const GamesResult = ({ searchTerm }: GamesResultProps) => { }, [searchTerm]) const filteredGames = useMemo(() => { - if (searchTerm === '') return [] - - const lowerCaseSearchTerm = searchTerm.toLowerCase() + const normalizedSearchTerm = normalizeSearchString(searchTerm) + if (normalizedSearchTerm === '') return [] return games.filter((game) => - game['Game Name'].toLowerCase().includes(lowerCaseSearchTerm) + memoizedNormalizeSearchString(game['Game Name']).includes( + normalizedSearchTerm + ) ) }, [searchTerm, games]) diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 7cc8a73..5f75773 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -198,3 +198,78 @@ export function adjustTextareaHeight(textarea: HTMLTextAreaElement) { textarea.style.height = 'auto' textarea.style.height = `${textarea.scrollHeight}px` } + +// Normalizing search terms +const removeAccents = (str: string): string => { + return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '') +} + +const removeSpecialCharacters = (str: string): string => { + return str.replace(/[.,/#!$%^&*;:{}=\-_`~()&\s]/g, '') +} + +// Replace Roman numerals with their Arabic counterparts +const ROMAN_TO_ARABIC_MAP: { [key: string]: string } = { + i: '1', + ii: '2', + iii: '3', + iv: '4', + v: '5', + vi: '6', + vii: '7', + viii: '8', + ix: '9', + x: '10', + xi: '11', + xii: '12', + xiii: '13', + xiv: '14', + xv: '15', + xvi: '16', + xvii: '17', + xviii: '18', + xix: '19', + xx: '20' +} + +const romanRegex = new RegExp( + `\\b(${Object.keys(ROMAN_TO_ARABIC_MAP).join('|')})\\b`, + 'g' +) + +export const normalizeSearchString = (str: string): string => { + str = str.trim() + str = str.toLowerCase() + str = str.replace(romanRegex, (match) => ROMAN_TO_ARABIC_MAP[match]) + str = removeAccents(str) + str = removeSpecialCharacters(str) + return str +} + +// Memoization function to cache normalized results +const memoizeNormalize = (func: (str: string) => string) => { + const cache: { [key: string]: string } = {} + return (str: string): string => { + if (cache[str] !== undefined) { + return cache[str] + } + const result = func(str) + cache[str] = result + return result + } +} + +/** + * Memoize normalized search strings + * Should only be used for games (large list) + */ +export const memoizedNormalizeSearchString = memoizeNormalize( + normalizeSearchString +) + +export const normalizeUserSearchString = (str: string): string => { + str = str.trim() + str = str.toLowerCase() + str = removeAccents(str) + return str +}