diff --git a/.gitignore b/.gitignore index 29947561..1ecada63 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ public/images/chara* public/images/job* public/images/awakening* public/images/ax* +public/images/accessory* # Typescript v1 declaration files typings/ diff --git a/components/CharacterGrid/index.tsx b/components/CharacterGrid/index.tsx index d4c72e6e..f0b59039 100644 --- a/components/CharacterGrid/index.tsx +++ b/components/CharacterGrid/index.tsx @@ -55,6 +55,7 @@ const CharacterGrid = (props: Props) => { 2: undefined, 3: undefined, }) + const [jobAccessory, setJobAccessory] = useState() const [errorMessage, setErrorMessage] = useState('') // Create a temporary state to store previous weapon uncap values and transcendence stages @@ -81,6 +82,7 @@ const CharacterGrid = (props: Props) => { useEffect(() => { setJob(appState.party.job) setJobSkills(appState.party.jobSkills) + setJobAccessory(appState.party.accessory) }, [appState]) // Initialize an array of current uncap values for each characters @@ -186,7 +188,7 @@ const CharacterGrid = (props: Props) => { } // Methods: Saving job and job skills - const saveJob = async function (job?: Job) { + async function saveJob(job?: Job) { const payload = { party: { job_id: job ? job.id : -1, @@ -214,7 +216,7 @@ const CharacterGrid = (props: Props) => { } } - const saveJobSkill = function (skill: JobSkill, position: number) { + function saveJobSkill(skill: JobSkill, position: number) { if (party.id && appState.party.editable) { const positionedKey = `skill${position}_id` @@ -253,6 +255,24 @@ const CharacterGrid = (props: Props) => { } } + async function saveAccessory(accessory: JobAccessory) { + const payload = { + party: { + accessory_id: accessory.id, + }, + } + + if (appState.party.id) { + const response = await api.endpoints.parties.update( + appState.party.id, + payload + ) + const team = response.data.party + setJobAccessory(team.accessory) + appState.party.accessory = team.accessory + } + } + // Methods: Helpers function characterUncapLevel(character: Character) { let uncapLevel @@ -474,9 +494,11 @@ const CharacterGrid = (props: Props) => { { + // Localization + const router = useRouter() + const locale = + router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' + + return ( + + {accessory.name[locale]} +

{accessory.name[locale]}

+
+ ) +} + +export default JobAccessoryItem diff --git a/components/JobAccessoryPopover/index.scss b/components/JobAccessoryPopover/index.scss new file mode 100644 index 00000000..88ac72d4 --- /dev/null +++ b/components/JobAccessoryPopover/index.scss @@ -0,0 +1,67 @@ +.JobAccessory.Popover { + padding: $unit-2x; + min-width: 40vw; + max-width: 40vw; + max-height: 40vh; + overflow-y: scroll; + margin-left: $unit-2x; + + h3 { + font-size: $font-regular; + font-weight: $medium; + margin: 0 0 $unit $unit; + } + + &.ReadOnly { + min-width: inherit; + max-width: inherit; + } + + @include breakpoint(tablet) { + width: initial; + max-width: initial; + } + + @include breakpoint(phone) { + width: initial; + max-width: initial; + } + + .Accessories { + display: grid; + gap: $unit; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + + @include breakpoint(tablet) { + grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); + gap: 0; + } + } + + .EquippedAccessory { + display: flex; + flex-direction: column; + gap: $unit-2x; + + h3 { + margin: 0; + } + + .Accessory { + display: flex; + flex-direction: column; + gap: $unit; + + h4 { + font-size: $font-small; + font-weight: $medium; + text-align: center; + } + + img { + border-radius: $item-corner; + width: 150px; + } + } + } +} diff --git a/components/JobAccessoryPopover/index.tsx b/components/JobAccessoryPopover/index.tsx new file mode 100644 index 00000000..fa3bb6ba --- /dev/null +++ b/components/JobAccessoryPopover/index.tsx @@ -0,0 +1,155 @@ +import React, { PropsWithChildren, useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import { useTranslation } from 'next-i18next' + +import * as RadioGroup from '@radix-ui/react-radio-group' + +import Button from '~components/Button' +import { + Popover, + PopoverTrigger, + PopoverContent, +} from '~components/PopoverContent' +import JobAccessoryItem from '~components/JobAccessoryItem' + +import './index.scss' +import classNames from 'classnames' + +interface Props { + buttonref: React.RefObject + currentAccessory?: JobAccessory + accessories: JobAccessory[] + editable: boolean + open: boolean + job: Job + onAccessorySelected: (value: string) => void + onOpenChange: (open: boolean) => void +} + +const JobAccessoryPopover = ({ + buttonref, + currentAccessory, + accessories, + editable, + open: modalOpen, + children, + job, + onAccessorySelected, + onOpenChange, +}: PropsWithChildren) => { + // Localization + const { t } = useTranslation('common') + + const router = useRouter() + const locale = + router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' + + // Component state + const [open, setOpen] = useState(false) + + const classes = classNames({ + JobAccessory: true, + ReadOnly: !editable, + }) + + // Hooks + useEffect(() => { + setOpen(modalOpen) + }, [modalOpen]) + + // Event handlers + function handleAccessorySelected(value: string) { + onAccessorySelected(value) + closePopover() + } + + function handlePointerDownOutside( + event: CustomEvent<{ originalEvent: PointerEvent }> + ) { + const target = event.detail.originalEvent.target as Element + if ( + target && + buttonref.current && + target.closest('.JobAccessory.Button') !== buttonref.current + ) { + onOpenChange(false) + } + } + + function closePopover() { + onOpenChange(false) + } + + function capitalizeFirstLetter(string: string) { + return string.charAt(0).toUpperCase() + string.slice(1) + } + + const radioGroup = ( + <> +

+ {capitalizeFirstLetter( + job.accessory_type === 1 + ? `${t('accessories.paladin')}s` + : t('accessories.manadiver') + )} +

+ + {accessories.map((accessory) => ( + + ))} + + + ) + + const readOnly = currentAccessory ? ( +
+

+ {t('equipped')}{' '} + {job.accessory_type === 1 + ? `${t('accessories.paladin')}s` + : t('accessories.manadiver')} +

+
+ {currentAccessory.name[locale]} +

{currentAccessory.name[locale]}

+
+
+ ) : ( +

+ {t('no_accessory', { + accessory: t( + `accessories.${job.accessory_type === 1 ? 'paladin' : 'manadiver'}` + ), + })} +

+ ) + + return ( + + {children} + + {editable ? radioGroup : readOnly} + + + ) +} + +export default JobAccessoryPopover diff --git a/components/JobImage/index.scss b/components/JobImage/index.scss new file mode 100644 index 00000000..b5b8cef3 --- /dev/null +++ b/components/JobImage/index.scss @@ -0,0 +1,79 @@ +.JobImage { + $height: 252px; + $width: 447px; + + aspect-ratio: 7/9; + background: url('/images/background_a.jpg'); + background-size: 500px 281px; + border-radius: $unit; + box-sizing: border-box; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2); + display: block; + isolation: isolate; + flex-grow: 2; + flex-shrink: 0; + height: $height; + margin-right: $unit * 3; + max-height: $height; + max-width: $width; + overflow: hidden; + position: relative; + width: $width; + transition: box-shadow 0.15s ease-in-out; + + // prettier-ignore + @media only screen + and (max-width: 800px) + and (max-height: 920px) + and (-webkit-min-device-pixel-ratio: 2) { + margin-right: 0; + width: 100%; + } + + @include breakpoint(phone) { + aspect-ratio: 16/9; + margin: 0; + width: 100%; + height: inherit; + } + + img { + -webkit-filter: drop-shadow(4px 4px 8px rgba(0, 0, 0, 0.48)); + filter: drop-shadow(4px 4px 8px rgba(0, 0, 0, 0.48)); + position: relative; + top: $unit * -4; + left: 50%; + transform: translateX(-50%); + width: 100%; + z-index: 2; + } + + .JobAccessory.Button { + align-items: center; + border-radius: 99px; + justify-content: center; + position: relative; + padding: $unit * 1.5; + top: $unit; + left: $unit; + height: auto; + z-index: 10; + + &:hover .Accessory svg, + &.Selected .Accessory svg { + fill: var(--button-text-hover); + } + + .Accessory svg { + fill: var(--button-text); + width: $unit-3x; + height: auto; + } + } + + .Overlay { + background: none; + position: absolute; + z-index: 1; + } +} diff --git a/components/JobImage/index.tsx b/components/JobImage/index.tsx new file mode 100644 index 00000000..8902abaa --- /dev/null +++ b/components/JobImage/index.tsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react' +import { useRouter } from 'next/router' + +import Button from '~components/Button' +import JobAccessoryPopover from '~components/JobAccessoryPopover' + +import ShieldIcon from '~public/icons/Shield.svg' +import ManaturaIcon from '~public/icons/Manatura.svg' + +import './index.scss' +import classNames from 'classnames' + +interface Props { + job?: Job + currentAccessory?: JobAccessory + accessories?: JobAccessory[] + editable: boolean + user?: User + onAccessorySelected: (value: string) => void +} + +const JobImage = ({ + job, + currentAccessory, + editable, + accessories, + user, + onAccessorySelected, +}: Props) => { + // Localization + const router = useRouter() + const locale = + router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en' + + // Component state + const [open, setOpen] = useState(false) + + // Refs + const buttonRef = React.createRef() + + // Static variables + const imageUrl = () => { + let source = '' + + if (job) { + const slug = job.name.en.replaceAll(' ', '-').toLowerCase() + const gender = user && user.gender == 1 ? 'b' : 'a' + source = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/jobs/${slug}_${gender}.png` + } + + return source + } + + const hasAccessory = job && job.accessory + const image = {job?.name[locale]} + + const classes = classNames({ + JobAccessory: true, + Selected: open, + }) + + function handleAccessoryButtonClicked() { + setOpen(!open) + } + + function handlePopoverOpenChanged(open: boolean) { + setOpen(open) + } + + // Elements + const accessoryButton = () => { + let icon + + if (job && job.accessory_type === 1) icon = + else if (job && job.accessory_type === 2) icon = + + return ( +