diff --git a/.gitignore b/.gitignore index e3403051..9bb7f6d8 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ public/images/accessory* public/images/mastery* public/images/updates* public/images/guidebooks* +public/images/raids* # Typescript v1 declaration files typings/ diff --git a/components/FilterBar/index.scss b/components/FilterBar/index.scss index b8bde51e..5cfb852f 100644 --- a/components/FilterBar/index.scss +++ b/components/FilterBar/index.scss @@ -72,7 +72,7 @@ select, .SelectTrigger { - // background: url("/icons/Arrow.svg"), $grey-90; + // background: url("/icons/Chevron.svg"), $grey-90; // background-repeat: no-repeat; // background-position-y: center; // background-position-x: 95%; diff --git a/components/FilterBar/index.tsx b/components/FilterBar/index.tsx index 11b5332f..5d94deb9 100644 --- a/components/FilterBar/index.tsx +++ b/components/FilterBar/index.tsx @@ -4,7 +4,6 @@ import classNames from 'classnames' import equals from 'fast-deep-equal' import FilterModal from '~components/FilterModal' -import RaidDropdown from '~components/RaidDropdown' import Select from '~components/common/Select' import SelectItem from '~components/common/SelectItem' import Button from '~components/common/Button' @@ -15,6 +14,8 @@ import FilterIcon from '~public/icons/Filter.svg' import './index.scss' import { getCookie } from 'cookies-next' +import RaidCombobox from '~components/raids/RaidCombobox' +import { appState } from '~utils/appState' interface Props { children: React.ReactNode @@ -29,6 +30,8 @@ const FilterBar = (props: Props) => { // Set up translation const { t } = useTranslation('common') + const [currentRaid, setCurrentRaid] = useState() + const [recencyOpen, setRecencyOpen] = useState(false) const [elementOpen, setElementOpen] = useState(false) @@ -47,6 +50,16 @@ const FilterBar = (props: Props) => { FiltersActive: !matchesDefaultFilters, }) + // Convert raid slug to Raid object on mount + useEffect(() => { + const raid = appState.raidGroups + .filter((group) => group.section > 0) + .flatMap((group) => group.raids) + .find((raid) => raid.slug === props.raidSlug) + + setCurrentRaid(raid) + }, [props.raidSlug]) + useEffect(() => { // Fetch user's advanced filters const filtersCookie = getCookie('filters') @@ -76,8 +89,8 @@ const FilterBar = (props: Props) => { props.onFilter({ recency: recencyValue, ...advancedFilters }) } - function raidSelectChanged(slug?: string) { - props.onFilter({ raidSlug: slug, ...advancedFilters }) + function raidSelectChanged(raid?: Raid) { + props.onFilter({ raidSlug: raid?.slug, ...advancedFilters }) } function handleAdvancedFiltersChanged(filters: FilterSet) { @@ -90,6 +103,25 @@ const FilterBar = (props: Props) => { setRecencyOpen(name === 'recency' ? !recencyOpen : false) } + function generateSelectItems() { + const elements = [ + { element: 'all', key: -1, value: -1, text: t('elements.full.all') }, + { element: 'null', key: 0, value: 0, text: t('elements.full.null') }, + { element: 'wind', key: 1, value: 1, text: t('elements.full.wind') }, + { element: 'fire', key: 2, value: 2, text: t('elements.full.fire') }, + { element: 'water', key: 3, value: 3, text: t('elements.full.water') }, + { element: 'earth', key: 4, value: 4, text: t('elements.full.earth') }, + { element: 'dark', key: 5, value: 5, text: t('elements.full.dark') }, + { element: 'light', key: 6, value: 6, text: t('elements.full.light') }, + ] + + return elements.map(({ element, key, value, text }) => ( + + {text} + + )) + } + return ( <>
@@ -97,47 +129,26 @@ const FilterBar = (props: Props) => {
- setOpen(!open)} - onClick={openRaidSelect} - onValueChange={handleChange} - > - {Array.from(Array(sortedRaids?.length)).map((x, i) => - renderRaidGroup(i) - )} - - - ) - } -) - -export default RaidDropdown diff --git a/components/RaidSelect/index.scss b/components/RaidSelect/index.scss new file mode 100644 index 00000000..e6eaa60f --- /dev/null +++ b/components/RaidSelect/index.scss @@ -0,0 +1,22 @@ +.Raid.Select { + min-width: 420px; + + .Top { + display: flex; + flex-direction: column; + gap: $unit; + padding: $unit 0; + + .SegmentedControl { + width: 100%; + } + + .Input.Bound { + background-color: var(--select-contained-bg); + + &:hover { + background-color: var(--select-contained-bg-hover); + } + } + } +} diff --git a/components/RaidSelect/index.tsx b/components/RaidSelect/index.tsx new file mode 100644 index 00000000..8d114b59 --- /dev/null +++ b/components/RaidSelect/index.tsx @@ -0,0 +1,170 @@ +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import * as RadixSelect from '@radix-ui/react-select' +import classNames from 'classnames' + +import Overlay from '~components/common/Overlay' + +import ChevronIcon from '~public/icons/Chevron.svg' + +import './index.scss' +import SegmentedControl from '~components/common/SegmentedControl' +import Segment from '~components/common/Segment' +import Input from '~components/common/Input' + +// Props +interface Props + extends React.DetailedHTMLProps< + React.SelectHTMLAttributes, + HTMLSelectElement + > { + altText?: string + currentSegment: number + iconSrc?: string + open: boolean + trigger?: React.ReactNode + children?: React.ReactNode + onOpenChange?: () => void + onValueChange?: (value: string) => void + onSegmentClick: (segment: number) => void + onClose?: () => void + triggerClass?: string + overlayVisible?: boolean +} + +const RaidSelect = React.forwardRef(function Select( + props: Props, + forwardedRef +) { + // Import translations + const { t } = useTranslation('common') + + const searchInput = React.createRef() + + const [open, setOpen] = useState(false) + const [value, setValue] = useState('') + const [query, setQuery] = useState('') + + const triggerClasses = classNames( + { + SelectTrigger: true, + Disabled: props.disabled, + }, + props.triggerClass + ) + + useEffect(() => { + setOpen(props.open) + }, [props.open]) + + useEffect(() => { + if (props.value && props.value !== '') setValue(`${props.value}`) + else setValue('') + }, [props.value]) + + function onValueChange(newValue: string) { + setValue(`${newValue}`) + if (props.onValueChange) props.onValueChange(newValue) + } + + function onCloseAutoFocus() { + setOpen(false) + if (props.onClose) props.onClose() + } + + function onEscapeKeyDown() { + setOpen(false) + if (props.onClose) props.onClose() + } + + function onPointerDownOutside() { + setOpen(false) + if (props.onClose) props.onClose() + } + + return ( + + + {props.iconSrc ? {props.altText} : ''} + + {!props.disabled ? ( + + + + ) : ( + '' + )} + + + + <> + + + +
+ {}} + /> + + props.onSegmentClick(1)} + > + {t('raids.sections.events')} + + props.onSegmentClick(0)} + > + {t('raids.sections.raids')} + + props.onSegmentClick(2)} + > + {t('raids.sections.solo')} + + +
+ {props.children} +
+ +
+
+ ) +}) + +RaidSelect.defaultProps = { + overlayVisible: true, +} + +export default RaidSelect diff --git a/components/auth/AccountModal/index.tsx b/components/auth/AccountModal/index.tsx index 9b37b831..93394c9a 100644 --- a/components/auth/AccountModal/index.tsx +++ b/components/auth/AccountModal/index.tsx @@ -330,10 +330,13 @@ const AccountModal = React.forwardRef( {themeField()}
-
diff --git a/components/character/CharacterGrid/index.tsx b/components/character/CharacterGrid/index.tsx index 958d374e..048ba655 100644 --- a/components/character/CharacterGrid/index.tsx +++ b/components/character/CharacterGrid/index.tsx @@ -259,6 +259,23 @@ const CharacterGrid = (props: Props) => { } } + function removeJobSkill(position: number) { + if (party.id && props.editable) { + api + .removeJobSkill({ partyId: party.id, position: position }) + .then((response) => { + // Update the current skills + const newSkills = response.data.job_skills + setJobSkills(newSkills) + appState.party.jobSkills = newSkills + }) + .catch((error) => { + const data = error.response.data + console.log(data) + }) + } + } + async function saveAccessory(accessory: JobAccessory) { const payload = { party: { @@ -506,6 +523,7 @@ const CharacterGrid = (props: Props) => { editable={props.editable} saveJob={saveJob} saveSkill={saveJobSkill} + removeSkill={removeJobSkill} saveAccessory={saveAccessory} /> void } -interface KeyNames { - [key: string]: { - en: string - jp: string - } -} - const CharacterHovercard = (props: Props) => { const router = useRouter() const { t } = useTranslation('common') @@ -181,27 +173,20 @@ const CharacterHovercard = (props: Props) => { const awakeningSection = () => { const gridAwakening = props.gridCharacter.awakening - const awakening = characterAwakening.find( - (awakening) => awakening.id === gridAwakening?.type - ) - if (gridAwakening && awakening) { + if (gridAwakening) { return (
{t('modals.characters.subtitles.awakening')}
- {gridAwakening.type > 1 ? ( - {awakening.name[locale]} - ) : ( - '' - )} + {gridAwakening.type.name[locale]} - {`${awakening.name[locale]}`}  + {`${gridAwakening.type.name[locale]}`}  {`Lv${gridAwakening.level}`}
diff --git a/components/character/CharacterModal/index.tsx b/components/character/CharacterModal/index.tsx index c23fc881..29568c76 100644 --- a/components/character/CharacterModal/index.tsx +++ b/components/character/CharacterModal/index.tsx @@ -1,13 +1,7 @@ // Core dependencies -import React, { - PropsWithChildren, - useCallback, - useEffect, - useState, -} from 'react' +import React, { PropsWithChildren, useEffect, useState } from 'react' import { useRouter } from 'next/router' import { useTranslation } from 'next-i18next' -import { AxiosResponse } from 'axios' import classNames from 'classnames' // UI dependencies @@ -20,14 +14,10 @@ import { import DialogContent from '~components/common/DialogContent' import Button from '~components/common/Button' import SelectWithInput from '~components/common/SelectWithInput' -import AwakeningSelect from '~components/mastery/AwakeningSelect' import RingSelect from '~components/mastery/RingSelect' import Switch from '~components/common/Switch' // Utilities -import api from '~utils/api' -import { appState } from '~utils/appState' -import { retrieveCookies } from '~utils/retrieveCookies' import elementalizeAetherialMastery from '~utils/elementalizeAetherialMastery' // Data @@ -36,6 +26,8 @@ const emptyExtendedMastery: ExtendedMastery = { strength: 0, } +const MAX_AWAKENING_LEVEL = 9 + // Styles and icons import CrossIcon from '~public/icons/Cross.svg' import './index.scss' @@ -46,6 +38,7 @@ import { ExtendedMastery, GridCharacterObject, } from '~types' +import AwakeningSelectWithInput from '~components/mastery/AwakeningSelectWithInput' interface Props { gridCharacter: GridCharacter @@ -66,9 +59,6 @@ const CharacterModal = ({ router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' const { t } = useTranslation('common') - // Cookies - const cookies = retrieveCookies() - // UI state const [open, setOpen] = useState(false) const [formValid, setFormValid] = useState(false) @@ -103,8 +93,8 @@ const CharacterModal = ({ const [earring, setEarring] = useState(emptyExtendedMastery) // Character properties: Awakening - const [awakeningType, setAwakeningType] = useState(0) - const [awakeningLevel, setAwakeningLevel] = useState(0) + const [awakening, setAwakening] = useState() + const [awakeningLevel, setAwakeningLevel] = useState(1) // Character properties: Transcendence const [transcendenceStep, setTranscendenceStep] = useState(0) @@ -118,7 +108,7 @@ const CharacterModal = ({ }) } - setAwakeningType(gridCharacter.awakening.type) + setAwakening(gridCharacter.awakening.type) setAwakeningLevel(gridCharacter.awakening.level) setPerpetuity(gridCharacter.perpetuity) }, [gridCharacter]) @@ -147,15 +137,16 @@ const CharacterModal = ({ modifier: earring.modifier, strength: earring.strength, }, - awakening: { - type: awakeningType, - level: awakeningLevel, - }, transcendence_step: transcendenceStep, perpetuity: perpetuity, }, } + if (awakening) { + object.character.awakening_id = awakening.id + object.character.awakening_level = awakeningLevel + } + return object } @@ -191,8 +182,8 @@ const CharacterModal = ({ if (onOpenChange) onOpenChange(false) } - function receiveAwakeningValues(type: number, level: number) { - setAwakeningType(type) + function receiveAwakeningValues(id: string, level: number) { + setAwakening(gridCharacter.object.awakenings.find((a) => a.id === id)) setAwakeningLevel(level) } @@ -234,10 +225,16 @@ const CharacterModal = ({ return (

{t('modals.characters.subtitles.awakening')}

- a.slug === 'character-balanced' + )! + } + maxLevel={MAX_AWAKENING_LEVEL} sendValidity={receiveValidity} sendValues={receiveAwakeningValues} /> diff --git a/components/common/Alert/index.scss b/components/common/Alert/index.scss index aba79f93..536edae8 100644 --- a/components/common/Alert/index.scss +++ b/components/common/Alert/index.scss @@ -30,6 +30,7 @@ .description { font-size: $font-regular; line-height: 1.4; + white-space: pre-line; strong { font-weight: $bold; diff --git a/components/common/Alert/index.tsx b/components/common/Alert/index.tsx index dc6d49c9..c6a34b48 100644 --- a/components/common/Alert/index.tsx +++ b/components/common/Alert/index.tsx @@ -12,6 +12,7 @@ interface Props { message: string | React.ReactNode primaryAction?: () => void primaryActionText?: string + primaryActionClassName?: string cancelAction: () => void cancelActionText: string } @@ -22,7 +23,10 @@ const Alert = (props: Props) => {
- + {props.title ? ( {props.title} ) : ( @@ -42,6 +46,7 @@ const Alert = (props: Props) => { {props.primaryAction ? (
) - function jobLabel() { - return job ? filledJobLabel : emptyJobLabel - } - // Render: JSX components return (
@@ -209,7 +215,7 @@ const JobSection = (props: Props) => { )} -
    +
      {[...Array(numSkills)].map((e, i) => (
    • {canEditSkill(skills[i]) diff --git a/components/job/JobSkillItem/index.scss b/components/job/JobSkillItem/index.scss index 9a15bb62..df2fa67b 100644 --- a/components/job/JobSkillItem/index.scss +++ b/components/job/JobSkillItem/index.scss @@ -1,47 +1,81 @@ +.JobSkills { + &.editable .JobSkill { + .Info { + padding: $unit-half * 1.5; + + & > img, + & > div.placeholder { + width: $unit-4x; + height: $unit-4x; + } + } + } +} + .JobSkill { display: flex; - gap: $unit; - align-items: center; + align-items: stretch; + justify-content: space-between; + + &.editable .Info:hover { + background-color: var(--button-bg-hover); + } &.editable:hover { cursor: pointer; - & > img.editable, - & > div.placeholder.editable { - border: $hover-stroke; - box-shadow: $hover-shadow; - cursor: pointer; - transform: $scale-tall; - } + .Info { + & > img.editable, + & > div.placeholder.editable { + border: $hover-stroke; + box-shadow: $hover-shadow; + cursor: pointer; + transform: $scale-tall; + } - & p.placeholder { - color: var(--text-tertiary-hover); - } + & p.placeholder { + color: var(--text-tertiary-hover); + } - & svg { - fill: var(--icon-secondary-hover); + & svg { + fill: var(--icon-secondary-hover); + } } } - & > img, - & > div.placeholder { - background: var(--card-bg); - border-radius: calc($unit / 2); - border: 1px solid rgba(0, 0, 0, 0); - width: $unit * 5; - height: $unit * 5; - } - - & > div.placeholder { - display: flex; + .Info { align-items: center; - justify-content: center; + border-radius: $input-corner; + display: flex; + flex-grow: 1; + gap: $unit; - & > svg { - fill: var(--icon-secondary); - width: $unit * 2; - height: $unit * 2; + & > img, + & > div.placeholder { + background: var(--card-bg); + border-radius: calc($unit / 2); + border: 1px solid rgba(0, 0, 0, 0); + width: $unit-5x; + height: $unit-5x; } + + & > div.placeholder { + display: flex; + align-items: center; + justify-content: center; + + & > svg { + fill: var(--icon-secondary); + width: $unit-2x; + height: $unit-2x; + } + } + } + + & > .Button { + justify-content: center; + max-width: $unit-6x; + height: auto; } p { diff --git a/components/job/JobSkillItem/index.tsx b/components/job/JobSkillItem/index.tsx index bac8f729..a1ded0d3 100644 --- a/components/job/JobSkillItem/index.tsx +++ b/components/job/JobSkillItem/index.tsx @@ -1,21 +1,43 @@ -import React from 'react' +import React, { useState } from 'react' import { useRouter } from 'next/router' -import { useTranslation } from 'next-i18next' - +import { Trans, useTranslation } from 'next-i18next' import classNames from 'classnames' -import PlusIcon from '~public/icons/Add.svg' +import Alert from '~components/common/Alert' +import Button from '~components/common/Button' +import { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, +} from '~components/common/ContextMenu' +import ContextMenuItem from '~components/common/ContextMenuItem' + +import EllipsisIcon from '~public/icons/Ellipsis.svg' +import PlusIcon from '~public/icons/Add.svg' import './index.scss' // Props interface Props extends React.ComponentPropsWithoutRef<'div'> { skill?: JobSkill + position: number editable: boolean hasJob: boolean + removeJobSkill: (position: number) => void } const JobSkillItem = React.forwardRef( - function useJobSkillItem({ ...props }, forwardedRef) { + function useJobSkillItem( + { + skill, + position, + editable, + hasJob, + removeJobSkill: sendJobSkillToRemove, + ...props + }, + forwardedRef + ) { + // Set up translation const router = useRouter() const { t } = useTranslation('common') const locale = @@ -23,31 +45,55 @@ const JobSkillItem = React.forwardRef( ? router.locale : 'en' + // States: Component + const [alertOpen, setAlertOpen] = useState(false) + const [contextMenuOpen, setContextMenuOpen] = useState(false) + + // Classes const classes = classNames({ JobSkill: true, - editable: props.editable, + editable: editable, }) const imageClasses = classNames({ - placeholder: !props.skill, - editable: props.editable && props.hasJob, + placeholder: !skill, + editable: editable && hasJob, }) + const buttonClasses = classNames({ + Clicked: contextMenuOpen, + }) + + // Methods: Data mutation + function removeJobSkill() { + if (skill) sendJobSkillToRemove(position) + setAlertOpen(false) + } + + // Methods: Context menu + function handleButtonClicked() { + setContextMenuOpen(!contextMenuOpen) + } + + function handleContextMenuOpenChange(open: boolean) { + if (!open) setContextMenuOpen(false) + } + const skillImage = () => { let jsx: React.ReactNode - if (props.skill) { + if (skill) { jsx = ( {props.skill.name[locale]} ) } else { jsx = (
      - {props.editable && props.hasJob ? : ''} + {editable && hasJob ? : ''}
      ) } @@ -58,9 +104,9 @@ const JobSkillItem = React.forwardRef( const label = () => { let jsx: React.ReactNode - if (props.skill) { - jsx =

      {props.skill.name[locale]}

      - } else if (props.editable && props.hasJob) { + if (skill) { + jsx =

      {skill.name[locale]}

      + } else if (editable && hasJob) { jsx =

      {t('job_skills.state.selectable')}

      } else { jsx =

      {t('job_skills.state.no_skill')}

      @@ -69,10 +115,55 @@ const JobSkillItem = React.forwardRef( return jsx } + const removeAlert = () => { + return ( + setAlertOpen(false)} + cancelActionText={t('buttons.cancel')} + message={ + + Are you sure you want to remove{' '} + {{ job_skill: skill?.name[locale] }} from your + team? + + } + /> + ) + } + + const contextMenu = () => { + return ( + <> + + +
- ) - } - - const clearTimeString = () => { - const minutes = Math.floor(clearTime / 60) - const seconds = clearTime - minutes * 60 - - if (minutes > 0) - return `${minutes}${t('party.details.suffix.minutes')} ${seconds}${t( - 'party.details.suffix.seconds' - )}` - else return `${seconds}${t('party.details.suffix.seconds')}` - } - - const buttonChainToken = () => { - if (buttonCount || chainCount) { - let string = '' - - if (buttonCount && buttonCount > 0) { - string += `${buttonCount}b` - } - - if (!buttonCount && chainCount && chainCount > 0) { - string += `0${t('party.details.suffix.buttons')}${chainCount}${t( - 'party.details.suffix.chains' - )}` - } else if (buttonCount && chainCount && chainCount > 0) { - string += `${chainCount}${t('party.details.suffix.chains')}` - } else if (buttonCount && !chainCount) { - string += `0${t('party.details.suffix.chains')}` - } - - return {string} - } - } - const readOnly = () => { return (
-
- - {`${t('party.details.labels.charge_attack')} ${ - chargeAttack ? 'On' : 'Off' - }`} - - - - {`${t('party.details.labels.full_auto')} ${ - fullAuto ? 'On' : 'Off' - }`} - - - - {`${t('party.details.labels.auto_guard')} ${ - autoGuard ? 'On' : 'Off' - }`} - - - {turnCount ? ( - - {t('party.details.turns.with_count', { - count: turnCount, - })} - - ) : ( - '' - )} - {clearTime > 0 ? {clearTimeString()} : ''} - {buttonChainToken()} -
{embeddedDescription}
) @@ -764,58 +192,7 @@ const PartyDetails = (props: Props) => { return ( <> -
-
-
-
-

- {name ? name : t('no_title')} -

- {party.remix && party.sourceParty ? ( - -
-
- {renderUserBlock()} - {party.raid ? linkedRaidBlock(party.raid) : ''} - {party.created_at != '' ? ( - - ) : ( - '' - )} -
-
- {party.editable ? ( -
-
- ) : ( - '' - )} -
- {readOnly()} - {editable()} - - {deleteAlert()} -
+
{readOnly()}
{remixes && remixes.length > 0 ? remixSection() : ''} ) diff --git a/components/party/PartyDropdown/index.scss b/components/party/PartyDropdown/index.scss new file mode 100644 index 00000000..e69de29b diff --git a/components/party/PartyDropdown/index.tsx b/components/party/PartyDropdown/index.tsx new file mode 100644 index 00000000..189f867c --- /dev/null +++ b/components/party/PartyDropdown/index.tsx @@ -0,0 +1,197 @@ +// Libraries +import React, { useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import { subscribe, useSnapshot } from 'valtio' +import { Trans, useTranslation } from 'next-i18next' +import Link from 'next/link' +import classNames from 'classnames' + +// Dependencies: Common +import Button from '~components/common/Button' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, +} from '~components/common/DropdownMenuContent' + +// Dependencies: Toasts +import RemixedToast from '~components/toasts/RemixedToast' +import UrlCopiedToast from '~components/toasts/UrlCopiedToast' + +// Dependencies: Alerts +import DeleteTeamAlert from '~components/dialogs/DeleteTeamAlert' +import RemixTeamAlert from '~components/dialogs/RemixTeamAlert' + +// Dependencies: Utils +import api from '~utils/api' +import { accountState } from '~utils/accountState' +import { appState } from '~utils/appState' +import { getLocalId } from '~utils/localId' +import { retrieveLocaleCookies } from '~utils/retrieveCookies' +import { setEditKey, storeEditKey } from '~utils/userToken' + +// Dependencies: Icons +import EllipsisIcon from '~public/icons/Ellipsis.svg' + +// Dependencies: Props +interface Props { + editable: boolean + deleteTeamCallback: () => void + remixTeamCallback: () => void +} + +const PartyDropdown = ({ + editable, + deleteTeamCallback, + remixTeamCallback, +}: Props) => { + // Localization + const { t } = useTranslation('common') + + // Router + const router = useRouter() + const locale = + router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' + const localeData = retrieveLocaleCookies() + + const [open, setOpen] = useState(false) + + const [deleteAlertOpen, setDeleteAlertOpen] = useState(false) + const [remixAlertOpen, setRemixAlertOpen] = useState(false) + + const [copyToastOpen, setCopyToastOpen] = useState(false) + const [remixToastOpen, setRemixToastOpen] = useState(false) + + const [name, setName] = useState('') + const [originalName, setOriginalName] = useState('') + + // Snapshots + const { account } = useSnapshot(accountState) + const { party: partySnapshot } = useSnapshot(appState) + + // Subscribe to app state to listen for party name and + // unsubscribe when component is unmounted + const unsubscribe = subscribe(appState, () => { + const newName = + appState.party && appState.party.name ? appState.party.name : '' + setName(newName) + }) + + useEffect(() => () => unsubscribe(), []) + + // Methods: Event handlers (Buttons) + function handleButtonClicked() { + setOpen(!open) + } + + // Methods: Event handlers (Menus) + function handleOpenChange(open: boolean) { + setOpen(open) + } + + function closeMenu() { + setOpen(false) + } + + // Method: Actions + function copyToClipboard() { + if (router.asPath.split('/')[1] === 'p') { + navigator.clipboard.writeText(window.location.href) + setCopyToastOpen(true) + } + } + + // Methods: Event handlers + + // Alerts / Delete team + function openDeleteTeamAlert() { + setDeleteAlertOpen(true) + } + + function handleDeleteTeamAlertChange(open: boolean) { + setDeleteAlertOpen(open) + } + + // Alerts / Remix team + function openRemixTeamAlert() { + setRemixAlertOpen(true) + } + + function handleRemixTeamAlertChange(open: boolean) { + setRemixAlertOpen(open) + } + + // Toasts / Copy URL + function handleCopyToastOpenChanged(open: boolean) { + setCopyToastOpen(open) + } + + function handleCopyToastCloseClicked() { + setCopyToastOpen(false) + } + + // Toasts / Remix team + function handleRemixToastOpenChanged(open: boolean) { + setRemixToastOpen(open) + } + + function handleRemixToastCloseClicked() { + setRemixToastOpen(false) + } + + const editableItems = () => { + return ( + <> + + + Copy link to team + + + Remix team + + + + + Delete team + + + + ) + } + + return ( + <> + + + + + + + ) +} + +export default PartyDropdown diff --git a/components/party/PartyHeader/index.scss b/components/party/PartyHeader/index.scss new file mode 100644 index 00000000..e617331c --- /dev/null +++ b/components/party/PartyHeader/index.scss @@ -0,0 +1,240 @@ +.DetailsWrapper { + display: flex; + flex-direction: column; + gap: $unit-2x; + margin: $unit-4x auto 0 auto; + max-width: $grid-width; + + @include breakpoint(phone) { + .Button:not(.IconButton) { + justify-content: center; + width: 100%; + + .Text { + width: auto; + } + } + } + + .PartyDetails { + box-sizing: border-box; + display: block; + line-height: 1.4; + white-space: pre-wrap; + margin: 0 auto $unit-2x; + max-width: $unit * 94; + overflow: hidden; + width: 100%; + + @include breakpoint(phone) { + padding: 0 $unit; + } + + a:hover { + text-decoration: underline; + } + + p { + font-size: $font-regular; + line-height: $font-regular * 1.2; + white-space: pre-line; + } + + .Tokens { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: $unit; + margin-bottom: $unit-2x; + } + + .YoutubeWrapper { + background-color: var(--card-bg); + border-radius: $card-corner; + margin: $unit 0; + position: relative; + display: block; + contain: content; + background-position: center center; + background-size: cover; + cursor: pointer; + width: 60%; + height: 60%; + + @include breakpoint(tablet) { + width: 100%; + height: 100%; + } + + /* gradient */ + &::before { + content: ''; + display: block; + position: absolute; + top: 0; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADGCAYAAAAT+OqFAAAAdklEQVQoz42QQQ7AIAgEF/T/D+kbq/RWAlnQyyazA4aoAB4FsBSA/bFjuF1EOL7VbrIrBuusmrt4ZZORfb6ehbWdnRHEIiITaEUKa5EJqUakRSaEYBJSCY2dEstQY7AuxahwXFrvZmWl2rh4JZ07z9dLtesfNj5q0FU3A5ObbwAAAABJRU5ErkJggg==); + background-position: top; + background-repeat: repeat-x; + height: 60px; + padding-bottom: 50px; + width: 100%; + transition: all 0.2s cubic-bezier(0, 0, 0.2, 1); + } + + /* responsive iframe with a 16:9 aspect ratio + thanks https://css-tricks.com/responsive-iframes/ + */ + &::after { + content: ''; + display: block; + padding-bottom: calc(100% / (16 / 9)); + } + + &:hover > .PlayerButton { + opacity: 1; + } + + & > iframe { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + } + + /* Play button */ + & > .PlayerButton { + background: none; + border: none; + background-image: url('/icons/youtube.svg'); + width: 68px; + height: 68px; + opacity: 0.8; + transition: all 0.2s cubic-bezier(0, 0, 0.2, 1); + } + + & > .PlayerButton, + & > .PlayerButton:before { + position: absolute; + top: 50%; + left: 50%; + transform: translate3d(-50%, -50%, 0); + } + + /* Post-click styles */ + &.lyt-activated { + cursor: unset; + } + &.lyt-activated::before, + &.lyt-activated > .PlayerButton { + opacity: 0; + pointer-events: none; + } + } + } + + .PartyInfo { + box-sizing: border-box; + display: flex; + flex-direction: row; + gap: $unit; + margin: 0 auto; + max-width: $unit * 94; + width: 100%; + + @include breakpoint(phone) { + flex-direction: column; + gap: $unit; + padding: 0 $unit; + } + + & > .Right { + display: flex; + gap: $unit-half; + } + + & > .Left { + flex-grow: 1; + + .Header { + align-items: center; + display: flex; + gap: $unit; + margin-bottom: $unit; + + h1 { + font-size: $font-xlarge; + font-weight: $normal; + text-align: left; + color: var(--text-primary); + + &.empty { + color: var(--text-secondary); + } + } + } + + .attribution { + align-items: center; + display: flex; + flex-direction: row; + + & > div { + align-items: center; + display: inline-flex; + font-size: $font-small; + height: 26px; + } + + time { + font-size: $font-small; + } + + a:visited:not(.fire):not(.water):not(.wind):not(.earth):not(.dark):not( + .light + ) { + color: var(--text-primary); + } + + a:hover:not(.fire):not(.water):not(.wind):not(.earth):not(.dark):not( + .light + ) { + color: $blue; + } + + & > *:not(:last-child):after { + content: ' · '; + margin: 0 calc($unit / 2); + } + } + } + + .user { + align-items: center; + display: inline-flex; + gap: calc($unit / 2); + margin-top: 1px; + + img, + .no-user { + $diameter: 24px; + + border-radius: calc($diameter / 2); + height: $diameter; + width: $diameter; + } + + img.gran { + background-color: #cee7fe; + } + + img.djeeta { + background-color: #ffe1fe; + } + + .no-user { + background: $grey-80; + } + } + } +} diff --git a/components/party/PartyHeader/index.tsx b/components/party/PartyHeader/index.tsx new file mode 100644 index 00000000..2e192d51 --- /dev/null +++ b/components/party/PartyHeader/index.tsx @@ -0,0 +1,405 @@ +import React, { useEffect, useState } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useSnapshot } from 'valtio' +import { useTranslation } from 'next-i18next' +import classNames from 'classnames' + +import Button from '~components/common/Button' +import Tooltip from '~components/common/Tooltip' +import Token from '~components/common/Token' + +import EditPartyModal from '~components/party/EditPartyModal' +import PartyDropdown from '~components/party/PartyDropdown' + +import { accountState } from '~utils/accountState' +import { appState, initialAppState } from '~utils/appState' +import { formatTimeAgo } from '~utils/timeAgo' + +import EditIcon from '~public/icons/Edit.svg' +import RemixIcon from '~public/icons/Remix.svg' +import SaveIcon from '~public/icons/Save.svg' + +import type { DetailsObject } from 'types' + +import './index.scss' +import api from '~utils/api' + +// Props +interface Props { + party?: Party + new: boolean + editable: boolean + deleteCallback: () => void + remixCallback: () => void + updateCallback: (details: DetailsObject) => void +} + +const PartyHeader = (props: Props) => { + const { party } = useSnapshot(appState) + + const { t } = useTranslation('common') + const router = useRouter() + const locale = router.locale || 'en' + + const { party: partySnapshot } = useSnapshot(appState) + + const [name, setName] = useState('') + + const [chargeAttack, setChargeAttack] = useState(true) + const [fullAuto, setFullAuto] = useState(false) + const [autoGuard, setAutoGuard] = useState(false) + + const [buttonCount, setButtonCount] = useState(undefined) + const [chainCount, setChainCount] = useState(undefined) + const [turnCount, setTurnCount] = useState(undefined) + const [clearTime, setClearTime] = useState(0) + + const classes = classNames({ + PartyDetails: true, + }) + + const userClass = classNames({ + user: true, + empty: !party.user, + }) + + const linkClass = classNames({ + wind: party && party.element == 1, + fire: party && party.element == 2, + water: party && party.element == 3, + earth: party && party.element == 4, + dark: party && party.element == 5, + light: party && party.element == 6, + }) + + useEffect(() => { + if (props.party) { + setName(props.party.name) + setAutoGuard(props.party.auto_guard) + setFullAuto(props.party.full_auto) + setChargeAttack(props.party.charge_attack) + setClearTime(props.party.clear_time) + if (props.party.turn_count) setTurnCount(props.party.turn_count) + if (props.party.button_count) setButtonCount(props.party.button_count) + if (props.party.chain_count) setChainCount(props.party.chain_count) + } + }, [props.party]) + + // Subscribe to router changes and reset state + // if the new route is a new team + useEffect(() => { + router.events.on('routeChangeStart', (url, { shallow }) => { + if (url === '/new' || url === '/') { + const party = initialAppState.party + + setName(party.name ? party.name : '') + setAutoGuard(party.autoGuard) + setFullAuto(party.fullAuto) + setChargeAttack(party.chargeAttack) + setClearTime(party.clearTime) + setTurnCount(party.turnCount) + setButtonCount(party.buttonCount) + setChainCount(party.chainCount) + } + }) + }, []) + + // Actions: Favorites + function toggleFavorite() { + if (appState.party.favorited) unsaveFavorite() + else saveFavorite() + } + + function saveFavorite() { + if (appState.party.id) + api.saveTeam({ id: appState.party.id }).then((response) => { + if (response.status == 201) appState.party.favorited = true + }) + else console.error('Failed to save team: No party ID') + } + + function unsaveFavorite() { + if (appState.party.id) + api.unsaveTeam({ id: appState.party.id }).then((response) => { + if (response.status == 200) appState.party.favorited = false + }) + else console.error('Failed to unsave team: No party ID') + } + + // Methods: Navigation + function goTo(shortcode?: string) { + if (shortcode) router.push(`/p/${shortcode}`) + } + + const userImage = (picture?: string, element?: string) => { + if (picture && element) + return ( + {picture} + ) + else + return ( + {t('no_user')} + ) + } + + const userBlock = (username?: string, picture?: string, element?: string) => { + return ( +
+ {userImage(picture, element)} + {username ? username : t('no_user')} +
+ ) + } + + const renderUserBlock = () => { + let username, picture, element + if (accountState.account.authorized && props.new) { + username = accountState.account.user?.username + picture = accountState.account.user?.avatar.picture + element = accountState.account.user?.avatar.element + } else if (party.user && !props.new) { + username = party.user.username + picture = party.user.avatar.picture + element = party.user.avatar.element + } + + if (username && picture && element) { + return linkedUserBlock(username, picture, element) + } else if (!props.new) { + return userBlock() + } + } + + const linkedUserBlock = ( + username?: string, + picture?: string, + element?: string + ) => { + return ( + + ) + } + + const linkedRaidBlock = (raid: Raid) => { + return ( + + ) + } + + // Render: Tokens + const chargeAttackToken = ( + + {`${t('party.details.labels.charge_attack')} ${ + chargeAttack ? 'On' : 'Off' + }`} + + ) + + const fullAutoToken = ( + + {`${t('party.details.labels.full_auto')} ${fullAuto ? 'On' : 'Off'}`} + + ) + + const autoGuardToken = ( + + {`${t('party.details.labels.auto_guard')} ${autoGuard ? 'On' : 'Off'}`} + + ) + + const turnCountToken = ( + + {t('party.details.turns.with_count', { + count: turnCount, + })} + + ) + + const buttonChainToken = () => { + if (buttonCount || chainCount) { + let string = '' + + if (buttonCount && buttonCount > 0) { + string += `${buttonCount}b` + } + + if (!buttonCount && chainCount && chainCount > 0) { + string += `0${t('party.details.suffix.buttons')}${chainCount}${t( + 'party.details.suffix.chains' + )}` + } else if (buttonCount && chainCount && chainCount > 0) { + string += `${chainCount}${t('party.details.suffix.chains')}` + } else if (buttonCount && !chainCount) { + string += `0${t('party.details.suffix.chains')}` + } + + return {string} + } + } + + const clearTimeToken = () => { + const minutes = Math.floor(clearTime / 60) + const seconds = clearTime - minutes * 60 + + let string = '' + if (minutes > 0) + string = `${minutes}${t('party.details.suffix.minutes')} ${seconds}${t( + 'party.details.suffix.seconds' + )}` + else string = `${seconds}${t('party.details.suffix.seconds')}` + + return {string} + } + + function renderTokens() { + return ( +
+ {chargeAttackToken} + {fullAutoToken} + {autoGuardToken} + {turnCount ? turnCountToken : ''} + {clearTime > 0 ? clearTimeToken() : ''} + {buttonChainToken()} +
+ ) + } + + // Render: Buttons + const saveButton = () => { + return ( + +
+ + ) +} + +export default PartyHeader diff --git a/components/party/PartySegmentedControl/index.scss b/components/party/PartySegmentedControl/index.scss index a6fd6c50..c58ebcc8 100644 --- a/components/party/PartySegmentedControl/index.scss +++ b/components/party/PartySegmentedControl/index.scss @@ -22,7 +22,12 @@ width: 100%; } + @include breakpoint(phone) { + padding: 0; + } + .SegmentedControl { + gap: $unit; flex-grow: 1; // prettier-ignore @@ -31,6 +36,7 @@ and (max-height: 920px) and (-webkit-min-device-pixel-ratio: 2) { flex-grow: 1; + gap: 0; width: 100%; display: grid; grid-template-columns: auto auto auto; diff --git a/components/party/PartySegmentedControl/index.tsx b/components/party/PartySegmentedControl/index.tsx index 1c807dfb..e45fb56e 100644 --- a/components/party/PartySegmentedControl/index.tsx +++ b/components/party/PartySegmentedControl/index.tsx @@ -1,22 +1,29 @@ import React from 'react' import { useSnapshot } from 'valtio' import { useTranslation } from 'next-i18next' +import classNames from 'classnames' import { appState } from '~utils/appState' +import { accountState } from '~utils/accountState' import SegmentedControl from '~components/common/SegmentedControl' -import Segment from '~components/common/Segment' -import ToggleSwitch from '~components/common/ToggleSwitch' +import RepSegment from '~components/reps/RepSegment' +import CharacterRep from '~components/reps/CharacterRep' +import WeaponRep from '~components/reps/WeaponRep' +import SummonRep from '~components/reps/SummonRep' import { GridType } from '~utils/enums' import './index.scss' -import classNames from 'classnames' + +// Fix for valtio readonly array +declare module 'valtio' { + function useSnapshot(p: T): T +} interface Props { selectedTab: GridType onClick: (event: React.ChangeEvent) => void - onCheckboxChange: (event: React.ChangeEvent) => void } const PartySegmentedControl = (props: Props) => { @@ -25,7 +32,7 @@ const PartySegmentedControl = (props: Props) => { const { party, grid } = useSnapshot(appState) - function getElement() { + const getElement = () => { let element: number = 0 if (party.element == 0 && grid.weapons.mainWeapon) element = grid.weapons.mainWeapon.element @@ -47,17 +54,56 @@ const PartySegmentedControl = (props: Props) => { } } - const extraToggle = ( -
- Extra - -
- ) + const characterSegment = () => { + return ( + + + + ) + } + + const weaponSegment = () => { + { + return ( + + + + ) + } + } + + const summonSegment = () => { + return ( + + + + ) + } return (
{ })} > - - {t('party.segmented_control.characters')} - - - - {t('party.segmented_control.weapons')} - - - - {t('party.segmented_control.summons')} - + {characterSegment()} + {weaponSegment()} + {summonSegment()} - - {(() => { - if (party.editable && props.selectedTab == GridType.Weapon) { - return extraToggle - } - })()}
) } diff --git a/components/raids/RaidCombobox/index.scss b/components/raids/RaidCombobox/index.scss new file mode 100644 index 00000000..f3c689f3 --- /dev/null +++ b/components/raids/RaidCombobox/index.scss @@ -0,0 +1,199 @@ +.Combobox.Raid { + box-sizing: border-box; + + .Header { + background: var(--dialog-bg); + border-top-left-radius: $card-corner; + border-top-right-radius: $card-corner; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: $unit; + padding: $unit; + width: 100%; + + .Clear.Button { + background: none; + padding: ($unit * 0.75) $unit-half $unit-half; + display: none; + + &:hover svg { + fill: var(--text-primary); + } + + &.Visible { + display: block; + } + + svg { + fill: var(--text-tertiary); + width: $unit-2x; + height: $unit-2x; + } + } + + .Controls { + display: flex; + gap: $unit; + + .Button.Blended.small { + padding: $unit ($unit * 1.25); + + &:hover { + background: var(--button-contained-bg); + } + + @include breakpoint(phone) { + width: auto; + } + } + + .Flipped { + transform: rotate(180deg); + } + + .SegmentedControlWrapper { + flex-grow: 1; + + .SegmentedControl { + width: 100%; + } + } + } + } + + .Raids { + border-bottom-left-radius: $card-corner; + border-bottom-right-radius: $card-corner; + height: 36vh; + overflow-y: scroll; + padding: 0 $unit; + + @include breakpoint(phone) { + height: 28vh; + } + + &.Searching { + .CommandGroup { + padding-top: 0; + padding-bottom: 0; + + .Label { + display: none; + } + + .SelectItem { + margin: 0; + } + } + + .CommandGroup.Hidden { + display: block; + } + } + + .CommandGroup { + &.Hidden { + display: none; + } + + .Label { + align-items: center; + color: var(--text-tertiary); + display: flex; + flex-direction: row; + flex-shrink: 0; + font-size: $font-small; + font-weight: $medium; + gap: $unit; + padding: $unit $unit-2x $unit-half ($unit * 1.5); + + .Separator { + background: var(--select-separator); + border-radius: 1px; + display: block; + flex-grow: 1; + height: 2px; + } + } + } + } +} + +.DetailsWrapper .PartyDetails.Editable .Raid.SelectTrigger, +.EditTeam .Raid.SelectTrigger { + background: var(--input-bound-bg); + display: flex; + padding-top: 10px; + padding-bottom: 11px; + min-height: 51px; + + .Value { + display: flex; + gap: $unit-half; + width: 100%; + + .Info { + display: flex; + align-items: center; + gap: $unit-half; + flex-grow: 1; + } + + .ExtraIndicator { + background: var(--extra-purple-secondary); + border-radius: $full-corner; + color: $grey-100; + display: flex; + font-weight: $bold; + font-size: $font-tiny; + width: $unit-3x; + height: $unit-3x; + justify-content: center; + align-items: center; + } + + .Group, + .Separator { + color: var(--text-tertiary); + } + + .Raid.wind { + color: var(--wind-text); + } + + .Raid.fire { + color: var(--fire-text); + } + + .Raid.water { + color: var(--water-text); + } + + .Raid.earth { + color: var(--earth-text); + } + + .Raid.dark { + color: var(--dark-text); + } + + .Raid.light { + color: var(--light-text); + } + } +} + +.Filters .SelectTrigger.Raid { + & > span { + overflow: hidden; + } + + .Raid { + display: block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + } +} diff --git a/components/raids/RaidCombobox/index.tsx b/components/raids/RaidCombobox/index.tsx new file mode 100644 index 00000000..5cbe7c0d --- /dev/null +++ b/components/raids/RaidCombobox/index.tsx @@ -0,0 +1,569 @@ +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) => 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.DESCENDING) + const [scrolled, setScrolled] = useState(false) + + // Data state + const [currentSection, setCurrentSection] = useState(1) + const [query, setQuery] = useState('') + const [sections, setSections] = useState() + const [currentRaid, setCurrentRaid] = useState() + const [tabIndex, setTabIndex] = useState(NUM_ELEMENTS + 1) + + // Data + const [farmingRaid, setFarmingRaid] = useState() + + // Refs + const listRef = createRef() + const inputRef = createRef() + const sortButtonRef = createRef() + + // ---------------------------------------------- + // 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) + setCurrentSection(appState.party.raid.group.section) + } else if (props.showAllRaidsOption && !currentRaid) { + setCurrentRaid(allRaidsOption) + } + }, []) + + // Set current raid and section when the current raid changes + useEffect(() => { + if (props.currentRaid) { + setCurrentRaid(props.currentRaid) + setCurrentSection(props.currentRaid.group.section) + } + }, [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 + ) => { + // 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) => { + 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') { + 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 = ( +
+ {group.name[locale]} +
+
+ ) + + return ( + + {options} + + ) + } + + // Render the ungrouped raid group + function renderUngroupedRaids() { + let ungroupedRaids = farmingRaid ? [farmingRaid] : [] + + if (props.showAllRaidsOption) { + ungroupedRaids.push(allRaidsOption) + } + + const options = generateRaidItems(ungroupedRaids) + + return ( + + {options} + + ) + } + + // 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 ( + handleValueChange(raid)} + > + {raid.name[locale]} + + ) + } + + // Renders a SegmentedControl component for selecting raid sections. + function renderSegmentedControl() { + return ( + + setCurrentSection(2)} + > + {t('raids.sections.events')} + + setCurrentSection(1)} + > + {t('raids.sections.raids')} + + setCurrentSection(3)} + > + {t('raids.sections.solo')} + + + ) + } + + // Renders a Button for sorting raids and a Tooltip for explaining what it does. + function renderSortButton() { + return ( + +
- + diff --git a/components/search/SearchModal/index.scss b/components/search/SearchModal/index.scss index 3d3638f7..37c517f3 100644 --- a/components/search/SearchModal/index.scss +++ b/components/search/SearchModal/index.scss @@ -61,6 +61,11 @@ #Results { margin: 0; padding: 0 ($unit * 1.5); + padding-bottom: $unit * 1.5; + + // Infinite scroll + overflow-y: auto; + max-height: 500px; @include breakpoint(phone) { max-height: inherit; diff --git a/components/search/SearchModal/index.tsx b/components/search/SearchModal/index.tsx index a35184c2..09cca333 100644 --- a/components/search/SearchModal/index.tsx +++ b/components/search/SearchModal/index.tsx @@ -19,6 +19,7 @@ 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' @@ -31,7 +32,7 @@ interface Props extends DialogProps { placeholderText: string fromPosition: number job?: Job - object: 'weapons' | 'characters' | 'summons' | 'job_skills' + object: 'weapons' | 'characters' | 'summons' | 'job_skills' | 'guidebooks' } const SearchModal = (props: Props) => { @@ -184,7 +185,7 @@ const SearchModal = (props: Props) => { } else if (open && currentPage == 1) { fetchResults({ replace: true }) } - }, [currentPage]) + }, [open, currentPage]) useEffect(() => { // Filters changed @@ -219,6 +220,17 @@ const SearchModal = (props: Props) => { } }, [query]) + useEffect(() => { + if (open && props.object === 'guidebooks') { + setCurrentPage(1) + fetchResults({ replace: true }) + } + }, [query, open]) + + function incrementPage() { + setCurrentPage(currentPage + 1) + } + function renderResults() { let jsx @@ -235,12 +247,15 @@ const SearchModal = (props: Props) => { case 'job_skills': jsx = renderJobSkillSearchResults(results) break + case 'guidebooks': + jsx = renderGuidebookSearchResults(results) + break } return ( 0 ? results.length : 0} - next={() => setCurrentPage(currentPage + 1)} + next={incrementPage} hasMore={totalPages > currentPage} scrollableTarget="Results" loader={
Loading...
} @@ -334,6 +349,27 @@ const SearchModal = (props: Props) => { 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 ( + { + storeRecentResult(result) + }} + /> + ) + }) + } + + return jsx + } + function openChange() { if (open) { setQuery('') @@ -365,6 +401,7 @@ const SearchModal = (props: Props) => { diff --git a/components/summon/ExtraSummons/index.scss b/components/summon/ExtraSummons/index.scss index 1d9bb8de..72e27b10 100644 --- a/components/summon/ExtraSummons/index.scss +++ b/components/summon/ExtraSummons/index.scss @@ -70,4 +70,8 @@ .SummonUnit .SummonImage .icon svg { fill: var(--subaura-orange-secondary); } + + .SummonUnit .QuickSummon { + display: none; + } } diff --git a/components/summon/SummonGrid/index.scss b/components/summon/SummonGrid/index.scss index 830cfee2..5f575ff9 100644 --- a/components/summon/SummonGrid/index.scss +++ b/components/summon/SummonGrid/index.scss @@ -1,6 +1,6 @@ #SummonGrid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)) 2fr; + grid-template-columns: 1.17fr 2fr 1.17fr; gap: $unit-3x; justify-content: center; margin: 0 auto; diff --git a/components/summon/SummonGrid/index.tsx b/components/summon/SummonGrid/index.tsx index 75f2b089..3a72028b 100644 --- a/components/summon/SummonGrid/index.tsx +++ b/components/summon/SummonGrid/index.tsx @@ -452,8 +452,8 @@ const SummonGrid = (props: Props) => {
{mainSummonElement} - {friendSummonElement} {summonGridElement} + {friendSummonElement}
{subAuraSummonElement} diff --git a/components/summon/SummonUnit/index.scss b/components/summon/SummonUnit/index.scss index 2124ed0b..ac8be29e 100644 --- a/components/summon/SummonUnit/index.scss +++ b/components/summon/SummonUnit/index.scss @@ -112,4 +112,51 @@ opacity: 0; } } + + &:hover .QuickSummon.Empty { + opacity: 1; + } + + &.main .QuickSummon { + $diameter: $unit-6x; + background-size: $diameter $diameter; + top: -2%; + right: 28%; + width: $diameter; + height: $diameter; + } + + &.friend .QuickSummon { + display: none; + } + + &.grid .QuickSummon { + $diameter: $unit-5x; + background-size: $diameter $diameter; + top: -5%; + right: 22%; + width: $diameter; + height: $diameter; + } + + .QuickSummon { + position: absolute; + background-image: url('/icons/quick_summon/filled.svg'); + z-index: 20; + transition: $duration-zoom opacity ease-in-out; + + &:hover { + background-image: url('/icons/quick_summon/empty.svg'); + cursor: pointer; + } + + &.Empty { + background-image: url('/icons/quick_summon/empty.svg'); + opacity: 0; + + &:hover { + background-image: url('/icons/quick_summon/filled.svg'); + } + } + } } diff --git a/components/summon/SummonUnit/index.tsx b/components/summon/SummonUnit/index.tsx index 2359cce3..35bbe18d 100644 --- a/components/summon/SummonUnit/index.tsx +++ b/components/summon/SummonUnit/index.tsx @@ -1,8 +1,12 @@ import React, { MouseEvent, useEffect, useState } from 'react' import { useRouter } from 'next/router' import { Trans, useTranslation } from 'next-i18next' +import { AxiosResponse } from 'axios' import classNames from 'classnames' +import api from '~utils/api' +import { appState } from '~utils/appState' + import Alert from '~components/common/Alert' import Button from '~components/common/Button' import { @@ -93,6 +97,10 @@ const SummonUnit = ({ setContextMenuOpen(!contextMenuOpen) } + function handleQuickSummonClick() { + if (gridSummon) updateQuickSummon(!gridSummon.quick_summon) + } + // Methods: Handle open change function handleContextMenuOpenChange(open: boolean) { if (!open) setContextMenuOpen(false) @@ -103,6 +111,38 @@ const SummonUnit = ({ } // Methods: Mutate data + + // Send the GridSummonObject to the server + async function updateQuickSummon(value: boolean) { + if (gridSummon) + return await api + .updateQuickSummon({ id: gridSummon.id, value: value }) + .then((response) => processResult(response)) + .catch((error) => processError(error)) + } + + // Save the server's response to state + function processResult(response: AxiosResponse) { + // TODO: We will have to update multiple grid summons at once + // because there can only be one at once. + // If a user sets a quick summon while one is already set, + // the previous one will be unset. + const gridSummons: GridSummon[] = response.data.summons + for (const gridSummon of gridSummons) { + if (gridSummon.main) { + appState.grid.summons.mainSummon = gridSummon + } else if (gridSummon.friend) { + appState.grid.summons.friendSummon = gridSummon + } else { + appState.grid.summons.allSummons[gridSummon.position] = gridSummon + } + } + } + + function processError(error: any) { + console.error(error) + } + function passUncapData(uncap: number) { if (gridSummon) updateUncap(gridSummon.id, position, uncap) } @@ -230,6 +270,17 @@ const SummonUnit = ({ } // Methods: Core element rendering + const quickSummon = () => { + if (gridSummon) { + const classes = classNames({ + QuickSummon: true, + Empty: !gridSummon.quick_summon, + }) + + return + } + } + const image = () => { let image = (
{contextMenu()} + {quickSummon()} {image()} {gridSummon ? ( void + onOpenChange: (open: boolean) => void + onCloseClick: () => void +} + +const RemixedToast = ({ + partyName, + open, + onOpenChange, + onCloseClick, +}: Props) => { + const { t } = useTranslation('common') + + // Methods: Event handlers + function handleOpenChange() { + onOpenChange(open) + } + + function handleCloseClick() { + onCloseClick() + } + + return ( + + You remixed {{ title: partyName }} + + } + onOpenChange={handleOpenChange} + onCloseClick={handleCloseClick} + /> + ) +} + +export default RemixedToast diff --git a/components/about/UpdateToast/index.scss b/components/toasts/UpdateToast/index.scss similarity index 100% rename from components/about/UpdateToast/index.scss rename to components/toasts/UpdateToast/index.scss diff --git a/components/about/UpdateToast/index.tsx b/components/toasts/UpdateToast/index.tsx similarity index 100% rename from components/about/UpdateToast/index.tsx rename to components/toasts/UpdateToast/index.tsx diff --git a/components/toasts/UrlCopiedToast/index.tsx b/components/toasts/UrlCopiedToast/index.tsx new file mode 100644 index 00000000..60ffea89 --- /dev/null +++ b/components/toasts/UrlCopiedToast/index.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import Toast from '~components/common/Toast' + +import './index.scss' +import { useTranslation } from 'next-i18next' + +interface Props { + open: boolean + onActionClick?: () => void + onOpenChange: (open: boolean) => void + onCloseClick: () => void +} + +const UrlCopiedToast = ({ open, onOpenChange, onCloseClick }: Props) => { + const { t } = useTranslation('common') + + // Methods: Event handlers + function handleOpenChange() { + onOpenChange(open) + } + + function handleCloseClick() { + onCloseClick() + } + + return ( + + ) +} + +export default UrlCopiedToast diff --git a/components/weapon/ExtraWeapons/index.scss b/components/weapon/ExtraWeapons/index.scss deleted file mode 100644 index d4cc6d5b..00000000 --- a/components/weapon/ExtraWeapons/index.scss +++ /dev/null @@ -1,59 +0,0 @@ -.ExtraGrid.Weapons { - background: var(--extra-purple-bg); - border-radius: $card-corner; - box-sizing: border-box; - display: grid; - grid-template-columns: 1.42fr 3fr; - justify-content: center; - margin: 20px auto; - max-width: calc($grid-width + 20px); - padding: $unit-2x $unit-2x $unit-2x 0; - position: relative; - left: $unit; - - @include breakpoint(tablet) { - left: auto; - max-width: auto; - width: 100%; - } - - @include breakpoint(phone) { - display: flex; - gap: $unit-2x; - padding: $unit-2x; - flex-direction: column; - } - - & > span { - color: var(--extra-purple-text); - display: flex; - align-items: center; - flex-grow: 1; - justify-content: center; - line-height: 1.2; - font-weight: 500; - text-align: center; - } - - #ExtraWeapons { - display: grid; - gap: $unit-3x; - grid-template-columns: repeat(3, minmax(0, 1fr)); - - @include breakpoint(tablet) { - gap: $unit-2x; - } - - @include breakpoint(phone) { - gap: $unit; - } - } - - .WeaponUnit .WeaponImage { - background: var(--extra-purple-card-bg); - } - - .WeaponUnit .WeaponImage .icon svg { - fill: var(--extra-purple-secondary); - } -} diff --git a/components/weapon/ExtraWeapons/index.tsx b/components/weapon/ExtraWeapons/index.tsx deleted file mode 100644 index 6c1ec552..00000000 --- a/components/weapon/ExtraWeapons/index.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react' -import { useTranslation } from 'next-i18next' -import WeaponUnit from '~components/weapon/WeaponUnit' - -import type { SearchableObject } from '~types' - -import './index.scss' - -// Props -interface Props { - grid: GridArray - editable: boolean - found?: boolean - offset: number - removeWeapon: (id: string) => void - updateObject: (object: SearchableObject, position: number) => void - updateUncap: (id: string, position: number, uncap: number) => void -} - -const ExtraWeapons = (props: Props) => { - const numWeapons: number = 3 - const { t } = useTranslation('common') - - return ( -
- {t('extra_weapons')} -
    - {Array.from(Array(numWeapons)).map((x, i) => { - return ( -
  • - -
  • - ) - })} -
-
- ) -} - -export default ExtraWeapons diff --git a/components/weapon/WeaponGrid/index.scss b/components/weapon/WeaponGrid/index.scss index 7d04123d..92324d01 100644 --- a/components/weapon/WeaponGrid/index.scss +++ b/components/weapon/WeaponGrid/index.scss @@ -13,10 +13,7 @@ gap: $unit-3x; grid-template-columns: 1.278fr 3fr; justify-items: center; - grid-template-areas: - 'mainhand grid' - 'mainhand grid' - 'mainhand grid'; + grid-template-areas: 'mainhand grid'; max-width: $grid-width; @include breakpoint(tablet) { @@ -49,7 +46,7 @@ } } - li { - list-style: none; + li:not(.Empty) { + // aspect-ratio: 1 / 1.035; } } diff --git a/components/weapon/WeaponGrid/index.tsx b/components/weapon/WeaponGrid/index.tsx index 36ba8843..b27cb658 100644 --- a/components/weapon/WeaponGrid/index.tsx +++ b/components/weapon/WeaponGrid/index.tsx @@ -6,10 +6,13 @@ import { useTranslation } from 'next-i18next' import { AxiosError, AxiosResponse } from 'axios' import debounce from 'lodash.debounce' +import classNames from 'classnames' import Alert from '~components/common/Alert' import WeaponUnit from '~components/weapon/WeaponUnit' -import ExtraWeapons from '~components/weapon/ExtraWeapons' +import ExtraWeaponsGrid from '~components/extra/ExtraWeaponsGrid' +import ExtraContainer from '~components/extra/ExtraContainer' +import GuidebooksGrid from '~components/extra/GuidebooksGrid' import WeaponConflictModal from '~components/weapon/WeaponConflictModal' import api from '~utils/api' @@ -24,8 +27,11 @@ interface Props { new: boolean editable: boolean weapons?: GridWeapon[] + guidebooks?: GuidebookList createParty: (details: DetailsObject) => Promise pushHistory?: (path: string) => void + updateExtra: (enabled: boolean) => void + updateGuidebook: (book: Guidebook | undefined, position: number) => void } const WeaponGrid = (props: Props) => { @@ -115,6 +121,13 @@ const WeaponGrid = (props: Props) => { } } + function receiveGuidebookFromSearch( + object: SearchableObject, + position: number + ) { + props.updateGuidebook(object as Guidebook, position) + } + async function handleWeaponResponse(data: any) { if (data.hasOwnProperty('conflicts')) { if (data.incoming) setIncoming(data.incoming) @@ -236,6 +249,10 @@ const WeaponGrid = (props: Props) => { } } + async function removeGuidebook(position: number) { + props.updateGuidebook(undefined, position) + } + // Methods: Updating uncap level // Note: Saves, but debouncing is not working properly async function saveUncap(id: string, position: number, uncapLevel: number) { @@ -318,6 +335,12 @@ const WeaponGrid = (props: Props) => { setPreviousUncapValues(newPreviousValues) } + // Methods: Convenience + const displayExtraContainer = + props.editable || + appState.party.extra || + Object.values(appState.party.guidebooks).every((el) => el === undefined) + // Render: JSX components const mainhandElement = ( { ) const weaponGridElement = Array.from(Array(numWeapons)).map((x, i) => { + const itemClasses = classNames({ + Empty: appState.grid.weapons.allWeapons[i] === undefined, + }) + return ( -
  • +
  • { ) }) - const extraGridElement = ( - - ) + const extraElement = () => { + if (appState.party.raid && appState.party.raid.group.extra) { + return ( + + {appState.party.raid && appState.party.raid.group.extra && ( + + )} + {appState.party.raid && appState.party.raid.group.guidebooks && ( + + )} + + ) + } + } const conflictModal = () => { return incoming && conflicts ? ( @@ -409,9 +452,7 @@ const WeaponGrid = (props: Props) => {
      {weaponGridElement}
  • - {(() => { - return party.extra ? extraGridElement : '' - })()} + {displayExtraContainer ? extraElement() : ''}
    ) } diff --git a/components/weapon/WeaponHovercard/index.tsx b/components/weapon/WeaponHovercard/index.tsx index c0e3f48b..1e9132c1 100644 --- a/components/weapon/WeaponHovercard/index.tsx +++ b/components/weapon/WeaponHovercard/index.tsx @@ -12,7 +12,6 @@ import WeaponLabelIcon from '~components/weapon/WeaponLabelIcon' import UncapIndicator from '~components/uncap/UncapIndicator' import ax from '~data/ax' -import { weaponAwakening } from '~data/awakening' import './index.scss' @@ -146,11 +145,8 @@ const WeaponHovercard = (props: Props) => { const awakeningSection = () => { const gridAwakening = props.gridWeapon.awakening - const awakening = weaponAwakening.find( - (awakening) => awakening.id === gridAwakening?.type - ) - if (gridAwakening && awakening) { + if (gridAwakening) { return (
    @@ -158,11 +154,11 @@ const WeaponHovercard = (props: Props) => {
    {awakening.name[locale]} - {`${awakening.name[locale]}`}  + {`${gridAwakening.type.name[locale]}`}  {`Lv${gridAwakening.level}`}
    diff --git a/components/weapon/WeaponModal/index.tsx b/components/weapon/WeaponModal/index.tsx index 157529be..477b78e9 100644 --- a/components/weapon/WeaponModal/index.tsx +++ b/components/weapon/WeaponModal/index.tsx @@ -11,14 +11,15 @@ import { DialogTrigger, } from '~components/common/Dialog' import DialogContent from '~components/common/DialogContent' +import AwakeningSelectWithInput from '~components/mastery/AwakeningSelectWithInput' import AXSelect from '~components/mastery/AxSelect' -import AwakeningSelect from '~components/mastery/AwakeningSelect' import ElementToggle from '~components/ElementToggle' import WeaponKeySelect from '~components/weapon/WeaponKeySelect' import Button from '~components/common/Button' import api from '~utils/api' import { appState } from '~utils/appState' +import { NO_AWAKENING } from '~data/awakening' import CrossIcon from '~public/icons/Cross.svg' import './index.scss' @@ -33,7 +34,7 @@ interface GridWeaponObject { ax_modifier2?: number ax_strength1?: number ax_strength2?: number - awakening_type?: number + awakening_id?: string awakening_level?: Number } } @@ -70,7 +71,7 @@ const WeaponModal = ({ const [element, setElement] = useState(-1) - const [awakeningType, setAwakeningType] = useState(0) + const [awakening, setAwakening] = useState() const [awakeningLevel, setAwakeningLevel] = useState(1) const [primaryAxModifier, setPrimaryAxModifier] = useState(-1) @@ -136,9 +137,10 @@ const WeaponModal = ({ setFormValid(isValid) } - function receiveAwakeningValues(type: number, level: number) { - setAwakeningType(type) + function receiveAwakeningValues(id: string, level: number) { + setAwakening(gridWeapon.object.awakenings.find((a) => a.id === id)) setAwakeningLevel(level) + setFormValid(true) } function receiveElementValue(element: string) { @@ -167,8 +169,8 @@ const WeaponModal = ({ object.weapon.ax_strength2 = secondaryAxValue } - if (gridWeapon.object.awakening) { - object.weapon.awakening_type = awakeningType + if (gridWeapon.object.awakenings) { + object.weapon.awakening_id = awakening?.id object.weapon.awakening_level = awakeningLevel } @@ -313,10 +315,12 @@ const WeaponModal = ({ return (

    {t('modals.weapon.subtitles.awakening')}

    -