hensei-web/components/search/SearchModal/index.tsx

461 lines
13 KiB
TypeScript

import React, { useEffect, useState } from 'react'
import { getCookie, setCookie } from 'cookies-next'
import { useRouter } from 'next/router'
import { useTranslation } from 'react-i18next'
import InfiniteScroll from 'react-infinite-scroll-component'
import cloneDeep from 'lodash.clonedeep'
import api from '~utils/api'
import { Dialog, DialogTrigger, DialogClose } from '~components/common/Dialog'
import DialogContent from '~components/common/DialogContent'
import Input from '~components/common/Input'
import CharacterSearchFilterBar from '~components/character/CharacterSearchFilterBar'
import WeaponSearchFilterBar from '~components/weapon/WeaponSearchFilterBar'
import SummonSearchFilterBar from '~components/summon/SummonSearchFilterBar'
import JobSkillSearchFilterBar from '~components/job/JobSkillSearchFilterBar'
import * as WeaponTransformer from '~transformers/WeaponTransformer'
import * as SummonTransformer from '~transformers/SummonTransformer'
import * as CharacterTransformer from '~transformers/CharacterTransformer'
import * as JobSkillTransformer from '~transformers/JobSkillTransformer'
import * as GuidebookTransformer from '~transformers/GuidebookTransformer'
import CharacterResult from '~components/character/CharacterResult'
import WeaponResult from '~components/weapon/WeaponResult'
import SummonResult from '~components/summon/SummonResult'
import JobSkillResult from '~components/job/JobSkillResult'
import GuidebookResult from '~components/extra/GuidebookResult'
import type { DialogProps } from '@radix-ui/react-dialog'
import type { SearchableObject, SearchableObjectArray } from '~types'
import styles from './index.module.scss'
import CrossIcon from '~public/icons/Cross.svg'
interface Props extends DialogProps {
send: (object: SearchableObject, position: number) => any
placeholderText: string
fromPosition: number
job?: Job
object: 'weapons' | 'characters' | 'summons' | 'job_skills' | 'guidebooks'
}
const SearchModal = (props: Props) => {
// Set up router
const router = useRouter()
const locale = router.locale
// Set up translation
const { t } = useTranslation('common')
// Refs
const headerRef = React.createRef<HTMLDivElement>()
const searchInput = React.createRef<HTMLInputElement>()
const scrollContainer = React.createRef<HTMLDivElement>()
const [firstLoad, setFirstLoad] = useState(true)
const [filters, setFilters] = useState<{ [key: string]: any }>()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchableObjectArray>([])
// Pagination states
const [recordCount, setRecordCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
useEffect(() => {
if (searchInput.current) searchInput.current.focus()
}, [searchInput])
useEffect(() => {
if (props.open !== undefined) setOpen(props.open)
})
function inputChanged(event: React.ChangeEvent<HTMLInputElement>) {
const text = event.target.value
if (text.length) {
setQuery(text)
} else {
setQuery('')
}
}
function fetchResults({ replace = false }: { replace?: boolean }) {
api
.search({
object: props.object,
query: query,
job: props.job?.id,
filters: filters,
locale: locale,
page: currentPage,
})
.then((response) => {
setTotalPages(response.data.meta.total_pages)
setRecordCount(response.data.meta.count)
if (replace) {
replaceResults(response.data.meta.count, response.data.results)
} else {
appendResults(response.data.results)
}
})
.catch((error) => {
console.error(error)
})
}
function replaceResults(count: number, list: SearchableObjectArray) {
if (count > 0) {
setResults(list)
} else {
setResults([])
}
}
function appendResults(list: SearchableObjectArray) {
setResults([...results, ...list])
}
function storeRecentResult(result: SearchableObject) {
const key = `recent_${props.object}`
const cookie = getCookie(key)
const cookieObj: SearchableObjectArray = cookie
? JSON.parse(cookie as string)
: []
let recents: SearchableObjectArray = []
if (props.object === 'weapons') {
recents = cloneDeep(cookieObj as Weapon[]) || []
if (
!recents.find(
(item) =>
(item as Weapon).granblueId === (result as Weapon).granblueId
)
) {
recents.unshift(result as Weapon)
}
} else if (props.object === 'summons') {
recents = cloneDeep(cookieObj as Summon[]) || []
if (
!recents.find(
(item) =>
(item as Summon).granblueId === (result as Summon).granblueId
)
) {
recents.unshift(result as Summon)
}
}
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 60)
if (recents && recents.length > 5) recents.pop()
setCookie(`recent_${props.object}`, recents, {
path: '/',
expires: expiresAt,
})
sendData(result)
}
function sendData(result: SearchableObject) {
props.send(result, props.fromPosition)
openChange()
}
const extraPositions = () => {
if (props.object === 'weapons') return [9, 10, 11]
else if (props.object === 'summons') return [4, 5]
else return []
}
function receiveFilters(filters: { [key: string]: any }) {
setCurrentPage(1)
setResults([])
// Only show extra or subaura objects if invoked from those positions
if (extraPositions().includes(props.fromPosition)) {
if (props.object === 'weapons') filters.extra = true
else if (props.object === 'summons') filters.subaura = true
}
setFilters(filters)
}
useEffect(() => {
// Current page changed
if (open && currentPage > 1) {
fetchResults({ replace: false })
} else if (open && currentPage == 1) {
fetchResults({ replace: true })
}
}, [open, currentPage])
useEffect(() => {
// Filters changed
const key = `recent_${props.object}`
const cookie = getCookie(key)
const cookieObj: Weapon[] | Summon[] | Character[] = cookie
? JSON.parse(cookie as string)
: []
if (open) {
if (
firstLoad &&
cookieObj &&
cookieObj.length > 0 &&
!extraPositions().includes(props.fromPosition)
) {
setResults(cookieObj)
setRecordCount(cookieObj.length)
setFirstLoad(false)
} else {
setCurrentPage(1)
fetchResults({ replace: true })
}
}
}, [filters])
useEffect(() => {
// Query changed
if (open && query.length != 1) {
setCurrentPage(1)
fetchResults({ replace: true })
}
}, [query])
useEffect(() => {
if (open && props.object === 'guidebooks') {
setCurrentPage(1)
fetchResults({ replace: true })
}
}, [query, open])
function incrementPage() {
setCurrentPage(currentPage + 1)
}
function renderResults() {
let jsx
switch (props.object) {
case 'weapons':
jsx = renderWeaponSearchResults()
break
case 'summons':
jsx = renderSummonSearchResults(results)
break
case 'characters':
jsx = renderCharacterSearchResults(results)
break
case 'job_skills':
jsx = renderJobSkillSearchResults(results)
break
case 'guidebooks':
jsx = renderGuidebookSearchResults(results)
break
}
return (
<InfiniteScroll
dataLength={results && results.length > 0 ? results.length : 0}
next={incrementPage}
hasMore={totalPages > currentPage}
scrollableTarget="Results"
loader={<div className="footer">Loading...</div>}
>
{jsx}
</InfiniteScroll>
)
}
function renderWeaponSearchResults() {
let jsx: React.ReactNode
const castResults: Weapon[] = results as Weapon[]
if (castResults && Object.keys(castResults).length > 0) {
jsx = castResults.map((result: any) => {
const weapon = WeaponTransformer.toObject(result)
return (
<WeaponResult
key={weapon.id}
data={weapon}
onClick={() => {
storeRecentResult(weapon)
}}
/>
)
})
}
return jsx
}
function renderSummonSearchResults(results: { [key: string]: any }) {
let jsx: React.ReactNode
const castResults: Summon[] = results as Summon[]
if (castResults && Object.keys(castResults).length > 0) {
jsx = castResults.map((result: any) => {
const summon = SummonTransformer.toObject(result)
return (
<SummonResult
key={summon.id}
data={summon}
onClick={() => {
storeRecentResult(summon)
}}
/>
)
})
}
return jsx
}
function renderCharacterSearchResults(results: { [key: string]: any }) {
let jsx: React.ReactNode
const castResults: Character[] = results as Character[]
if (castResults && Object.keys(castResults).length > 0) {
jsx = castResults.map((result: any) => {
const character = CharacterTransformer.toObject(result)
return (
<CharacterResult
key={character.id}
data={character}
onClick={() => {
storeRecentResult(character)
}}
/>
)
})
}
return jsx
}
function renderJobSkillSearchResults(results: { [key: string]: any }) {
let jsx: React.ReactNode
const castResults: JobSkill[] = results as JobSkill[]
if (castResults && Object.keys(castResults).length > 0) {
jsx = castResults.map((result: JobSkill) => {
const jobSkill = JobSkillTransformer.toObject(result)
return (
<JobSkillResult
key={jobSkill.id}
data={jobSkill}
onClick={() => {
storeRecentResult(jobSkill)
}}
/>
)
})
}
return jsx
}
function renderGuidebookSearchResults(results: { [key: string]: any }) {
let jsx: React.ReactNode
const castResults: Guidebook[] = results as Guidebook[]
if (castResults && Object.keys(castResults).length > 0) {
jsx = castResults.map((result: Guidebook) => {
const guidebook = GuidebookTransformer.toObject(result)
return (
<GuidebookResult
key={guidebook.id}
data={guidebook}
onClick={() => {
storeRecentResult(guidebook)
}}
/>
)
})
}
return jsx
}
function openChange() {
if (open) {
setQuery('')
setFirstLoad(true)
setResults([])
setRecordCount(0)
setCurrentPage(1)
setOpen(false)
if (props.onOpenChange) props.onOpenChange(false)
} else {
setOpen(true)
if (props.onOpenChange) props.onOpenChange(true)
}
}
function onEscapeKeyDown(event: KeyboardEvent) {
event.preventDefault()
openChange()
}
function onOpenAutoFocus(event: Event) {
event.preventDefault()
if (searchInput.current) searchInput.current.focus()
}
const filterBar = () => {
if (props.object === 'characters') {
return <CharacterSearchFilterBar sendFilters={receiveFilters} />
} else if (props.object === 'weapons') {
return <WeaponSearchFilterBar sendFilters={receiveFilters} />
} else if (props.object === 'summons') {
return <SummonSearchFilterBar sendFilters={receiveFilters} />
} else if (props.object === 'job_skills') {
return <JobSkillSearchFilterBar sendFilters={receiveFilters} />
}
}
return (
<Dialog open={open} onOpenChange={openChange}>
<DialogTrigger asChild>{props.children}</DialogTrigger>
<DialogContent
className="search"
headerref={headerRef}
scrollable={false}
onEscapeKeyDown={onEscapeKeyDown}
onOpenAutoFocus={onOpenAutoFocus}
>
<header className={styles.header} ref={headerRef}>
<div className={styles.searchBar}>
<Input
bound={true}
fieldsetClassName="full"
autoComplete="off"
name="query"
placeholder={props.placeholderText}
ref={searchInput}
value={query}
onChange={inputChanged}
/>
<DialogClose className={styles.close} onClick={openChange}>
<CrossIcon />
</DialogClose>
</div>
{filterBar()}
</header>
<div className={styles.results} ref={scrollContainer}>
<h5 className={styles.total}>
{t('search.result_count', { record_count: recordCount })}
</h5>
{open ? renderResults() : ''}
</div>
</DialogContent>
</Dialog>
)
}
export default SearchModal