Merge pull request #27 from jedmund/search-filters

Implement filters in Search
This commit is contained in:
Justin Edmund 2022-03-11 01:19:59 -08:00 committed by GitHub
commit cc01aa41c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1820 additions and 403 deletions

View file

@ -0,0 +1,205 @@
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next'
import cloneDeep from 'lodash.clonedeep'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import SearchFilter from '~components/SearchFilter'
import SearchFilterCheckboxItem from '~components/SearchFilterCheckboxItem'
import './index.scss'
import { emptyElementState, emptyProficiencyState, emptyRarityState } from '~utils/emptyStates'
import { elements, proficiencies, rarities } from '~utils/stateValues'
interface Props {
sendFilters: (filters: { [key: string]: number[] }) => void
}
const CharacterSearchFilterBar = (props: Props) => {
const { t } = useTranslation('common')
const [rarityMenu, setRarityMenu] = useState(false)
const [elementMenu, setElementMenu] = useState(false)
const [proficiency1Menu, setProficiency1Menu] = useState(false)
const [proficiency2Menu, setProficiency2Menu] = useState(false)
const [rarityState, setRarityState] = useState<RarityState>(emptyRarityState)
const [elementState, setElementState] = useState<ElementState>(emptyElementState)
const [proficiency1State, setProficiency1State] = useState<ProficiencyState>(emptyProficiencyState)
const [proficiency2State, setProficiency2State] = useState<ProficiencyState>(emptyProficiencyState)
function rarityMenuOpened(open: boolean) {
if (open) {
setRarityMenu(true)
setElementMenu(false)
setProficiency1Menu(false)
setProficiency2Menu(false)
} else setRarityMenu(false)
}
function elementMenuOpened(open: boolean) {
if (open) {
setRarityMenu(false)
setElementMenu(true)
setProficiency1Menu(false)
setProficiency2Menu(false)
} else setElementMenu(false)
}
function proficiency1MenuOpened(open: boolean) {
if (open) {
setRarityMenu(false)
setElementMenu(false)
setProficiency1Menu(true)
setProficiency2Menu(false)
} else setProficiency1Menu(false)
}
function proficiency2MenuOpened(open: boolean) {
if (open) {
setRarityMenu(false)
setElementMenu(false)
setProficiency1Menu(false)
setProficiency2Menu(true)
} else setProficiency2Menu(false)
}
function handleRarityChange(checked: boolean, key: string) {
let newRarityState = cloneDeep(rarityState)
newRarityState[key].checked = checked
setRarityState(newRarityState)
}
function handleElementChange(checked: boolean, key: string) {
let newElementState = cloneDeep(elementState)
newElementState[key].checked = checked
setElementState(newElementState)
}
function handleProficiency1Change(checked: boolean, key: string) {
let newProficiencyState = cloneDeep(proficiency1State)
newProficiencyState[key].checked = checked
setProficiency1State(newProficiencyState)
}
function handleProficiency2Change(checked: boolean, key: string) {
let newProficiencyState = cloneDeep(proficiency2State)
newProficiencyState[key].checked = checked
setProficiency2State(newProficiencyState)
}
function sendFilters() {
const checkedRarityFilters = Object.values(rarityState).filter(x => x.checked).map((x, i) => x.id)
const checkedElementFilters = Object.values(elementState).filter(x => x.checked).map((x, i) => x.id)
const checkedProficiency1Filters = Object.values(proficiency1State).filter(x => x.checked).map((x, i) => x.id)
const checkedProficiency2Filters = Object.values(proficiency2State).filter(x => x.checked).map((x, i) => x.id)
const filters = {
rarity: checkedRarityFilters,
element: checkedElementFilters,
proficiency1: checkedProficiency1Filters,
proficiency2: checkedProficiency2Filters
}
props.sendFilters(filters)
}
useEffect(() => {
sendFilters()
}, [rarityState, elementState, proficiency1State, proficiency2State])
function renderProficiencyFilter(proficiency: 1 | 2) {
const onCheckedChange = (proficiency == 1) ? handleProficiency1Change : handleProficiency2Change
const numSelected = (proficiency == 1)
? Object.values(proficiency1State).map(x => x.checked).filter(Boolean).length
: Object.values(proficiency2State).map(x => x.checked).filter(Boolean).length
const open = (proficiency == 1) ? proficiency1Menu : proficiency2Menu
const onOpenChange = (proficiency == 1) ? proficiency1MenuOpened : proficiency2MenuOpened
return (
<SearchFilter
label={`${t('filters.labels.proficiency')} ${proficiency}`}
numSelected={numSelected}
open={open}
onOpenChange={onOpenChange}>
<DropdownMenu.Label className="Label">{`${t('filters.labels.proficiency')} ${proficiency}`}</DropdownMenu.Label>
<section>
<DropdownMenu.Group className="Group">
{ Array.from(Array(proficiencies.length / 2)).map((x, i) => {
const checked = (proficiency == 1)
? proficiency1State[proficiencies[i]].checked
: proficiency2State[proficiencies[i]].checked
return (
<SearchFilterCheckboxItem
key={proficiencies[i]}
onCheckedChange={onCheckedChange}
checked={checked}
valueKey={proficiencies[i]}>
{t(`proficiencies.${proficiencies[i]}`)}
</SearchFilterCheckboxItem>
)}
) }
</DropdownMenu.Group>
<DropdownMenu.Group className="Group">
{ Array.from(Array(proficiencies.length / 2)).map((x, i) => {
const checked = (proficiency == 1)
? proficiency1State[proficiencies[i + (proficiencies.length / 2)]].checked
: proficiency2State[proficiencies[i + (proficiencies.length / 2)]].checked
return (
<SearchFilterCheckboxItem
key={proficiencies[i + (proficiencies.length / 2)]}
onCheckedChange={onCheckedChange}
checked={checked}
valueKey={proficiencies[i + (proficiencies.length / 2)]}>
{t(`proficiencies.${proficiencies[i + (proficiencies.length / 2)]}`)}
</SearchFilterCheckboxItem>
)}
) }
</DropdownMenu.Group>
</section>
</SearchFilter>
)
}
return (
<div className="SearchFilterBar">
<SearchFilter label={t('filters.labels.rarity')} numSelected={Object.values(rarityState).map(x => x.checked).filter(Boolean).length} open={rarityMenu} onOpenChange={rarityMenuOpened}>
<DropdownMenu.Label className="Label">{t('filters.labels.rarity')}</DropdownMenu.Label>
{ Array.from(Array(rarities.length)).map((x, i) => {
return (
<SearchFilterCheckboxItem
key={rarities[i]}
onCheckedChange={handleRarityChange}
checked={rarityState[rarities[i]].checked}
valueKey={rarities[i]}>
{t(`rarities.${rarities[i]}`)}
</SearchFilterCheckboxItem>
)}
) }
</SearchFilter>
<SearchFilter label={t('filters.labels.element')} numSelected={Object.values(elementState).map(x => x.checked).filter(Boolean).length} open={elementMenu} onOpenChange={elementMenuOpened}>
<DropdownMenu.Label className="Label">{t('filters.labels.element')}</DropdownMenu.Label>
{ Array.from(Array(elements.length)).map((x, i) => {
return (
<SearchFilterCheckboxItem
key={elements[i]}
onCheckedChange={handleElementChange}
checked={elementState[elements[i]].checked}
valueKey={elements[i]}>
{t(`elements.${elements[i]}`)}
</SearchFilterCheckboxItem>
)}
) }
</SearchFilter>
{ renderProficiencyFilter(1) }
{ renderProficiencyFilter(2) }
</div>
)
}
export default CharacterSearchFilterBar

View file

@ -0,0 +1,74 @@
.DropdownLabel {
align-items: center;
background: $grey-90;
border: none;
border-radius: $unit * 2;
color: $grey-40;
display: flex;
gap: calc($unit / 2);
flex-direction: row;
padding: ($unit) ($unit * 2);
&:hover {
background: $grey-80;
color: $grey-00;
cursor: pointer;
}
.count {
color: $grey-60;
font-weight: $medium;
}
& > .icon {
$diameter: 12px;
height: $diameter;
width: $diameter;
svg {
transform: scale(0.85);
path {
fill: $grey-60;
}
}
}
}
.Dropdown {
background: white;
border-radius: $unit;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.18);
display: flex;
flex-direction: column;
gap: calc($unit / 2);
padding: $unit;
min-width: 120px;
& > span {
overflow: hidden;
svg {
fill: white;
filter: drop-shadow(0px 0px 1px rgb(0 0 0 / 0.18));
}
}
section {
display: flex;
flex-direction: row;
gap: $unit;
}
.Group {
flex: 1 1 0px;
flex-direction: column;
}
.Label {
color: $grey-60;
font-size: $font-small;
margin-bottom: calc($unit / 2);
padding-left: calc($unit / 2);
}
}

View file

@ -0,0 +1,34 @@
import React from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import ArrowIcon from '~public/icons/Arrow.svg'
import './index.scss'
interface Props {
label: string
open: boolean
numSelected: number
onOpenChange: (open: boolean) => void
children: React.ReactNode
}
const SearchFilter = (props: Props) => {
return (
<DropdownMenu.Root open={props.open} onOpenChange={props.onOpenChange}>
<DropdownMenu.Trigger className="DropdownLabel">
{props.label}
<span className="count">{props.numSelected}</span>
<span className="icon">
<ArrowIcon />
</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content className="Dropdown" sideOffset={4}>
{props.children}
<DropdownMenu.Arrow />
</DropdownMenu.Content>
</DropdownMenu.Root>
)
}
export default SearchFilter

View file

@ -0,0 +1,41 @@
.Item {
align-items: center;
border-radius: calc($unit / 2);
color: $grey-40;
font-size: $font-regular;
line-height: 1.2;
min-width: 100px;
position: relative;
padding: $unit;
padding-left: $unit * 3;
&:hover {
background: $grey-90;
cursor: pointer;
}
&[data-state="checked"] {
background: $grey-90;
svg {
fill: $grey-50;
}
}
.Indicator {
$diameter: 18px;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
left: calc($unit / 2);
height: $diameter;
width: $diameter;
svg {
height: $diameter;
width: $diameter;
}
}
}

View file

@ -0,0 +1,34 @@
import React from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import CheckIcon from '~public/icons/Check.svg'
import './index.scss'
interface Props {
checked?: boolean
valueKey: string
onCheckedChange: (open: boolean, key: string) => void
children: React.ReactNode
}
const SearchFilterCheckboxItem = (props: Props) => {
function handleCheckedChange(checked: boolean) {
props.onCheckedChange(checked, props.valueKey)
}
return (
<DropdownMenu.CheckboxItem
className="Item"
checked={props.checked || false}
onCheckedChange={handleCheckedChange}
onSelect={ (event) => event.preventDefault() }>
<DropdownMenu.ItemIndicator className="Indicator">
<CheckIcon />
</DropdownMenu.ItemIndicator>
{props.children}
</DropdownMenu.CheckboxItem>
)
}
export default SearchFilterCheckboxItem

View file

@ -3,38 +3,52 @@
flex-direction: column;
min-height: 431px;
width: 600px;
height: 480px;
gap: 0;
padding: 0;
#Header {
border-top-left-radius: $unit;
border-top-right-radius: $unit;
border-bottom: 1px solid transparent;
display: flex;
gap: $unit * 2.5;
margin: 0;
padding: ($unit * 3) ($unit * 3) ($unit) ($unit * 3);
position: sticky;
top: 0;
flex-direction: column;
gap: $unit;
padding-bottom: $unit * 2;
button {
background: transparent;
border: none;
height: 42px;
padding: 0;
&.scrolled {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.12);
}
label {
width: 100%;
#Bar {
border-top-left-radius: $unit;
border-top-right-radius: $unit;
display: flex;
gap: $unit * 2.5;
margin: 0;
padding: ($unit * 3) ($unit * 3) 0 ($unit * 3);
position: sticky;
top: 0;
.Input {
background: $grey-90;
button {
background: transparent;
border: none;
border-radius: calc($unit / 2);
box-sizing: border-box;
font-size: $font-regular;
padding: $unit * 1.5;
text-align: left;
height: 42px;
padding: 0;
}
label {
width: 100%;
.Input {
background: $grey-90;
border: none;
border-radius: calc($unit / 2);
box-sizing: border-box;
font-size: $font-regular;
padding: $unit * 1.5;
text-align: left;
width: 100%;
}
}
}
}
@ -45,6 +59,23 @@
padding: 0 ($unit * 1.5);
overflow-y: scroll;
h5.total {
font-size: $font-regular;
font-weight: $normal;
color: $grey-40;
padding: calc($unit / 2) ($unit * 1.5);
}
.footer {
align-items: center;
display: flex;
color: $grey-60;
font-size: $font-regular;
font-weight: $normal;
height: $unit * 10;
justify-content: center;
}
.WeaponResult:last-child {
margin-bottom: $unit * 1.5;
}

View file

@ -1,12 +1,18 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'react-i18next'
import InfiniteScroll from 'react-infinite-scroll-component'
import { appState } from '~utils/appState'
import api from '~utils/api'
import * as Dialog from '@radix-ui/react-dialog'
import CharacterSearchFilterBar from '~components/CharacterSearchFilterBar'
import WeaponSearchFilterBar from '~components/WeaponSearchFilterBar'
import SummonSearchFilterBar from '~components/SummonSearchFilterBar'
import CharacterResult from '~components/CharacterResult'
import WeaponResult from '~components/WeaponResult'
import SummonResult from '~components/SummonResult'
@ -28,17 +34,23 @@ const SearchModal = (props: Props) => {
const router = useRouter()
const locale = router.locale
const { t } = useTranslation('common')
let searchInput = React.createRef<HTMLInputElement>()
let scrollContainer = React.createRef<HTMLDivElement>()
const [objects, setObjects] = useState<{[id: number]: GridCharacter | GridWeapon | GridSummon}>()
const [filters, setFilters] = useState<{ [key: string]: number[] }>()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState({})
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [totalResults, setTotalResults] = useState(0)
const [results, setResults] = useState<(Weapon | Summon | Character)[]>([])
useEffect(() => {
// Pagination states
const [recordCount, setRecordCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
useEffect(() => {
setObjects(grid[props.object])
}, [grid, props.object])
@ -47,50 +59,46 @@ const SearchModal = (props: Props) => {
searchInput.current.focus()
}, [searchInput])
useEffect(() => {
if (query.length > 1)
fetchResults()
}, [query])
function inputChanged(event: React.ChangeEvent<HTMLInputElement>) {
const text = event.target.value
if (text.length) {
setQuery(text)
setLoading(true)
setMessage('')
} else {
setQuery('')
setResults({})
setMessage('')
}
}
function fetchResults() {
// Exclude objects in grid from search results
// unless the object is in the position that the user clicked
// so that users can replace object versions with
// compatible other objects.
const excludes = (objects) ? Object.values(objects)
.filter(filterExclusions)
.map((o) => { return (o.object) ? o.object.name.en : undefined }).join(',') : ''
function fetchResults({ replace = false }: { replace?: boolean }) {
api.search({
object: props.object,
query: query,
filters: filters,
locale: locale,
page: currentPage
}).then(response => {
setTotalPages(response.data.total_pages)
setRecordCount(response.data.count)
api.search(props.object, query, excludes, locale)
.then(response => {
setResults(response.data)
setTotalResults(response.data.length)
setLoading(false)
})
.catch(error => {
setMessage(error)
setLoading(false)
})
if (replace) {
replaceResults(response.data.count, response.data.results)
} else {
appendResults(response.data.results)
}
}).catch(error => {
console.error(error)
})
}
function filterExclusions(gridObject: GridCharacter | GridWeapon | GridSummon) {
if (objects && gridObject.object &&
gridObject.object.granblue_id === objects[props.fromPosition]?.object.granblue_id)
return null
else return gridObject
function replaceResults(count: number, list: Weapon[] | Summon[] | Character[]) {
if (count > 0) {
setResults(list)
} else {
setResults([])
}
}
function appendResults(list: Weapon[] | Summon[] | Character[]) {
setResults([...results, ...list])
}
function sendData(result: Character | Weapon | Summon) {
@ -98,106 +106,160 @@ const SearchModal = (props: Props) => {
setOpen(false)
}
function receiveFilters(filters: { [key: string]: number[] }) {
setCurrentPage(1)
setResults([])
setFilters(filters)
}
useEffect(() => {
// Current page changed
if (open && currentPage > 1) {
fetchResults({ replace: false })
} else if (open && currentPage == 1) {
fetchResults({ replace: true })
}
}, [currentPage])
useEffect(() => {
// Filters changed
if (open) {
setCurrentPage(1)
fetchResults({ replace: true })
}
}, [filters])
useEffect(() => {
// Query changed
if (open && query.length != 1) {
setCurrentPage(1)
fetchResults({ replace: true })
}
}, [query])
function renderResults() {
let jsx
switch(props.object) {
case 'weapons':
return renderWeaponSearchResults(results)
jsx = renderWeaponSearchResults()
break
case 'summons':
return renderSummonSearchResults(results)
jsx = renderSummonSearchResults(results)
break
case 'characters':
return renderCharacterSearchResults(results)
jsx = renderCharacterSearchResults(results)
break
}
}
function renderWeaponSearchResults(results: { [key: string]: any }) {
const elements = results.map((result: Weapon) => {
return <WeaponResult
key={result.id}
data={result}
onClick={() => { sendData(result) }}
/>
})
return (<ul id="Results">{elements}</ul>)
}
function renderSummonSearchResults(results: { [key: string]: any }) {
const elements = results.map((result: Summon) => {
return <SummonResult
key={result.id}
data={result}
onClick={() => { sendData(result) }}
/>
})
return (<ul id="Results">{elements}</ul>)
}
function renderCharacterSearchResults(results: { [key: string]: any }) {
const elements = results.map((result: Character) => {
return <CharacterResult
key={result.id}
data={result}
onClick={() => { sendData(result) }}
/>
})
return (<ul id="Results">{elements}</ul>)
}
function renderEmptyState() {
let string = ''
if (query === '') {
string = `No ${props.object}`
} else if (query.length < 2) {
string = `Type at least 2 characters`
} else {
string = `No results found for '${query}'`
}
return (
<div id="NoResults">
<h2>{string}</h2>
</div>
<InfiniteScroll
dataLength={ (results && results.length > 0) ? results.length : 0}
next={ () => setCurrentPage(currentPage + 1) }
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={() => { sendData(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={() => { sendData(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={() => { sendData(result) }}
/>
})
}
return jsx
}
function resetAndClose() {
setQuery('')
setResults({})
setOpen(true)
function openChange() {
if (open) {
setQuery('')
setResults([])
setOpen(false)
} else {
setOpen(true)
}
}
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Root open={open} onOpenChange={openChange}>
<Dialog.Trigger asChild>
{props.children}
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content className="Search Dialog">
<div id="Header">
<label className="search_label" htmlFor="search_input">
<input
autoComplete="off"
type="text"
name="query"
className="Input"
id="search_input"
ref={searchInput}
value={query}
placeholder={props.placeholderText}
onChange={inputChanged}
/>
</label>
<Dialog.Close className="DialogClose" onClick={resetAndClose}>
<CrossIcon />
</Dialog.Close>
<div id="Bar">
<label className="search_label" htmlFor="search_input">
<input
autoComplete="off"
type="text"
name="query"
className="Input"
id="search_input"
ref={searchInput}
value={query}
placeholder={props.placeholderText}
onChange={inputChanged}
/>
</label>
<Dialog.Close className="DialogClose" onClick={openChange}>
<CrossIcon />
</Dialog.Close>
</div>
{ (props.object === 'characters') ? <CharacterSearchFilterBar sendFilters={receiveFilters} /> : '' }
{ (props.object === 'weapons') ? <WeaponSearchFilterBar sendFilters={receiveFilters} /> : '' }
{ (props.object === 'summons') ? <SummonSearchFilterBar sendFilters={receiveFilters} /> : '' }
</div>
<div id="Results" ref={scrollContainer}>
<h5 className="total">{t('search.result_count', { "record_count": recordCount })}</h5>
{ (open) ? renderResults() : ''}
</div>
{ ((Object.entries(results).length == 0) ? renderEmptyState() : renderResults()) }
</Dialog.Content>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>

View file

@ -0,0 +1,105 @@
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next'
import cloneDeep from 'lodash.clonedeep'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import SearchFilter from '~components/SearchFilter'
import SearchFilterCheckboxItem from '~components/SearchFilterCheckboxItem'
import './index.scss'
import { emptyElementState, emptyRarityState } from '~utils/emptyStates'
import { elements, rarities } from '~utils/stateValues'
interface Props {
sendFilters: (filters: { [key: string]: number[] }) => void
}
const SummonSearchFilterBar = (props: Props) => {
const { t } = useTranslation('common')
const [rarityMenu, setRarityMenu] = useState(false)
const [elementMenu, setElementMenu] = useState(false)
const [rarityState, setRarityState] = useState<RarityState>(emptyRarityState)
const [elementState, setElementState] = useState<ElementState>(emptyElementState)
function rarityMenuOpened(open: boolean) {
if (open) {
setRarityMenu(true)
setElementMenu(false)
} else setRarityMenu(false)
}
function elementMenuOpened(open: boolean) {
if (open) {
setRarityMenu(false)
setElementMenu(true)
} else setElementMenu(false)
}
function handleRarityChange(checked: boolean, key: string) {
let newRarityState = cloneDeep(rarityState)
newRarityState[key].checked = checked
setRarityState(newRarityState)
}
function handleElementChange(checked: boolean, key: string) {
let newElementState = cloneDeep(elementState)
newElementState[key].checked = checked
setElementState(newElementState)
}
function sendFilters() {
const checkedRarityFilters = Object.values(rarityState).filter(x => x.checked).map((x, i) => x.id)
const checkedElementFilters = Object.values(elementState).filter(x => x.checked).map((x, i) => x.id)
const filters = {
rarity: checkedRarityFilters,
element: checkedElementFilters
}
props.sendFilters(filters)
}
useEffect(() => {
sendFilters()
}, [rarityState, elementState])
return (
<div className="SearchFilterBar">
<SearchFilter label={t('filters.labels.rarity')} numSelected={Object.values(rarityState).map(x => x.checked).filter(Boolean).length} open={rarityMenu} onOpenChange={rarityMenuOpened}>
<DropdownMenu.Label className="Label">{t('filters.labels.rarity')}</DropdownMenu.Label>
{ Array.from(Array(rarities.length)).map((x, i) => {
return (
<SearchFilterCheckboxItem
key={rarities[i]}
onCheckedChange={handleRarityChange}
checked={rarityState[rarities[i]].checked}
valueKey={rarities[i]}>
{t(`rarities.${rarities[i]}`)}
</SearchFilterCheckboxItem>
)}
) }
</SearchFilter>
<SearchFilter label={t('filters.labels.element')} numSelected={Object.values(elementState).map(x => x.checked).filter(Boolean).length} open={elementMenu} onOpenChange={elementMenuOpened}>
<DropdownMenu.Label className="Label">{t('filters.labels.element')}</DropdownMenu.Label>
{ Array.from(Array(elements.length)).map((x, i) => {
return (
<SearchFilterCheckboxItem
key={elements[i]}
onCheckedChange={handleElementChange}
checked={elementState[elements[i]].checked}
valueKey={elements[i]}>
{t(`elements.${elements[i]}`)}
</SearchFilterCheckboxItem>
)}
) }
</SearchFilter>
</div>
)
}
export default SummonSearchFilterBar

View file

@ -0,0 +1,224 @@
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next'
import cloneDeep from 'lodash.clonedeep'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import SearchFilter from '~components/SearchFilter'
import SearchFilterCheckboxItem from '~components/SearchFilterCheckboxItem'
import './index.scss'
import { emptyElementState, emptyProficiencyState, emptyRarityState, emptyWeaponSeriesState } from '~utils/emptyStates'
import { elements, proficiencies, rarities, weaponSeries } from '~utils/stateValues'
interface Props {
sendFilters: (filters: { [key: string]: number[] }) => void
}
const WeaponSearchFilterBar = (props: Props) => {
const { t } = useTranslation('common')
const [rarityMenu, setRarityMenu] = useState(false)
const [elementMenu, setElementMenu] = useState(false)
const [proficiencyMenu, setProficiencyMenu] = useState(false)
const [seriesMenu, setSeriesMenu] = useState(false)
const [rarityState, setRarityState] = useState<RarityState>(emptyRarityState)
const [elementState, setElementState] = useState<ElementState>(emptyElementState)
const [proficiencyState, setProficiencyState] = useState<ProficiencyState>(emptyProficiencyState)
const [seriesState, setSeriesState] = useState<WeaponSeriesState>(emptyWeaponSeriesState)
function rarityMenuOpened(open: boolean) {
if (open) {
setRarityMenu(true)
setElementMenu(false)
setProficiencyMenu(false)
setSeriesMenu(false)
} else setRarityMenu(false)
}
function elementMenuOpened(open: boolean) {
if (open) {
setRarityMenu(false)
setElementMenu(true)
setProficiencyMenu(false)
setSeriesMenu(false)
} else setElementMenu(false)
}
function proficiencyMenuOpened(open: boolean) {
if (open) {
setRarityMenu(false)
setElementMenu(false)
setProficiencyMenu(true)
setSeriesMenu(false)
} else setProficiencyMenu(false)
}
function seriesMenuOpened(open: boolean) {
if (open) {
setRarityMenu(false)
setElementMenu(false)
setProficiencyMenu(false)
setSeriesMenu(true)
} else setSeriesMenu(false)
}
function handleRarityChange(checked: boolean, key: string) {
let newRarityState = cloneDeep(rarityState)
newRarityState[key].checked = checked
setRarityState(newRarityState)
}
function handleElementChange(checked: boolean, key: string) {
let newElementState = cloneDeep(elementState)
newElementState[key].checked = checked
setElementState(newElementState)
}
function handleProficiencyChange(checked: boolean, key: string) {
let newProficiencyState = cloneDeep(proficiencyState)
newProficiencyState[key].checked = checked
setProficiencyState(newProficiencyState)
}
function handleSeriesChange(checked: boolean, key: string) {
let newSeriesState = cloneDeep(seriesState)
newSeriesState[key].checked = checked
setSeriesState(newSeriesState)
}
function sendFilters() {
const checkedRarityFilters = Object.values(rarityState).filter(x => x.checked).map((x, i) => x.id)
const checkedElementFilters = Object.values(elementState).filter(x => x.checked).map((x, i) => x.id)
const checkedProficiencyFilters = Object.values(proficiencyState).filter(x => x.checked).map((x, i) => x.id)
const checkedSeriesFilters = Object.values(seriesState).filter(x => x.checked).map((x, i) => x.id)
const filters = {
rarity: checkedRarityFilters,
element: checkedElementFilters,
proficiency1: checkedProficiencyFilters,
series: checkedSeriesFilters
}
props.sendFilters(filters)
}
useEffect(() => {
sendFilters()
}, [rarityState, elementState, proficiencyState, seriesState])
return (
<div className="SearchFilterBar">
<SearchFilter label={t('filters.labels.rarity')} numSelected={Object.values(rarityState).map(x => x.checked).filter(Boolean).length} open={rarityMenu} onOpenChange={rarityMenuOpened}>
<DropdownMenu.Label className="Label">{t('filters.labels.rarity')}</DropdownMenu.Label>
{ Array.from(Array(rarities.length)).map((x, i) => {
return (
<SearchFilterCheckboxItem
key={rarities[i]}
onCheckedChange={handleRarityChange}
checked={rarityState[rarities[i]].checked}
valueKey={rarities[i]}>
{t(`rarities.${rarities[i]}`)}
</SearchFilterCheckboxItem>
)}
) }
</SearchFilter>
<SearchFilter label={t('filters.labels.element')} numSelected={Object.values(elementState).map(x => x.checked).filter(Boolean).length} open={elementMenu} onOpenChange={elementMenuOpened}>
<DropdownMenu.Label className="Label">{t('filters.labels.element')}</DropdownMenu.Label>
{ Array.from(Array(elements.length)).map((x, i) => {
return (
<SearchFilterCheckboxItem
key={elements[i]}
onCheckedChange={handleElementChange}
checked={elementState[elements[i]].checked}
valueKey={elements[i]}>
{t(`elements.${elements[i]}`)}
</SearchFilterCheckboxItem>
)}
) }
</SearchFilter>
<SearchFilter label={t('filters.labels.proficiency')} numSelected={Object.values(proficiencyState).map(x => x.checked).filter(Boolean).length} open={proficiencyMenu} onOpenChange={proficiencyMenuOpened}>
<DropdownMenu.Label className="Label">{t('filters.labels.proficiency')}</DropdownMenu.Label>
<section>
<DropdownMenu.Group className="Group">
{ Array.from(Array(proficiencies.length / 2)).map((x, i) => {
return (
<SearchFilterCheckboxItem
key={proficiencies[i]}
onCheckedChange={handleProficiencyChange}
checked={proficiencyState[proficiencies[i]].checked}
valueKey={proficiencies[i]}>
{t(`proficiencies.${proficiencies[i]}`)}
</SearchFilterCheckboxItem>
)}
) }
</DropdownMenu.Group>
<DropdownMenu.Group className="Group">
{ Array.from(Array(proficiencies.length / 2)).map((x, i) => {
return (
<SearchFilterCheckboxItem
key={proficiencies[i + (proficiencies.length / 2)]}
onCheckedChange={handleProficiencyChange}
checked={proficiencyState[proficiencies[i + (proficiencies.length / 2)]].checked}
valueKey={proficiencies[i + (proficiencies.length / 2)]}>
{t(`proficiencies.${proficiencies[i + (proficiencies.length / 2)]}`)}
</SearchFilterCheckboxItem>
)}
) }
</DropdownMenu.Group>
</section>
</SearchFilter>
<SearchFilter label={t('filters.labels.series')} numSelected={Object.values(seriesState).map(x => x.checked).filter(Boolean).length} open={seriesMenu} onOpenChange={seriesMenuOpened}>
<DropdownMenu.Label className="Label">{t('filters.labels.series')}</DropdownMenu.Label>
<section>
<DropdownMenu.Group className="Group">
{ Array.from(Array(weaponSeries.length / 3)).map((x, i) => {
return (
<SearchFilterCheckboxItem
key={weaponSeries[i]}
onCheckedChange={handleSeriesChange}
checked={seriesState[weaponSeries[i]].checked}
valueKey={weaponSeries[i]}>
{t(`series.${weaponSeries[i]}`)}
</SearchFilterCheckboxItem>
)}
) }
</DropdownMenu.Group>
<DropdownMenu.Group className="Group">
{ Array.from(Array(weaponSeries.length / 3)).map((x, i) => {
return (
<SearchFilterCheckboxItem
key={weaponSeries[i + (weaponSeries.length / 3)]}
onCheckedChange={handleSeriesChange}
checked={seriesState[weaponSeries[i + (weaponSeries.length / 3)]].checked}
valueKey={weaponSeries[i + (weaponSeries.length / 3)]}>
{t(`series.${weaponSeries[i + (weaponSeries.length / 3)]}`)}
</SearchFilterCheckboxItem>
)}
) }
</DropdownMenu.Group>
<DropdownMenu.Group className="Group">
{ Array.from(Array(weaponSeries.length / 3)).map((x, i) => {
return (
<SearchFilterCheckboxItem
key={weaponSeries[i + (2 * (weaponSeries.length / 3))]}
onCheckedChange={handleSeriesChange}
checked={seriesState[weaponSeries[i + (2 * (weaponSeries.length / 3))]].checked}
valueKey={weaponSeries[i + (2 * (weaponSeries.length / 3))]}>
{t(`series.${weaponSeries[i + (2 * (weaponSeries.length / 3))]}`)}
</SearchFilterCheckboxItem>
)}
) }
</DropdownMenu.Group>
</section>
</SearchFilter>
</div>
)
}
export default WeaponSearchFilterBar

740
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,7 @@
"dependencies": {
"@radix-ui/react-alert-dialog": "^0.1.5",
"@radix-ui/react-dialog": "^0.1.5",
"@radix-ui/react-dropdown-menu": "^0.1.4",
"@radix-ui/react-dropdown-menu": "^0.1.6",
"@radix-ui/react-hover-card": "^0.1.5",
"@radix-ui/react-label": "^0.1.4",
"@radix-ui/react-switch": "^0.1.5",
@ -36,6 +36,8 @@
"react-cookie": "^4.1.1",
"react-dom": "^17.0.2",
"react-i18next": "^11.15.5",
"react-infinite-scroll-component": "^6.1.0",
"react-infinite-scroller": "^1.2.5",
"react-linkify": "^1.0.0-alpha",
"react-scroll": "^1.8.5",
"sass": "^1.49.0",
@ -47,6 +49,7 @@
"@types/node": "17.0.11",
"@types/react": "17.0.38",
"@types/react-dom": "^17.0.11",
"@types/react-infinite-scroller": "^1.2.2",
"@types/react-linkify": "^1.0.1",
"@types/react-scroll": "^1.8.3",
"eslint": "8.7.0",

3
public/icons/Check.svg Normal file
View file

@ -0,0 +1,3 @@
<svg viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.8536 4.85355C11.0488 4.65829 11.0488 4.34171 10.8536 4.14645C10.6583 3.95118 10.3417 3.95118 10.1464 4.14645L5.5 8.79289L3.85355 7.14645C3.65829 6.95118 3.34171 6.95118 3.14645 7.14645C2.95118 7.34171 2.95118 7.65829 3.14645 7.85355L5.14645 9.85355C5.34171 10.0488 5.65829 10.0488 5.85355 9.85355C5.85365 9.85346 5.85375 9.85336 5.85385 9.85326L10.8536 4.85355Z" />
</svg>

After

Width:  |  Height:  |  Size: 499 B

View file

@ -17,6 +17,18 @@
"new": "New",
"wiki": "View more on gbf.wiki"
},
"filters": {
"labels": {
"element": "Element",
"series": "Series",
"proficiency": "Proficiency",
"rarity": "Rarity"
}
},
"rarities": {
"sr": "SR",
"ssr": "SSR"
},
"elements": {
"null": "Null",
"wind": "Wind",
@ -36,6 +48,44 @@
"light": "Light"
}
},
"proficiencies": {
"sabre": "Sabre",
"dagger": "Dagger",
"spear": "Spear",
"axe": "Axe",
"staff": "Staff",
"gun": "Gun",
"melee": "Melee",
"bow": "Bow",
"harp": "Harp",
"katana": "Katana"
},
"series": {
"seraphic": "Seraphic",
"grand": "Grand",
"opus": "Dark Opus",
"draconic": "Draconic",
"primal": "Primal",
"olden_primal": "Olden Primal",
"beast": "Beast",
"omega": "Omega",
"militis": "Militis",
"xeno": "Xeno",
"astral": "Astral",
"rose": "Rose",
"hollowsky": "Hollowsky",
"ultima": "Ultima",
"bahamut": "Bahamut",
"epic": "Epic",
"ennead": "Ennead",
"cosmos": "Cosmos",
"ancestral": "Ancestral",
"superlative": "Superlative",
"vintage": "Vintage",
"class_champion": "Class Champion",
"sephira": "Sephira",
"new_world": "New World Foundation"
},
"recency": {
"all_time": "All time",
"last_day": "Last day",
@ -158,10 +208,12 @@
"not_found": "You haven't saved any teams"
},
"search": {
"result_count": "{{record_count}} results",
"errors": {
"start_typing": "Start typing the name of a {{object}}",
"min_length": "Type at least 3 characters",
"no_results": "No results found for '{{query}}'"
"no_results": "No results found for '{{query}}'",
"end_results": "No more results"
},
"placeholders": {
"weapon": "Search for a weapon...",

View file

@ -17,6 +17,18 @@
"new": "作成",
"wiki": "gbf.wikiで詳しく見る"
},
"filters": {
"labels": {
"element": "属性",
"series": "シリーズ",
"proficiency": "武器種",
"rarity": "レアリティ"
}
},
"rarities": {
"sr": "SR",
"ssr": "SSR"
},
"elements": {
"null": "無",
"wind": "風",
@ -36,6 +48,44 @@
"light": "光属性"
}
},
"proficiencies": {
"sabre": "剣",
"dagger": "短剣",
"spear": "槍",
"axe": "斧",
"staff": "杖",
"gun": "銃",
"melee": "拳",
"bow": "弓",
"harp": "琴",
"katana": "刀"
},
"series": {
"seraphic": "セラフィックウェポン",
"grand": "リミテッドシリーズ",
"opus": "終末の神器",
"draconic": "ドラコニックウェポン",
"primal": "プライマルシリーズ",
"olden_primal": "オールド・プライマルシリーズ",
"beast": "四象武器",
"omega": "マグナシリーズ",
"militis": "ミーレスシリーズ",
"xeno": "六道武器",
"astral": "アストラルウェポン",
"rose": "ローズシリーズ",
"hollowsky": "虚ろなる神器",
"ultima": "オメガウェポン",
"bahamut": "バハムートウェポン",
"epic": "エピックウェポン",
"ennead": "エニアドシリーズ",
"cosmos": "コスモスシリーズ",
"ancestral": "アンセスタルシリーズ",
"superlative": "スペリオシリーズ",
"vintage": "ヴィンテージシリーズ",
"class_champion": "英雄武器",
"sephira": "セフィラン・オールドウェポン",
"new_world": "新世界の礎"
},
"recency": {
"all_time": "全ての期間",
"last_day": "1日",
@ -159,10 +209,12 @@
"not_found": "編成はまだ保存していません"
},
"search": {
"result_count": "{{record_count}}件",
"errors": {
"start_typing": "{{object}}名を入力してください",
"min_length": "3文字以上を入力してください",
"no_results": "'{{query}}'の検索結果が見つかりませんでした"
"no_results": "'{{query}}'の検索結果が見つかりませんでした",
"end_results": "検索結果これ以上ありません"
},
"placeholders": {
"weapon": "武器を検索...",

View file

@ -327,6 +327,16 @@ i.tag {
text-transform: uppercase;
}
.infinite-scroll-component {
overflow: hidden !important;
}
.SearchFilterBar {
display: flex;
gap: $unit;
padding: 0 ($unit * 3);
}
@keyframes openModal {
0% {
opacity: 0;

4
types/CheckedState.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
interface CheckedState {
id: number
checked: boolean
}

10
types/ElementState.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
interface ElementState {
[key: string]: CheckedState
null: CheckedState
wind: CheckedState
fire: CheckedState
water: CheckedState
earth: CheckedState
dark: CheckedState
light: CheckedState
}

13
types/ProficiencyState.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
interface ProficiencyState {
[key: string]: CheckedState
sabre: CheckedState
dagger: CheckedState
spear: CheckedState
axe: CheckedState
staff: CheckedState
melee: CheckedState
gun: CheckedState
bow: CheckedState
harp: CheckedState
katana: CheckedState
}

5
types/RarityState.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
interface RarityState {
[key: string]: CheckedState
sr: CheckedState
ssr: CheckedState
}

27
types/WeaponSeries.d.ts vendored Normal file
View file

@ -0,0 +1,27 @@
interface WeaponSeriesState {
[key: string]: CheckedState
seraphic: CheckedState
grand: CheckedState
opus: CheckedState
draconic: CheckedState
ultima: CheckedState
bahamut: CheckedState
omega: CheckedState
primal: CheckedState
olden_primal: CheckedState
militis: CheckedState
beast: CheckedState
rose: CheckedState
xeno: CheckedState
hollowsky: CheckedState
astral: CheckedState
epic: CheckedState
ennead: CheckedState
cosmos: CheckedState
ancestral: CheckedState
superlative: CheckedState
vintage: CheckedState
class_champion: CheckedState
sephira: CheckedState
new_world: CheckedState
}

View file

@ -55,12 +55,17 @@ class Api {
return axios.post(`${ oauthUrl }/token`, object)
}
search(object: string, query: string, excludes: string, locale: string = 'en') {
search({ object, query, filters, locale = "en", page = 0 }:
{ object: string, query: string, filters?: { [key: string]: number[] }, locale?: string, page?: number }) {
const resourceUrl = `${this.url}/${name}`
const url = (excludes.length > 0) ?
`${resourceUrl}search/${object}?query=${query}&locale=${locale}&excludes=${excludes}` :
`${resourceUrl}search/${object}?query=${query}&locale=${locale}`
return axios.get(url)
return axios.post(`${resourceUrl}search/${object}`, {
search: {
query: query,
filters: filters,
locale: locale,
page: page
}
})
}
check(resource: string, value: string) {

187
utils/emptyStates.tsx Normal file
View file

@ -0,0 +1,187 @@
export const emptyRarityState: RarityState = {
sr: {
id: 2,
checked: false
},
ssr: {
id: 3,
checked: true
}
}
export const emptyElementState: ElementState = {
null: {
id: 0,
checked: false
},
wind: {
id: 1,
checked: false
},
fire: {
id: 2,
checked: false
},
water: {
id: 3,
checked: false
},
earth: {
id: 4,
checked: false
},
dark: {
id: 5,
checked: false
},
light: {
id: 6,
checked: false
}
}
export const emptyProficiencyState: ProficiencyState = {
sabre: {
id: 1,
checked: false
},
dagger: {
id: 2,
checked: false
},
axe: {
id: 3,
checked: false
},
spear: {
id: 4,
checked: false
},
bow: {
id: 5,
checked: false
},
staff: {
id: 6,
checked: false
},
melee: {
id: 7,
checked: false
},
harp: {
id: 8,
checked: false
},
gun: {
id: 9,
checked: false
},
katana: {
id: 10,
checked: false
}
}
export const emptyWeaponSeriesState: WeaponSeriesState = {
seraphic: {
id: 0,
checked: false
},
grand: {
id: 1,
checked: false
},
opus: {
id: 2,
checked: false
},
draconic: {
id: 3,
checked: false
},
ultima: {
id: 17,
checked: false
},
bahamut: {
id: 16,
checked: false
},
regalia: {
id: 8,
checked: false
},
omega: {
id: 9,
checked: false
},
primal: {
id: 6,
checked: false
},
olden_primal: {
id: 10,
checked: false
},
militis: {
id: 11,
checked: false
},
beast: {
id: 7,
checked: false
},
rose: {
id: 15,
checked: false
},
xeno: {
id: 13,
checked: false
},
hollowsky: {
id: 12,
checked: false
},
astral: {
id: 14,
checked: false
},
epic: {
id: 18,
checked: false
},
ennead: {
id: 19,
checked: false
},
cosmos: {
id: 20,
checked: false
},
ancestral: {
id: 21,
checked: false
},
superlative: {
id: 22,
checked: false
},
vintage: {
id: 23,
checked: false
},
class_champion: {
id: 24,
checked: false
},
sephira: {
id: 28,
checked: false
},
new_world: {
id: 29,
checked: false
}
}

16
utils/stateValues.tsx Normal file
View file

@ -0,0 +1,16 @@
export const rarities = ["sr", "ssr"]
export const elements = ["null", "wind", "fire", "water", "earth", "dark", "light"]
export const proficiencies = [
"sabre", "dagger", "spear", "axe", "staff",
"melee", "gun", "bow", "harp", "katana"
]
export const weaponSeries = [
"seraphic", "grand", "opus", "draconic", "ultima",
"bahamut", "omega", "primal", "olden_primal", "militis",
"beast", "rose", "xeno", "hollowsky", "astral",
"epic", "ennead", "cosmos", "ancestral", "superlative",
"vintage", "class_champion", "sephira", "new_world"
]