hensei-web/components/search/SearchModal/index.tsx
Justin Edmund d765b00120
Redesigned team navigation (#310)
* Add ellipsis icon

* Reduce size of tokens

* Move UpdateToast to toasts folder

* Update variables.scss

* Add reps for grid objects

These reps act like the existing PartyRep except for Characters and Summons, as well as a new component just for Weapons.

They only render the grid of objects and nothing else.

Eventually PartyRep will use WeaponRep

* Added RepSegment

This is a Character, Weapon or Summon rep wrapped with an input and label for use in a SegmentedControl

* Modify PartySegmentedControl to use RepSegments

This will not work on mobile yet, where it should gracefully degrade to a normal SegmentedControl with only text

* Extract URL copied and Remixed toasts into files

* Extract delete team alert into a file

Also, to support this:
* Added `Destructive` class to Button
* Added `primaryActionClassName` prop to Alert

* Added an alert for when remixing teams

* Began refactoring PartyDetails into several files

* PartyHeader will live at the top, above the new segmented control
* PartyDetails stays below, only showing remixed teams and the description
* PartyDropdown handles the new ... menu

* Remove duplicated code

This is description and remix code that is still in `PartyDetails`

* Small fixes for weapon grid

* Add placeholder image for guidebooks

* Add localizations

* Add Guidebook type and update other types

* Update gitignore

Don't commit guidebook images

* Indicate if a dialog is scrollable

We had broken paging in the infinite scroll component. Turning off "scrolling" at the dialog levels fixes it without adding scrollbars in environments that persistently show them

* Add ExtraContainer

This is the purple container that will contain additional weapons and sephira guidebooks

* Move ExtraWeapons to ExtraWeaponsGrid

And put it in ExtraContainer

* Added GuidebooksGrid and GuidebookUnit

These are the display components for Guidebooks in the WeaponGrid

* Visual adjustments to summon grid

* Add Empty class to weapons when unit is unfilled

* Implement GuidebooksGrid in WeaponGrid

* Remove extra switch

* Remove old dependencies and props

* Implement searching for/adding guidebooks to party

* Update styles

* Fix dependency

* Properly determine when extra container should display

* Change to 1-indexing for guidebooks

* Add support for removing guidebooks

* Display guidebook validation error

* Move read only buttons to PartyHeader

Also broke up tokens and made them easier to render

* Add guidebooks to DetailsObject

* Remove preview when on mobile sizes
2023-06-16 18:49:55 -07:00

456 lines
12 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/LabelledInput'
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 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 './index.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).granblue_id === (result as Weapon).granblue_id
)
) {
recents.unshift(result as Weapon)
}
} else if (props.object === 'summons') {
recents = cloneDeep(cookieObj as Summon[]) || []
if (
!recents.find(
(item) =>
(item as Summon).granblue_id === (result as Summon).granblue_id
)
) {
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: Weapon) => {
return (
<WeaponResult
key={result.id}
data={result}
onClick={() => {
storeRecentResult(result)
}}
/>
)
})
}
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: Summon) => {
return (
<SummonResult
key={result.id}
data={result}
onClick={() => {
storeRecentResult(result)
}}
/>
)
})
}
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: Character) => {
return (
<CharacterResult
key={result.id}
data={result}
onClick={() => {
storeRecentResult(result)
}}
/>
)
})
}
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) => {
return (
<JobSkillResult
key={result.id}
data={result}
onClick={() => {
storeRecentResult(result)
}}
/>
)
})
}
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) => {
return (
<GuidebookResult
key={result.id}
data={result}
onClick={() => {
storeRecentResult(result)
}}
/>
)
})
}
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()
}
return (
<Dialog open={open} onOpenChange={openChange}>
<DialogTrigger asChild>{props.children}</DialogTrigger>
<DialogContent
className="Search"
headerref={headerRef}
scrollable={false}
onEscapeKeyDown={onEscapeKeyDown}
onOpenAutoFocus={onOpenAutoFocus}
>
<div className="Search DialogHeader" ref={headerRef}>
<div id="Bar">
<Input
autoComplete="off"
className="Search Bound"
name="query"
placeholder={props.placeholderText}
ref={searchInput}
value={query}
onChange={inputChanged}
/>
<DialogClose className="DialogClose" onClick={openChange}>
<CrossIcon />
</DialogClose>
</div>
{props.object === 'characters' ? (
<CharacterSearchFilterBar sendFilters={receiveFilters} />
) : (
''
)}
{props.object === 'weapons' ? (
<WeaponSearchFilterBar sendFilters={receiveFilters} />
) : (
''
)}
{props.object === 'summons' ? (
<SummonSearchFilterBar sendFilters={receiveFilters} />
) : (
''
)}
{props.object === 'job_skills' ? (
<JobSkillSearchFilterBar sendFilters={receiveFilters} />
) : (
''
)}
</div>
<div id="Results" ref={scrollContainer}>
<h5 className="total">
{t('search.result_count', { record_count: recordCount })}
</h5>
{open ? renderResults() : ''}
</div>
</DialogContent>
</Dialog>
)
}
export default SearchModal