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/summon*
public/images/chara*
public/images/jobs
public/images/job*
# Typescript v1 declaration files
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 > 2) suffix = "02"
console.log(appState.grid.weapons.mainWeapon)
// Special casing for Lyria (and Young Cat eventually)
if (character?.granblue_id === "3030182000") {
let element = 1

View file

@ -6,14 +6,17 @@ import { useSnapshot } from "valtio"
import { AxiosResponse } from "axios"
import debounce from "lodash.debounce"
import Alert from "~components/Alert"
import JobSection from "~components/JobSection"
import CharacterUnit from "~components/CharacterUnit"
import CharacterConflictModal from "~components/CharacterConflictModal"
import type { JobSkillObject, SearchableObject } from "~types"
import api from "~utils/api"
import { appState } from "~utils/appState"
import "./index.scss"
import CharacterConflictModal from "~components/CharacterConflictModal"
// Props
interface Props {
@ -46,6 +49,16 @@ const CharacterGrid = (props: Props) => {
const [conflicts, setConflicts] = useState<GridCharacter[]>([])
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
const [previousUncapValues, setPreviousUncapValues] = useState<{
[key: number]: number | undefined
@ -62,6 +75,11 @@ const CharacterGrid = (props: Props) => {
else appState.party.editable = false
}, [props.new, accountData, party])
useEffect(() => {
setJob(appState.party.job)
setJobSkills(appState.party.jobSkills)
}, [appState])
// Initialize an array of current uncap values for each characters
useEffect(() => {
let initialPreviousUncapValues: { [key: number]: number } = {}
@ -73,7 +91,7 @@ const CharacterGrid = (props: Props) => {
// Methods: Adding an object from search
function receiveCharacterFromSearch(
object: Character | Weapon | Summon,
object: SearchableObject,
position: number
) {
const character = object as Character
@ -163,6 +181,69 @@ const CharacterGrid = (props: Props) => {
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
function characterUncapLevel(character: Character) {
let uncapLevel
@ -250,11 +331,27 @@ const CharacterGrid = (props: Props) => {
}
}
function cancelAlert() {
setErrorMessage("")
}
// Render: JSX components
return (
<div>
<Alert
open={errorMessage.length > 0}
message={errorMessage}
cancelAction={cancelAlert}
cancelActionText={"Got it"}
/>
<div id="CharacterGrid">
<JobSection />
<JobSection
job={job}
jobSkills={jobSkills}
editable={party.editable}
saveJob={saveJob}
saveSkill={saveJobSkill}
/>
<CharacterConflictModal
open={modalOpen}
incomingCharacter={incoming}

View file

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

View file

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

View file

@ -1,97 +1,113 @@
import React, { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import React, { useEffect, useState } from "react"
import { useRouter } from "next/router"
import { useSnapshot } from "valtio"
import api from '~utils/api'
import { appState } from '~utils/appState'
import { jobGroups } from '~utils/jobGroups'
import { appState } from "~utils/appState"
import { jobGroups } from "~utils/jobGroups"
import './index.scss'
import "./index.scss"
// Props
interface Props {
currentJob?: string
onChange?: (job?: Job) => void
onBlur?: (event: React.ChangeEvent<HTMLSelectElement>) => void
currentJob?: string
onChange?: (job?: Job) => void
onBlur?: (event: React.ChangeEvent<HTMLSelectElement>) => void
}
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
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
const [currentJob, setCurrentJob] = useState<Job>()
const [jobs, setJobs] = useState<Job[]>()
const [sortedJobs, setSortedJobs] = useState<GroupedJob>()
// Organize jobs into groups on mount
const organizeJobs = useCallback((jobs: Job[]) => {
const jobGroups = jobs.map(job => job.row).filter((value, index, self) => self.indexOf(value) === index)
let groupedJobs: GroupedJob = {}
jobGroups.forEach(group => {
groupedJobs[group] = jobs.filter(job => job.row === group)
})
setJobs(jobs)
setSortedJobs(groupedJobs)
appState.jobs = jobs
// Set current job from state on mount
useEffect(() => {
setCurrentJob(party.job)
}, [])
// Fetch all jobs on mount
// Organize jobs into groups on mount
useEffect(() => {
api.endpoints.jobs.getAll()
.then(response => organizeJobs(response.data))
}, [organizeJobs])
const jobGroups = appState.jobs
.map((job) => job.row)
.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
useEffect(() => {
if (jobs && props.currentJob) {
const job = jobs.find(job => job.id === props.currentJob)
setCurrentJob(job)
}
}, [jobs, props.currentJob])
if (jobs && props.currentJob) {
const job = appState.jobs.find((job) => job.id === props.currentJob)
setCurrentJob(job)
}
}, [appState, props.currentJob])
// Enable changing select value
function handleChange(event: React.ChangeEvent<HTMLSelectElement>) {
if (jobs) {
const job = jobs.find(job => job.id === event.target.value)
if (props.onChange) props.onChange(job)
setCurrentJob(job)
}
if (jobs) {
const job = jobs.find((job) => job.id === event.target.value)
if (props.onChange) props.onChange(job)
setCurrentJob(job)
}
}
// Render JSX for each job option, sorted into optgroups
function renderJobGroup(group: string) {
const options = sortedJobs && sortedJobs[group].length > 0 &&
sortedJobs[group].sort((a, b) => a.order - b.order).map((item, i) => {
return (
<option key={i} value={item.id}>{item.name[locale]}</option>
)
})
const options =
sortedJobs &&
sortedJobs[group].length > 0 &&
sortedJobs[group]
.sort((a, b) => a.order - b.order)
.map((item, i) => {
return (
<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 (
<optgroup key={group} label={groupName}>
{options}
</optgroup>
)
return (
<optgroup key={group} label={groupName}>
{options}
</optgroup>
)
}
return (
<select
key={currentJob?.id}
value={currentJob?.id}
onBlur={props.onBlur}
onChange={handleChange}
ref={ref}>
<option key="no-job" value={-1}>No class</option>
{ (sortedJobs) ? Object.keys(sortedJobs).map(x => renderJobGroup(x)) : '' }
</select>
<select
key={currentJob ? currentJob.id : -1}
value={currentJob ? currentJob.id : -1}
onBlur={props.onBlur}
onChange={handleChange}
ref={ref}
>
<option key="no-job" value={-1}>
No class
</option>
{sortedJobs
? Object.keys(sortedJobs).map((x) => renderJobGroup(x))
: ""}
</select>
)
})
}
)
export default JobDropdown

View file

@ -1,44 +1,73 @@
#Job {
display: flex;
margin-bottom: $unit * 3;
select {
flex-grow: 1;
width: auto;
}
.JobDetails {
display: flex;
margin-bottom: $unit * 3;
flex-direction: column;
width: 100%;
h3 {
font-size: $font-medium;
font-weight: $medium;
padding: $unit 0 $unit * 2;
}
select {
flex-grow: 1;
width: auto;
flex-grow: 0;
}
.JobImage {
$height: 249px;
$width: 447px;
background: url('/images/background_a.jpg');
background-size: 500px 281px;
border-radius: $unit;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2);
display: block;
flex-grow: 2;
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;
img {
position: relative;
top: $unit * -4;
left: 50%;
transform: translateX(-50%);
width: 100%;
z-index: 2;
}
.Overlay {
background: rgba(255, 255, 255, 0.12);
position: absolute;
z-index: 1;
}
.JobSkills {
flex-grow: 2;
}
}
}
.JobImage {
$height: 249px;
$width: 447px;
background: url("/images/background_a.jpg");
background-size: 500px 281px;
border-radius: $unit;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2);
display: block;
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;
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;
}
.Overlay {
background: none;
position: absolute;
z-index: 1;
}
}
.JobSkills {
display: flex;
flex-direction: column;
gap: $unit;
}
}

View file

@ -1,64 +1,165 @@
import React, { useEffect, useState } from 'react'
import { useSnapshot } from 'valtio'
import React, { ForwardedRef, useEffect, useState } from "react"
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
interface Props {}
const JobSection = (props: Props) => {
const [job, setJob] = useState<Job>()
const [imageUrl, setImageUrl] = useState('')
const { party } = useSnapshot(appState)
useEffect(() => {
// Set current job based on ID
setJob(party.job)
}, [])
useEffect(() => {
generateImageUrl()
})
useEffect(() => {
if (job) appState.party.job = job
}, [job])
function receiveJob(job?: Job) {
setJob(job)
}
function generateImageUrl() {
let imgSrc = ""
if (job) {
const slug = job?.name.en.replaceAll(' ', '-').toLowerCase()
const gender = (party.user && party.user.gender == 1) ? 'b' : 'a'
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/jobs/${slug}_${gender}.png`
}
setImageUrl(imgSrc)
}
// Render: JSX components
return (
<section id="Job">
<div className="JobImage">
<img src={imageUrl} />
<div className="Overlay" />
</div>
<JobDropdown
currentJob={ (party.job) ? party.job.id : undefined}
onChange={receiveJob}
/>
</section>
)
interface Props {
job?: Job
jobSkills: JobSkillObject
editable: boolean
saveJob: (job: Job) => void
saveSkill: (skill: JobSkill, position: number) => void
}
export default JobSection
const JobSection = (props: Props) => {
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(() => {
// Set current job based on ID
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(() => {
generateImageUrl()
})
useEffect(() => {
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])
function receiveJob(job?: Job) {
if (job) {
setJob(job)
props.saveJob(job)
}
}
function generateImageUrl() {
let imgSrc = ""
if (job) {
const slug = job?.name.en.replaceAll(" ", "-").toLowerCase()
const gender = party.user && party.user.gender == 1 ? "b" : "a"
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/jobs/${slug}_${gender}.png`
}
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
return (
<section id="Job">
<div className="JobImage">
<img src={imageUrl} />
<div className="Overlay" />
</div>
<div className="JobDetails">
{props.editable ? (
<JobDropdown
currentJob={party.job?.id}
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>
)
}
export default JobSection

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
const { party } = useSnapshot(appState)
const jobState = party.job
const [job, setJob] = useState<Job>()
const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon)
// Reset state on first load
@ -54,14 +51,6 @@ const Party = (props: Props) => {
if (props.team) storeParty(props.team)
}, [])
useEffect(() => {
setJob(jobState)
}, [jobState])
useEffect(() => {
jobChanged()
}, [job])
// Methods: Creating a new party
async function createParty(extra: boolean = false) {
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) {
if (
appState.party.name !== name ||
@ -160,6 +137,8 @@ const Party = (props: Props) => {
appState.party.description = party.description
appState.party.raid = party.raid
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.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 { useRouter } from "next/router"
import { useSnapshot } from "valtio"
import { useTranslation } from "react-i18next"
import InfiniteScroll from "react-infinite-scroll-component"
import { appState } from "~utils/appState"
import api from "~utils/api"
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 WeaponSearchFilterBar from "~components/WeaponSearchFilterBar"
import SummonSearchFilterBar from "~components/SummonSearchFilterBar"
import JobSkillSearchFilterBar from "~components/JobSkillSearchFilterBar"
import CharacterResult from "~components/CharacterResult"
import WeaponResult from "~components/WeaponResult"
import SummonResult from "~components/SummonResult"
import JobSkillResult from "~components/JobSkillResult"
import type { SearchableObject, SearchableObjectArray } from "~types"
import "./index.scss"
import CrossIcon from "~public/icons/Cross.svg"
import cloneDeep from "lodash.clonedeep"
interface Props {
send: (object: Character | Weapon | Summon, position: number) => any
send: (object: SearchableObject, position: number) => any
placeholderText: string
fromPosition: number
object: "weapons" | "characters" | "summons"
job?: Job
object: "weapons" | "characters" | "summons" | "job_skills"
children: React.ReactNode
}
const SearchModal = (props: Props) => {
// Set up snapshot of app state
let { grid, search } = useSnapshot(appState)
// Set up router
const router = useRouter()
const locale = router.locale
@ -45,23 +45,16 @@ const SearchModal = (props: Props) => {
let scrollContainer = React.createRef<HTMLDivElement>()
const [firstLoad, setFirstLoad] = useState(true)
const [objects, setObjects] = useState<{
[id: number]: GridCharacter | GridWeapon | GridSummon | undefined
}>()
const [filters, setFilters] = useState<{ [key: string]: number[] }>()
const [filters, setFilters] = useState<{ [key: string]: any }>()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const [results, setResults] = useState<(Weapon | Summon | Character)[]>([])
const [results, setResults] = useState<SearchableObjectArray>([])
// Pagination states
const [recordCount, setRecordCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
useEffect(() => {
setObjects(grid[props.object])
}, [grid, props.object])
useEffect(() => {
if (searchInput.current) searchInput.current.focus()
}, [searchInput])
@ -80,6 +73,7 @@ const SearchModal = (props: Props) => {
.search({
object: props.object,
query: query,
job: props.job?.id,
filters: filters,
locale: locale,
page: currentPage,
@ -99,10 +93,7 @@ const SearchModal = (props: Props) => {
})
}
function replaceResults(
count: number,
list: Weapon[] | Summon[] | Character[]
) {
function replaceResults(count: number, list: SearchableObjectArray) {
if (count > 0) {
setResults(list)
} else {
@ -110,26 +101,36 @@ const SearchModal = (props: Props) => {
}
}
function appendResults(list: Weapon[] | Summon[] | Character[]) {
function appendResults(list: SearchableObjectArray) {
setResults([...results, ...list])
}
function storeRecentResult(result: Character | Weapon | Summon) {
function storeRecentResult(result: SearchableObject) {
const key = `recent_${props.object}`
const cookie = getCookie(key)
const cookieObj: Character[] | Weapon[] | Summon[] = cookie
const cookieObj: SearchableObjectArray = cookie
? JSON.parse(cookie as string)
: []
let recents: Character[] | Weapon[] | Summon[] = []
let recents: SearchableObjectArray = []
if (props.object === "weapons") {
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)
}
} else if (props.object === "summons") {
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)
}
}
@ -139,12 +140,12 @@ const SearchModal = (props: Props) => {
sendData(result)
}
function sendData(result: Character | Weapon | Summon) {
function sendData(result: SearchableObject) {
props.send(result, props.fromPosition)
openChange()
}
function receiveFilters(filters: { [key: string]: number[] }) {
function receiveFilters(filters: { [key: string]: any }) {
setCurrentPage(1)
setResults([])
setFilters(filters)
@ -200,6 +201,9 @@ const SearchModal = (props: Props) => {
case "characters":
jsx = renderCharacterSearchResults(results)
break
case "job_skills":
jsx = renderJobSkillSearchResults(results)
break
}
return (
@ -278,6 +282,27 @@ const SearchModal = (props: Props) => {
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() {
if (open) {
setQuery("")
@ -330,6 +355,11 @@ const SearchModal = (props: Props) => {
) : (
""
)}
{props.object === "job_skills" ? (
<JobSkillSearchFilterBar sendFilters={receiveFilters} />
) : (
""
)}
</div>
<div id="Results" ref={scrollContainer}>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,131 +1,145 @@
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import classnames from 'classnames'
import React, { useEffect, useState } from "react"
import { useRouter } from "next/router"
import { useTranslation } from "next-i18next"
import classnames from "classnames"
import SearchModal from '~components/SearchModal'
import WeaponModal from '~components/WeaponModal'
import WeaponHovercard from '~components/WeaponHovercard'
import UncapIndicator from '~components/UncapIndicator'
import Button from '~components/Button'
import SearchModal from "~components/SearchModal"
import WeaponModal from "~components/WeaponModal"
import WeaponHovercard from "~components/WeaponHovercard"
import UncapIndicator from "~components/UncapIndicator"
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 './index.scss'
import PlusIcon from "~public/icons/Add.svg"
import "./index.scss"
interface Props {
gridWeapon: GridWeapon | undefined
unitType: 0 | 1
position: number
editable: boolean
updateObject: (object: Character | Weapon | Summon, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
gridWeapon: GridWeapon | undefined
unitType: 0 | 1
position: number
editable: boolean
updateObject: (object: SearchableObject, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
}
const WeaponUnit = (props: Props) => {
const { t } = useTranslation('common')
const { t } = useTranslation("common")
const [imageUrl, setImageUrl] = useState('')
const [imageUrl, setImageUrl] = useState("")
const router = useRouter()
const locale = (router.locale && ['en', 'ja'].includes(router.locale)) ? router.locale : 'en'
const router = useRouter()
const locale =
router.locale && ["en", "ja"].includes(router.locale) ? router.locale : "en"
const classes = classnames({
WeaponUnit: true,
'mainhand': props.unitType == 0,
'grid': props.unitType == 1,
'editable': props.editable,
'filled': (props.gridWeapon !== undefined)
})
const classes = classnames({
WeaponUnit: true,
mainhand: props.unitType == 0,
grid: props.unitType == 1,
editable: props.editable,
filled: props.gridWeapon !== undefined,
})
const gridWeapon = props.gridWeapon
const weapon = gridWeapon?.object
const gridWeapon = props.gridWeapon
const weapon = gridWeapon?.object
useEffect(() => {
generateImageUrl()
})
useEffect(() => {
generateImageUrl()
})
function generateImageUrl() {
let imgSrc = ""
if (props.gridWeapon) {
const weapon = props.gridWeapon.object!
if (props.unitType == 0) {
if (props.gridWeapon.object.element == 0 && props.gridWeapon.element)
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${weapon.granblue_id}_${props.gridWeapon.element}.jpg`
else
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${weapon.granblue_id}.jpg`
} else {
if (props.gridWeapon.object.element == 0 && props.gridWeapon.element)
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}_${props.gridWeapon.element}.jpg`
else
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}.jpg`
}
}
setImageUrl(imgSrc)
function generateImageUrl() {
let imgSrc = ""
if (props.gridWeapon) {
const weapon = props.gridWeapon.object!
if (props.unitType == 0) {
if (props.gridWeapon.object.element == 0 && props.gridWeapon.element)
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${weapon.granblue_id}_${props.gridWeapon.element}.jpg`
else
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${weapon.granblue_id}.jpg`
} else {
if (props.gridWeapon.object.element == 0 && props.gridWeapon.element)
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}_${props.gridWeapon.element}.jpg`
else
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}.jpg`
}
}
function passUncapData(uncap: number) {
if (props.gridWeapon)
props.updateUncap(props.gridWeapon.id, props.position, uncap)
}
setImageUrl(imgSrc)
}
function canBeModified(gridWeapon: GridWeapon) {
const weapon = gridWeapon.object
function passUncapData(uncap: number) {
if (props.gridWeapon)
props.updateUncap(props.gridWeapon.id, props.position, uncap)
}
return weapon.ax > 0 ||
(weapon.series) && [2, 3, 17, 22, 24].includes(weapon.series)
}
function canBeModified(gridWeapon: GridWeapon) {
const weapon = gridWeapon.object
const image = (
<div className="WeaponImage">
<img alt={weapon?.name.en} className="grid_image" src={imageUrl} />
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' }
</div>
return (
weapon.ax > 0 ||
(weapon.series && [2, 3, 17, 22, 24].includes(weapon.series))
)
}
const editableImage = (
<SearchModal
placeholderText={t('search.placeholders.weapon')}
fromPosition={props.position}
object="weapons"
send={props.updateObject}>
{image}
</SearchModal>
)
const image = (
<div className="WeaponImage">
<img alt={weapon?.name.en} className="grid_image" src={imageUrl} />
{props.editable ? (
<span className="icon">
<PlusIcon />
</span>
) : (
""
)}
</div>
)
const unitContent = (
<div className={classes}>
{ (props.editable && gridWeapon && canBeModified(gridWeapon)) ?
<WeaponModal gridWeapon={gridWeapon}>
<div>
<Button icon="settings" type={ButtonType.IconOnly}/>
</div>
</WeaponModal>: '' }
{ (props.editable) ? editableImage : image }
{ (gridWeapon) ?
<UncapIndicator
type="weapon"
ulb={gridWeapon.object.uncap.ulb || false}
flb={gridWeapon.object.uncap.flb || false}
uncapLevel={gridWeapon.uncap_level}
updateUncap={passUncapData}
special={false}
/> : ''
}
<h3 className="WeaponName">{weapon?.name[locale]}</h3>
</div>
)
const editableImage = (
<SearchModal
placeholderText={t("search.placeholders.weapon")}
fromPosition={props.position}
object="weapons"
send={props.updateObject}
>
{image}
</SearchModal>
)
const withHovercard = (
<WeaponHovercard gridWeapon={gridWeapon!}>
{unitContent}
</WeaponHovercard>
)
const unitContent = (
<div className={classes}>
{props.editable && gridWeapon && canBeModified(gridWeapon) ? (
<WeaponModal gridWeapon={gridWeapon}>
<div>
<Button icon="settings" type={ButtonType.IconOnly} />
</div>
</WeaponModal>
) : (
""
)}
{props.editable ? editableImage : image}
{gridWeapon ? (
<UncapIndicator
type="weapon"
ulb={gridWeapon.object.uncap.ulb || false}
flb={gridWeapon.object.uncap.flb || false}
uncapLevel={gridWeapon.uncap_level}
updateUncap={passUncapData}
special={false}
/>
) : (
""
)}
<h3 className="WeaponName">{weapon?.name[locale]}</h3>
</div>
)
return (gridWeapon && !props.editable) ? withHovercard : unitContent
const withHovercard = (
<WeaponHovercard gridWeapon={gridWeapon!}>{unitContent}</WeaponHovercard>
)
return gridWeapon && !props.editable ? withHovercard : unitContent
}
export default WeaponUnit

View file

@ -1,13 +1,17 @@
import React from "react"
import React, { useEffect } from "react"
import { getCookie } from "cookies-next"
import { serverSideTranslations } from "next-i18next/serverSideTranslations"
import Party from "~components/Party"
import { appState } from "~utils/appState"
import api from "~utils/api"
import type { NextApiRequest, NextApiResponse } from "next"
interface Props {
jobs: Job[]
jobSkills: JobSkill[]
raids: Raid[]
sortedRaids: Raid[][]
}
@ -18,6 +22,16 @@ const NewRoute: React.FC<Props> = (props: Props) => {
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 (
<div id="Content">
<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 })
.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 {
props: {
jobs: jobs,
jobSkills: jobSkills,
raids: raids,
sortedRaids: sortedRaids,
...(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 { serverSideTranslations } from "next-i18next/serverSideTranslations"
import Party from "~components/Party"
import { appState } from "~utils/appState"
import api from "~utils/api"
import type { NextApiRequest, NextApiResponse } from "next"
interface Props {
party: Party
jobs: Job[]
jobSkills: JobSkill[]
raids: Raid[]
sortedRaids: Raid[][]
}
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 (
<div id="Content">
<Party team={props.party} raids={props.sortedRaids} />
@ -48,6 +61,16 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
.getAll()
.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
if (query.party) {
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 {
props: {
party: party,
jobs: jobs,
jobSkills: jobSkills,
raids: raids,
sortedRaids: sortedRaids,
...(await serverSideTranslations(locale, ["common"])),

View file

@ -232,7 +232,8 @@
"placeholders": {
"weapon": "Search for a weapon...",
"summon": "Search for a summon...",
"character": "Search for a character..."
"character": "Search for a character...",
"job_skill": "Search job skills..."
}
},
"teams": {
@ -240,6 +241,19 @@
"loading": "Loading teams...",
"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",
"coming_soon": "Coming Soon",
"no_title": "Untitled",

View file

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

31
types/Job.d.ts vendored
View file

@ -1,15 +1,16 @@
interface Job {
id: string
row: string
ml: boolean
order: number
name: {
[key: string]: string
en: string
ja: string
}
proficiency: {
proficiency1: number
proficiency2: number
}
}
interface Job {
id: string
row: string
ml: boolean
order: number
name: {
[key: string]: string
en: string
ja: string
}
proficiency: {
proficiency1: 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 {
id: string
name: string
description: string
raid: Raid
job: Job
job_skills: JobSkillObject
shortcode: string
extra: 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
endpoints: { [key: string]: EndpointMap }
constructor({url}: {url: string}) {
constructor({ url }: { url: string }) {
this.url = url
this.endpoints = {}
}
@ -56,13 +56,14 @@ class Api {
return axios.post(`${ oauthUrl }/token`, object)
}
search({ object, query, filters, locale = "en", page = 0 }:
{ object: string, query: string, filters?: { [key: string]: number[] }, locale?: string, page?: number }) {
search({ object, query, job, filters, locale = "en", page = 0 }:
{ object: string, query: string, job?: string, filters?: { [key: string]: number[] }, locale?: string, page?: number }) {
const resourceUrl = `${this.url}/${name}`
return axios.post(`${resourceUrl}search/${object}`, {
search: {
query: query,
filters: filters,
job: job,
locale: locale,
page: page
}
@ -92,6 +93,22 @@ class Api {
const resourceUrl = `${this.url}/characters/resolve`
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: {}) {
const resourceUrl = `${this.url}/parties/favorites`
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'})
api.createEntity( { name: 'users' })
api.createEntity( { name: 'parties' })
api.createEntity( { name: 'grid_weapons' })
api.createEntity( { name: 'characters' })
api.createEntity( { name: 'weapons' })
api.createEntity( { name: 'summons' })
api.createEntity( { name: 'jobs' })
api.createEntity( { name: 'raids' })
api.createEntity( { name: 'weapon_keys' })
api.createEntity( { name: 'favorites' })
api.createEntity({ name: 'users' })
api.createEntity({ name: 'parties' })
api.createEntity({ name: 'grid_weapons' })
api.createEntity({ name: 'characters' })
api.createEntity({ name: 'weapons' })
api.createEntity({ name: 'summons' })
api.createEntity({ name: 'jobs' })
api.createEntity({ name: 'raids' })
api.createEntity({ name: 'weapon_keys' })
api.createEntity({ name: 'favorites' })
export default api

View file

@ -1,4 +1,5 @@
import { proxy } from "valtio"
import { JobSkillObject } from "~types"
const emptyJob: Job = {
id: "-1",
@ -25,6 +26,7 @@ interface AppState {
name: string | undefined
description: string | undefined
job: Job
jobSkills: JobSkillObject
raid: Raid | undefined
element: number
extra: boolean
@ -53,6 +55,8 @@ interface AppState {
}
}
raids: Raid[]
jobs: Job[]
jobSkills: JobSkill[]
}
export const initialAppState: AppState = {
@ -63,6 +67,12 @@ export const initialAppState: AppState = {
name: undefined,
description: undefined,
job: emptyJob,
jobSkills: {
0: undefined,
1: undefined,
2: undefined,
3: undefined,
},
raid: undefined,
element: 0,
extra: false,
@ -91,6 +101,8 @@ export const initialAppState: AppState = {
},
},
raids: [],
jobs: [],
jobSkills: [],
}
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: "ベースアビリティ",
},
},
]