Merge pull request #45 from jedmund/job-skills

Implement job skills
This commit is contained in:
Justin Edmund 2022-12-03 19:15:02 -08:00 committed by GitHub
commit a3ac29deb8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1637 additions and 670 deletions

2
.gitignore vendored
View file

@ -50,7 +50,7 @@ dist/
public/images/weapon* public/images/weapon*
public/images/summon* public/images/summon*
public/images/chara* public/images/chara*
public/images/jobs public/images/job*
# Typescript v1 declaration files # Typescript v1 declaration files
typings/ typings/

View file

@ -0,0 +1,52 @@
.AlertWrapper {
align-items: center;
display: flex;
justify-content: center;
position: absolute;
height: 100vh;
width: 100vw;
top: 0;
left: 0;
z-index: 21;
}
.Alert {
background: white;
border-radius: $unit;
display: flex;
flex-direction: column;
gap: $unit;
min-width: $unit * 20;
max-width: $unit * 40;
padding: $unit * 4;
.description {
font-size: $font-regular;
line-height: 1.26;
}
.buttons {
align-self: flex-end;
}
.Button {
font-size: $font-regular;
padding: ($unit * 1.5) ($unit * 2);
margin-top: $unit * 2;
&.btn-disabled {
background: $grey-90;
color: $grey-70;
cursor: not-allowed;
}
&:not(.btn-disabled) {
background: $grey-90;
color: $grey-40;
&:hover {
background: $grey-80;
}
}
}
}

View file

@ -0,0 +1,51 @@
import React from "react"
import * as AlertDialog from "@radix-ui/react-alert-dialog"
import "./index.scss"
import Button from "~components/Button"
import { ButtonType } from "~utils/enums"
// Props
interface Props {
open: boolean
title?: string
message: string
primaryAction?: () => void
primaryActionText?: string
cancelAction: () => void
cancelActionText: string
}
const Alert = (props: Props) => {
return (
<AlertDialog.Root open={props.open}>
<AlertDialog.Portal>
<AlertDialog.Overlay className="Overlay" onClick={props.cancelAction} />
<div className="AlertWrapper">
<AlertDialog.Content className="Alert">
{props.title ? <AlertDialog.Title>Error</AlertDialog.Title> : ""}
<AlertDialog.Description className="description">
{props.message}
</AlertDialog.Description>
<div className="buttons">
<AlertDialog.Cancel asChild>
<Button onClick={props.cancelAction}>
{props.cancelActionText}
</Button>
</AlertDialog.Cancel>
{props.primaryAction ? (
<AlertDialog.Action onClick={props.primaryAction}>
{props.primaryActionText}
</AlertDialog.Action>
) : (
""
)}
</div>
</AlertDialog.Content>
</div>
</AlertDialog.Portal>
</AlertDialog.Root>
)
}
export default Alert

View file

@ -40,7 +40,6 @@ const CharacterConflictModal = (props: Props) => {
else if (uncap == 5) suffix = "03" else if (uncap == 5) suffix = "03"
else if (uncap > 2) suffix = "02" else if (uncap > 2) suffix = "02"
console.log(appState.grid.weapons.mainWeapon)
// Special casing for Lyria (and Young Cat eventually) // Special casing for Lyria (and Young Cat eventually)
if (character?.granblue_id === "3030182000") { if (character?.granblue_id === "3030182000") {
let element = 1 let element = 1

View file

@ -6,14 +6,17 @@ import { useSnapshot } from "valtio"
import { AxiosResponse } from "axios" import { AxiosResponse } from "axios"
import debounce from "lodash.debounce" import debounce from "lodash.debounce"
import Alert from "~components/Alert"
import JobSection from "~components/JobSection" import JobSection from "~components/JobSection"
import CharacterUnit from "~components/CharacterUnit" import CharacterUnit from "~components/CharacterUnit"
import CharacterConflictModal from "~components/CharacterConflictModal"
import type { JobSkillObject, SearchableObject } from "~types"
import api from "~utils/api" import api from "~utils/api"
import { appState } from "~utils/appState" import { appState } from "~utils/appState"
import "./index.scss" import "./index.scss"
import CharacterConflictModal from "~components/CharacterConflictModal"
// Props // Props
interface Props { interface Props {
@ -46,6 +49,16 @@ const CharacterGrid = (props: Props) => {
const [conflicts, setConflicts] = useState<GridCharacter[]>([]) const [conflicts, setConflicts] = useState<GridCharacter[]>([])
const [position, setPosition] = useState(0) const [position, setPosition] = useState(0)
// Set up state for data
const [job, setJob] = useState<Job | undefined>()
const [jobSkills, setJobSkills] = useState<JobSkillObject>({
0: undefined,
1: undefined,
2: undefined,
3: undefined,
})
const [errorMessage, setErrorMessage] = useState("")
// Create a temporary state to store previous character uncap values // Create a temporary state to store previous character uncap values
const [previousUncapValues, setPreviousUncapValues] = useState<{ const [previousUncapValues, setPreviousUncapValues] = useState<{
[key: number]: number | undefined [key: number]: number | undefined
@ -62,6 +75,11 @@ const CharacterGrid = (props: Props) => {
else appState.party.editable = false else appState.party.editable = false
}, [props.new, accountData, party]) }, [props.new, accountData, party])
useEffect(() => {
setJob(appState.party.job)
setJobSkills(appState.party.jobSkills)
}, [appState])
// Initialize an array of current uncap values for each characters // Initialize an array of current uncap values for each characters
useEffect(() => { useEffect(() => {
let initialPreviousUncapValues: { [key: number]: number } = {} let initialPreviousUncapValues: { [key: number]: number } = {}
@ -73,7 +91,7 @@ const CharacterGrid = (props: Props) => {
// Methods: Adding an object from search // Methods: Adding an object from search
function receiveCharacterFromSearch( function receiveCharacterFromSearch(
object: Character | Weapon | Summon, object: SearchableObject,
position: number position: number
) { ) {
const character = object as Character const character = object as Character
@ -163,6 +181,69 @@ const CharacterGrid = (props: Props) => {
setIncoming(undefined) setIncoming(undefined)
} }
// Methods: Saving job and job skills
const saveJob = function (job: Job) {
const payload = {
party: {
job_id: job ? job.id : "",
},
...headers,
}
if (party.id && appState.party.editable) {
api.updateJob({ partyId: party.id, params: payload }).then((response) => {
const newParty = response.data.party
setJob(newParty.job)
appState.party.job = newParty.job
setJobSkills(newParty.job_skills)
appState.party.jobSkills = newParty.job_skills
})
}
}
const saveJobSkill = function (skill: JobSkill, position: number) {
if (party.id && appState.party.editable) {
const positionedKey = `skill${position}_id`
let skillObject: {
[key: string]: string | undefined
skill0_id?: string
skill1_id?: string
skill2_id?: string
skill3_id?: string
} = {}
const payload = {
party: skillObject,
...headers,
}
skillObject[positionedKey] = skill.id
api
.updateJobSkills({ partyId: party.id, params: payload })
.then((response) => {
// Update the current skills
const newSkills = response.data.party.job_skills
setJobSkills(newSkills)
appState.party.jobSkills = newSkills
})
.catch((error) => {
const data = error.response.data
if (data.code == "too_many_skills_of_type") {
const message = `You can only add up to 2 ${
data.skill_type === "emp"
? data.skill_type.toUpperCase()
: data.skill_type
} skills to your party at once.`
setErrorMessage(message)
}
console.log(error.response.data)
})
}
}
// Methods: Helpers // Methods: Helpers
function characterUncapLevel(character: Character) { function characterUncapLevel(character: Character) {
let uncapLevel let uncapLevel
@ -250,11 +331,27 @@ const CharacterGrid = (props: Props) => {
} }
} }
function cancelAlert() {
setErrorMessage("")
}
// Render: JSX components // Render: JSX components
return ( return (
<div> <div>
<Alert
open={errorMessage.length > 0}
message={errorMessage}
cancelAction={cancelAlert}
cancelActionText={"Got it"}
/>
<div id="CharacterGrid"> <div id="CharacterGrid">
<JobSection /> <JobSection
job={job}
jobSkills={jobSkills}
editable={party.editable}
saveJob={saveJob}
saveSkill={saveJobSkill}
/>
<CharacterConflictModal <CharacterConflictModal
open={modalOpen} open={modalOpen}
incomingCharacter={incoming} incomingCharacter={incoming}

View file

@ -1,41 +1,43 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from "react"
import { useRouter } from 'next/router' import { useRouter } from "next/router"
import { useSnapshot } from 'valtio' import { useSnapshot } from "valtio"
import { useTranslation } from 'next-i18next' import { useTranslation } from "next-i18next"
import classnames from 'classnames' import classnames from "classnames"
import { appState } from '~utils/appState' import { appState } from "~utils/appState"
import CharacterHovercard from '~components/CharacterHovercard' import CharacterHovercard from "~components/CharacterHovercard"
import SearchModal from '~components/SearchModal' import SearchModal from "~components/SearchModal"
import UncapIndicator from '~components/UncapIndicator' import UncapIndicator from "~components/UncapIndicator"
import PlusIcon from '~public/icons/Add.svg' import PlusIcon from "~public/icons/Add.svg"
import './index.scss' import type { SearchableObject } from "~types"
import { getRedirectStatus } from 'next/dist/lib/load-custom-routes'
import "./index.scss"
interface Props { interface Props {
gridCharacter: GridCharacter | undefined gridCharacter?: GridCharacter
position: number position: number
editable: boolean editable: boolean
updateObject: (object: Character | Weapon | Summon, position: number) => void updateObject: (object: SearchableObject, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void updateUncap: (id: string, position: number, uncap: number) => void
} }
const CharacterUnit = (props: Props) => { const CharacterUnit = (props: Props) => {
const { t } = useTranslation('common') const { t } = useTranslation("common")
const { party, grid } = useSnapshot(appState) const { party, grid } = useSnapshot(appState)
const router = useRouter() const router = useRouter()
const locale = (router.locale && ['en', 'ja'].includes(router.locale)) ? router.locale : 'en' const locale =
router.locale && ["en", "ja"].includes(router.locale) ? router.locale : "en"
const [imageUrl, setImageUrl] = useState('') const [imageUrl, setImageUrl] = useState("")
const classes = classnames({ const classes = classnames({
CharacterUnit: true, CharacterUnit: true,
'editable': props.editable, editable: props.editable,
'filled': (props.gridCharacter !== undefined) filled: props.gridCharacter !== undefined,
}) })
const gridCharacter = props.gridCharacter const gridCharacter = props.gridCharacter
@ -52,16 +54,13 @@ const CharacterUnit = (props: Props) => {
const character = props.gridCharacter.object! const character = props.gridCharacter.object!
// Change the image based on the uncap level // Change the image based on the uncap level
let suffix = '01' let suffix = "01"
if (props.gridCharacter.uncap_level == 6) if (props.gridCharacter.uncap_level == 6) suffix = "04"
suffix = '04' else if (props.gridCharacter.uncap_level == 5) suffix = "03"
else if (props.gridCharacter.uncap_level == 5) else if (props.gridCharacter.uncap_level > 2) suffix = "02"
suffix = '03'
else if (props.gridCharacter.uncap_level > 2)
suffix = '02'
// Special casing for Lyria (and Young Cat eventually) // Special casing for Lyria (and Young Cat eventually)
if (props.gridCharacter.object.granblue_id === '3030182000') { if (props.gridCharacter.object.granblue_id === "3030182000") {
let element = 1 let element = 1
if (grid.weapons.mainWeapon && grid.weapons.mainWeapon.element) { if (grid.weapons.mainWeapon && grid.weapons.mainWeapon.element) {
element = grid.weapons.mainWeapon.element element = grid.weapons.mainWeapon.element
@ -86,24 +85,31 @@ const CharacterUnit = (props: Props) => {
const image = ( const image = (
<div className="CharacterImage"> <div className="CharacterImage">
<img alt={character?.name.en} className="grid_image" src={imageUrl} /> <img alt={character?.name.en} className="grid_image" src={imageUrl} />
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' } {props.editable ? (
<span className="icon">
<PlusIcon />
</span>
) : (
""
)}
</div> </div>
) )
const editableImage = ( const editableImage = (
<SearchModal <SearchModal
placeholderText={t('search.placeholders.character')} placeholderText={t("search.placeholders.character")}
fromPosition={props.position} fromPosition={props.position}
object="characters" object="characters"
send={props.updateObject}> send={props.updateObject}
>
{image} {image}
</SearchModal> </SearchModal>
) )
const unitContent = ( const unitContent = (
<div className={classes}> <div className={classes}>
{ (props.editable) ? editableImage : image } {props.editable ? editableImage : image}
{ (gridCharacter && character) ? {gridCharacter && character ? (
<UncapIndicator <UncapIndicator
type="character" type="character"
flb={character.uncap.flb || false} flb={character.uncap.flb || false}
@ -111,7 +117,10 @@ const CharacterUnit = (props: Props) => {
uncapLevel={gridCharacter.uncap_level} uncapLevel={gridCharacter.uncap_level}
updateUncap={passUncapData} updateUncap={passUncapData}
special={character.special} special={character.special}
/> : '' } />
) : (
""
)}
<h3 className="CharacterName">{character?.name[locale]}</h3> <h3 className="CharacterName">{character?.name[locale]}</h3>
</div> </div>
) )
@ -122,9 +131,7 @@ const CharacterUnit = (props: Props) => {
</CharacterHovercard> </CharacterHovercard>
) )
return ( return gridCharacter && !props.editable ? withHovercard : unitContent
(gridCharacter && !props.editable) ? withHovercard : unitContent
)
} }
export default CharacterUnit export default CharacterUnit

View file

@ -1,7 +1,8 @@
import React from 'react' import React from "react"
import { useTranslation } from 'next-i18next' import { useTranslation } from "next-i18next"
import SummonUnit from '~components/SummonUnit' import SummonUnit from "~components/SummonUnit"
import './index.scss' import { SearchableObject } from "~types"
import "./index.scss"
// Props // Props
interface Props { interface Props {
@ -10,23 +11,22 @@ interface Props {
exists: boolean exists: boolean
found?: boolean found?: boolean
offset: number offset: number
updateObject: (object: Character | Weapon | Summon, position: number) => void updateObject: (object: SearchableObject, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void updateUncap: (id: string, position: number, uncap: number) => void
} }
const ExtraSummons = (props: Props) => { const ExtraSummons = (props: Props) => {
const numSummons: number = 2 const numSummons: number = 2
const { t } = useTranslation('common') const { t } = useTranslation("common")
return ( return (
<div id="ExtraSummons"> <div id="ExtraSummons">
<span>{t('summons.subaura')}</span> <span>{t("summons.subaura")}</span>
<ul id="grid_summons"> <ul id="grid_summons">
{ {Array.from(Array(numSummons)).map((x, i) => {
Array.from(Array(numSummons)).map((x, i) => {
return ( return (
<li key={`grid_unit_${i}`} > <li key={`grid_unit_${i}`}>
<SummonUnit <SummonUnit
editable={props.editable} editable={props.editable}
position={props.offset + i} position={props.offset + i}
@ -37,8 +37,7 @@ const ExtraSummons = (props: Props) => {
/> />
</li> </li>
) )
}) })}
}
</ul> </ul>
</div> </div>
) )

View file

@ -1,11 +1,11 @@
import React, { useCallback, useEffect, useState } from 'react' import React, { useEffect, useState } from "react"
import { useRouter } from 'next/router' import { useRouter } from "next/router"
import { useSnapshot } from "valtio"
import api from '~utils/api' import { appState } from "~utils/appState"
import { appState } from '~utils/appState' import { jobGroups } from "~utils/jobGroups"
import { jobGroups } from '~utils/jobGroups'
import './index.scss' import "./index.scss"
// Props // Props
interface Props { interface Props {
@ -16,48 +16,52 @@ interface Props {
type GroupedJob = { [key: string]: Job[] } type GroupedJob = { [key: string]: Job[] }
const JobDropdown = React.forwardRef<HTMLSelectElement, Props>(function useFieldSet(props, ref) { const JobDropdown = React.forwardRef<HTMLSelectElement, Props>(
function useFieldSet(props, ref) {
// Set up router for locale // Set up router for locale
const router = useRouter() const router = useRouter()
const locale = router.locale || 'en' const locale = router.locale || "en"
// Create snapshot of app state
const { party } = useSnapshot(appState)
// Set up local states for storing jobs // Set up local states for storing jobs
const [currentJob, setCurrentJob] = useState<Job>() const [currentJob, setCurrentJob] = useState<Job>()
const [jobs, setJobs] = useState<Job[]>() const [jobs, setJobs] = useState<Job[]>()
const [sortedJobs, setSortedJobs] = useState<GroupedJob>() const [sortedJobs, setSortedJobs] = useState<GroupedJob>()
// Organize jobs into groups on mount // Set current job from state on mount
const organizeJobs = useCallback((jobs: Job[]) => { useEffect(() => {
const jobGroups = jobs.map(job => job.row).filter((value, index, self) => self.indexOf(value) === index) setCurrentJob(party.job)
let groupedJobs: GroupedJob = {}
jobGroups.forEach(group => {
groupedJobs[group] = jobs.filter(job => job.row === group)
})
setJobs(jobs)
setSortedJobs(groupedJobs)
appState.jobs = jobs
}, []) }, [])
// Fetch all jobs on mount // Organize jobs into groups on mount
useEffect(() => { useEffect(() => {
api.endpoints.jobs.getAll() const jobGroups = appState.jobs
.then(response => organizeJobs(response.data)) .map((job) => job.row)
}, [organizeJobs]) .filter((value, index, self) => self.indexOf(value) === index)
let groupedJobs: GroupedJob = {}
jobGroups.forEach((group) => {
groupedJobs[group] = appState.jobs.filter((job) => job.row === group)
})
setJobs(appState.jobs)
setSortedJobs(groupedJobs)
}, [appState])
// Set current job on mount // Set current job on mount
useEffect(() => { useEffect(() => {
if (jobs && props.currentJob) { if (jobs && props.currentJob) {
const job = jobs.find(job => job.id === props.currentJob) const job = appState.jobs.find((job) => job.id === props.currentJob)
setCurrentJob(job) setCurrentJob(job)
} }
}, [jobs, props.currentJob]) }, [appState, props.currentJob])
// Enable changing select value // Enable changing select value
function handleChange(event: React.ChangeEvent<HTMLSelectElement>) { function handleChange(event: React.ChangeEvent<HTMLSelectElement>) {
if (jobs) { if (jobs) {
const job = jobs.find(job => job.id === event.target.value) const job = jobs.find((job) => job.id === event.target.value)
if (props.onChange) props.onChange(job) if (props.onChange) props.onChange(job)
setCurrentJob(job) setCurrentJob(job)
} }
@ -65,14 +69,20 @@ const JobDropdown = React.forwardRef<HTMLSelectElement, Props>(function useField
// Render JSX for each job option, sorted into optgroups // Render JSX for each job option, sorted into optgroups
function renderJobGroup(group: string) { function renderJobGroup(group: string) {
const options = sortedJobs && sortedJobs[group].length > 0 && const options =
sortedJobs[group].sort((a, b) => a.order - b.order).map((item, i) => { sortedJobs &&
sortedJobs[group].length > 0 &&
sortedJobs[group]
.sort((a, b) => a.order - b.order)
.map((item, i) => {
return ( return (
<option key={i} value={item.id}>{item.name[locale]}</option> <option key={i} value={item.id}>
{item.name[locale]}
</option>
) )
}) })
const groupName = jobGroups.find(g => g.slug === group)?.name[locale] const groupName = jobGroups.find((g) => g.slug === group)?.name[locale]
return ( return (
<optgroup key={group} label={groupName}> <optgroup key={group} label={groupName}>
@ -83,15 +93,21 @@ const JobDropdown = React.forwardRef<HTMLSelectElement, Props>(function useField
return ( return (
<select <select
key={currentJob?.id} key={currentJob ? currentJob.id : -1}
value={currentJob?.id} value={currentJob ? currentJob.id : -1}
onBlur={props.onBlur} onBlur={props.onBlur}
onChange={handleChange} onChange={handleChange}
ref={ref}> ref={ref}
<option key="no-job" value={-1}>No class</option> >
{ (sortedJobs) ? Object.keys(sortedJobs).map(x => renderJobGroup(x)) : '' } <option key="no-job" value={-1}>
No class
</option>
{sortedJobs
? Object.keys(sortedJobs).map((x) => renderJobGroup(x))
: ""}
</select> </select>
) )
}) }
)
export default JobDropdown export default JobDropdown

View file

@ -7,16 +7,37 @@
width: auto; width: auto;
} }
.JobDetails {
display: flex;
flex-direction: column;
width: 100%;
h3 {
font-size: $font-medium;
font-weight: $medium;
padding: $unit 0 $unit * 2;
}
select {
flex-grow: 0;
}
.JobSkills {
flex-grow: 2;
}
}
.JobImage { .JobImage {
$height: 249px; $height: 249px;
$width: 447px; $width: 447px;
background: url('/images/background_a.jpg'); background: url("/images/background_a.jpg");
background-size: 500px 281px; background-size: 500px 281px;
border-radius: $unit; border-radius: $unit;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2);
display: block; display: block;
flex-grow: 2; flex-grow: 2;
flex-shrink: 0;
height: $height; height: $height;
margin-right: $unit * 3; margin-right: $unit * 3;
max-height: $height; max-height: $height;
@ -27,6 +48,8 @@
transition: box-shadow 0.15s ease-in-out; transition: box-shadow 0.15s ease-in-out;
img { 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; position: relative;
top: $unit * -4; top: $unit * -4;
left: 50%; left: 50%;
@ -36,9 +59,15 @@
} }
.Overlay { .Overlay {
background: rgba(255, 255, 255, 0.12); background: none;
position: absolute; position: absolute;
z-index: 1; z-index: 1;
} }
} }
.JobSkills {
display: flex;
flex-direction: column;
gap: $unit;
}
} }

View file

@ -1,44 +1,85 @@
import React, { useEffect, useState } from 'react' import React, { ForwardedRef, useEffect, useState } from "react"
import { useSnapshot } from 'valtio' import { useRouter } from "next/router"
import { useSnapshot } from "valtio"
import { useTranslation } from "next-i18next"
import JobDropdown from '~components/JobDropdown' import JobDropdown from "~components/JobDropdown"
import JobSkillItem from "~components/JobSkillItem"
import SearchModal from "~components/SearchModal"
import { appState } from '~utils/appState' import { appState } from "~utils/appState"
import './index.scss' import type { JobSkillObject, SearchableObject } from "~types"
import "./index.scss"
// Props // Props
interface Props {} interface Props {
job?: Job
jobSkills: JobSkillObject
editable: boolean
saveJob: (job: Job) => void
saveSkill: (skill: JobSkill, position: number) => void
}
const JobSection = (props: Props) => { const JobSection = (props: Props) => {
const [job, setJob] = useState<Job>()
const [imageUrl, setImageUrl] = useState('')
const { party } = useSnapshot(appState) const { party } = useSnapshot(appState)
const { t } = useTranslation("common")
const router = useRouter()
const locale =
router.locale && ["en", "ja"].includes(router.locale) ? router.locale : "en"
const [job, setJob] = useState<Job>()
const [imageUrl, setImageUrl] = useState("")
const [numSkills, setNumSkills] = useState(4)
const [skills, setSkills] = useState<{ [key: number]: JobSkill | undefined }>(
[]
)
const selectRef = React.createRef<HTMLSelectElement>()
useEffect(() => { useEffect(() => {
// Set current job based on ID // Set current job based on ID
setJob(party.job) if (props.job) {
}, []) setJob(props.job)
setSkills({
0: props.jobSkills[0],
1: props.jobSkills[1],
2: props.jobSkills[2],
3: props.jobSkills[3],
})
if (selectRef.current) selectRef.current.value = props.job.id
}
}, [props])
useEffect(() => { useEffect(() => {
generateImageUrl() generateImageUrl()
}) })
useEffect(() => { useEffect(() => {
if (job) appState.party.job = job if (job) {
if ((party.job && job.id != party.job.id) || !party.job)
appState.party.job = job
if (job.row === "1") setNumSkills(3)
else setNumSkills(4)
}
}, [job]) }, [job])
function receiveJob(job?: Job) { function receiveJob(job?: Job) {
if (job) {
setJob(job) setJob(job)
props.saveJob(job)
}
} }
function generateImageUrl() { function generateImageUrl() {
let imgSrc = "" let imgSrc = ""
if (job) { if (job) {
const slug = job?.name.en.replaceAll(' ', '-').toLowerCase() const slug = job?.name.en.replaceAll(" ", "-").toLowerCase()
const gender = (party.user && party.user.gender == 1) ? 'b' : 'a' const gender = party.user && party.user.gender == 1 ? "b" : "a"
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/jobs/${slug}_${gender}.png` imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/jobs/${slug}_${gender}.png`
} }
@ -46,6 +87,49 @@ const JobSection = (props: Props) => {
setImageUrl(imgSrc) setImageUrl(imgSrc)
} }
const canEditSkill = (skill?: JobSkill) => {
if (job && skill) {
if (skill.job.id === job.id && skill.main && !skill.sub) return false
}
return props.editable
}
const skillItem = (index: number, editable: boolean) => {
return (
<JobSkillItem
skill={skills[index]}
editable={canEditSkill(skills[index])}
key={`skill-${index}`}
hasJob={job != undefined && job.id != "-1"}
/>
)
}
const editableSkillItem = (index: number) => {
return (
<SearchModal
placeholderText={t("search.placeholders.job_skill")}
fromPosition={index}
object="job_skills"
job={job}
send={saveJobSkill}
>
{skillItem(index, true)}
</SearchModal>
)
}
function saveJobSkill(object: SearchableObject, position: number) {
const skill = object as JobSkill
const newSkills = skills
newSkills[position] = skill
setSkills(newSkills)
props.saveSkill(skill, position)
}
// Render: JSX components // Render: JSX components
return ( return (
<section id="Job"> <section id="Job">
@ -53,10 +137,27 @@ const JobSection = (props: Props) => {
<img src={imageUrl} /> <img src={imageUrl} />
<div className="Overlay" /> <div className="Overlay" />
</div> </div>
<div className="JobDetails">
{props.editable ? (
<JobDropdown <JobDropdown
currentJob={ (party.job) ? party.job.id : undefined} currentJob={party.job?.id}
onChange={receiveJob} onChange={receiveJob}
ref={selectRef}
/> />
) : (
<h3>{party.job?.name[locale]}</h3>
)}
<ul className="JobSkills">
{[...Array(numSkills)].map((e, i) => (
<li key={`job-${i}`}>
{canEditSkill(skills[i])
? editableSkillItem(i)
: skillItem(i, false)}
</li>
))}
</ul>
</div>
</section> </section>
) )
} }

View file

@ -0,0 +1,46 @@
.JobSkill {
display: flex;
gap: $unit;
align-items: center;
&.editable:hover {
cursor: pointer;
& > img.editable,
& > div.placeholder.editable {
border: $hover-stroke;
box-shadow: $hover-shadow;
cursor: pointer;
transform: $scale-tall;
}
& p.placeholder {
color: $grey-20;
}
}
& > img,
& > div.placeholder {
background: white;
border-radius: calc($unit / 2);
border: 1px solid rgba(0, 0, 0, 0);
width: $unit * 5;
height: $unit * 5;
}
& > div.placeholder {
display: flex;
align-items: center;
justify-content: center;
& > svg {
fill: $grey-60;
width: $unit * 2;
height: $unit * 2;
}
}
p.placeholder {
color: $grey-50;
}
}

View file

@ -0,0 +1,81 @@
import React from "react"
import { useRouter } from "next/router"
import { useTranslation } from "next-i18next"
import classNames from "classnames"
import PlusIcon from "~public/icons/Add.svg"
import "./index.scss"
// Props
interface Props extends React.ComponentPropsWithoutRef<"div"> {
skill?: JobSkill
editable: boolean
hasJob: boolean
}
const JobSkillItem = React.forwardRef<HTMLDivElement, Props>(
({ ...props }, forwardedRef) => {
const router = useRouter()
const { t } = useTranslation("common")
const locale =
router.locale && ["en", "ja"].includes(router.locale)
? router.locale
: "en"
const classes = classNames({
JobSkill: true,
editable: props.editable,
})
const imageClasses = classNames({
placeholder: !props.skill,
editable: props.editable && props.hasJob,
})
const skillImage = () => {
let jsx: React.ReactNode
if (props.skill) {
jsx = (
<img
alt={props.skill.name[locale]}
className={imageClasses}
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}job-skills/${props.skill.slug}.png`}
/>
)
} else {
jsx = (
<div className={imageClasses}>
{props.editable && props.hasJob ? <PlusIcon /> : ""}
</div>
)
}
return jsx
}
const label = () => {
let jsx: React.ReactNode
if (props.skill) {
jsx = <p>{props.skill.name[locale]}</p>
} else if (props.editable && props.hasJob) {
jsx = <p className="placeholder">{t("job_skills.state.selectable")}</p>
} else {
jsx = <p className="placeholder">{t("job_skills.state.no_skill")}</p>
}
return jsx
}
return (
<div className={classes} onClick={props.onClick} ref={forwardedRef}>
{skillImage()}
{label()}
</div>
)
}
)
export default JobSkillItem

View file

@ -0,0 +1,71 @@
.JobSkillResult {
border-radius: 6px;
display: flex;
gap: $unit;
padding: $unit * 1.5;
align-items: center;
&:hover {
background: $grey-90;
cursor: pointer;
.Info .skill.pill {
background: $grey-80;
}
}
.Info {
display: flex;
flex-direction: row;
gap: calc($unit / 2);
width: 100%;
.skill.pill {
background: $grey-90;
border-radius: $unit * 2;
color: $grey-00;
display: inline;
font-size: $font-tiny;
font-weight: $medium;
padding: calc($unit / 2) $unit;
&.buffing {
background-color: $light-bg-dark;
color: $light-text-dark;
}
&.debuffing {
background-color: $water-bg-dark;
color: $water-text-dark;
}
&.healing {
background-color: $wind-bg-dark;
color: $wind-text-dark;
}
&.damaging {
background-color: $fire-bg-dark;
color: $fire-text-dark;
}
&.field {
background-color: $dark-bg-dark;
color: $dark-text-dark;
}
}
h5 {
color: #555;
display: inline-block;
font-size: $font-medium;
font-weight: $medium;
flex-grow: 1;
}
}
img {
width: $unit * 6;
height: $unit * 6;
}
}

View file

@ -0,0 +1,41 @@
import React, { useEffect, useState } from "react"
import { useRouter } from "next/router"
import { SkillGroup, skillClassification } from "~utils/skillGroups"
import "./index.scss"
interface Props {
data: JobSkill
onClick: () => void
}
const JobSkillResult = (props: Props) => {
const router = useRouter()
const locale =
router.locale && ["en", "ja"].includes(router.locale) ? router.locale : "en"
const skill = props.data
const [group, setGroup] = useState<SkillGroup | undefined>()
useEffect(() => {
setGroup(skillClassification.find((group) => group.id === skill.color))
}, [skill, setGroup, skillClassification])
const jobSkillUrl = () =>
`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-skills/${skill.slug}.png`
return (
<li className="JobSkillResult" onClick={props.onClick}>
<img alt={skill.name[locale]} src={jobSkillUrl()} />
<div className="Info">
<h5>{skill.name[locale]}</h5>
<div className={`skill pill ${group?.name["en"].toLowerCase()}`}>
{group?.name[locale]}
</div>
</div>
</li>
)
}
export default JobSkillResult

View file

@ -0,0 +1,3 @@
.SearchFilterBar select {
background-color: $grey-90;
}

View file

@ -0,0 +1,71 @@
import React, { useEffect, useState } from "react"
import { useRouter } from "next/router"
import { useTranslation } from "react-i18next"
import { skillGroups } from "~utils/skillGroups"
import "./index.scss"
interface Props {
sendFilters: (filters: { [key: string]: number }) => void
}
const JobSkillSearchFilterBar = (props: Props) => {
// Set up translation
const { t } = useTranslation("common")
const [currentGroup, setCurrentGroup] = useState(-1)
function onChange(event: React.ChangeEvent<HTMLSelectElement>) {
setCurrentGroup(parseInt(event.target.value))
}
function onBlur(event: React.ChangeEvent<HTMLSelectElement>) {}
function sendFilters() {
const filters = {
group: currentGroup,
}
props.sendFilters(filters)
}
useEffect(() => {
sendFilters()
}, [currentGroup])
return (
<div className="SearchFilterBar">
<select
key="job-skill-groups"
value={currentGroup}
onBlur={onBlur}
onChange={onChange}
>
<option key="all" value={-1}>
{t(`job_skills.all`)}
</option>
<option key="damaging" value={2}>
{t(`job_skills.damaging`)}
</option>
<option key="buffing" value={0}>
{t(`job_skills.buffing`)}
</option>
<option key="debuffing" value={1}>
{t(`job_skills.debuffing`)}
</option>
<option key="healing" value={3}>
{t(`job_skills.healing`)}
</option>
<option key="emp" value={4}>
{t(`job_skills.emp`)}
</option>
<option key="base" value={5}>
{t(`job_skills.base`)}
</option>
</select>
</div>
)
}
export default JobSkillSearchFilterBar

View file

@ -42,9 +42,6 @@ const Party = (props: Props) => {
// Set up states // Set up states
const { party } = useSnapshot(appState) const { party } = useSnapshot(appState)
const jobState = party.job
const [job, setJob] = useState<Job>()
const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon) const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon)
// Reset state on first load // Reset state on first load
@ -54,14 +51,6 @@ const Party = (props: Props) => {
if (props.team) storeParty(props.team) if (props.team) storeParty(props.team)
}, []) }, [])
useEffect(() => {
setJob(jobState)
}, [jobState])
useEffect(() => {
jobChanged()
}, [job])
// Methods: Creating a new party // Methods: Creating a new party
async function createParty(extra: boolean = false) { async function createParty(extra: boolean = false) {
let body = { let body = {
@ -89,18 +78,6 @@ const Party = (props: Props) => {
} }
} }
function jobChanged() {
if (party.id && appState.party.editable) {
api.endpoints.parties.update(
party.id,
{
party: { job_id: job ? job.id : "" },
},
headers
)
}
}
function updateDetails(name?: string, description?: string, raid?: Raid) { function updateDetails(name?: string, description?: string, raid?: Raid) {
if ( if (
appState.party.name !== name || appState.party.name !== name ||
@ -160,6 +137,8 @@ const Party = (props: Props) => {
appState.party.description = party.description appState.party.description = party.description
appState.party.raid = party.raid appState.party.raid = party.raid
appState.party.updated_at = party.updated_at appState.party.updated_at = party.updated_at
appState.party.job = party.job
appState.party.jobSkills = party.job_skills
appState.party.id = party.id appState.party.id = party.id
appState.party.extra = party.extra appState.party.extra = party.extra

View file

@ -1,11 +1,9 @@
import React, { useEffect, useRef, useState } from "react" import React, { useEffect, useState } from "react"
import { getCookie, setCookie } from "cookies-next" import { getCookie, setCookie } from "cookies-next"
import { useRouter } from "next/router" import { useRouter } from "next/router"
import { useSnapshot } from "valtio"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import InfiniteScroll from "react-infinite-scroll-component" import InfiniteScroll from "react-infinite-scroll-component"
import { appState } from "~utils/appState"
import api from "~utils/api" import api from "~utils/api"
import * as Dialog from "@radix-ui/react-dialog" import * as Dialog from "@radix-ui/react-dialog"
@ -13,27 +11,29 @@ import * as Dialog from "@radix-ui/react-dialog"
import CharacterSearchFilterBar from "~components/CharacterSearchFilterBar" import CharacterSearchFilterBar from "~components/CharacterSearchFilterBar"
import WeaponSearchFilterBar from "~components/WeaponSearchFilterBar" import WeaponSearchFilterBar from "~components/WeaponSearchFilterBar"
import SummonSearchFilterBar from "~components/SummonSearchFilterBar" import SummonSearchFilterBar from "~components/SummonSearchFilterBar"
import JobSkillSearchFilterBar from "~components/JobSkillSearchFilterBar"
import CharacterResult from "~components/CharacterResult" import CharacterResult from "~components/CharacterResult"
import WeaponResult from "~components/WeaponResult" import WeaponResult from "~components/WeaponResult"
import SummonResult from "~components/SummonResult" import SummonResult from "~components/SummonResult"
import JobSkillResult from "~components/JobSkillResult"
import type { SearchableObject, SearchableObjectArray } from "~types"
import "./index.scss" import "./index.scss"
import CrossIcon from "~public/icons/Cross.svg" import CrossIcon from "~public/icons/Cross.svg"
import cloneDeep from "lodash.clonedeep" import cloneDeep from "lodash.clonedeep"
interface Props { interface Props {
send: (object: Character | Weapon | Summon, position: number) => any send: (object: SearchableObject, position: number) => any
placeholderText: string placeholderText: string
fromPosition: number fromPosition: number
object: "weapons" | "characters" | "summons" job?: Job
object: "weapons" | "characters" | "summons" | "job_skills"
children: React.ReactNode children: React.ReactNode
} }
const SearchModal = (props: Props) => { const SearchModal = (props: Props) => {
// Set up snapshot of app state
let { grid, search } = useSnapshot(appState)
// Set up router // Set up router
const router = useRouter() const router = useRouter()
const locale = router.locale const locale = router.locale
@ -45,23 +45,16 @@ const SearchModal = (props: Props) => {
let scrollContainer = React.createRef<HTMLDivElement>() let scrollContainer = React.createRef<HTMLDivElement>()
const [firstLoad, setFirstLoad] = useState(true) const [firstLoad, setFirstLoad] = useState(true)
const [objects, setObjects] = useState<{ const [filters, setFilters] = useState<{ [key: string]: any }>()
[id: number]: GridCharacter | GridWeapon | GridSummon | undefined
}>()
const [filters, setFilters] = useState<{ [key: string]: number[] }>()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [results, setResults] = useState<(Weapon | Summon | Character)[]>([]) const [results, setResults] = useState<SearchableObjectArray>([])
// Pagination states // Pagination states
const [recordCount, setRecordCount] = useState(0) const [recordCount, setRecordCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1) const [totalPages, setTotalPages] = useState(1)
useEffect(() => {
setObjects(grid[props.object])
}, [grid, props.object])
useEffect(() => { useEffect(() => {
if (searchInput.current) searchInput.current.focus() if (searchInput.current) searchInput.current.focus()
}, [searchInput]) }, [searchInput])
@ -80,6 +73,7 @@ const SearchModal = (props: Props) => {
.search({ .search({
object: props.object, object: props.object,
query: query, query: query,
job: props.job?.id,
filters: filters, filters: filters,
locale: locale, locale: locale,
page: currentPage, page: currentPage,
@ -99,10 +93,7 @@ const SearchModal = (props: Props) => {
}) })
} }
function replaceResults( function replaceResults(count: number, list: SearchableObjectArray) {
count: number,
list: Weapon[] | Summon[] | Character[]
) {
if (count > 0) { if (count > 0) {
setResults(list) setResults(list)
} else { } else {
@ -110,26 +101,36 @@ const SearchModal = (props: Props) => {
} }
} }
function appendResults(list: Weapon[] | Summon[] | Character[]) { function appendResults(list: SearchableObjectArray) {
setResults([...results, ...list]) setResults([...results, ...list])
} }
function storeRecentResult(result: Character | Weapon | Summon) { function storeRecentResult(result: SearchableObject) {
const key = `recent_${props.object}` const key = `recent_${props.object}`
const cookie = getCookie(key) const cookie = getCookie(key)
const cookieObj: Character[] | Weapon[] | Summon[] = cookie const cookieObj: SearchableObjectArray = cookie
? JSON.parse(cookie as string) ? JSON.parse(cookie as string)
: [] : []
let recents: Character[] | Weapon[] | Summon[] = [] let recents: SearchableObjectArray = []
if (props.object === "weapons") { if (props.object === "weapons") {
recents = cloneDeep(cookieObj as Weapon[]) || [] recents = cloneDeep(cookieObj as Weapon[]) || []
if (!recents.find((item) => item.granblue_id === result.granblue_id)) { if (
!recents.find(
(item) =>
(item as Weapon).granblue_id === (result as Weapon).granblue_id
)
) {
recents.unshift(result as Weapon) recents.unshift(result as Weapon)
} }
} else if (props.object === "summons") { } else if (props.object === "summons") {
recents = cloneDeep(cookieObj as Summon[]) || [] recents = cloneDeep(cookieObj as Summon[]) || []
if (!recents.find((item) => item.granblue_id === result.granblue_id)) { if (
!recents.find(
(item) =>
(item as Summon).granblue_id === (result as Summon).granblue_id
)
) {
recents.unshift(result as Summon) recents.unshift(result as Summon)
} }
} }
@ -139,12 +140,12 @@ const SearchModal = (props: Props) => {
sendData(result) sendData(result)
} }
function sendData(result: Character | Weapon | Summon) { function sendData(result: SearchableObject) {
props.send(result, props.fromPosition) props.send(result, props.fromPosition)
openChange() openChange()
} }
function receiveFilters(filters: { [key: string]: number[] }) { function receiveFilters(filters: { [key: string]: any }) {
setCurrentPage(1) setCurrentPage(1)
setResults([]) setResults([])
setFilters(filters) setFilters(filters)
@ -200,6 +201,9 @@ const SearchModal = (props: Props) => {
case "characters": case "characters":
jsx = renderCharacterSearchResults(results) jsx = renderCharacterSearchResults(results)
break break
case "job_skills":
jsx = renderJobSkillSearchResults(results)
break
} }
return ( return (
@ -278,6 +282,27 @@ const SearchModal = (props: Props) => {
return jsx return jsx
} }
function renderJobSkillSearchResults(results: { [key: string]: any }) {
let jsx: React.ReactNode
const castResults: JobSkill[] = results as JobSkill[]
if (castResults && Object.keys(castResults).length > 0) {
jsx = castResults.map((result: JobSkill) => {
return (
<JobSkillResult
key={result.id}
data={result}
onClick={() => {
storeRecentResult(result)
}}
/>
)
})
}
return jsx
}
function openChange() { function openChange() {
if (open) { if (open) {
setQuery("") setQuery("")
@ -330,6 +355,11 @@ const SearchModal = (props: Props) => {
) : ( ) : (
"" ""
)} )}
{props.object === "job_skills" ? (
<JobSkillSearchFilterBar sendFilters={receiveFilters} />
) : (
""
)}
</div> </div>
<div id="Results" ref={scrollContainer}> <div id="Results" ref={scrollContainer}>

View file

@ -12,6 +12,7 @@ import ExtraSummons from "~components/ExtraSummons"
import api from "~utils/api" import api from "~utils/api"
import { appState } from "~utils/appState" import { appState } from "~utils/appState"
import type { SearchableObject } from "~types"
import "./index.scss" import "./index.scss"
@ -83,10 +84,7 @@ const SummonGrid = (props: Props) => {
]) ])
// Methods: Adding an object from search // Methods: Adding an object from search
function receiveSummonFromSearch( function receiveSummonFromSearch(object: SearchableObject, position: number) {
object: Character | Weapon | Summon,
position: number
) {
const summon = object as Summon const summon = object as Summon
if (!party.id) { if (!party.id) {

View file

@ -1,39 +1,42 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from "react"
import { useRouter } from 'next/router' import { useRouter } from "next/router"
import { useTranslation } from 'next-i18next' import { useTranslation } from "next-i18next"
import classnames from 'classnames' import classnames from "classnames"
import SearchModal from '~components/SearchModal' import SearchModal from "~components/SearchModal"
import SummonHovercard from '~components/SummonHovercard' import SummonHovercard from "~components/SummonHovercard"
import UncapIndicator from '~components/UncapIndicator' import UncapIndicator from "~components/UncapIndicator"
import PlusIcon from '~public/icons/Add.svg' import PlusIcon from "~public/icons/Add.svg"
import './index.scss' import type { SearchableObject } from "~types"
import "./index.scss"
interface Props { interface Props {
gridSummon: GridSummon | undefined gridSummon: GridSummon | undefined
unitType: 0 | 1 | 2 unitType: 0 | 1 | 2
position: number position: number
editable: boolean editable: boolean
updateObject: (object: Character | Weapon | Summon, position: number) => void updateObject: (object: SearchableObject, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void updateUncap: (id: string, position: number, uncap: number) => void
} }
const SummonUnit = (props: Props) => { const SummonUnit = (props: Props) => {
const { t } = useTranslation('common') const { t } = useTranslation("common")
const [imageUrl, setImageUrl] = useState('') const [imageUrl, setImageUrl] = useState("")
const router = useRouter() const router = useRouter()
const locale = (router.locale && ['en', 'ja'].includes(router.locale)) ? router.locale : 'en' const locale =
router.locale && ["en", "ja"].includes(router.locale) ? router.locale : "en"
const classes = classnames({ const classes = classnames({
SummonUnit: true, SummonUnit: true,
'main': props.unitType == 0, main: props.unitType == 0,
'grid': props.unitType == 1, grid: props.unitType == 1,
'friend': props.unitType == 2, friend: props.unitType == 2,
'editable': props.editable, editable: props.editable,
'filled': (props.gridSummon !== undefined) filled: props.gridSummon !== undefined,
}) })
const gridSummon = props.gridSummon const gridSummon = props.gridSummon
@ -49,15 +52,28 @@ const SummonUnit = (props: Props) => {
const summon = props.gridSummon.object! const summon = props.gridSummon.object!
const upgradedSummons = [ const upgradedSummons = [
'2040094000', '2040100000', '2040080000', '2040098000', "2040094000",
'2040090000', '2040084000', '2040003000', '2040056000', "2040100000",
'2040020000', '2040034000', '2040028000', '2040027000', "2040080000",
'2040046000', '2040047000' "2040098000",
"2040090000",
"2040084000",
"2040003000",
"2040056000",
"2040020000",
"2040034000",
"2040028000",
"2040027000",
"2040046000",
"2040047000",
] ]
let suffix = '' let suffix = ""
if (upgradedSummons.indexOf(summon.granblue_id.toString()) != -1 && props.gridSummon.uncap_level == 5) if (
suffix = '_02' upgradedSummons.indexOf(summon.granblue_id.toString()) != -1 &&
props.gridSummon.uncap_level == 5
)
suffix = "_02"
// Generate the correct source for the summon // Generate the correct source for the summon
if (props.unitType == 0 || props.unitType == 2) if (props.unitType == 0 || props.unitType == 2)
@ -77,24 +93,31 @@ const SummonUnit = (props: Props) => {
const image = ( const image = (
<div className="SummonImage"> <div className="SummonImage">
<img alt={summon?.name.en} className="grid_image" src={imageUrl} /> <img alt={summon?.name.en} className="grid_image" src={imageUrl} />
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' } {props.editable ? (
<span className="icon">
<PlusIcon />
</span>
) : (
""
)}
</div> </div>
) )
const editableImage = ( const editableImage = (
<SearchModal <SearchModal
placeholderText={t('search.placeholders.summon')} placeholderText={t("search.placeholders.summon")}
fromPosition={props.position} fromPosition={props.position}
object="summons" object="summons"
send={props.updateObject}> send={props.updateObject}
>
{image} {image}
</SearchModal> </SearchModal>
) )
const unitContent = ( const unitContent = (
<div className={classes}> <div className={classes}>
{ (props.editable) ? editableImage : image } {props.editable ? editableImage : image}
{ (gridSummon) ? {gridSummon ? (
<UncapIndicator <UncapIndicator
type="summon" type="summon"
ulb={gridSummon.object.uncap.ulb || false} ulb={gridSummon.object.uncap.ulb || false}
@ -102,19 +125,19 @@ const SummonUnit = (props: Props) => {
uncapLevel={gridSummon.uncap_level} uncapLevel={gridSummon.uncap_level}
updateUncap={passUncapData} updateUncap={passUncapData}
special={false} special={false}
/> : '' />
} ) : (
""
)}
<h3 className="SummonName">{summon?.name[locale]}</h3> <h3 className="SummonName">{summon?.name[locale]}</h3>
</div> </div>
) )
const withHovercard = ( const withHovercard = (
<SummonHovercard gridSummon={gridSummon!}> <SummonHovercard gridSummon={gridSummon!}>{unitContent}</SummonHovercard>
{unitContent}
</SummonHovercard>
) )
return (gridSummon && !props.editable) ? withHovercard : unitContent return gridSummon && !props.editable ? withHovercard : unitContent
} }
export default SummonUnit export default SummonUnit

View file

@ -1,10 +1,10 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from "react"
import UncapStar from '~components/UncapStar' import UncapStar from "~components/UncapStar"
import './index.scss' import "./index.scss"
interface Props { interface Props {
type: 'character' | 'weapon' | 'summon' type: "character" | "weapon" | "summon"
rarity?: number rarity?: number
uncapLevel?: number uncapLevel?: number
flb: boolean flb: boolean
@ -20,7 +20,7 @@ const UncapIndicator = (props: Props) => {
function setNumStars() { function setNumStars() {
let numStars let numStars
if (props.type === 'character') { if (props.type === "character") {
if (props.special) { if (props.special) {
if (props.ulb) { if (props.ulb) {
numStars = 5 numStars = 5
@ -59,41 +59,69 @@ const UncapIndicator = (props: Props) => {
} }
const transcendence = (i: number) => { const transcendence = (i: number) => {
return <UncapStar ulb={true} empty={ (props.uncapLevel) ? i >= props.uncapLevel : false } key={`star_${i}`} index={i} onClick={toggleStar} /> return (
<UncapStar
ulb={true}
empty={props.uncapLevel ? i >= props.uncapLevel : false}
key={`star_${i}`}
index={i}
onClick={toggleStar}
/>
)
} }
const ulb = (i: number) => { const ulb = (i: number) => {
return <UncapStar ulb={true} empty={ (props.uncapLevel) ? i >= props.uncapLevel : false } key={`star_${i}`} index={i} onClick={toggleStar} /> return (
<UncapStar
ulb={true}
empty={props.uncapLevel ? i >= props.uncapLevel : false}
key={`star_${i}`}
index={i}
onClick={toggleStar}
/>
)
} }
const flb = (i: number) => { const flb = (i: number) => {
return <UncapStar flb={true} empty={ (props.uncapLevel) ? i >= props.uncapLevel : false } key={`star_${i}`} index={i} onClick={toggleStar} /> return (
<UncapStar
flb={true}
empty={props.uncapLevel ? i >= props.uncapLevel : false}
key={`star_${i}`}
index={i}
onClick={toggleStar}
/>
)
} }
const mlb = (i: number) => { const mlb = (i: number) => {
// console.log("MLB; Number of stars:", props.uncapLevel) // console.log("MLB; Number of stars:", props.uncapLevel)
return <UncapStar empty={ (props.uncapLevel) ? i >= props.uncapLevel : false } key={`star_${i}`} index={i} onClick={toggleStar} /> return (
<UncapStar
empty={props.uncapLevel ? i >= props.uncapLevel : false}
key={`star_${i}`}
index={i}
onClick={toggleStar}
/>
)
} }
return ( return (
<ul className="UncapIndicator"> <ul className="UncapIndicator">
{ {Array.from(Array(numStars)).map((x, i) => {
Array.from(Array(numStars)).map((x, i) => { if (props.type === "character" && i > 4) {
if (props.type === 'character' && i > 4) { if (props.special) return ulb(i)
if (props.special) else return transcendence(i)
return ulb(i)
else
return transcendence(i)
} else if ( } else if (
props.special && props.type === 'character' && i == 3 || (props.special && props.type === "character" && i == 3) ||
props.type === 'character' && i == 4 || (props.type === "character" && i == 4) ||
props.type !== 'character' && i > 2) { (props.type !== "character" && i > 2)
) {
return flb(i) return flb(i)
} else { } else {
return mlb(i) return mlb(i)
} }
}) })}
}
</ul> </ul>
) )
} }

View file

@ -12,6 +12,8 @@ import ExtraWeapons from "~components/ExtraWeapons"
import api from "~utils/api" import api from "~utils/api"
import { appState } from "~utils/appState" import { appState } from "~utils/appState"
import type { SearchableObject } from "~types"
import "./index.scss" import "./index.scss"
// Props // Props
@ -71,10 +73,7 @@ const WeaponGrid = (props: Props) => {
}, [appState.grid.weapons.mainWeapon, appState.grid.weapons.allWeapons]) }, [appState.grid.weapons.mainWeapon, appState.grid.weapons.allWeapons])
// Methods: Adding an object from search // Methods: Adding an object from search
function receiveWeaponFromSearch( function receiveWeaponFromSearch(object: SearchableObject, position: number) {
object: Character | Weapon | Summon,
position: number
) {
const weapon = object as Weapon const weapon = object as Weapon
if (position == 1) appState.party.element = weapon.element if (position == 1) appState.party.element = weapon.element

View file

@ -1,42 +1,44 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from "react"
import { useRouter } from 'next/router' import { useRouter } from "next/router"
import { useTranslation } from 'next-i18next' import { useTranslation } from "next-i18next"
import classnames from 'classnames' import classnames from "classnames"
import SearchModal from '~components/SearchModal' import SearchModal from "~components/SearchModal"
import WeaponModal from '~components/WeaponModal' import WeaponModal from "~components/WeaponModal"
import WeaponHovercard from '~components/WeaponHovercard' import WeaponHovercard from "~components/WeaponHovercard"
import UncapIndicator from '~components/UncapIndicator' import UncapIndicator from "~components/UncapIndicator"
import Button from '~components/Button' import Button from "~components/Button"
import { ButtonType } from '~utils/enums' import { ButtonType } from "~utils/enums"
import type { SearchableObject } from "~types"
import PlusIcon from '~public/icons/Add.svg' import PlusIcon from "~public/icons/Add.svg"
import './index.scss' import "./index.scss"
interface Props { interface Props {
gridWeapon: GridWeapon | undefined gridWeapon: GridWeapon | undefined
unitType: 0 | 1 unitType: 0 | 1
position: number position: number
editable: boolean editable: boolean
updateObject: (object: Character | Weapon | Summon, position: number) => void updateObject: (object: SearchableObject, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void updateUncap: (id: string, position: number, uncap: number) => void
} }
const WeaponUnit = (props: Props) => { const WeaponUnit = (props: Props) => {
const { t } = useTranslation('common') const { t } = useTranslation("common")
const [imageUrl, setImageUrl] = useState('') const [imageUrl, setImageUrl] = useState("")
const router = useRouter() const router = useRouter()
const locale = (router.locale && ['en', 'ja'].includes(router.locale)) ? router.locale : 'en' const locale =
router.locale && ["en", "ja"].includes(router.locale) ? router.locale : "en"
const classes = classnames({ const classes = classnames({
WeaponUnit: true, WeaponUnit: true,
'mainhand': props.unitType == 0, mainhand: props.unitType == 0,
'grid': props.unitType == 1, grid: props.unitType == 1,
'editable': props.editable, editable: props.editable,
'filled': (props.gridWeapon !== undefined) filled: props.gridWeapon !== undefined,
}) })
const gridWeapon = props.gridWeapon const gridWeapon = props.gridWeapon
@ -75,37 +77,49 @@ const WeaponUnit = (props: Props) => {
function canBeModified(gridWeapon: GridWeapon) { function canBeModified(gridWeapon: GridWeapon) {
const weapon = gridWeapon.object const weapon = gridWeapon.object
return weapon.ax > 0 || return (
(weapon.series) && [2, 3, 17, 22, 24].includes(weapon.series) weapon.ax > 0 ||
(weapon.series && [2, 3, 17, 22, 24].includes(weapon.series))
)
} }
const image = ( const image = (
<div className="WeaponImage"> <div className="WeaponImage">
<img alt={weapon?.name.en} className="grid_image" src={imageUrl} /> <img alt={weapon?.name.en} className="grid_image" src={imageUrl} />
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' } {props.editable ? (
<span className="icon">
<PlusIcon />
</span>
) : (
""
)}
</div> </div>
) )
const editableImage = ( const editableImage = (
<SearchModal <SearchModal
placeholderText={t('search.placeholders.weapon')} placeholderText={t("search.placeholders.weapon")}
fromPosition={props.position} fromPosition={props.position}
object="weapons" object="weapons"
send={props.updateObject}> send={props.updateObject}
>
{image} {image}
</SearchModal> </SearchModal>
) )
const unitContent = ( const unitContent = (
<div className={classes}> <div className={classes}>
{ (props.editable && gridWeapon && canBeModified(gridWeapon)) ? {props.editable && gridWeapon && canBeModified(gridWeapon) ? (
<WeaponModal gridWeapon={gridWeapon}> <WeaponModal gridWeapon={gridWeapon}>
<div> <div>
<Button icon="settings" type={ButtonType.IconOnly}/> <Button icon="settings" type={ButtonType.IconOnly} />
</div> </div>
</WeaponModal>: '' } </WeaponModal>
{ (props.editable) ? editableImage : image } ) : (
{ (gridWeapon) ? ""
)}
{props.editable ? editableImage : image}
{gridWeapon ? (
<UncapIndicator <UncapIndicator
type="weapon" type="weapon"
ulb={gridWeapon.object.uncap.ulb || false} ulb={gridWeapon.object.uncap.ulb || false}
@ -113,19 +127,19 @@ const WeaponUnit = (props: Props) => {
uncapLevel={gridWeapon.uncap_level} uncapLevel={gridWeapon.uncap_level}
updateUncap={passUncapData} updateUncap={passUncapData}
special={false} special={false}
/> : '' />
} ) : (
""
)}
<h3 className="WeaponName">{weapon?.name[locale]}</h3> <h3 className="WeaponName">{weapon?.name[locale]}</h3>
</div> </div>
) )
const withHovercard = ( const withHovercard = (
<WeaponHovercard gridWeapon={gridWeapon!}> <WeaponHovercard gridWeapon={gridWeapon!}>{unitContent}</WeaponHovercard>
{unitContent}
</WeaponHovercard>
) )
return (gridWeapon && !props.editable) ? withHovercard : unitContent return gridWeapon && !props.editable ? withHovercard : unitContent
} }
export default WeaponUnit export default WeaponUnit

View file

@ -1,13 +1,17 @@
import React from "react" import React, { useEffect } from "react"
import { getCookie } from "cookies-next" import { getCookie } from "cookies-next"
import { serverSideTranslations } from "next-i18next/serverSideTranslations" import { serverSideTranslations } from "next-i18next/serverSideTranslations"
import Party from "~components/Party" import Party from "~components/Party"
import { appState } from "~utils/appState"
import api from "~utils/api" import api from "~utils/api"
import type { NextApiRequest, NextApiResponse } from "next" import type { NextApiRequest, NextApiResponse } from "next"
interface Props { interface Props {
jobs: Job[]
jobSkills: JobSkill[]
raids: Raid[] raids: Raid[]
sortedRaids: Raid[][] sortedRaids: Raid[][]
} }
@ -18,6 +22,16 @@ const NewRoute: React.FC<Props> = (props: Props) => {
window.history.replaceState(null, `Grid Tool`, `${path}`) window.history.replaceState(null, `Grid Tool`, `${path}`)
} }
useEffect(() => {
persistStaticData()
}, [persistStaticData])
function persistStaticData() {
appState.raids = props.raids
appState.jobs = props.jobs
appState.jobSkills = props.jobSkills
}
return ( return (
<div id="Content"> <div id="Content">
<Party new={true} raids={props.sortedRaids} pushHistory={callback} /> <Party new={true} raids={props.sortedRaids} pushHistory={callback} />
@ -51,8 +65,17 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
.getAll({ params: headers }) .getAll({ params: headers })
.then((response) => organizeRaids(response.data.map((r: any) => r.raid))) .then((response) => organizeRaids(response.data.map((r: any) => r.raid)))
let jobs = await api.endpoints.jobs
.getAll({ params: headers })
.then((response) => { return response.data })
let jobSkills = await api.allSkills(headers)
.then((response) => { return response.data })
return { return {
props: { props: {
jobs: jobs,
jobSkills: jobSkills,
raids: raids, raids: raids,
sortedRaids: sortedRaids, sortedRaids: sortedRaids,
...(await serverSideTranslations(locale, ["common"])), ...(await serverSideTranslations(locale, ["common"])),

View file

@ -1,20 +1,33 @@
import React from "react" import React, { useEffect } from "react"
import { getCookie } from "cookies-next" import { getCookie } from "cookies-next"
import { serverSideTranslations } from "next-i18next/serverSideTranslations" import { serverSideTranslations } from "next-i18next/serverSideTranslations"
import Party from "~components/Party" import Party from "~components/Party"
import { appState } from "~utils/appState"
import api from "~utils/api" import api from "~utils/api"
import type { NextApiRequest, NextApiResponse } from "next" import type { NextApiRequest, NextApiResponse } from "next"
interface Props { interface Props {
party: Party party: Party
jobs: Job[]
jobSkills: JobSkill[]
raids: Raid[] raids: Raid[]
sortedRaids: Raid[][] sortedRaids: Raid[][]
} }
const PartyRoute: React.FC<Props> = (props: Props) => { const PartyRoute: React.FC<Props> = (props: Props) => {
useEffect(() => {
persistStaticData()
}, [persistStaticData])
function persistStaticData() {
appState.raids = props.raids
appState.jobs = props.jobs
appState.jobSkills = props.jobSkills
}
return ( return (
<div id="Content"> <div id="Content">
<Party team={props.party} raids={props.sortedRaids} /> <Party team={props.party} raids={props.sortedRaids} />
@ -48,6 +61,16 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
.getAll() .getAll()
.then((response) => organizeRaids(response.data.map((r: any) => r.raid))) .then((response) => organizeRaids(response.data.map((r: any) => r.raid)))
let jobs = await api.endpoints.jobs
.getAll({ params: headers })
.then((response) => {
return response.data
})
let jobSkills = await api.allSkills(headers).then((response) => {
return response.data
})
let party: Party | null = null let party: Party | null = null
if (query.party) { if (query.party) {
let response = await api.endpoints.parties.getOne({ id: query.party, params: headers }) let response = await api.endpoints.parties.getOne({ id: query.party, params: headers })
@ -59,6 +82,8 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
return { return {
props: { props: {
party: party, party: party,
jobs: jobs,
jobSkills: jobSkills,
raids: raids, raids: raids,
sortedRaids: sortedRaids, sortedRaids: sortedRaids,
...(await serverSideTranslations(locale, ["common"])), ...(await serverSideTranslations(locale, ["common"])),

View file

@ -232,7 +232,8 @@
"placeholders": { "placeholders": {
"weapon": "Search for a weapon...", "weapon": "Search for a weapon...",
"summon": "Search for a summon...", "summon": "Search for a summon...",
"character": "Search for a character..." "character": "Search for a character...",
"job_skill": "Search job skills..."
} }
}, },
"teams": { "teams": {
@ -240,6 +241,19 @@
"loading": "Loading teams...", "loading": "Loading teams...",
"not_found": "No teams found" "not_found": "No teams found"
}, },
"job_skills": {
"all": "All skills",
"buffing": "Buffing",
"debuffing": "Debuffing",
"damaging": "Damaging",
"healing": "Healing",
"emp": "Extended Mastery",
"base": "Base Skills",
"state": {
"selectable": "Select a skill",
"no_skill": "No skill"
}
},
"extra_weapons": "Additional Weapons", "extra_weapons": "Additional Weapons",
"coming_soon": "Coming Soon", "coming_soon": "Coming Soon",
"no_title": "Untitled", "no_title": "Untitled",

View file

@ -233,7 +233,8 @@
"placeholders": { "placeholders": {
"weapon": "武器を検索...", "weapon": "武器を検索...",
"summon": "召喚石を検索...", "summon": "召喚石を検索...",
"character": "キャラを検索..." "character": "キャラを検索...",
"job_skill": "ジョブのスキルを検索..."
} }
}, },
"teams": { "teams": {
@ -241,6 +242,19 @@
"loading": "ロード中...", "loading": "ロード中...",
"not_found": "編成は見つかりませんでした" "not_found": "編成は見つかりませんでした"
}, },
"job_skills": {
"all": "全てのアビリティ",
"buffing": "強化アビリティ",
"debuffing": "弱体アビリティ",
"damaging": "ダメージアビリティ",
"healing": "回復アビリティ",
"emp": "リミットアビリティ",
"base": "ベースアビリティ",
"state": {
"selectable": "アビリティを選択",
"no_skill": "設定されていません"
}
},
"extra_weapons": "Additional<br/>Weapons", "extra_weapons": "Additional<br/>Weapons",
"coming_soon": "開発中", "coming_soon": "開発中",
"no_title": "無題", "no_title": "無題",

1
types/Job.d.ts vendored
View file

@ -12,4 +12,5 @@ interface Job {
proficiency1: number proficiency1: number
proficiency2: number proficiency2: number
} }
base_job?: Job
} }

16
types/JobSkill.d.ts vendored Normal file
View file

@ -0,0 +1,16 @@
interface JobSkill {
id: string
job: Job
name: {
[key: string]: string
en: string
ja: string
}
slug: string
color: number
main: boolean
base: boolean
sub: boolean
emp: boolean
order: number
}

10
types/Party.d.ts vendored
View file

@ -1,8 +1,18 @@
type JobSkillObject = {
[key: number]: JobSkill | undefined
0: JobSkill | undefined
1: JobSkill | undefined
2: JobSkill | undefined
3: JobSkill | undefined
}
interface Party { interface Party {
id: string id: string
name: string name: string
description: string description: string
raid: Raid raid: Raid
job: Job
job_skills: JobSkillObject
shortcode: string shortcode: string
extra: boolean extra: boolean
favorited: boolean favorited: boolean

9
types/index.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
export type SearchableObject = Character | Weapon | Summon | JobSkill
export type SearchableObjectArray = (Character | Weapon | Summon | JobSkill)[]
export type JobSkillObject = {
[key: number]: JobSkill | undefined
0: JobSkill | undefined
1: JobSkill | undefined
2: JobSkill | undefined
3: JobSkill | undefined
}

View file

@ -25,7 +25,7 @@ class Api {
url: string url: string
endpoints: { [key: string]: EndpointMap } endpoints: { [key: string]: EndpointMap }
constructor({url}: {url: string}) { constructor({ url }: { url: string }) {
this.url = url this.url = url
this.endpoints = {} this.endpoints = {}
} }
@ -56,13 +56,14 @@ class Api {
return axios.post(`${ oauthUrl }/token`, object) return axios.post(`${ oauthUrl }/token`, object)
} }
search({ object, query, filters, locale = "en", page = 0 }: search({ object, query, job, filters, locale = "en", page = 0 }:
{ object: string, query: string, filters?: { [key: string]: number[] }, locale?: string, page?: number }) { { object: string, query: string, job?: string, filters?: { [key: string]: number[] }, locale?: string, page?: number }) {
const resourceUrl = `${this.url}/${name}` const resourceUrl = `${this.url}/${name}`
return axios.post(`${resourceUrl}search/${object}`, { return axios.post(`${resourceUrl}search/${object}`, {
search: { search: {
query: query, query: query,
filters: filters, filters: filters,
job: job,
locale: locale, locale: locale,
page: page page: page
} }
@ -92,6 +93,22 @@ class Api {
const resourceUrl = `${this.url}/characters/resolve` const resourceUrl = `${this.url}/characters/resolve`
return axios.post(resourceUrl, body, { headers: params }) return axios.post(resourceUrl, body, { headers: params })
} }
updateJob({ partyId, params }: { partyId: string, params?: {} }) {
const resourceUrl = `${this.url}/parties/${partyId}/jobs`
return axios.put(resourceUrl, params)
}
updateJobSkills({ partyId, params }: { partyId: string, params?: {} }) {
const resourceUrl = `${this.url}/parties/${partyId}/job_skills`
return axios.put(resourceUrl, params)
}
allSkills(params: {}) {
const resourceUrl = `${this.url}/jobs/skills`
return axios.get(resourceUrl, params)
}
savedTeams(params: {}) { savedTeams(params: {}) {
const resourceUrl = `${this.url}/parties/favorites` const resourceUrl = `${this.url}/parties/favorites`
return axios.get(resourceUrl, params) return axios.get(resourceUrl, params)
@ -127,15 +144,15 @@ class Api {
} }
const api: Api = new Api({ url: process.env.NEXT_PUBLIC_SIERO_API_URL || 'https://localhost:3000/api/v1'}) const api: Api = new Api({ url: process.env.NEXT_PUBLIC_SIERO_API_URL || 'https://localhost:3000/api/v1'})
api.createEntity( { name: 'users' }) api.createEntity({ name: 'users' })
api.createEntity( { name: 'parties' }) api.createEntity({ name: 'parties' })
api.createEntity( { name: 'grid_weapons' }) api.createEntity({ name: 'grid_weapons' })
api.createEntity( { name: 'characters' }) api.createEntity({ name: 'characters' })
api.createEntity( { name: 'weapons' }) api.createEntity({ name: 'weapons' })
api.createEntity( { name: 'summons' }) api.createEntity({ name: 'summons' })
api.createEntity( { name: 'jobs' }) api.createEntity({ name: 'jobs' })
api.createEntity( { name: 'raids' }) api.createEntity({ name: 'raids' })
api.createEntity( { name: 'weapon_keys' }) api.createEntity({ name: 'weapon_keys' })
api.createEntity( { name: 'favorites' }) api.createEntity({ name: 'favorites' })
export default api export default api

View file

@ -1,4 +1,5 @@
import { proxy } from "valtio" import { proxy } from "valtio"
import { JobSkillObject } from "~types"
const emptyJob: Job = { const emptyJob: Job = {
id: "-1", id: "-1",
@ -25,6 +26,7 @@ interface AppState {
name: string | undefined name: string | undefined
description: string | undefined description: string | undefined
job: Job job: Job
jobSkills: JobSkillObject
raid: Raid | undefined raid: Raid | undefined
element: number element: number
extra: boolean extra: boolean
@ -53,6 +55,8 @@ interface AppState {
} }
} }
raids: Raid[] raids: Raid[]
jobs: Job[]
jobSkills: JobSkill[]
} }
export const initialAppState: AppState = { export const initialAppState: AppState = {
@ -63,6 +67,12 @@ export const initialAppState: AppState = {
name: undefined, name: undefined,
description: undefined, description: undefined,
job: emptyJob, job: emptyJob,
jobSkills: {
0: undefined,
1: undefined,
2: undefined,
3: undefined,
},
raid: undefined, raid: undefined,
element: 0, element: 0,
extra: false, extra: false,
@ -91,6 +101,8 @@ export const initialAppState: AppState = {
}, },
}, },
raids: [], raids: [],
jobs: [],
jobSkills: [],
} }
export const appState = proxy(initialAppState) export const appState = proxy(initialAppState)

91
utils/skillGroups.tsx Normal file
View file

@ -0,0 +1,91 @@
export interface SkillGroup {
id: number
name: {
[key: string]: string
en: string
ja: string
}
}
export const skillClassification: SkillGroup[] = [
{
id: 0,
name: {
en: "Buffing",
ja: "強化アビリティ",
},
},
{
id: 1,
name: {
en: "Debuffing",
ja: "弱体アビリティ",
},
},
{
id: 2,
name: {
en: "Damaging",
ja: "ダメージアビリティ",
},
},
{
id: 3,
name: {
en: "Healing",
ja: "回復アビリティ",
},
},
{
id: 4,
name: {
en: "Field",
ja: "フィールドアビリティ",
},
},
]
export const skillGroups: SkillGroup[] = [
{
id: 0,
name: {
en: "Buffing",
ja: "強化アビリティ",
},
},
{
id: 1,
name: {
en: "Debuffing",
ja: "弱体アビリティ",
},
},
{
id: 2,
name: {
en: "Damaging",
ja: "ダメージアビリティ",
},
},
{
id: 3,
name: {
en: "Healing",
ja: "回復アビリティ",
},
},
{
id: 4,
name: {
en: "Extended Mastery",
ja: "リミットアビリティ",
},
},
{
id: 5,
name: {
en: "Base",
ja: "ベースアビリティ",
},
},
]