diff --git a/components/CharacterConflictModal/index.scss b/components/CharacterConflictModal/index.scss new file mode 100644 index 00000000..48176c81 --- /dev/null +++ b/components/CharacterConflictModal/index.scss @@ -0,0 +1,63 @@ +.Conflict.Dialog { + & > p { + line-height: 1.2; + max-width: 400px; + } + img { + border-radius: 1rem; + } + + .arrow { + color: $grey-50; + font-size: 4rem; + text-align: center; + } + + .character { + display: flex; + flex-direction: column; + gap: $unit; + text-align: center; + width: 12rem; + font-weight: $medium; + } + + .diagram { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + align-items: center; + + ul { + display: flex; + flex-direction: column; + gap: $unit * 2; + } + } + + footer { + display: flex; + flex-direction: row; + gap: $unit; + + .Button { + font-size: $font-regular; + padding: ($unit * 1.5) ($unit * 2); + width: 100%; + + &.btn-disabled { + background: $grey-90; + color: $grey-70; + cursor: not-allowed; + } + + &:not(.btn-disabled) { + background: $grey-90; + color: $grey-40; + + &:hover { + background: $grey-80; + } + } + } + } +} diff --git a/components/CharacterConflictModal/index.tsx b/components/CharacterConflictModal/index.tsx new file mode 100644 index 00000000..5d393d28 --- /dev/null +++ b/components/CharacterConflictModal/index.tsx @@ -0,0 +1,114 @@ +import React, { useEffect, useState } from "react" +import { setCookie } from "cookies-next" +import Router, { useRouter } from "next/router" +import { useTranslation } from "react-i18next" +import { AxiosResponse } from "axios" + +import * as Dialog from "@radix-ui/react-dialog" + +import api from "~utils/api" +import { appState } from "~utils/appState" +import { accountState } from "~utils/accountState" + +import Button from "~components/Button" + +import "./index.scss" + +interface Props { + open: boolean + incomingCharacter?: Character + conflictingCharacters?: GridCharacter[] + desiredPosition: number + resolveConflict: () => void + resetConflict: () => void +} + +const CharacterConflictModal = (props: Props) => { + const { t } = useTranslation("common") + + // States + const [open, setOpen] = useState(false) + + useEffect(() => { + setOpen(props.open) + }, [setOpen, props.open]) + + function imageUrl(character?: Character, uncap: number = 0) { + // Change the image based on the uncap level + let suffix = "01" + if (uncap == 6) suffix = "04" + 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 + if ( + appState.grid.weapons.mainWeapon && + appState.grid.weapons.mainWeapon.element + ) { + element = appState.grid.weapons.mainWeapon.element + } else if (appState.party.element != 0) { + element = appState.party.element + } + + suffix = `${suffix}_0${element}` + } + + return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-square/${character?.granblue_id}_${suffix}.jpg` + } + + function openChange(open: boolean) { + setOpen(open) + } + + function close() { + setOpen(false) + props.resetConflict() + } + + return ( + + + event.preventDefault()} + > +

+ Only one version of a character can be included in each party. Do + you want to change your party members? +

+
+
    + {props.conflictingCharacters?.map((character) => ( +
  • + {character.object.name.en} + {character.object.name.en} +
  • + ))} +
+ +
+ {props.incomingCharacter?.name.en} + {props.incomingCharacter?.name.en} +
+
+
+ + +
+
+ +
+
+ ) +} + +export default CharacterConflictModal diff --git a/components/CharacterGrid/index.tsx b/components/CharacterGrid/index.tsx index ebfb9115..18329974 100644 --- a/components/CharacterGrid/index.tsx +++ b/components/CharacterGrid/index.tsx @@ -13,6 +13,8 @@ import api from "~utils/api" import { appState } from "~utils/appState" import "./index.scss" +import CharacterConflictModal from "~components/CharacterConflictModal" +import { resolve } from "path" // Props interface Props { @@ -38,6 +40,12 @@ const CharacterGrid = (props: Props) => { // Set up state for view management const { party, grid } = useSnapshot(appState) const [slug, setSlug] = useState() + const [modalOpen, setModalOpen] = useState(false) + + // Set up state for conflict management + const [incoming, setIncoming] = useState() + const [conflicts, setConflicts] = useState([]) + const [position, setPosition] = useState(0) // Create a temporary state to store previous character uncap values const [previousUncapValues, setPreviousUncapValues] = useState<{ @@ -85,11 +93,22 @@ const CharacterGrid = (props: Props) => { } else { if (party.editable) saveCharacter(party.id, character, position) - .then((response) => storeGridCharacter(response.data.grid_character)) + .then((response) => handleCharacterResponse(response.data)) .catch((error) => console.error(error)) } } + async function handleCharacterResponse(data: any) { + if (data.hasOwnProperty("conflicts")) { + setIncoming(data.incoming) + setConflicts(data.conflicts) + setPosition(data.position) + setModalOpen(true) + } else { + storeGridCharacter(data.grid_character) + } + } + async function saveCharacter( partyId: string, character: Character, @@ -112,6 +131,39 @@ const CharacterGrid = (props: Props) => { appState.grid.characters[gridCharacter.position] = gridCharacter } + async function resolveConflict() { + if (incoming && conflicts.length > 0) { + await api + .resolveCharacterConflict({ + incoming: incoming.id, + conflicting: conflicts.map((c) => c.id), + position: position, + params: headers, + }) + .then((response) => { + // Store new character in state + storeGridCharacter(response.data.grid_character) + + // Remove conflicting characters from state + conflicts.forEach( + (c) => (appState.grid.characters[c.position] = undefined) + ) + + // Reset conflict + resetConflict() + + // Close modal + setModalOpen(false) + }) + } + } + + function resetConflict() { + setPosition(-1) + setConflicts([]) + setIncoming(undefined) + } + // Methods: Helpers function characterUncapLevel(character: Character) { let uncapLevel @@ -197,6 +249,14 @@ const CharacterGrid = (props: Props) => {
+
    {Array.from(Array(numCharacters)).map((x, i) => { return ( diff --git a/types/Character.d.ts b/types/Character.d.ts index 373c340f..b7706be4 100644 --- a/types/Character.d.ts +++ b/types/Character.d.ts @@ -1,39 +1,40 @@ interface Character { - type: 'character' - - id: string - granblue_id: string - element: number - rarity: number - gender: number - max_level: number - name: { - [key: string]: string - en: string - ja: string - } - hp: { - min_hp: number - max_hp: number - max_hp_flb: number - } - atk: { - min_atk: number - max_atk: number - max_atk_flb: number - } - uncap: { - flb: boolean - ulb: boolean - } - race: { - race1: number - race2: number - } - proficiency: { - proficiency1: number - proficiency2: number - } - position?: number - special: boolean -} \ No newline at end of file + type: "character" + + id: string + granblue_id: string + character_id: readonly number[] + element: number + rarity: number + gender: number + max_level: number + name: { + [key: string]: string + en: string + ja: string + } + hp: { + min_hp: number + max_hp: number + max_hp_flb: number + } + atk: { + min_atk: number + max_atk: number + max_atk_flb: number + } + uncap: { + flb: boolean + ulb: boolean + } + race: { + race1: number + race2: number + } + proficiency: { + proficiency1: number + proficiency2: number + } + position?: number + special: boolean +} diff --git a/utils/api.tsx b/utils/api.tsx index 2cad0cea..0fd196a7 100644 --- a/utils/api.tsx +++ b/utils/api.tsx @@ -1,9 +1,10 @@ import axios, { Axios, AxiosRequestConfig, AxiosResponse } from "axios" interface Entity { - name: string + name: string } +// prettier-ignore type CollectionEndpoint = (params?: {}) => Promise> type IdEndpoint = ({ id, params }: { id: string, params?: {} }) => Promise> type IdWithObjectEndpoint = ({ id, object, params }: { id: string, object: string, params?: {} }) => Promise> @@ -12,101 +13,117 @@ type PutEndpoint = (id: string, object: {}, headers?: {}) => Promise Promise> interface EndpointMap { - getAll: CollectionEndpoint - getOne: IdEndpoint - getOneWithObject: IdWithObjectEndpoint - create: PostEndpoint - update: PutEndpoint - destroy: DestroyEndpoint + getAll: CollectionEndpoint + getOne: IdEndpoint + getOneWithObject: IdWithObjectEndpoint + create: PostEndpoint + update: PutEndpoint + destroy: DestroyEndpoint } class Api { - url: string - endpoints: { [key: string]: EndpointMap } + url: string + endpoints: { [key: string]: EndpointMap } + + constructor({url}: {url: string}) { + this.url = url + this.endpoints = {} + } + + createEntity(entity: Entity) { + this.endpoints[entity.name] = this.createEndpoints(entity) + } + + createEntities(entities: Entity[]) { + entities.forEach(this.createEntity.bind(this)) + } + + createEndpoints({name}: {name: string}) { + const resourceUrl = `${this.url}/${name}` - constructor({url}: {url: string}) { - this.url = url - this.endpoints = {} - } - - createEntity(entity: Entity) { - this.endpoints[entity.name] = this.createEndpoints(entity) - } - - createEntities(entities: Entity[]) { - entities.forEach(this.createEntity.bind(this)) - } - - createEndpoints({name}: {name: string}) { - const resourceUrl = `${this.url}/${name}` - - return { - getAll: (params?: {}) => axios.get(resourceUrl, params), - getOne: ({ id, params }: { id: string, params?: {} }) => axios.get(`${resourceUrl}/${id}/`, params), - getOneWithObject: ({ id, object, params }: { id: string, object: string, params?: {} }) => axios.get(`${resourceUrl}/${id}/${object}`, params), - create: (object: {}, headers?: {}) => axios.post(resourceUrl, object, headers), - update: (id: string, object: {}, headers?: {}) => axios.put(`${resourceUrl}/${id}`, object, headers), - destroy: ({ id, params }: { id: string, params?: {} }) => axios.delete(`${resourceUrl}/${id}`, params) - } as EndpointMap - } - - login(object: {}) { - const oauthUrl = process.env.NEXT_PUBLIC_SIERO_OAUTH_URL || 'https://localhost:3000/oauth' - 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 }) { - const resourceUrl = `${this.url}/${name}` - return axios.post(`${resourceUrl}search/${object}`, { - search: { - query: query, - filters: filters, - locale: locale, - page: page - } - }) - } - - check(resource: string, value: string) { - const resourceUrl = `${this.url}/check/${resource}` - return axios.post(resourceUrl, { - [resource]: value - }) - } - - savedTeams(params: {}) { - const resourceUrl = `${this.url}/parties/favorites` - return axios.get(resourceUrl, params) - } - - saveTeam({ id, params }: { id: string, params?: {} }) { - const body = { favorite: { party_id: id } } - const resourceUrl = `${this.url}/favorites` - return axios.post(resourceUrl, body, { headers: params }) - } - - unsaveTeam({ id, params }: { id: string, params?: {} }) { - const body = { favorite: { party_id: id } } - const resourceUrl = `${this.url}/favorites` - return axios.delete(resourceUrl, { data: body, headers: params }) - } - - updateUncap(resource: 'character'|'weapon'|'summon', id: string, value: number) { - const pluralized = resource + 's' - const resourceUrl = `${this.url}/${pluralized}/update_uncap` - return axios.post(resourceUrl, { - [resource]: { - id: id, - uncap_level: value - } - }) - } - - userInfo(id: string) { - const resourceUrl = `${this.url}/users/info/${id}` - return axios.get(resourceUrl) + return { + getAll: (params?: {}) => axios.get(resourceUrl, params), + getOne: ({ id, params }: { id: string, params?: {} }) => axios.get(`${resourceUrl}/${id}/`, params), + getOneWithObject: ({ id, object, params }: { id: string, object: string, params?: {} }) => axios.get(`${resourceUrl}/${id}/${object}`, params), + create: (object: {}, headers?: {}) => axios.post(resourceUrl, object, headers), + update: (id: string, object: {}, headers?: {}) => axios.put(`${resourceUrl}/${id}`, object, headers), + destroy: ({ id, params }: { id: string, params?: {} }) => axios.delete(`${resourceUrl}/${id}`, params) + } as EndpointMap + } + + login(object: {}) { + const oauthUrl = process.env.NEXT_PUBLIC_SIERO_OAUTH_URL || 'https://localhost:3000/oauth' + 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 }) { + const resourceUrl = `${this.url}/${name}` + return axios.post(`${resourceUrl}search/${object}`, { + search: { + query: query, + filters: filters, + locale: locale, + page: page + } + }) + } + + check(resource: string, value: string) { + const resourceUrl = `${this.url}/check/${resource}` + return axios.post(resourceUrl, { + [resource]: value + }) + } + + resolveCharacterConflict({ incoming, conflicting, position, params }: { + incoming: string + conflicting: string[] + position: number, + params?: {} + }) { + const body = { + resolve: { + incoming: incoming, + conflicting: conflicting, + position: position, + }, } + const resourceUrl = `${this.url}/characters/resolve` + return axios.post(resourceUrl, body, { headers: params }) + } + savedTeams(params: {}) { + const resourceUrl = `${this.url}/parties/favorites` + return axios.get(resourceUrl, params) + } + + saveTeam({ id, params }: { id: string, params?: {} }) { + const body = { favorite: { party_id: id } } + const resourceUrl = `${this.url}/favorites` + return axios.post(resourceUrl, body, { headers: params }) + } + + unsaveTeam({ id, params }: { id: string, params?: {} }) { + const body = { favorite: { party_id: id } } + const resourceUrl = `${this.url}/favorites` + return axios.delete(resourceUrl, { data: body, headers: params }) + } + + updateUncap(resource: 'character'|'weapon'|'summon', id: string, value: number) { + const pluralized = resource + 's' + const resourceUrl = `${this.url}/${pluralized}/update_uncap` + return axios.post(resourceUrl, { + [resource]: { + id: id, + uncap_level: value + } + }) + } + + userInfo(id: string) { + const resourceUrl = `${this.url}/users/info/${id}` + return axios.get(resourceUrl) + } } const api: Api = new Api({ url: process.env.NEXT_PUBLIC_SIERO_API_URL || 'https://localhost:3000/api/v1'}) @@ -121,4 +138,4 @@ api.createEntity( { name: 'raids' }) api.createEntity( { name: 'weapon_keys' }) api.createEntity( { name: 'favorites' }) -export default api \ No newline at end of file +export default api diff --git a/utils/appState.tsx b/utils/appState.tsx index 22db2cbc..dedf8e30 100644 --- a/utils/appState.tsx +++ b/utils/appState.tsx @@ -1,96 +1,96 @@ -import { proxy } from "valtio"; +import { proxy } from "valtio" const emptyJob: Job = { - id: "-1", - row: "", - ml: false, - order: 0, - name: { - en: "", - ja: "" - }, - proficiency: { - proficiency1: 0, - proficiency2: 0 - } + id: "-1", + row: "", + ml: false, + order: 0, + name: { + en: "", + ja: "", + }, + proficiency: { + proficiency1: 0, + proficiency2: 0, + }, } interface AppState { - [key: string]: any - - party: { - id: string | undefined, - editable: boolean, - detailsVisible: boolean, - name: string | undefined, - description: string | undefined, - job: Job, - raid: Raid | undefined, - element: number, - extra: boolean, - user: User | undefined, - favorited: boolean, - created_at: string - updated_at: string - }, - grid: { - weapons: { - mainWeapon: GridWeapon | undefined, - allWeapons: GridArray - }, - summons: { - mainSummon: GridSummon | undefined, - friendSummon: GridSummon | undefined, - allSummons: GridArray - }, - characters: GridArray - }, - search: { - recents: { - characters: Character[] - weapons: Weapon[] - summons: Summon[] - } - }, - raids: Raid[] + [key: string]: any + + party: { + id: string | undefined + editable: boolean + detailsVisible: boolean + name: string | undefined + description: string | undefined + job: Job + raid: Raid | undefined + element: number + extra: boolean + user: User | undefined + favorited: boolean + created_at: string + updated_at: string + } + grid: { + weapons: { + mainWeapon: GridWeapon | undefined + allWeapons: GridArray + } + summons: { + mainSummon: GridSummon | undefined + friendSummon: GridSummon | undefined + allSummons: GridArray + } + characters: GridArray + } + search: { + recents: { + characters: Character[] + weapons: Weapon[] + summons: Summon[] + } + } + raids: Raid[] } export const initialAppState: AppState = { - party: { - id: undefined, - editable: false, - detailsVisible: false, - name: undefined, - description: undefined, - job: emptyJob, - raid: undefined, - element: 0, - extra: false, - user: undefined, - favorited: false, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() + party: { + id: undefined, + editable: false, + detailsVisible: false, + name: undefined, + description: undefined, + job: emptyJob, + raid: undefined, + element: 0, + extra: false, + user: undefined, + favorited: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + grid: { + weapons: { + mainWeapon: undefined, + allWeapons: {}, }, - grid: { - weapons: { - mainWeapon: undefined, - allWeapons: {} - }, - summons: { - mainSummon: undefined, - friendSummon: undefined, - allSummons: {} - }, - characters: {} + summons: { + mainSummon: undefined, + friendSummon: undefined, + allSummons: {}, }, - search: { - recents: { - characters: [], - weapons: [], - summons: [] - } + characters: {}, + }, + search: { + recents: { + characters: [], + weapons: [], + summons: [], }, - raids: [] + }, + raids: [], } -export const appState = proxy(initialAppState) \ No newline at end of file +export const appState = proxy(initialAppState)