diff --git a/components/character/CharacterModal/index.module.scss b/components/character/CharacterModal/index.module.scss index f2f35bf6..bbd5f676 100644 --- a/components/character/CharacterModal/index.module.scss +++ b/components/character/CharacterModal/index.module.scss @@ -1,78 +1,32 @@ -.Character.DialogContent { - gap: $unit; - min-width: 480px; +.mods { + display: flex; + flex-direction: column; + gap: $unit-4x; + padding: 0 $unit-4x $unit-2x; - @include breakpoint(phone) { - min-width: inherit; - } - - .DialogHeader { - transition: 0.18s padding-top ease-in-out; - position: sticky; - top: 0; - - &.Scrolled { - border-bottom: 1px solid rgba(0, 0, 0, 0.2); - box-shadow: 0 1px 12px rgba(0, 0, 0, 0.34); - padding-top: $unit-2x; - } - - img { - transition: 0.2s width ease-in-out; - width: $unit-6x !important; - } - - .DialogTitle { - font-size: $font-large; - } - - .SubTitle { - display: none; - } - } - - .mods { + section { display: flex; flex-direction: column; - gap: $unit-4x; - padding: 0 $unit-4x $unit-2x; + gap: $unit-half; - section { - display: flex; - flex-direction: column; - gap: $unit-half; - - &.inline { - align-items: center; - flex-direction: row; - justify-content: space-between; - - h3 { - margin: 0; - } - } + &.inline { + align-items: center; + flex-direction: row; + justify-content: space-between; h3 { - color: $grey-55; - font-size: $font-small; - margin-bottom: $unit; - } - - select { - background-color: $grey-90; + margin: 0; } } - .Button { - font-size: $font-regular; - padding: ($unit * 1.5) ($unit-2x); - width: 100%; + h3 { + color: $grey-55; + font-size: $font-small; + margin-bottom: $unit; + } - &.btn-disabled { - background: $grey-90; - color: $grey-70; - cursor: not-allowed; - } + select { + background-color: $grey-90; } } } diff --git a/components/character/CharacterModal/index.tsx b/components/character/CharacterModal/index.tsx index f93ef9d5..edfc961f 100644 --- a/components/character/CharacterModal/index.tsx +++ b/components/character/CharacterModal/index.tsx @@ -2,15 +2,11 @@ import React, { PropsWithChildren, useEffect, useState } from 'react' import { useRouter } from 'next/router' import { useTranslation } from 'next-i18next' -import classNames from 'classnames' +import isEqual from 'lodash/isEqual' // UI dependencies -import { - Dialog, - DialogClose, - DialogTitle, - DialogTrigger, -} from '~components/common/Dialog' +import Alert from '~components/common/Alert' +import { Dialog, DialogTrigger } from '~components/common/Dialog' import DialogContent from '~components/common/DialogContent' import Button from '~components/common/Button' import SelectWithInput from '~components/common/SelectWithInput' @@ -29,7 +25,6 @@ const emptyExtendedMastery: ExtendedMastery = { const MAX_AWAKENING_LEVEL = 9 // Styles and icons -import CrossIcon from '~public/icons/Cross.svg' import styles from './index.module.scss' // Types @@ -39,6 +34,8 @@ import { GridCharacterObject, } from '~types' import AwakeningSelectWithInput from '~components/mastery/AwakeningSelectWithInput' +import DialogHeader from '~components/common/DialogHeader' +import DialogFooter from '~components/common/DialogFooter' interface Props { gridCharacter: GridCharacter @@ -54,6 +51,7 @@ const CharacterModal = ({ onOpenChange, updateCharacter, }: PropsWithChildren) => { + // Router and localization const router = useRouter() const locale = router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' @@ -67,39 +65,27 @@ const CharacterModal = ({ const headerRef = React.createRef() const footerRef = React.createRef() - // Classes - const headerClasses = classNames({ - DialogHeader: true, - Short: true, - }) + // State: Component + const [alertOpen, setAlertOpen] = useState(false) - // Callbacks and Hooks - useEffect(() => { - setOpen(modalOpen) - }, [modalOpen]) - - // Character properties: Perpetuity + // State: Data const [perpetuity, setPerpetuity] = useState(false) - - // Character properties: Ring const [rings, setRings] = useState({ 1: { ...emptyExtendedMastery, modifier: 1 }, 2: { ...emptyExtendedMastery, modifier: 2 }, 3: emptyExtendedMastery, 4: emptyExtendedMastery, }) - - // Character properties: Earrings const [earring, setEarring] = useState(emptyExtendedMastery) - - // Character properties: Awakening const [awakening, setAwakening] = useState() const [awakeningLevel, setAwakeningLevel] = useState(1) - - // Character properties: Transcendence const [transcendenceStep, setTranscendenceStep] = useState(0) // Hooks + useEffect(() => { + setOpen(modalOpen) + }, [modalOpen]) + useEffect(() => { if (gridCharacter.aetherial_mastery) { setEarring({ @@ -150,10 +136,80 @@ const CharacterModal = ({ return object } + // Methods: Convenience + function hasBeenModified() { + const rings = ringsChanged() + const aetherialMastery = aetherialMasteryChanged() + const awakening = awakeningChanged() + + return ( + rings || + aetherialMastery || + awakening || + gridCharacter.perpetuity !== perpetuity + ) + } + + function ringsChanged() { + // Create an empty ExtendedMastery object + const emptyRingset: CharacterOverMastery = { + 1: { ...emptyExtendedMastery, modifier: 1 }, + 2: { ...emptyExtendedMastery, modifier: 2 }, + 3: emptyExtendedMastery, + 4: emptyExtendedMastery, + } + + // Check if the current ringset is empty on the current GridCharacter and our local state + const isEmptyRingset = + gridCharacter.over_mastery === undefined && isEqual(emptyRingset, rings) + + // Check if the ringset in local state is different from the one on the current GridCharacter + const ringsChanged = !isEqual(gridCharacter.over_mastery, rings) + + // Return true if the ringset has been modified and is not empty + return ringsChanged && !isEmptyRingset + } + + function aetherialMasteryChanged() { + // Create an empty ExtendedMastery object + const emptyAetherialMastery: ExtendedMastery = { + modifier: 0, + strength: 0, + } + + // Check if the current earring is empty on the current GridCharacter and our local state + const isEmptyRingset = + gridCharacter.aetherial_mastery === undefined && + isEqual(emptyAetherialMastery, earring) + + // Check if the earring in local state is different from the one on the current GridCharacter + const aetherialMasteryChanged = !isEqual( + gridCharacter.aetherial_mastery, + earring + ) + + // Return true if the earring has been modified and is not empty + return aetherialMasteryChanged && !isEmptyRingset + } + + function awakeningChanged() { + // Check if the awakening in local state is different from the one on the current GridCharacter + const awakeningChanged = + !isEqual(gridCharacter.awakening.type, awakening) || + gridCharacter.awakening.level !== awakeningLevel + + // Return true if the awakening has been modified and is not empty + return awakeningChanged + } + // Methods: UI state management function handleOpenChange(open: boolean) { - setOpen(open) - onOpenChange(open) + if (hasBeenModified()) { + setAlertOpen(!open) + } else { + setOpen(open) + onOpenChange(open) + } } // Methods: Receive data from components @@ -167,21 +223,10 @@ const CharacterModal = ({ ) { setEarring({ modifier: earringModifier, - strength: earringStrength, + strength: earringModifier > 0 ? earringStrength : 0, }) } - function handleCheckedChange(checked: boolean) { - setPerpetuity(checked) - } - - async function handleUpdateCharacter() { - await updateCharacter(prepareObject()) - - setOpen(false) - if (onOpenChange) onOpenChange(false) - } - function receiveAwakeningValues(id: string, level: number) { setAwakening(gridCharacter.object.awakenings.find((a) => a.id === id)) setAwakeningLevel(level) @@ -191,113 +236,145 @@ const CharacterModal = ({ setFormValid(isValid) } - const ringSelect = () => { - return ( -
-

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

- -
- ) + // Methods: Event handlers + function handleCheckedChange(checked: boolean) { + setPerpetuity(checked) } - const earringSelect = () => { - const earringData = elementalizeAetherialMastery(gridCharacter) + async function handleUpdateCharacter() { + await updateCharacter(prepareObject()) - return ( -
-

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

- -
- ) + setOpen(false) + if (onOpenChange) onOpenChange(false) } - const awakeningSelect = () => { - return ( -
-

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

- a.slug === 'character-balanced' - )! - } - maxLevel={MAX_AWAKENING_LEVEL} - sendValidity={receiveValidity} - sendValues={receiveAwakeningValues} - /> -
- ) + function close() { + setAlertOpen(false) + setOpen(false) + onOpenChange(false) } - const perpetuitySwitch = () => { - return ( -
-

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

- -
- ) - } + // Constants: Rendering + const confirmationAlert = ( + + You will lose all changes to{' '} + {gridCharacter.object.name[locale]} if you continue. +
+
+ Are you sure you want to continue without saving? + + } + open={alertOpen} + primaryActionText="Close" + primaryAction={close} + cancelActionText="Nevermind" + cancelAction={() => setAlertOpen(false)} + /> + ) + const ringSelect = ( +
+

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

+ +
+ ) + + const earringSelect = ( +
+

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

+ +
+ ) + + const awakeningSelect = ( +
+

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

+ a.slug === 'character-balanced' + )! + } + maxLevel={MAX_AWAKENING_LEVEL} + sendValidity={receiveValidity} + sendValues={receiveAwakeningValues} + /> +
+ ) + + const perpetuitySwitch = ( +
+

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

+ +
+ ) + + // Methods: Rendering return ( - - {children} - event.preventDefault()} - onEscapeKeyDown={() => {}} - > -
- {gridCharacter.object.name[locale]} + {confirmationAlert} + + {children} + event.preventDefault()} + onEscapeKeyDown={() => {}} + > + -
- - {t('modals.characters.title')} - - - {gridCharacter.object.name[locale]} - -
- - - - - -
- -
- {perpetuitySwitch()} - {ringSelect()} - {earringSelect()} - {awakeningSelect()} -
-
-
-
-
+ + + ) }