* Move min-width to RaidCombobox, not Popover This fixes #318 * Use snapshots to make tokens reactive This fixes #319 * Revert ChatGPT refactor of this method Oops. This code was nice, but it didn't actually assign `false` to keys to be sent to the server. We will revisit this, but it needs to be fixed right now. This fixes #325 * Ignore gacha directory We will probably scrape these images soon. * Add translation for Auto Summon token * Add auto summon token to app state * Set battle settings in state on update Also renames PartyDetails to PartyFooter and makes description reactive * Stop 1password icon from appearing in name field * Use snapshot for reactive Edit party modal * Fix Edit modal placeholder colors * Fix bug with RaidCombobox and Farming Selecting farming then opening the raid combobox *twice* consecutively would put you in no segment, so no raids appeared Fixes #323 * Fix values staying in Edit team even if not saved The values a user entered in the Edit team modal would persist even if the user hit cancel to close the modal. They wouldn't save to the server, but very confusing nonetheless. Now fixed. * Fix unreadable colors in ElementToggle * Fix button alignment in weapon modal * Add text to filters button on small screens The FilterBar showed a left aligned filter icon on mobile for months and it was driving me insane * Remove extraneous code from Header Including the party name, since it's at the top now * Fix Alert at small sizes * Make copy link toast work again * Remove stylesheet links * Fix remix toasts and alerts from both locations The remix toast and alert was barely hooked up and not showing up when invoked from PartyHeader. It now shows up whether you remix your own team (from PartyDropdown) or if you remix another person's team (from PartyHeader).
579 lines
16 KiB
TypeScript
579 lines
16 KiB
TypeScript
import { createRef, useCallback, useEffect, useState, useRef } from 'react'
|
|
import { useRouter } from 'next/router'
|
|
import { useTranslation } from 'react-i18next'
|
|
import classNames from 'classnames'
|
|
|
|
import { Command, CommandGroup, CommandInput } from 'cmdk'
|
|
import Popover from '~components/common/Popover'
|
|
import SegmentedControl from '~components/common/SegmentedControl'
|
|
import Segment from '~components/common/Segment'
|
|
import RaidItem from '~components/raids/RaidItem'
|
|
import Tooltip from '~components/common/Tooltip'
|
|
|
|
import api from '~utils/api'
|
|
import { appState } from '~utils/appState'
|
|
|
|
interface Props {
|
|
showAllRaidsOption: boolean
|
|
currentRaid?: Raid
|
|
defaultRaid?: Raid
|
|
minimal?: boolean
|
|
tabIndex?: number
|
|
onChange?: (raid?: Raid) => void
|
|
onBlur?: (event: React.ChangeEvent<HTMLSelectElement>) => void
|
|
}
|
|
|
|
import Button from '~components/common/Button'
|
|
import ArrowIcon from '~public/icons/Arrow.svg'
|
|
import CrossIcon from '~public/icons/Cross.svg'
|
|
|
|
import './index.scss'
|
|
|
|
const NUM_SECTIONS = 3
|
|
const NUM_ELEMENTS = 5
|
|
|
|
enum Sort {
|
|
ASCENDING,
|
|
DESCENDING,
|
|
}
|
|
|
|
// Set up empty raid for "All raids"
|
|
const untitledGroup: RaidGroup = {
|
|
id: '0',
|
|
name: {
|
|
en: '',
|
|
ja: '',
|
|
},
|
|
section: 0,
|
|
order: 0,
|
|
extra: false,
|
|
guidebooks: false,
|
|
raids: [],
|
|
difficulty: 0,
|
|
hl: false,
|
|
}
|
|
|
|
// Set up empty raid for "All raids"
|
|
const allRaidsOption: Raid = {
|
|
id: '0',
|
|
name: {
|
|
en: 'All battles',
|
|
ja: '全てのバトル',
|
|
},
|
|
group: untitledGroup,
|
|
slug: 'all',
|
|
level: 0,
|
|
element: 0,
|
|
}
|
|
|
|
const RaidCombobox = (props: Props) => {
|
|
// Set up router for locale
|
|
const router = useRouter()
|
|
const locale = router.locale || 'en'
|
|
|
|
// Set up translations
|
|
const { t } = useTranslation('common')
|
|
|
|
// Component state
|
|
const [open, setOpen] = useState(false)
|
|
const [sort, setSort] = useState<Sort>(Sort.DESCENDING)
|
|
const [scrolled, setScrolled] = useState(false)
|
|
|
|
// Data state
|
|
const [currentSection, setCurrentSection] = useState(1)
|
|
const [query, setQuery] = useState('')
|
|
const [sections, setSections] = useState<RaidGroup[][]>()
|
|
const [currentRaid, setCurrentRaid] = useState<Raid>()
|
|
const [tabIndex, setTabIndex] = useState(NUM_ELEMENTS + 1)
|
|
|
|
// Data
|
|
const [farmingRaid, setFarmingRaid] = useState<Raid>()
|
|
|
|
// Refs
|
|
const listRef = createRef<HTMLDivElement>()
|
|
const inputRef = createRef<HTMLInputElement>()
|
|
const sortButtonRef = createRef<HTMLButtonElement>()
|
|
|
|
// ----------------------------------------------
|
|
// Methods: Lifecycle Hooks
|
|
// ----------------------------------------------
|
|
|
|
// Fetch all raids on mount
|
|
useEffect(() => {
|
|
api.raidGroups().then((response) => sortGroups(response.data))
|
|
}, [])
|
|
|
|
// Set current raid and section when the component mounts
|
|
useEffect(() => {
|
|
// if (appState.party.raid) {
|
|
// setCurrentRaid(appState.party.raid)
|
|
// if (appState.party.raid.group.section > 0) {
|
|
// setCurrentSection(appState.party.raid.group.section)
|
|
// } else {
|
|
// setCurrentSection(1)
|
|
// }
|
|
// } else if (props.showAllRaidsOption && !currentRaid) {
|
|
// setCurrentRaid(allRaidsOption)
|
|
// }
|
|
}, [])
|
|
|
|
// Set current raid and section when the current raid changes
|
|
useEffect(() => {
|
|
if (props.currentRaid) {
|
|
setCurrentRaid(props.currentRaid)
|
|
if (appState.party.raid && appState.party.raid.group.section > 0)
|
|
setCurrentSection(props.currentRaid.group.section)
|
|
else setCurrentSection(1)
|
|
}
|
|
}, [props.currentRaid])
|
|
|
|
// Scroll to the top of the list when the user switches tabs
|
|
useEffect(() => {
|
|
if (listRef.current) {
|
|
listRef.current.scrollTop = 0
|
|
}
|
|
}, [currentSection])
|
|
|
|
useEffect(() => {
|
|
setTabIndex(NUM_ELEMENTS + 1)
|
|
}, [currentSection])
|
|
|
|
// ----------------------------------------------
|
|
// Methods: Event Handlers
|
|
// ----------------------------------------------
|
|
|
|
// Handle Escape key press event
|
|
const handleEscapeKeyPressed = useCallback(() => {
|
|
if (listRef.current) {
|
|
listRef.current.focus()
|
|
}
|
|
}, [open, currentRaid, sortButtonRef])
|
|
|
|
// Handle Arrow key press event by focusing the list item above or below the current one based on the direction
|
|
const handleArrowKeyPressed = useCallback(
|
|
(direction: 'Up' | 'Down') => {
|
|
const current = listRef.current?.querySelector(
|
|
'.Raid:focus'
|
|
) as HTMLElement | null
|
|
|
|
if (current) {
|
|
let next: Element | null | undefined
|
|
|
|
if (direction === 'Down' && !current.nextElementSibling) {
|
|
const nextParent =
|
|
current.parentElement?.parentElement?.nextElementSibling
|
|
next = nextParent?.querySelector('.Raid')
|
|
} else if (direction === 'Up' && !current.previousElementSibling) {
|
|
const previousParent =
|
|
current.parentElement?.parentElement?.previousElementSibling
|
|
next = previousParent?.querySelector('.Raid:last-child')
|
|
} else {
|
|
next =
|
|
direction === 'Up'
|
|
? current.previousElementSibling
|
|
: current.nextElementSibling
|
|
}
|
|
|
|
if (next) {
|
|
;(next as HTMLElement).focus()
|
|
}
|
|
}
|
|
},
|
|
[open, currentRaid, listRef]
|
|
)
|
|
|
|
// Scroll to an item in the list when it is selected
|
|
const scrollToItem = useCallback(
|
|
(node) => {
|
|
if (!scrolled && open && currentRaid && listRef.current && node) {
|
|
const { top: listTop } = listRef.current.getBoundingClientRect()
|
|
const { top: itemTop } = node.getBoundingClientRect()
|
|
|
|
listRef.current.scrollTop = itemTop - listTop
|
|
node.focus()
|
|
setScrolled(true)
|
|
}
|
|
},
|
|
[scrolled, open, currentRaid, listRef]
|
|
)
|
|
|
|
// Reverse the sort order
|
|
function reverseSort() {
|
|
if (sort === Sort.ASCENDING) setSort(Sort.DESCENDING)
|
|
else setSort(Sort.ASCENDING)
|
|
}
|
|
|
|
// Sorts the raid groups into sections
|
|
const sortGroups = useCallback(
|
|
(groups: RaidGroup[]) => {
|
|
const sections: [RaidGroup[], RaidGroup[], RaidGroup[]] = [[], [], []]
|
|
|
|
groups.forEach((group) => {
|
|
if (group.section > 0) sections[group.section - 1].push(group)
|
|
})
|
|
|
|
setFarmingRaid(groups[0].raids[0])
|
|
|
|
setSections(sections)
|
|
},
|
|
[setSections]
|
|
)
|
|
|
|
const handleSortButtonKeyDown = (
|
|
event: React.KeyboardEvent<HTMLButtonElement>
|
|
) => {
|
|
// If the tab key is pressed without the Shift key, focus the raid list
|
|
if (event.key === 'Tab' && !event.shiftKey) {
|
|
if (listRef.current) {
|
|
listRef.current.focus()
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleListKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
if (event.key === 'Tab' && !event.shiftKey) {
|
|
event.preventDefault()
|
|
if (inputRef.current) {
|
|
inputRef.current.focus()
|
|
}
|
|
} else if (event.key === 'Tab' && event.shiftKey) {
|
|
event.preventDefault()
|
|
if (sortButtonRef.current) {
|
|
sortButtonRef.current.focus()
|
|
}
|
|
}
|
|
|
|
// If the enter key is pressed, focus the first raid item in the list
|
|
else if (event.key === 'Enter') {
|
|
event.preventDefault()
|
|
if (listRef.current) {
|
|
const raid = listRef.current.querySelector('.Raid')
|
|
if (raid) {
|
|
;(raid as HTMLElement).focus()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle value change for the raid selection
|
|
function handleValueChange(raid: Raid) {
|
|
setCurrentRaid(raid)
|
|
setOpen(false)
|
|
setScrolled(false)
|
|
if (props.onChange) props.onChange(raid)
|
|
}
|
|
|
|
// Toggle the open state of the combobox
|
|
function toggleOpen() {
|
|
if (open) {
|
|
if (
|
|
currentRaid &&
|
|
currentRaid.slug !== 'all' &&
|
|
currentRaid.group.section > 0
|
|
) {
|
|
setCurrentSection(currentRaid.group.section)
|
|
}
|
|
setScrolled(false)
|
|
}
|
|
setOpen(!open)
|
|
}
|
|
|
|
// Clear the search query
|
|
function clearSearch() {
|
|
setQuery('')
|
|
}
|
|
|
|
// ----------------------------------------------
|
|
// Methods: Rendering
|
|
// ----------------------------------------------
|
|
|
|
// Renders each raid section
|
|
function renderRaidSections() {
|
|
return Array.from({ length: NUM_SECTIONS }, (_, i) => renderRaidSection(i))
|
|
}
|
|
|
|
// Renders the specified raid section
|
|
function renderRaidSection(section: number) {
|
|
const currentSection = sections?.[section]
|
|
if (!currentSection) return
|
|
|
|
const sortedGroups = currentSection.sort((a, b) => {
|
|
return sort === Sort.ASCENDING ? a.order - b.order : b.order - a.order
|
|
})
|
|
|
|
return sortedGroups.map((group, i) => renderRaidGroup(section, i))
|
|
}
|
|
|
|
// Renders the specified raid group
|
|
function renderRaidGroup(section: number, index: number) {
|
|
if (!sections?.[section]?.[index]) return
|
|
|
|
const group = sections[section][index]
|
|
const options = generateRaidItems(group.raids)
|
|
|
|
const groupClassName = classNames({
|
|
CommandGroup: true,
|
|
Hidden: group.section !== currentSection,
|
|
})
|
|
|
|
const heading = (
|
|
<div className="Label">
|
|
{group.name[locale]}
|
|
<div className="Separator" />
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<CommandGroup
|
|
data-section={group.section}
|
|
className={groupClassName}
|
|
key={group.name[locale].toLowerCase().replace(' ', '-')}
|
|
heading={heading}
|
|
>
|
|
{options}
|
|
</CommandGroup>
|
|
)
|
|
}
|
|
|
|
// Render the ungrouped raid group
|
|
function renderUngroupedRaids() {
|
|
let ungroupedRaids = farmingRaid ? [farmingRaid] : []
|
|
|
|
if (props.showAllRaidsOption) {
|
|
ungroupedRaids.push(allRaidsOption)
|
|
}
|
|
|
|
const options = generateRaidItems(ungroupedRaids)
|
|
|
|
return (
|
|
<CommandGroup
|
|
data-section={untitledGroup.section}
|
|
className={classNames({
|
|
CommandGroup: true,
|
|
})}
|
|
key="ungrouped-raids"
|
|
>
|
|
{options}
|
|
</CommandGroup>
|
|
)
|
|
}
|
|
|
|
// Generates a list of RaidItem components from the specified raids
|
|
function generateRaidItems(raids: Raid[]) {
|
|
return raids
|
|
.sort((a, b) => {
|
|
if (a.element > 0 && b.element > 0) return a.element - b.element
|
|
if (a.name.en.includes('NM') && b.name.en.includes('NM'))
|
|
return a.level - b.level
|
|
return a.name.en.localeCompare(b.name.en)
|
|
})
|
|
.map((item, i) => renderRaidItem(item, i))
|
|
}
|
|
|
|
// Renders a RaidItem component for the specified raid
|
|
function renderRaidItem(raid: Raid, key: number) {
|
|
const isSelected = currentRaid?.id === raid.id
|
|
const isRef = isSelected ? scrollToItem : undefined
|
|
const imageUrl = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/raids/${raid.slug}.png`
|
|
|
|
return (
|
|
<RaidItem
|
|
className={isSelected ? 'Selected' : ''}
|
|
icon={{ alt: raid.name[locale], src: imageUrl }}
|
|
extra={raid.group.extra}
|
|
key={key}
|
|
selected={isSelected}
|
|
ref={isRef}
|
|
role="listitem"
|
|
tabIndex={0}
|
|
value={raid.slug}
|
|
onEscapeKeyPressed={handleEscapeKeyPressed}
|
|
onArrowKeyPressed={handleArrowKeyPressed}
|
|
onSelect={() => handleValueChange(raid)}
|
|
>
|
|
{raid.name[locale]}
|
|
</RaidItem>
|
|
)
|
|
}
|
|
|
|
// Renders a SegmentedControl component for selecting raid sections.
|
|
function renderSegmentedControl() {
|
|
return (
|
|
<SegmentedControl blended={true}>
|
|
<Segment
|
|
groupName="raid_section"
|
|
name="events"
|
|
selected={currentSection === 2}
|
|
tabIndex={2}
|
|
onClick={() => setCurrentSection(2)}
|
|
>
|
|
{t('raids.sections.events')}
|
|
</Segment>
|
|
<Segment
|
|
groupName="raid_section"
|
|
name="raids"
|
|
selected={currentSection === 1}
|
|
tabIndex={3}
|
|
onClick={() => setCurrentSection(1)}
|
|
>
|
|
{t('raids.sections.raids')}
|
|
</Segment>
|
|
<Segment
|
|
groupName="raid_section"
|
|
name="solo"
|
|
selected={currentSection === 3}
|
|
tabIndex={4}
|
|
onClick={() => setCurrentSection(3)}
|
|
>
|
|
{t('raids.sections.solo')}
|
|
</Segment>
|
|
</SegmentedControl>
|
|
)
|
|
}
|
|
|
|
// Renders a Button for sorting raids and a Tooltip for explaining what it does.
|
|
function renderSortButton() {
|
|
return (
|
|
<Tooltip
|
|
content={
|
|
sort === Sort.ASCENDING
|
|
? 'Lower difficulty battles first'
|
|
: 'Higher difficulty battles first'
|
|
}
|
|
>
|
|
<Button
|
|
blended={true}
|
|
buttonSize="small"
|
|
leftAccessoryIcon={<ArrowIcon />}
|
|
leftAccessoryClassName={sort === Sort.DESCENDING ? 'Flipped' : ''}
|
|
onClick={reverseSort}
|
|
onKeyDown={handleSortButtonKeyDown}
|
|
ref={sortButtonRef}
|
|
tabIndex={5}
|
|
/>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
|
|
// Renders the content for the Popover trigger.
|
|
function renderTriggerContent() {
|
|
if (currentRaid) {
|
|
const element = (
|
|
<>
|
|
{!props.minimal ? (
|
|
<div className="Info">
|
|
<span className="Group">{currentRaid.group.name[locale]}</span>
|
|
<span className="Separator">/</span>
|
|
<span className={classNames({ Raid: true }, linkClass)}>
|
|
{currentRaid.name[locale]}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<span className={classNames({ Raid: true }, linkClass)}>
|
|
{currentRaid.name[locale]}
|
|
</span>
|
|
)}
|
|
|
|
{currentRaid.group.extra && !props.minimal && (
|
|
<i className="ExtraIndicator">EX</i>
|
|
)}
|
|
</>
|
|
)
|
|
|
|
return {
|
|
element,
|
|
rawValue: currentRaid.id,
|
|
}
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
// Renders the search input for the raid combobox
|
|
function renderSearchInput() {
|
|
return (
|
|
<div className="Bound Joined">
|
|
<CommandInput
|
|
className="Input"
|
|
placeholder={t('search.placeholders.raid')}
|
|
tabIndex={1}
|
|
ref={inputRef}
|
|
value={query}
|
|
onValueChange={setQuery}
|
|
/>
|
|
<div
|
|
className={classNames({
|
|
Button: true,
|
|
Clear: true,
|
|
Visible: query.length > 0,
|
|
})}
|
|
onClick={clearSearch}
|
|
>
|
|
<CrossIcon />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ----------------------------------------------
|
|
// Methods: Utility
|
|
// ----------------------------------------------
|
|
function slugToRaid(slug: string) {
|
|
return appState.raidGroups
|
|
.filter((group) => group.section > 0)
|
|
.flatMap((group) => group.raids)
|
|
.find((raid) => raid.slug === slug)
|
|
}
|
|
|
|
const linkClass = classNames({
|
|
wind: currentRaid && currentRaid.element == 1,
|
|
fire: currentRaid && currentRaid.element == 2,
|
|
water: currentRaid && currentRaid.element == 3,
|
|
earth: currentRaid && currentRaid.element == 4,
|
|
dark: currentRaid && currentRaid.element == 5,
|
|
light: currentRaid && currentRaid.element == 6,
|
|
})
|
|
|
|
// ----------------------------------------------
|
|
// Render
|
|
// ----------------------------------------------
|
|
return (
|
|
<Popover
|
|
className="Flush"
|
|
open={open}
|
|
onOpenChange={toggleOpen}
|
|
placeholder={t('raids.placeholder')}
|
|
trigger={{ className: 'Raid' }}
|
|
triggerTabIndex={props.tabIndex}
|
|
value={renderTriggerContent()}
|
|
>
|
|
<Command className="Raid Combobox">
|
|
<div className="Header">
|
|
{renderSearchInput()}
|
|
{!query && (
|
|
<div className="Controls">
|
|
{renderSegmentedControl()}
|
|
{renderSortButton()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={classNames({ Raids: true, Searching: query !== '' })}
|
|
ref={listRef}
|
|
role="listbox"
|
|
tabIndex={6}
|
|
onKeyDown={handleListKeyDown}
|
|
>
|
|
{renderUngroupedRaids()}
|
|
{renderRaidSections()}
|
|
</div>
|
|
</Command>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
RaidCombobox.defaultProps = {
|
|
minimal: false,
|
|
}
|
|
|
|
export default RaidCombobox
|