Merge branch 'main' of github.com:jedmund/hensei-web

This commit is contained in:
Justin Edmund 2022-11-16 06:15:23 -08:00
commit c05f86d012
29 changed files with 4899 additions and 4334 deletions

View file

@ -1,211 +1,255 @@
import React, { useEffect, useState } from 'react'
import { useCookies } from 'react-cookie'
import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import React, { useEffect, useState } from "react"
import { getCookie } from "cookies-next"
import { useRouter } from "next/router"
import { useSnapshot } from "valtio"
import { useTranslation } from "next-i18next"
import * as Dialog from '@radix-ui/react-dialog'
import * as Switch from '@radix-ui/react-switch'
import * as Dialog from "@radix-ui/react-dialog"
import * as Switch from "@radix-ui/react-switch"
import api from '~utils/api'
import { accountState } from '~utils/accountState'
import { pictureData } from '~utils/pictureData'
import api from "~utils/api"
import { accountState } from "~utils/accountState"
import { pictureData } from "~utils/pictureData"
import Button from '~components/Button'
import Button from "~components/Button"
import CrossIcon from '~public/icons/Cross.svg'
import './index.scss'
import CrossIcon from "~public/icons/Cross.svg"
import "./index.scss"
const AccountModal = () => {
const { account } = useSnapshot(accountState)
const { account } = useSnapshot(accountState)
const router = useRouter()
const { t } = useTranslation('common')
const locale = (router.locale && ['en', 'ja'].includes(router.locale)) ? router.locale : 'en'
const router = useRouter()
const { t } = useTranslation("common")
const locale =
router.locale && ["en", "ja"].includes(router.locale) ? router.locale : "en"
// Cookies
const [cookies, setCookies] = useCookies()
// Cookies
const cookie = getCookie("account")
const headers = (cookies.account != null) ? {
headers: {
'Authorization': `Bearer ${cookies.account.access_token}`
}
} : {}
// State
const [open, setOpen] = useState(false)
const [picture, setPicture] = useState('')
const [language, setLanguage] = useState('')
const [gender, setGender] = useState(0)
const [privateProfile, setPrivateProfile] = useState(false)
const headers = {}
// cookies.account != null
// ? {
// headers: {
// Authorization: `Bearer ${cookies.account.access_token}`,
// },
// }
// : {}
// Refs
const pictureSelect = React.createRef<HTMLSelectElement>()
const languageSelect = React.createRef<HTMLSelectElement>()
const genderSelect = React.createRef<HTMLSelectElement>()
const privateSelect = React.createRef<HTMLInputElement>()
// State
const [open, setOpen] = useState(false)
const [picture, setPicture] = useState("")
const [language, setLanguage] = useState("")
const [gender, setGender] = useState(0)
const [privateProfile, setPrivateProfile] = useState(false)
useEffect(() => {
if (cookies.user) setPicture(cookies.user.picture)
if (cookies.user) setLanguage(cookies.user.language)
if (cookies.user) setGender(cookies.user.gender)
}, [cookies])
// Refs
const pictureSelect = React.createRef<HTMLSelectElement>()
const languageSelect = React.createRef<HTMLSelectElement>()
const genderSelect = React.createRef<HTMLSelectElement>()
const privateSelect = React.createRef<HTMLInputElement>()
const pictureOptions = (
pictureData.sort((a, b) => (a.name.en > b.name.en) ? 1 : -1).map((item, i) => {
return (
<option key={`picture-${i}`} value={item.filename}>{item.name[locale]}</option>
)
})
)
// useEffect(() => {
// if (cookies.user) setPicture(cookies.user.picture)
// if (cookies.user) setLanguage(cookies.user.language)
// if (cookies.user) setGender(cookies.user.gender)
// }, [cookies])
function handlePictureChange(event: React.ChangeEvent<HTMLSelectElement>) {
if (pictureSelect.current)
setPicture(pictureSelect.current.value)
const pictureOptions = pictureData
.sort((a, b) => (a.name.en > b.name.en ? 1 : -1))
.map((item, i) => {
return (
<option key={`picture-${i}`} value={item.filename}>
{item.name[locale]}
</option>
)
})
function handlePictureChange(event: React.ChangeEvent<HTMLSelectElement>) {
if (pictureSelect.current) setPicture(pictureSelect.current.value)
}
function handleLanguageChange(event: React.ChangeEvent<HTMLSelectElement>) {
if (languageSelect.current) setLanguage(languageSelect.current.value)
}
function handleGenderChange(event: React.ChangeEvent<HTMLSelectElement>) {
if (genderSelect.current) setGender(parseInt(genderSelect.current.value))
}
function handlePrivateChange(checked: boolean) {
setPrivateProfile(checked)
}
function update(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const object = {
user: {
picture: picture,
element: pictureData.find((i) => i.filename === picture)?.element,
language: language,
gender: gender,
private: privateProfile,
},
}
function handleLanguageChange(event: React.ChangeEvent<HTMLSelectElement>) {
if (languageSelect.current)
setLanguage(languageSelect.current.value)
}
// api.endpoints.users
// .update(cookies.account.user_id, object, headers)
// .then((response) => {
// const user = response.data.user
function handleGenderChange(event: React.ChangeEvent<HTMLSelectElement>) {
if (genderSelect.current)
setGender(parseInt(genderSelect.current.value))
}
// const cookieObj = {
// picture: user.picture.picture,
// element: user.picture.element,
// gender: user.gender,
// language: user.language,
// }
function handlePrivateChange(checked: boolean) {
setPrivateProfile(checked)
}
// setCookies("user", cookieObj, { path: "/" })
function update(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
// accountState.account.user = {
// id: user.id,
// username: user.username,
// picture: user.picture.picture,
// element: user.picture.element,
// gender: user.gender,
// }
const object = {
user: {
picture: picture,
element: pictureData.find(i => i.filename === picture)?.element,
language: language,
gender: gender,
private: privateProfile
}
}
// setOpen(false)
// changeLanguage(user.language)
// })
}
api.endpoints.users.update(cookies.account.user_id, object, headers)
.then(response => {
const user = response.data.user
function changeLanguage(newLanguage: string) {
// if (newLanguage !== router.locale) {
// setCookies("NEXT_LOCALE", newLanguage, { path: "/" })
// router.push(router.asPath, undefined, { locale: newLanguage })
// }
}
const cookieObj = {
picture: user.picture.picture,
element: user.picture.element,
gender: user.gender,
language: user.language
}
setCookies('user', cookieObj, { path: '/'})
function openChange(open: boolean) {
setOpen(open)
}
accountState.account.user = {
id: user.id,
username: user.username,
picture: user.picture.picture,
element: user.picture.element,
gender: user.gender
}
return (
<Dialog.Root open={open} onOpenChange={openChange}>
<Dialog.Trigger asChild>
<li className="MenuItem">
<span>{t("menu.settings")}</span>
</li>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content
className="Account Dialog"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div className="DialogHeader">
<div className="DialogTop">
<Dialog.Title className="SubTitle">
{t("modals.settings.title")}
</Dialog.Title>
<Dialog.Title className="DialogTitle">
@{account.user?.username}
</Dialog.Title>
</div>
<Dialog.Close className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</Dialog.Close>
</div>
setOpen(false)
changeLanguage(user.language)
})
}
<form onSubmit={update}>
<div className="field">
<div className="left">
<label>{t("modals.settings.labels.picture")}</label>
</div>
function changeLanguage(newLanguage: string) {
if (newLanguage !== router.locale) {
setCookies('NEXT_LOCALE', newLanguage, { path: '/'})
router.push(router.asPath, undefined, { locale: newLanguage })
}
}
function openChange(open: boolean) {
setOpen(open)
}
return (
<Dialog.Root open={open} onOpenChange={openChange}>
<Dialog.Trigger asChild>
<li className="MenuItem">
<span>{t('menu.settings')}</span>
</li>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content className="Account Dialog" onOpenAutoFocus={ (event) => event.preventDefault() }>
<div className="DialogHeader">
<div className="DialogTop">
<Dialog.Title className="SubTitle">{t('modals.settings.title')}</Dialog.Title>
<Dialog.Title className="DialogTitle">@{account.user?.username}</Dialog.Title>
</div>
<Dialog.Close className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</Dialog.Close>
</div>
<form onSubmit={update}>
<div className="field">
<div className="left">
<label>{t('modals.settings.labels.picture')}</label>
</div>
<div className={`preview ${pictureData.find(i => i.filename === picture)?.element}`}>
<img
alt="Profile preview"
srcSet={`/profile/${picture}.png,
<div
className={`preview ${
pictureData.find((i) => i.filename === picture)?.element
}`}
>
<img
alt="Profile preview"
srcSet={`/profile/${picture}.png,
/profile/${picture}@2x.png 2x`}
src={`/profile/${picture}.png`}
/>
</div>
src={`/profile/${picture}.png`}
/>
</div>
<select name="picture" onChange={handlePictureChange} value={picture} ref={pictureSelect}>
{pictureOptions}
</select>
</div>
<div className="field">
<div className="left">
<label>{t('modals.settings.labels.gender')}</label>
</div>
<select
name="picture"
onChange={handlePictureChange}
value={picture}
ref={pictureSelect}
>
{pictureOptions}
</select>
</div>
<div className="field">
<div className="left">
<label>{t("modals.settings.labels.gender")}</label>
</div>
<select name="gender" onChange={handleGenderChange} value={gender} ref={genderSelect}>
<option key="gran" value="0">{t('modals.settings.gender.gran')}</option>
<option key="djeeta" value="1">{t('modals.settings.gender.djeeta')}</option>
</select>
</div>
<div className="field">
<div className="left">
<label>{t('modals.settings.labels.language')}</label>
</div>
<select
name="gender"
onChange={handleGenderChange}
value={gender}
ref={genderSelect}
>
<option key="gran" value="0">
{t("modals.settings.gender.gran")}
</option>
<option key="djeeta" value="1">
{t("modals.settings.gender.djeeta")}
</option>
</select>
</div>
<div className="field">
<div className="left">
<label>{t("modals.settings.labels.language")}</label>
</div>
<select name="language" onChange={handleLanguageChange} value={language} ref={languageSelect}>
<option key="en" value="en">{t('modals.settings.language.english')}</option>
<option key="jp" value="ja">{t('modals.settings.language.japanese')}</option>
</select>
</div>
<div className="field">
<div className="left">
<label>{t('modals.settings.labels.private')}</label>
<p className={locale}>{t('modals.settings.descriptions.private')}</p>
</div>
<select
name="language"
onChange={handleLanguageChange}
value={language}
ref={languageSelect}
>
<option key="en" value="en">
{t("modals.settings.language.english")}
</option>
<option key="jp" value="ja">
{t("modals.settings.language.japanese")}
</option>
</select>
</div>
<div className="field">
<div className="left">
<label>{t("modals.settings.labels.private")}</label>
<p className={locale}>
{t("modals.settings.descriptions.private")}
</p>
</div>
<Switch.Root className="Switch" onCheckedChange={handlePrivateChange} checked={privateProfile}>
<Switch.Thumb className="Thumb" />
</Switch.Root>
</div>
<Switch.Root
className="Switch"
onCheckedChange={handlePrivateChange}
checked={privateProfile}
>
<Switch.Thumb className="Thumb" />
</Switch.Root>
</div>
<Button>{t('modals.settings.buttons.confirm')}</Button>
</form>
</Dialog.Content>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>
</Dialog.Root>
)
<Button>{t("modals.settings.buttons.confirm")}</Button>
</form>
</Dialog.Content>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>
</Dialog.Root>
)
}
export default AccountModal

View file

@ -1,254 +1,220 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useCookies } from 'react-cookie'
import { useSnapshot } from 'valtio'
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { getCookie } from "cookies-next"
import { useSnapshot } from "valtio"
import { AxiosResponse } from 'axios'
import debounce from 'lodash.debounce'
import { AxiosResponse } from "axios"
import debounce from "lodash.debounce"
import JobSection from '~components/JobSection'
import CharacterUnit from '~components/CharacterUnit'
import JobSection from "~components/JobSection"
import CharacterUnit from "~components/CharacterUnit"
import api from '~utils/api'
import { appState } from '~utils/appState'
import api from "~utils/api"
import { appState } from "~utils/appState"
import './index.scss'
import "./index.scss"
// Props
interface Props {
new: boolean
slug?: string
createParty: () => Promise<AxiosResponse<any, any>>
pushHistory?: (path: string) => void
new: boolean
characters?: GridCharacter[]
createParty: () => Promise<AxiosResponse<any, any>>
pushHistory?: (path: string) => void
}
const CharacterGrid = (props: Props) => {
// Constants
const numCharacters: number = 5
// Constants
const numCharacters: number = 5
// Cookies
const [cookies] = useCookies(['account'])
const headers = (cookies.account != null) ? {
headers: {
'Authorization': `Bearer ${cookies.account.access_token}`
}
} : {}
// Cookies
const cookie = getCookie("account")
const accountData: AccountCookie = cookie
? JSON.parse(cookie as string)
: null
const headers = accountData
? { headers: { Authorization: `Bearer ${accountData.token}` } }
: {}
// Set up state for view management
const { party, grid } = useSnapshot(appState)
// Set up state for view management
const { party, grid } = useSnapshot(appState)
const [slug, setSlug] = useState()
const [slug, setSlug] = useState()
const [found, setFound] = useState(false)
const [loading, setLoading] = useState(true)
const [firstLoadComplete, setFirstLoadComplete] = useState(false)
// Create a temporary state to store previous character uncap values
const [previousUncapValues, setPreviousUncapValues] = useState<{
[key: number]: number
}>({})
// Create a temporary state to store previous character uncap values
const [previousUncapValues, setPreviousUncapValues] = useState<{[key: number]: number}>({})
// Fetch data from the server
useEffect(() => {
const shortcode = (props.slug) ? props.slug : slug
if (shortcode) fetchGrid(shortcode)
else appState.party.editable = true
}, [slug, props.slug])
// Set the editable flag only on first load
useEffect(() => {
// If user is logged in and matches
if (
(accountData && party.user && accountData.userId === party.user.id) ||
props.new
)
appState.party.editable = true
else appState.party.editable = false
}, [props.new, accountData, party])
// Set the editable flag only on first load
useEffect(() => {
if (!loading && !firstLoadComplete) {
// If user is logged in and matches
if ((cookies.account && party.user && cookies.account.user_id === party.user.id) || props.new)
appState.party.editable = true
else
appState.party.editable = false
// Initialize an array of current uncap values for each characters
useEffect(() => {
let initialPreviousUncapValues: { [key: number]: number } = {}
Object.values(appState.grid.characters).map(
(o) => (initialPreviousUncapValues[o.position] = o.uncap_level)
)
setPreviousUncapValues(initialPreviousUncapValues)
}, [appState.grid.characters])
setFirstLoadComplete(true)
}
}, [props.new, cookies, party, loading, firstLoadComplete])
// Methods: Adding an object from search
function receiveCharacterFromSearch(
object: Character | Weapon | Summon,
position: number
) {
const character = object as Character
// Initialize an array of current uncap values for each characters
useEffect(() => {
let initialPreviousUncapValues: {[key: number]: number} = {}
Object.values(appState.grid.characters).map(o => initialPreviousUncapValues[o.position] = o.uncap_level)
setPreviousUncapValues(initialPreviousUncapValues)
}, [appState.grid.characters])
// Methods: Fetching an object from the server
async function fetchGrid(shortcode: string) {
return api.endpoints.parties.getOneWithObject({ id: shortcode, object: 'characters', params: headers })
.then(response => processResult(response))
.catch(error => processError(error))
}
function processResult(response: AxiosResponse) {
// Store the response
const party: Party = response.data.party
// Store the important party and state-keeping values
if (!party.id) {
props.createParty().then((response) => {
const party = response.data.party
appState.party.id = party.id
appState.party.user = party.user
appState.party.favorited = party.favorited
appState.party.created_at = party.created_at
appState.party.updated_at = party.updated_at
setFound(true)
setLoading(false)
setSlug(party.shortcode)
// Populate the weapons in state
populateCharacters(party.characters)
if (props.pushHistory) props.pushHistory(`/p/${party.shortcode}`)
saveCharacter(party.id, character, position)
.then((response) => storeGridCharacter(response.data.grid_character))
.catch((error) => console.error(error))
})
} else {
if (party.editable)
saveCharacter(party.id, character, position)
.then((response) => storeGridCharacter(response.data.grid_character))
.catch((error) => console.error(error))
}
}
async function saveCharacter(
partyId: string,
character: Character,
position: number
) {
return await api.endpoints.characters.create(
{
character: {
party_id: partyId,
character_id: character.id,
position: position,
uncap_level: characterUncapLevel(character),
},
},
headers
)
}
function storeGridCharacter(gridCharacter: GridCharacter) {
appState.grid.characters[gridCharacter.position] = gridCharacter
}
// Methods: Helpers
function characterUncapLevel(character: Character) {
let uncapLevel
if (character.special) {
uncapLevel = 3
if (character.uncap.ulb) uncapLevel = 5
else if (character.uncap.flb) uncapLevel = 4
} else {
uncapLevel = 4
if (character.uncap.ulb) uncapLevel = 6
else if (character.uncap.flb) uncapLevel = 5
}
function processError(error: any) {
if (error.response != null) {
if (error.response.status == 404) {
setFound(false)
setLoading(false)
}
} else {
console.error(error)
}
}
return uncapLevel
}
function populateCharacters(list: Array<GridCharacter>) {
list.forEach((object: GridCharacter) => {
if (object.position != null)
appState.grid.characters[object.position] = object
// Methods: Updating uncap level
// Note: Saves, but debouncing is not working properly
async function saveUncap(id: string, position: number, uncapLevel: number) {
storePreviousUncapValue(position)
try {
if (uncapLevel != previousUncapValues[position])
await api.updateUncap("character", id, uncapLevel).then((response) => {
storeGridCharacter(response.data.grid_character)
})
} catch (error) {
console.error(error)
// Revert optimistic UI
updateUncapLevel(position, previousUncapValues[position])
// Remove optimistic key
let newPreviousValues = { ...previousUncapValues }
delete newPreviousValues[position]
setPreviousUncapValues(newPreviousValues)
}
}
// Methods: Adding an object from search
function receiveCharacterFromSearch(object: Character | Weapon | Summon, position: number) {
const character = object as Character
function initiateUncapUpdate(
id: string,
position: number,
uncapLevel: number
) {
memoizeAction(id, position, uncapLevel)
if (!party.id) {
props.createParty()
.then(response => {
const party = response.data.party
appState.party.id = party.id
setSlug(party.shortcode)
// Optimistically update UI
updateUncapLevel(position, uncapLevel)
}
if (props.pushHistory) props.pushHistory(`/p/${party.shortcode}`)
saveCharacter(party.id, character, position)
.then(response => storeGridCharacter(response.data.grid_character))
.catch(error => console.error(error))
})
} else {
if (party.editable)
saveCharacter(party.id, character, position)
.then(response => storeGridCharacter(response.data.grid_character))
.catch(error => console.error(error))
}
const memoizeAction = useCallback(
(id: string, position: number, uncapLevel: number) => {
debouncedAction(id, position, uncapLevel)
},
[props, previousUncapValues]
)
const debouncedAction = useMemo(
() =>
debounce((id, position, number) => {
saveUncap(id, position, number)
}, 500),
[props, saveUncap]
)
const updateUncapLevel = (position: number, uncapLevel: number) => {
appState.grid.characters[position].uncap_level = uncapLevel
}
function storePreviousUncapValue(position: number) {
// Save the current value in case of an unexpected result
let newPreviousValues = { ...previousUncapValues }
if (grid.characters[position]) {
newPreviousValues[position] = grid.characters[position].uncap_level
setPreviousUncapValues(newPreviousValues)
}
}
async function saveCharacter(partyId: string, character: Character, position: number) {
return await api.endpoints.characters.create({
'character': {
'party_id': partyId,
'character_id': character.id,
'position': position,
'uncap_level': characterUncapLevel(character)
}
}, headers)
}
function storeGridCharacter(gridCharacter: GridCharacter) {
appState.grid.characters[gridCharacter.position] = gridCharacter
}
// Methods: Helpers
function characterUncapLevel(character: Character) {
let uncapLevel
if (character.special) {
uncapLevel = 3
if (character.uncap.ulb) uncapLevel = 5
else if (character.uncap.flb) uncapLevel = 4
} else {
uncapLevel = 4
if (character.uncap.ulb) uncapLevel = 6
else if (character.uncap.flb) uncapLevel = 5
}
return uncapLevel
}
// Methods: Updating uncap level
// Note: Saves, but debouncing is not working properly
async function saveUncap(id: string, position: number, uncapLevel: number) {
storePreviousUncapValue(position)
try {
if (uncapLevel != previousUncapValues[position])
await api.updateUncap('character', id, uncapLevel)
.then(response => { storeGridCharacter(response.data.grid_character) })
} catch (error) {
console.error(error)
// Revert optimistic UI
updateUncapLevel(position, previousUncapValues[position])
// Remove optimistic key
let newPreviousValues = {...previousUncapValues}
delete newPreviousValues[position]
setPreviousUncapValues(newPreviousValues)
}
}
function initiateUncapUpdate(id: string, position: number, uncapLevel: number) {
memoizeAction(id, position, uncapLevel)
// Optimistically update UI
updateUncapLevel(position, uncapLevel)
}
const memoizeAction = useCallback(
(id: string, position: number, uncapLevel: number) => {
debouncedAction(id, position, uncapLevel)
}, [props, previousUncapValues]
)
const debouncedAction = useMemo(() =>
debounce((id, position, number) => {
saveUncap(id, position, number)
}, 500), [props, saveUncap]
)
const updateUncapLevel = (position: number, uncapLevel: number) => {
appState.grid.characters[position].uncap_level = uncapLevel
}
function storePreviousUncapValue(position: number) {
// Save the current value in case of an unexpected result
let newPreviousValues = {...previousUncapValues}
if (grid.characters[position]) {
newPreviousValues[position] = grid.characters[position].uncap_level
setPreviousUncapValues(newPreviousValues)
}
}
// Render: JSX components
return (
<div>
<div id="CharacterGrid">
<JobSection />
<ul id="grid_characters">
{Array.from(Array(numCharacters)).map((x, i) => {
return (
<li key={`grid_unit_${i}`} >
<CharacterUnit
gridCharacter={grid.characters[i]}
editable={party.editable}
position={i}
updateObject={receiveCharacterFromSearch}
updateUncap={initiateUncapUpdate}
/>
</li>
)
})}
</ul>
</div>
</div>
)
// Render: JSX components
return (
<div>
<div id="CharacterGrid">
<JobSection />
<ul id="grid_characters">
{Array.from(Array(numCharacters)).map((x, i) => {
return (
<li key={`grid_unit_${i}`}>
<CharacterUnit
gridCharacter={grid.characters[i]}
editable={party.editable}
position={i}
updateObject={receiveCharacterFromSearch}
updateUncap={initiateUncapUpdate}
/>
</li>
)
})}
</ul>
</div>
</div>
)
}
export default CharacterGrid

View file

@ -1,187 +1,201 @@
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 { accountState } from "~utils/accountState"
import { formatTimeAgo } from "~utils/timeAgo"
import { accountState } from '~utils/accountState'
import { formatTimeAgo } from '~utils/timeAgo'
import Button from "~components/Button"
import { ButtonType } from "~utils/enums"
import Button from '~components/Button'
import { ButtonType } from '~utils/enums'
import './index.scss'
import "./index.scss"
interface Props {
shortcode: string
id: string
name: string
raid: Raid
grid: GridWeapon[]
user?: User
favorited: boolean
createdAt: Date
displayUser?: boolean | false
onClick: (shortcode: string) => void
onSave?: (partyId: string, favorited: boolean) => void
shortcode: string
id: string
name: string
raid: Raid
grid: GridWeapon[]
user?: User
favorited: boolean
createdAt: Date
displayUser?: boolean | false
onClick: (shortcode: string) => void
onSave?: (partyId: string, favorited: boolean) => void
}
const GridRep = (props: Props) => {
const numWeapons: number = 9
const numWeapons: number = 9
const { account } = useSnapshot(accountState)
const { account } = useSnapshot(accountState)
const router = useRouter()
const { t } = useTranslation('common')
const locale = (router.locale && ['en', 'ja'].includes(router.locale)) ? router.locale : 'en'
const router = useRouter()
const { t } = useTranslation("common")
const locale =
router.locale && ["en", "ja"].includes(router.locale) ? router.locale : "en"
const [mainhand, setMainhand] = useState<Weapon>()
const [weapons, setWeapons] = useState<GridArray<Weapon>>({})
const [mainhand, setMainhand] = useState<Weapon>()
const [weapons, setWeapons] = useState<GridArray<Weapon>>({})
const titleClass = classNames({
'empty': !props.name
})
const titleClass = classNames({
empty: !props.name,
})
const raidClass = classNames({
'raid': true,
'empty': !props.raid
})
const raidClass = classNames({
raid: true,
empty: !props.raid,
})
const userClass = classNames({
'user': true,
'empty': !props.user
})
const userClass = classNames({
user: true,
empty: !props.user,
})
useEffect(() => {
const newWeapons = Array(numWeapons)
useEffect(() => {
const newWeapons = Array(numWeapons)
for (const [key, value] of Object.entries(props.grid)) {
if (value.position == -1)
setMainhand(value.object)
else if (!value.mainhand && value.position != null)
newWeapons[value.position] = value.object
}
setWeapons(newWeapons)
}, [props.grid])
function navigate() {
props.onClick(props.shortcode)
for (const [key, value] of Object.entries(props.grid)) {
if (value.position == -1) setMainhand(value.object)
else if (!value.mainhand && value.position != null)
newWeapons[value.position] = value.object
}
function generateMainhandImage() {
let url = ''
setWeapons(newWeapons)
}, [props.grid])
if (mainhand) {
if (mainhand.element == 0 && props.grid[0].element) {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${mainhand.granblue_id}_${props.grid[0].element}.jpg`
} else {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${mainhand.granblue_id}.jpg`
}
}
function navigate() {
props.onClick(props.shortcode)
}
return (mainhand) ?
<img alt={mainhand.name[locale]} src={url} /> : ''
function generateMainhandImage() {
let url = ""
if (mainhand) {
if (mainhand.element == 0 && props.grid[0].element) {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${mainhand.granblue_id}_${props.grid[0].element}.jpg`
} else {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${mainhand.granblue_id}.jpg`
}
}
function generateGridImage(position: number) {
let url = ''
return mainhand && props.grid[0] ? (
<img alt={mainhand.name[locale]} src={url} />
) : (
""
)
}
if (weapons[position]) {
if (weapons[position].element == 0 && props.grid[position].element) {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapons[position]?.granblue_id}_${props.grid[position].element}.jpg`
} else {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapons[position]?.granblue_id}.jpg`
}
}
function generateGridImage(position: number) {
let url = ""
return (weapons[position]) ?
<img alt={weapons[position].name[locale]} src={url} /> : ''
if (weapons[position]) {
if (weapons[position].element == 0 && props.grid[position].element) {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapons[position]?.granblue_id}_${props.grid[position].element}.jpg`
} else {
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapons[position]?.granblue_id}.jpg`
}
}
function sendSaveData() {
if (props.onSave)
props.onSave(props.id, props.favorited)
}
return weapons[position] ? (
<img alt={weapons[position].name[locale]} src={url} />
) : (
""
)
}
const userImage = () => {
if (props.user)
return (
<img
alt={props.user.picture.picture}
className={`profile ${props.user.picture.element}`}
srcSet={`/profile/${props.user.picture.picture}.png,
function sendSaveData() {
if (props.onSave) props.onSave(props.id, props.favorited)
}
const userImage = () => {
if (props.user)
return (
<img
alt={props.user.picture.picture}
className={`profile ${props.user.picture.element}`}
srcSet={`/profile/${props.user.picture.picture}.png,
/profile/${props.user.picture.picture}@2x.png 2x`}
src={`/profile/${props.user.picture.picture}.png`}
/>
src={`/profile/${props.user.picture.picture}.png`}
/>
)
else return <div className="no-user" />
}
const details = (
<div className="Details">
<h2 className={titleClass} onClick={navigate}>
{props.name ? props.name : t("no_title")}
</h2>
<div className="bottom">
<div className={raidClass}>
{props.raid ? props.raid.name[locale] : t("no_raid")}
</div>
<time className="last-updated" dateTime={props.createdAt.toISOString()}>
{formatTimeAgo(props.createdAt, locale)}
</time>
</div>
</div>
)
const detailsWithUsername = (
<div className="Details">
<div className="top">
<div className="info">
<h2 className={titleClass} onClick={navigate}>
{props.name ? props.name : t("no_title")}
</h2>
<div className={raidClass}>
{props.raid ? props.raid.name[locale] : t("no_raid")}
</div>
</div>
{account.authorized &&
((props.user && account.user && account.user.id !== props.user.id) ||
!props.user) ? (
<Button
active={props.favorited}
icon="save"
type={ButtonType.IconOnly}
onClick={sendSaveData}
/>
) : (
""
)}
</div>
<div className="bottom">
<div className={userClass}>
{userImage()}
{props.user ? props.user.username : t("no_user")}
</div>
<time className="last-updated" dateTime={props.createdAt.toISOString()}>
{formatTimeAgo(props.createdAt, locale)}
</time>
</div>
</div>
)
return (
<div className="GridRep">
{props.displayUser ? detailsWithUsername : details}
<div className="Grid" onClick={navigate}>
<div className="weapon grid_mainhand">{generateMainhandImage()}</div>
<ul className="grid_weapons">
{Array.from(Array(numWeapons)).map((x, i) => {
return (
<li
key={`${props.shortcode}-${i}`}
className="weapon grid_weapon"
>
{generateGridImage(i)}
</li>
)
else
return (<div className="no-user" />)
}
const details = (
<div className="Details">
<h2 className={titleClass} onClick={navigate}>{ (props.name) ? props.name : t('no_title') }</h2>
<div className="bottom">
<div className={raidClass}>{ (props.raid) ? props.raid.name[locale] : t('no_raid') }</div>
<time className="last-updated" dateTime={props.createdAt.toISOString()}>{formatTimeAgo(props.createdAt, locale)}</time>
</div>
</div>
)
const detailsWithUsername = (
<div className="Details">
<div className="top">
<div className="info">
<h2 className={titleClass} onClick={navigate}>{ (props.name) ? props.name : t('no_title') }</h2>
<div className={raidClass}>{ (props.raid) ? props.raid.name[locale] : t('no_raid') }</div>
</div>
{
(account.authorized && (
(props.user && account.user && account.user.id !== props.user.id)
|| (!props.user)
)) ?
<Button
active={props.favorited}
icon="save"
type={ButtonType.IconOnly}
onClick={sendSaveData} />
: ''
}
</div>
<div className="bottom">
<div className={userClass}>
{ userImage() }
{ (props.user) ? props.user.username : t('no_user') }
</div>
<time className="last-updated" dateTime={props.createdAt.toISOString()}>{formatTimeAgo(props.createdAt, locale)}</time>
</div>
</div>
)
return (
<div className="GridRep">
{ (props.displayUser) ? detailsWithUsername : details}
<div className="Grid" onClick={navigate}>
<div className="weapon grid_mainhand">
{generateMainhandImage()}
</div>
<ul className="grid_weapons">
{
Array.from(Array(numWeapons)).map((x, i) => {
return (
<li key={`${props.shortcode}-${i}`} className="weapon grid_weapon">
{generateGridImage(i)}
</li>
)
})
}
</ul>
</div>
</div>
)
})}
</ul>
</div>
</div>
)
}
export default GridRep

View file

@ -1,15 +1,10 @@
.GridRepCollection {
display: grid;
grid-template-columns: auto auto auto;
margin: 0 auto;
opacity: 0;
padding: 0;
width: fit-content;
transition: opacity 0.14s ease-in-out;
// width: fit-content;
max-width: 996px;
&.visible {
opacity: 1;
}
}
display: grid;
grid-template-columns: auto auto auto;
margin: 0 auto;
padding: 0;
width: fit-content;
transition: opacity 0.14s ease-in-out;
// width: fit-content;
max-width: 996px;
}

View file

@ -1,24 +1,18 @@
import classNames from 'classnames'
import React from 'react'
import classNames from "classnames"
import React from "react"
import './index.scss'
import "./index.scss"
interface Props {
loading: boolean
children: React.ReactNode
children: React.ReactNode
}
const GridRepCollection = (props: Props) => {
const classes = classNames({
'GridRepCollection': true,
'visible': !props.loading
})
return (
<div className={classes}>
{props.children}
</div>
)
const classes = classNames({
GridRepCollection: true,
})
return <div className={classes}>{props.children}</div>
}
export default GridRepCollection

View file

@ -1,129 +1,141 @@
import React, { useEffect, useState } from 'react'
import { useCookies } from 'react-cookie'
import Router, { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import React, { useEffect, useState } from "react"
import { getCookie, setCookie } from "cookies-next"
import { useRouter } from "next/router"
import { useTranslation } from "next-i18next"
import Link from 'next/link'
import * as Switch from '@radix-ui/react-switch'
import Link from "next/link"
import * as Switch from "@radix-ui/react-switch"
import AboutModal from '~components/AboutModal'
import AccountModal from '~components/AccountModal'
import LoginModal from '~components/LoginModal'
import SignupModal from '~components/SignupModal'
import AboutModal from "~components/AboutModal"
import AccountModal from "~components/AccountModal"
import LoginModal from "~components/LoginModal"
import SignupModal from "~components/SignupModal"
import './index.scss'
import "./index.scss"
interface Props {
authenticated: boolean,
username?: string,
logout?: () => void
authenticated: boolean
username?: string
logout?: () => void
}
const HeaderMenu = (props: Props) => {
const router = useRouter()
const { t } = useTranslation('common')
const [accountCookies] = useCookies(['account'])
const [userCookies] = useCookies(['user'])
const [cookies, setCookies] = useCookies()
const router = useRouter()
const { t } = useTranslation("common")
const [checked, setChecked] = useState(false)
const accountCookie = getCookie("account")
const accountData: AccountCookie = accountCookie
? JSON.parse(accountCookie as string)
: null
useEffect(() => {
const locale = cookies['NEXT_LOCALE']
setChecked((locale === 'ja') ? true : false)
}, [cookies])
const userCookie = getCookie("user")
const userData: UserCookie = userCookie
? JSON.parse(userCookie as string)
: null
function handleCheckedChange(value: boolean) {
const language = (value) ? 'ja' : 'en'
setCookies('NEXT_LOCALE', language, { path: '/'})
router.push(router.asPath, undefined, { locale: language })
}
const localeCookie = getCookie("NEXT_LOCALE")
function authItems() {
return (
<nav>
<ul className="Menu auth">
<div className="MenuGroup">
<li className="MenuItem profile">
<Link href={`/${accountCookies.account.username}` || ''} passHref>
<div>
<span>{accountCookies.account.username}</span>
<img
alt={userCookies.user.picture}
className={`profile ${userCookies.user.element}`}
srcSet={`/profile/${userCookies.user.picture}.png,
/profile/${userCookies.user.picture}@2x.png 2x`}
src={`/profile/${userCookies.user.picture}.png`}
/>
</div>
</Link>
</li>
<li className="MenuItem">
<Link href={`/saved` || ''}>{t('menu.saved')}</Link>
</li>
</div>
<div className="MenuGroup">
<li className="MenuItem">
<Link href='/teams'>{t('menu.teams')}</Link>
</li>
const [checked, setChecked] = useState(false)
<li className="MenuItem disabled">
<div>
<span>{t('menu.guides')}</span>
<i className="tag">{t('coming_soon')}</i>
</div>
</li>
</div>
<div className="MenuGroup">
<AboutModal />
<AccountModal />
<li className="MenuItem" onClick={props.logout}>
<span>{t('menu.logout')}</span>
</li>
</div>
</ul>
</nav>
)
}
useEffect(() => {
const locale = localeCookie
setChecked(locale === "ja" ? true : false)
}, [localeCookie])
function unauthItems() {
return (
<ul className="Menu unauth">
<div className="MenuGroup">
<li className="MenuItem language">
<span>{t('menu.language')}</span>
<Switch.Root className="Switch" onCheckedChange={handleCheckedChange} checked={checked}>
<Switch.Thumb className="Thumb" />
<span className="left">JP</span>
<span className="right">EN</span>
</Switch.Root>
</li>
function handleCheckedChange(value: boolean) {
const language = value ? "ja" : "en"
setCookie("NEXT_LOCALE", language, { path: "/" })
router.push(router.asPath, undefined, { locale: language })
}
function authItems() {
return (
<nav>
<ul className="Menu auth">
<div className="MenuGroup">
<li className="MenuItem profile">
<Link href={`/${accountData.username}` || ""} passHref>
<div>
<span>{accountData.username}</span>
<img
alt={userData.picture}
className={`profile ${userData.element}`}
srcSet={`/profile/${userData.picture}.png,
/profile/${userData.picture}@2x.png 2x`}
src={`/profile/${userData.picture}.png`}
/>
</div>
<div className="MenuGroup">
<li className="MenuItem">
<Link href='/teams'>{t('menu.teams')}</Link>
</li>
</Link>
</li>
<li className="MenuItem">
<Link href={`/saved` || ""}>{t("menu.saved")}</Link>
</li>
</div>
<div className="MenuGroup">
<li className="MenuItem">
<Link href="/teams">{t("menu.teams")}</Link>
</li>
<li className="MenuItem disabled">
<div>
<span>{t('menu.guides')}</span>
<i className="tag">{t('coming_soon')}</i>
</div>
</li>
</div>
<div className="MenuGroup">
<AboutModal />
</div>
<div className="MenuGroup">
<LoginModal />
<SignupModal />
</div>
</ul>
)
}
<li className="MenuItem disabled">
<div>
<span>{t("menu.guides")}</span>
<i className="tag">{t("coming_soon")}</i>
</div>
</li>
</div>
<div className="MenuGroup">
<AboutModal />
<AccountModal />
<li className="MenuItem" onClick={props.logout}>
<span>{t("menu.logout")}</span>
</li>
</div>
</ul>
</nav>
)
}
return (props.authenticated) ? authItems() : unauthItems()
function unauthItems() {
return (
<ul className="Menu unauth">
<div className="MenuGroup">
<li className="MenuItem language">
<span>{t("menu.language")}</span>
<Switch.Root
className="Switch"
onCheckedChange={handleCheckedChange}
checked={checked}
>
<Switch.Thumb className="Thumb" />
<span className="left">JP</span>
<span className="right">EN</span>
</Switch.Root>
</li>
</div>
<div className="MenuGroup">
<li className="MenuItem">
<Link href="/teams">{t("menu.teams")}</Link>
</li>
<li className="MenuItem disabled">
<div>
<span>{t("menu.guides")}</span>
<i className="tag">{t("coming_soon")}</i>
</div>
</li>
</div>
<div className="MenuGroup">
<AboutModal />
</div>
<div className="MenuGroup">
<LoginModal />
<SignupModal />
</div>
</ul>
)
}
return props.authenticated ? authItems() : unauthItems()
}
export default HeaderMenu
export default HeaderMenu

View file

@ -1,213 +1,216 @@
import React, { useState } from 'react'
import { useCookies } from 'react-cookie'
import Router, { useRouter } from 'next/router'
import { useTranslation } from 'react-i18next'
import { AxiosResponse } from 'axios'
import React, { 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 * as Dialog from "@radix-ui/react-dialog"
import api from '~utils/api'
import { accountState } from '~utils/accountState'
import api from "~utils/api"
import { accountState } from "~utils/accountState"
import Button from '~components/Button'
import Fieldset from '~components/Fieldset'
import Button from "~components/Button"
import Fieldset from "~components/Fieldset"
import CrossIcon from '~public/icons/Cross.svg'
import './index.scss'
import CrossIcon from "~public/icons/Cross.svg"
import "./index.scss"
interface Props {}
interface ErrorMap {
[index: string]: string
email: string
password: string
[index: string]: string
email: string
password: string
}
const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
const emailRegex =
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
const LoginModal = (props: Props) => {
const router = useRouter()
const { t } = useTranslation('common')
const router = useRouter()
const { t } = useTranslation("common")
// Set up form states and error handling
const [formValid, setFormValid] = useState(false)
const [errors, setErrors] = useState<ErrorMap>({
email: '',
password: ''
})
// Set up form states and error handling
const [formValid, setFormValid] = useState(false)
const [errors, setErrors] = useState<ErrorMap>({
email: "",
password: "",
})
// Cookies
const [cookies, setCookies] = useCookies()
// States
const [open, setOpen] = useState(false)
// States
const [open, setOpen] = useState(false)
// Set up form refs
const emailInput: React.RefObject<HTMLInputElement> = React.createRef()
const passwordInput: React.RefObject<HTMLInputElement> = React.createRef()
const form: React.RefObject<HTMLInputElement>[] = [emailInput, passwordInput]
// Set up form refs
const emailInput: React.RefObject<HTMLInputElement> = React.createRef()
const passwordInput: React.RefObject<HTMLInputElement> = React.createRef()
const form: React.RefObject<HTMLInputElement>[] = [emailInput, passwordInput]
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const { name, value } = event.target
let newErrors = { ...errors }
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const { name, value } = event.target
let newErrors = {...errors}
switch (name) {
case "email":
if (value.length == 0)
newErrors.email = t("modals.login.errors.empty_email")
else if (!emailRegex.test(value))
newErrors.email = t("modals.login.errors.invalid_email")
else newErrors.email = ""
break
switch(name) {
case 'email':
if (value.length == 0)
newErrors.email = t('modals.login.errors.empty_email')
else if (!emailRegex.test(value))
newErrors.email = t('modals.login.errors.invalid_email')
else
newErrors.email = ''
break
case "password":
newErrors.password =
value.length == 0 ? t("modals.login.errors.empty_password") : ""
break
case 'password':
newErrors.password = value.length == 0
? t('modals.login.errors.empty_password')
: ''
break
default:
break
}
setErrors(newErrors)
setFormValid(validateForm(newErrors))
default:
break
}
function validateForm(errors: ErrorMap) {
let valid = true
setErrors(newErrors)
setFormValid(validateForm(newErrors))
}
Object.values(form).forEach(
(input) => input.current?.value.length == 0 && (valid = false)
)
function validateForm(errors: ErrorMap) {
let valid = true
Object.values(errors).forEach(
(error) => error.length > 0 && (valid = false)
)
return valid
}
function login(event: React.FormEvent) {
event.preventDefault()
const body = {
email: emailInput.current?.value,
password: passwordInput.current?.value,
grant_type: 'password'
}
if (formValid) {
api.login(body)
.then(response => {
storeCookieInfo(response)
return response.data.user.id
})
.then(id => fetchUserInfo(id))
.then(infoResponse => storeUserInfo(infoResponse))
}
}
function fetchUserInfo(id: string) {
return api.userInfo(id)
}
function storeCookieInfo(response: AxiosResponse) {
const user = response.data.user
const cookieObj = {
user_id: user.id,
username: user.username,
access_token: response.data.access_token
}
setCookies('account', cookieObj, { path: '/' })
}
function storeUserInfo(response: AxiosResponse) {
const user = response.data.user
const cookieObj = {
picture: user.picture.picture,
element: user.picture.element,
language: user.language,
gender: user.gender
}
setCookies('user', cookieObj, { path: '/' })
accountState.account.user = {
id: user.id,
username: user.username,
picture: user.picture.picture,
element: user.picture.element,
gender: user.gender
}
accountState.account.authorized = true
setOpen(false)
changeLanguage(user.language)
}
function changeLanguage(newLanguage: string) {
if (newLanguage !== router.locale) {
setCookies('NEXT_LOCALE', newLanguage, { path: '/'})
router.push(router.asPath, undefined, { locale: newLanguage })
}
}
function openChange(open: boolean) {
setOpen(open)
setErrors({
email: '',
password: ''
})
}
return (
<Dialog.Root open={open} onOpenChange={openChange}>
<Dialog.Trigger asChild>
<li className="MenuItem">
<span>{t('menu.login')}</span>
</li>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content className="Login Dialog" onOpenAutoFocus={ (event) => event.preventDefault() }>
<div className="DialogHeader">
<Dialog.Title className="DialogTitle">{t('modals.login.title')}</Dialog.Title>
<Dialog.Close className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</Dialog.Close>
</div>
<form className="form" onSubmit={login}>
<Fieldset
fieldName="email"
placeholder={t('modals.login.placeholders.email')}
onChange={handleChange}
error={errors.email}
ref={emailInput}
/>
<Fieldset
fieldName="password"
placeholder={t('modals.login.placeholders.password')}
onChange={handleChange}
error={errors.password}
ref={passwordInput}
/>
<Button>{t('modals.login.buttons.confirm')}</Button>
</form>
</Dialog.Content>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>
</Dialog.Root>
Object.values(form).forEach(
(input) => input.current?.value.length == 0 && (valid = false)
)
Object.values(errors).forEach(
(error) => error.length > 0 && (valid = false)
)
return valid
}
function login(event: React.FormEvent) {
event.preventDefault()
const body = {
email: emailInput.current?.value,
password: passwordInput.current?.value,
grant_type: "password",
}
if (formValid) {
api
.login(body)
.then((response) => {
storeCookieInfo(response)
return response.data.user.id
})
.then((id) => fetchUserInfo(id))
.then((infoResponse) => storeUserInfo(infoResponse))
}
}
function fetchUserInfo(id: string) {
return api.userInfo(id)
}
function storeCookieInfo(response: AxiosResponse) {
const user = response.data.user
const cookieObj: AccountCookie = {
userId: user.id,
username: user.username,
token: response.data.access_token,
}
setCookie("account", cookieObj, { path: "/" })
}
function storeUserInfo(response: AxiosResponse) {
const user = response.data.user
const cookieObj: UserCookie = {
picture: user.picture.picture,
element: user.picture.element,
language: user.language,
gender: user.gender,
}
setCookie("user", cookieObj, { path: "/" })
accountState.account.user = {
id: user.id,
username: user.username,
picture: user.picture.picture,
element: user.picture.element,
gender: user.gender,
}
console.log("Authorizing account...")
accountState.account.authorized = true
setOpen(false)
changeLanguage(user.language)
}
function changeLanguage(newLanguage: string) {
if (newLanguage !== router.locale) {
setCookie("NEXT_LOCALE", newLanguage, { path: "/" })
router.push(router.asPath, undefined, { locale: newLanguage })
}
}
function openChange(open: boolean) {
setOpen(open)
setErrors({
email: "",
password: "",
})
}
return (
<Dialog.Root open={open} onOpenChange={openChange}>
<Dialog.Trigger asChild>
<li className="MenuItem">
<span>{t("menu.login")}</span>
</li>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content
className="Login Dialog"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div className="DialogHeader">
<Dialog.Title className="DialogTitle">
{t("modals.login.title")}
</Dialog.Title>
<Dialog.Close className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</Dialog.Close>
</div>
<form className="form" onSubmit={login}>
<Fieldset
fieldName="email"
placeholder={t("modals.login.placeholders.email")}
onChange={handleChange}
error={errors.email}
ref={emailInput}
/>
<Fieldset
fieldName="password"
placeholder={t("modals.login.placeholders.password")}
onChange={handleChange}
error={errors.password}
ref={passwordInput}
/>
<Button>{t("modals.login.buttons.confirm")}</Button>
</form>
</Dialog.Content>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>
</Dialog.Root>
)
}
export default LoginModal
export default LoginModal

View file

@ -1,254 +1,291 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio'
import { useCookies } from 'react-cookie'
import clonedeep from 'lodash.clonedeep'
import { subscribeKey } from 'valtio/utils'
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { useRouter } from "next/router"
import { useSnapshot } from "valtio"
import { getCookie } from "cookies-next"
import clonedeep from "lodash.clonedeep"
import PartySegmentedControl from '~components/PartySegmentedControl'
import PartyDetails from '~components/PartyDetails'
import WeaponGrid from '~components/WeaponGrid'
import SummonGrid from '~components/SummonGrid'
import CharacterGrid from '~components/CharacterGrid'
import PartySegmentedControl from "~components/PartySegmentedControl"
import PartyDetails from "~components/PartyDetails"
import WeaponGrid from "~components/WeaponGrid"
import SummonGrid from "~components/SummonGrid"
import CharacterGrid from "~components/CharacterGrid"
import api from '~utils/api'
import { appState, initialAppState } from '~utils/appState'
import { GridType, TeamElement } from '~utils/enums'
import api from "~utils/api"
import { appState, initialAppState } from "~utils/appState"
import { GridType, TeamElement } from "~utils/enums"
import './index.scss'
import { AxiosResponse } from 'axios'
import "./index.scss"
// Props
interface Props {
new?: boolean
slug?: string
pushHistory?: (path: string) => void
new?: boolean
team?: Party
raids: Raid[][]
pushHistory?: (path: string) => void
}
const Party = (props: Props) => {
// Cookies
const [cookies] = useCookies(['account'])
const headers = useMemo(() => {
return (cookies.account != null) ? {
headers: { 'Authorization': `Bearer ${cookies.account.access_token}` }
} : {}
}, [cookies.account])
const Party = (props: Props) => {
// Cookies
const cookie = getCookie("account")
const accountData: AccountCookie = cookie
? JSON.parse(cookie as string)
: null
// Set up router
const router = useRouter()
const headers = useMemo(() => {
return accountData
? { headers: { Authorization: `Bearer ${accountData.token}` } }
: {}
}, [accountData])
// Set up states
const { party } = useSnapshot(appState)
const jobState = party.job
// Set up router
const router = useRouter()
const [job, setJob] = useState<Job>()
const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon)
// Set up states
const { party } = useSnapshot(appState)
const jobState = party.job
// Reset state on first load
useEffect(() => {
const resetState = clonedeep(initialAppState)
appState.grid = resetState.grid
}, [])
const [job, setJob] = useState<Job>()
const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon)
useEffect(() => {
setJob(jobState)
}, [jobState])
// Reset state on first load
useEffect(() => {
const resetState = clonedeep(initialAppState)
appState.grid = resetState.grid
if (props.team) storeParty(props.team)
}, [])
useEffect(() => {
jobChanged()
}, [job])
useEffect(() => {
setJob(jobState)
}, [jobState])
// Methods: Creating a new party
async function createParty(extra: boolean = false) {
let body = {
party: {
...(cookies.account) && { user_id: cookies.account.user_id },
extra: extra
}
}
useEffect(() => {
jobChanged()
}, [job])
return await api.endpoints.parties.create(body, headers)
// Methods: Creating a new party
async function createParty(extra: boolean = false) {
let body = {
party: {
...(accountData && { user_id: accountData.userId }),
extra: extra,
},
}
// Methods: Updating the party's details
function checkboxChanged(event: React.ChangeEvent<HTMLInputElement>) {
appState.party.extra = event.target.checked
return await api.endpoints.parties.create(body, headers)
}
if (party.id) {
api.endpoints.parties.update(party.id, {
'party': { 'extra': event.target.checked }
}, headers)
}
// Methods: Updating the party's details
function checkboxChanged(event: React.ChangeEvent<HTMLInputElement>) {
appState.party.extra = event.target.checked
if (party.id) {
api.endpoints.parties.update(
party.id,
{
party: { extra: event.target.checked },
},
headers
)
}
}
function jobChanged() {
if (party.id) {
api.endpoints.parties.update(party.id, {
'party': { 'job_id': (job) ? job.id : '' }
}, headers)
}
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 ||
appState.party.description !== description ||
appState.party.raid?.id !== raid?.id) {
if (appState.party.id)
api.endpoints.parties.update(appState.party.id, {
'party': {
'name': name,
'description': description,
'raid_id': raid?.id
}
}, headers)
.then(() => {
appState.party.name = name
appState.party.description = description
appState.party.raid = raid
appState.party.updated_at = party.updated_at
})
}
function updateDetails(name?: string, description?: string, raid?: Raid) {
if (
appState.party.name !== name ||
appState.party.description !== description ||
appState.party.raid?.id !== raid?.id
) {
if (appState.party.id)
api.endpoints.parties
.update(
appState.party.id,
{
party: {
name: name,
description: description,
raid_id: raid?.id,
},
},
headers
)
.then(() => {
appState.party.name = name
appState.party.description = description
appState.party.raid = raid
appState.party.updated_at = party.updated_at
})
}
}
// Deleting the party
function deleteTeam(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
if (appState.party.editable && appState.party.id) {
api.endpoints.parties.destroy({ id: appState.party.id, params: headers })
.then(() => {
// Push to route
router.push('/')
// Deleting the party
function deleteTeam(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
if (appState.party.editable && appState.party.id) {
api.endpoints.parties
.destroy({ id: appState.party.id, params: headers })
.then(() => {
// Push to route
router.push("/")
// Clean state
const resetState = clonedeep(initialAppState)
Object.keys(resetState).forEach((key) => {
appState[key] = resetState[key]
})
// Clean state
const resetState = clonedeep(initialAppState)
Object.keys(resetState).forEach((key) => {
appState[key] = resetState[key]
})
// Set party to be editable
appState.party.editable = true
})
.catch((error) => {
console.error(error)
})
}
// Set party to be editable
appState.party.editable = true
})
.catch((error) => {
console.error(error)
})
}
}
// Methods: Navigating with segmented control
function segmentClicked(event: React.ChangeEvent<HTMLInputElement>) {
switch(event.target.value) {
case 'class':
setCurrentTab(GridType.Class)
break
case 'characters':
setCurrentTab(GridType.Character)
break
case 'weapons':
setCurrentTab(GridType.Weapon)
break
case 'summons':
setCurrentTab(GridType.Summon)
break
default:
break
}
// Methods: Storing party data
const storeParty = function (party: Party) {
// Store the important party and state-keeping values
appState.party.name = party.name
appState.party.description = party.description
appState.party.raid = party.raid
appState.party.updated_at = party.updated_at
appState.party.id = party.id
appState.party.extra = party.extra
appState.party.user = party.user
appState.party.favorited = party.favorited
appState.party.created_at = party.created_at
appState.party.updated_at = party.updated_at
// Populate state
storeCharacters(party.characters)
storeWeapons(party.weapons)
storeSummons(party.summons)
}
const storeCharacters = (list: Array<GridCharacter>) => {
list.forEach((object: GridCharacter) => {
if (object.position != null)
appState.grid.characters[object.position] = object
})
}
const storeWeapons = (list: Array<GridWeapon>) => {
list.forEach((gridObject: GridWeapon) => {
if (gridObject.mainhand) {
appState.grid.weapons.mainWeapon = gridObject
appState.party.element = gridObject.object.element
} else if (!gridObject.mainhand && gridObject.position != null) {
appState.grid.weapons.allWeapons[gridObject.position] = gridObject
}
})
}
const storeSummons = (list: Array<GridSummon>) => {
list.forEach((gridObject: GridSummon) => {
if (gridObject.main) appState.grid.summons.mainSummon = gridObject
else if (gridObject.friend)
appState.grid.summons.friendSummon = gridObject
else if (
!gridObject.main &&
!gridObject.friend &&
gridObject.position != null
)
appState.grid.summons.allSummons[gridObject.position] = gridObject
})
}
// Methods: Navigating with segmented control
function segmentClicked(event: React.ChangeEvent<HTMLInputElement>) {
switch (event.target.value) {
case "class":
setCurrentTab(GridType.Class)
break
case "characters":
setCurrentTab(GridType.Character)
break
case "weapons":
setCurrentTab(GridType.Weapon)
break
case "summons":
setCurrentTab(GridType.Summon)
break
default:
break
}
}
// Methods: Fetch party details
const processResult = useCallback((response: AxiosResponse) => {
appState.party.id = response.data.party.id
appState.party.user = response.data.party.user
appState.party.favorited = response.data.party.favorited
appState.party.created_at = response.data.party.created_at
appState.party.updated_at = response.data.party.updated_at
// Render: JSX components
const navigation = (
<PartySegmentedControl
selectedTab={currentTab}
onClick={segmentClicked}
onCheckboxChange={checkboxChanged}
/>
)
// Store the party's user-generated details
appState.party.name = response.data.party.name
appState.party.description = response.data.party.description
appState.party.raid = response.data.party.raid
appState.party.job = response.data.party.job
}, [])
const weaponGrid = (
<WeaponGrid
new={props.new || false}
weapons={props.team?.weapons}
createParty={createParty}
pushHistory={props.pushHistory}
/>
)
const handleError = useCallback((error: any) => {
if (error.response != null && error.response.status == 404) {
// setFound(false)
} else if (error.response != null) {
console.error(error)
} else {
console.error("There was an error.")
}
}, [])
const summonGrid = (
<SummonGrid
new={props.new || false}
summons={props.team?.summons}
createParty={createParty}
pushHistory={props.pushHistory}
/>
)
const fetchDetails = useCallback((shortcode: string) => {
return api.endpoints.parties.getOne({ id: shortcode, params: headers })
.then(response => processResult(response))
.catch(error => handleError(error))
}, [headers, processResult, handleError])
const characterGrid = (
<CharacterGrid
new={props.new || false}
characters={props.team?.characters}
createParty={createParty}
pushHistory={props.pushHistory}
/>
)
useEffect(() => {
const shortcode = (props.slug) ? props.slug : undefined
if (shortcode) fetchDetails(shortcode)
}, [props.slug, fetchDetails])
const currentGrid = () => {
switch (currentTab) {
case GridType.Character:
return characterGrid
case GridType.Weapon:
return weaponGrid
case GridType.Summon:
return summonGrid
}
}
// Render: JSX components
const navigation = (
<PartySegmentedControl
selectedTab={currentTab}
onClick={segmentClicked}
onCheckboxChange={checkboxChanged}
return (
<div>
{navigation}
<section id="Party">{currentGrid()}</section>
{
<PartyDetails
editable={party.editable}
updateCallback={updateDetails}
deleteCallback={deleteTeam}
/>
)
const weaponGrid = (
<WeaponGrid
new={props.new || false}
slug={props.slug}
createParty={createParty}
pushHistory={props.pushHistory}
/>
)
const summonGrid = (
<SummonGrid
new={props.new || false}
slug={props.slug}
createParty={createParty}
pushHistory={props.pushHistory}
/>
)
const characterGrid = (
<CharacterGrid
new={props.new || false}
slug={props.slug}
createParty={createParty}
pushHistory={props.pushHistory}
/>
)
const currentGrid = () => {
switch(currentTab) {
case GridType.Character:
return characterGrid
case GridType.Weapon:
return weaponGrid
case GridType.Summon:
return summonGrid
}
}
return (
<div>
{ navigation }
<section id="Party">
{ currentGrid() }
</section>
{ <PartyDetails
editable={party.editable}
updateCallback={updateDetails}
deleteCallback={deleteTeam}
/>}
</div>
)
}
</div>
)
}
export default Party

View file

@ -1,317 +1,350 @@
import React, { useState } from 'react'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import React, { useState } from "react"
import Head from "next/head"
import { useRouter } from "next/router"
import { useSnapshot } from "valtio"
import { useTranslation } from "next-i18next"
import Linkify from 'react-linkify'
import classNames from 'classnames'
import Linkify from "react-linkify"
import classNames from "classnames"
import * as AlertDialog from '@radix-ui/react-alert-dialog'
import CrossIcon from '~public/icons/Cross.svg'
import * as AlertDialog from "@radix-ui/react-alert-dialog"
import CrossIcon from "~public/icons/Cross.svg"
import Button from '~components/Button'
import CharLimitedFieldset from '~components/CharLimitedFieldset'
import RaidDropdown from '~components/RaidDropdown'
import TextFieldset from '~components/TextFieldset'
import Button from "~components/Button"
import CharLimitedFieldset from "~components/CharLimitedFieldset"
import RaidDropdown from "~components/RaidDropdown"
import TextFieldset from "~components/TextFieldset"
import { accountState } from '~utils/accountState'
import { appState } from '~utils/appState'
import { accountState } from "~utils/accountState"
import { appState } from "~utils/appState"
import './index.scss'
import Link from 'next/link'
import { formatTimeAgo } from '~utils/timeAgo'
import "./index.scss"
import Link from "next/link"
import { formatTimeAgo } from "~utils/timeAgo"
const emptyRaid: Raid = {
id: '',
name: {
en: '',
ja: ''
},
slug: '',
level: 0,
group: 0,
element: 0
id: "",
name: {
en: "",
ja: "",
},
slug: "",
level: 0,
group: 0,
element: 0,
}
// Props
interface Props {
editable: boolean
updateCallback: (name?: string, description?: string, raid?: Raid) => void
deleteCallback: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void
editable: boolean
updateCallback: (name?: string, description?: string, raid?: Raid) => void
deleteCallback: (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void
}
const PartyDetails = (props: Props) => {
const { party, raids } = useSnapshot(appState)
const { account } = useSnapshot(accountState)
const { party, raids } = useSnapshot(appState)
const { account } = useSnapshot(accountState)
const { t } = useTranslation('common')
const router = useRouter()
const locale = router.locale || 'en'
const { t } = useTranslation("common")
const router = useRouter()
const locale = router.locale || "en"
const nameInput = React.createRef<HTMLInputElement>()
const descriptionInput = React.createRef<HTMLTextAreaElement>()
const raidSelect = React.createRef<HTMLSelectElement>()
const nameInput = React.createRef<HTMLInputElement>()
const descriptionInput = React.createRef<HTMLTextAreaElement>()
const raidSelect = React.createRef<HTMLSelectElement>()
const readOnlyClasses = classNames({
'PartyDetails': true,
'ReadOnly': true,
'Visible': !party.detailsVisible
})
const readOnlyClasses = classNames({
PartyDetails: true,
ReadOnly: true,
Visible: !party.detailsVisible,
})
const editableClasses = classNames({
'PartyDetails': true,
'Editable': true,
'Visible': party.detailsVisible
})
const editableClasses = classNames({
PartyDetails: true,
Editable: true,
Visible: party.detailsVisible,
})
const emptyClasses = classNames({
'EmptyDetails': true,
'Visible': !party.detailsVisible
})
const emptyClasses = classNames({
EmptyDetails: true,
Visible: !party.detailsVisible,
})
const userClass = classNames({
'user': true,
'empty': !party.user
})
const userClass = classNames({
user: true,
empty: !party.user,
})
const linkClass = classNames({
'wind': party && party.element == 1,
'fire': party && party.element == 2,
'water': party && party.element == 3,
'earth': party && party.element == 4,
'dark': party && party.element == 5,
'light': party && party.element == 6
})
const linkClass = classNames({
wind: party && party.element == 1,
fire: party && party.element == 2,
water: party && party.element == 3,
earth: party && party.element == 4,
dark: party && party.element == 5,
light: party && party.element == 6,
})
const [errors, setErrors] = useState<{ [key: string]: string }>({
name: '',
description: ''
})
const [errors, setErrors] = useState<{ [key: string]: string }>({
name: "",
description: "",
})
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault()
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault()
const { name, value } = event.target
let newErrors = errors
const { name, value } = event.target
let newErrors = errors
setErrors(newErrors)
}
setErrors(newErrors)
}
function handleTextAreaChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
event.preventDefault()
function handleTextAreaChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
event.preventDefault()
const { name, value } = event.target
let newErrors = errors
const { name, value } = event.target
let newErrors = errors
setErrors(newErrors)
}
setErrors(newErrors)
}
function toggleDetails() {
appState.party.detailsVisible = !appState.party.detailsVisible
}
function toggleDetails() {
appState.party.detailsVisible = !appState.party.detailsVisible
}
function updateDetails(event: React.MouseEvent) {
const nameValue = nameInput.current?.value
const descriptionValue = descriptionInput.current?.value
const raid = raids.find(raid => raid.slug === raidSelect.current?.value)
function updateDetails(event: React.MouseEvent) {
const nameValue = nameInput.current?.value
const descriptionValue = descriptionInput.current?.value
const raid = raids.find((raid) => raid.slug === raidSelect.current?.value)
props.updateCallback(nameValue, descriptionValue, raid)
toggleDetails()
}
props.updateCallback(nameValue, descriptionValue, raid)
toggleDetails()
}
const userImage = () => {
if (party.user)
return (
<img
alt={party.user.picture.picture}
className={`profile ${party.user.picture.element}`}
srcSet={`/profile/${party.user.picture.picture}.png,
const userImage = () => {
if (party.user)
return (
<img
alt={party.user.picture.picture}
className={`profile ${party.user.picture.element}`}
srcSet={`/profile/${party.user.picture.picture}.png,
/profile/${party.user.picture.picture}@2x.png 2x`}
src={`/profile/${party.user.picture.picture}.png`}
/>
)
else
return (<div className="no-user" />)
}
const userBlock = () => {
return (
<div className={userClass}>
{ userImage() }
{ (party.user) ? party.user.username : t('no_user') }
</div>
)
}
const linkedUserBlock = (user: User) => {
return (
<div>
<Link href={`/${user.username}`} passHref>
<a className={linkClass}>{userBlock()}</a>
</Link>
</div>
)
}
const linkedRaidBlock = (raid: Raid) => {
return (
<div>
<Link href={`/teams?raid=${raid.slug}`} passHref>
<a className={`Raid ${linkClass}`}>
{raid.name[locale]}
</a>
</Link>
</div>
)
}
const deleteButton = () => {
if (party.editable) {
return (
<AlertDialog.Root>
<AlertDialog.Trigger className="Button destructive">
<span className='icon'>
<CrossIcon />
</span>
<span className="text">{t('buttons.delete')}</span>
</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay className="Overlay" />
<AlertDialog.Content className="Dialog">
<AlertDialog.Title className="DialogTitle">
{t('modals.delete_team.title')}
</AlertDialog.Title>
<AlertDialog.Description className="DialogDescription">
{t('modals.delete_team.description')}
</AlertDialog.Description>
<div className="actions">
<AlertDialog.Cancel className="Button modal">{t('modals.delete_team.buttons.cancel')}</AlertDialog.Cancel>
<AlertDialog.Action className="Button modal destructive" onClick={(e) => props.deleteCallback(e)}>{t('modals.delete_team.buttons.confirm')}</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
)
} else {
return ('')
}
}
const editable = (
<section className={editableClasses}>
<CharLimitedFieldset
fieldName="name"
placeholder="Name your team"
value={party.name}
limit={50}
onChange={handleInputChange}
error={errors.name}
ref={nameInput}
/>
<RaidDropdown
showAllRaidsOption={false}
currentRaid={party.raid?.slug || ''}
ref={raidSelect}
/>
<TextFieldset
fieldName="name"
placeholder={"Write your notes here\n\n\nWatch out for the 50% trigger!\nMake sure to click Fediels 1 first\nGood luck with RNG!"}
value={party.description}
onChange={handleTextAreaChange}
error={errors.description}
ref={descriptionInput}
/>
<div className="bottom">
<div className="left">
{ (router.pathname !== '/new') ? deleteButton() : '' }
</div>
<div className="right">
<Button
active={true}
onClick={toggleDetails}>
{t('buttons.cancel')}
</Button>
<Button
active={true}
icon="check"
onClick={updateDetails}>
{t('buttons.save_info')}
</Button>
</div>
</div>
</section>
)
const readOnly = (
<section className={readOnlyClasses}>
<div className="info">
<div className="left">
{ (party.name) ? <h1>{party.name}</h1> : '' }
<div className="attribution">
{ (party.user) ? linkedUserBlock(party.user) : userBlock() }
{ (party.raid) ? linkedRaidBlock(party.raid) : '' }
{ (party.created_at != undefined)
? <time
className="last-updated"
dateTime={new Date(party.created_at).toString()}>
{formatTimeAgo(new Date(party.created_at), locale)}
</time>
: '' }
</div>
</div>
<div className="right">
{ (party.editable)
? <Button active={true} icon="edit" onClick={toggleDetails}>{t('buttons.show_info')}</Button>
: <div /> }
</div>
</div>
{ (party.description) ? <p><Linkify>{party.description}</Linkify></p> : '' }
</section>
)
const emptyDetails = (
<div className={emptyClasses}>
<Button active={true} icon="edit" onClick={toggleDetails}>{t('buttons.show_info')}</Button>
</div>
)
const generateTitle = () => {
let title = ''
const username = (party.user != null) ? `@${party.user?.username}` : 'Anonymous'
if (party.name != null)
title = `${party.name} by ${username}`
else if (party.name == null && party.editable && router.route === '/new')
title = "New Team"
else
title = `Untitled team by ${username}`
return title
}
src={`/profile/${party.user.picture.picture}.png`}
/>
)
else return <div className="no-user" />
}
const userBlock = () => {
return (
<div>
<Head>
<title>{generateTitle()}</title>
<meta property="og:title" content={generateTitle()} />
<meta property="og:description" content={ (party.description) ? party.description : '' } />
<meta property="og:url" content="https://app.granblue.team" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:domain" content="app.granblue.team" />
<meta name="twitter:title" content={generateTitle()} />
<meta name="twitter:description" content={ (party.description) ? party.description : '' } />
</Head>
{ (editable && (party.name || party.description || party.raid)) ? readOnly : emptyDetails}
{editable}
</div>
<div className={userClass}>
{userImage()}
{party.user ? party.user.username : t("no_user")}
</div>
)
}
const linkedUserBlock = (user: User) => {
return (
<div>
<Link href={`/${user.username}`} passHref>
<a className={linkClass}>{userBlock()}</a>
</Link>
</div>
)
}
const linkedRaidBlock = (raid: Raid) => {
return (
<div>
<Link href={`/teams?raid=${raid.slug}`} passHref>
<a className={`Raid ${linkClass}`}>{raid.name[locale]}</a>
</Link>
</div>
)
}
const deleteButton = () => {
if (party.editable) {
return (
<AlertDialog.Root>
<AlertDialog.Trigger className="Button destructive">
<span className="icon">
<CrossIcon />
</span>
<span className="text">{t("buttons.delete")}</span>
</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay className="Overlay" />
<AlertDialog.Content className="Dialog">
<AlertDialog.Title className="DialogTitle">
{t("modals.delete_team.title")}
</AlertDialog.Title>
<AlertDialog.Description className="DialogDescription">
{t("modals.delete_team.description")}
</AlertDialog.Description>
<div className="actions">
<AlertDialog.Cancel className="Button modal">
{t("modals.delete_team.buttons.cancel")}
</AlertDialog.Cancel>
<AlertDialog.Action
className="Button modal destructive"
onClick={(e) => props.deleteCallback(e)}
>
{t("modals.delete_team.buttons.confirm")}
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
)
} else {
return ""
}
}
const editable = (
<section className={editableClasses}>
<CharLimitedFieldset
fieldName="name"
placeholder="Name your team"
value={party.name}
limit={50}
onChange={handleInputChange}
error={errors.name}
ref={nameInput}
/>
<RaidDropdown
showAllRaidsOption={false}
currentRaid={party.raid?.slug || ""}
ref={raidSelect}
/>
<TextFieldset
fieldName="name"
placeholder={
"Write your notes here\n\n\nWatch out for the 50% trigger!\nMake sure to click Fediels 1 first\nGood luck with RNG!"
}
value={party.description}
onChange={handleTextAreaChange}
error={errors.description}
ref={descriptionInput}
/>
<div className="bottom">
<div className="left">
{router.pathname !== "/new" ? deleteButton() : ""}
</div>
<div className="right">
<Button active={true} onClick={toggleDetails}>
{t("buttons.cancel")}
</Button>
<Button active={true} icon="check" onClick={updateDetails}>
{t("buttons.save_info")}
</Button>
</div>
</div>
</section>
)
const readOnly = (
<section className={readOnlyClasses}>
<div className="info">
<div className="left">
{party.name ? <h1>{party.name}</h1> : ""}
<div className="attribution">
{party.user ? linkedUserBlock(party.user) : userBlock()}
{party.raid ? linkedRaidBlock(party.raid) : ""}
{party.created_at != undefined ? (
<time
className="last-updated"
dateTime={new Date(party.created_at).toString()}
>
{formatTimeAgo(new Date(party.created_at), locale)}
</time>
) : (
""
)}
</div>
</div>
<div className="right">
{party.editable ? (
<Button active={true} icon="edit" onClick={toggleDetails}>
{t("buttons.show_info")}
</Button>
) : (
<div />
)}
</div>
</div>
{party.description ? (
<p>
<Linkify>{party.description}</Linkify>
</p>
) : (
""
)}
</section>
)
const emptyDetails = (
<div className={emptyClasses}>
{party.editable ? (
<Button active={true} icon="edit" onClick={toggleDetails}>
{t("buttons.show_info")}
</Button>
) : (
<div />
)}
</div>
)
const generateTitle = () => {
let title = party.raid ? `[${party.raid?.name[locale]}] ` : ""
const username =
party.user != null ? `@${party.user?.username}` : t("header.anonymous")
if (party.name != null)
title += t("header.byline", { partyName: party.name, username: username })
else if (party.name == null && party.editable && router.route === "/new")
title = t("header.new_team")
else
title += t("header.untitled_team", {
username: username,
})
return title
}
return (
<div>
<Head>
<title>{generateTitle()}</title>
<meta property="og:title" content={generateTitle()} />
<meta
property="og:description"
content={party.description ? party.description : ""}
/>
<meta property="og:url" content="https://app.granblue.team" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:domain" content="app.granblue.team" />
<meta name="twitter:title" content={generateTitle()} />
<meta
name="twitter:description"
content={party.description ? party.description : ""}
/>
</Head>
{editable && (party.name || party.description || party.raid)
? readOnly
: emptyDetails}
{editable}
</div>
)
}
export default PartyDetails

View file

@ -1,311 +1,348 @@
import React, { useEffect, useRef, useState } from 'react'
import { useCookies } from 'react-cookie'
import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'react-i18next'
import InfiniteScroll from 'react-infinite-scroll-component'
import React, { useEffect, useRef, 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 { appState } from "~utils/appState"
import api from "~utils/api"
import * as Dialog from '@radix-ui/react-dialog'
import * as Dialog from "@radix-ui/react-dialog"
import CharacterSearchFilterBar from '~components/CharacterSearchFilterBar'
import WeaponSearchFilterBar from '~components/WeaponSearchFilterBar'
import SummonSearchFilterBar from '~components/SummonSearchFilterBar'
import CharacterSearchFilterBar from "~components/CharacterSearchFilterBar"
import WeaponSearchFilterBar from "~components/WeaponSearchFilterBar"
import SummonSearchFilterBar from "~components/SummonSearchFilterBar"
import CharacterResult from '~components/CharacterResult'
import WeaponResult from '~components/WeaponResult'
import SummonResult from '~components/SummonResult'
import CharacterResult from "~components/CharacterResult"
import WeaponResult from "~components/WeaponResult"
import SummonResult from "~components/SummonResult"
import './index.scss'
import CrossIcon from '~public/icons/Cross.svg'
import cloneDeep from 'lodash.clonedeep'
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
placeholderText: string
fromPosition: number
object: 'weapons' | 'characters' | 'summons',
children: React.ReactNode
send: (object: Character | Weapon | Summon, position: number) => any
placeholderText: string
fromPosition: number
object: "weapons" | "characters" | "summons"
children: React.ReactNode
}
const SearchModal = (props: Props) => {
// Set up snapshot of app state
let { grid, search } = useSnapshot(appState)
// Set up snapshot of app state
let { grid, search } = useSnapshot(appState)
// Set up router
const router = useRouter()
const locale = router.locale
// Set up router
const router = useRouter()
const locale = router.locale
// Set up translation
const { t } = useTranslation('common')
// Set up translation
const { t } = useTranslation("common")
// Set up cookies
const [cookies, setCookies] = useCookies()
let searchInput = React.createRef<HTMLInputElement>()
let scrollContainer = React.createRef<HTMLDivElement>()
let searchInput = React.createRef<HTMLInputElement>()
let scrollContainer = React.createRef<HTMLDivElement>()
const [firstLoad, setFirstLoad] = useState(true)
const [objects, setObjects] = useState<{
[id: number]: GridCharacter | GridWeapon | GridSummon
}>()
const [filters, setFilters] = useState<{ [key: string]: number[] }>()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const [results, setResults] = useState<(Weapon | Summon | Character)[]>([])
const [firstLoad, setFirstLoad] = useState(true)
const [objects, setObjects] = useState<{[id: number]: GridCharacter | GridWeapon | GridSummon}>()
const [filters, setFilters] = useState<{ [key: string]: number[] }>()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState<(Weapon | Summon | Character)[]>([])
// Pagination states
const [recordCount, setRecordCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
// 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(() => {
setObjects(grid[props.object])
}, [grid, props.object])
useEffect(() => {
if (searchInput.current) searchInput.current.focus()
}, [searchInput])
useEffect(() => {
if (searchInput.current)
searchInput.current.focus()
}, [searchInput])
function inputChanged(event: React.ChangeEvent<HTMLInputElement>) {
const text = event.target.value
if (text.length) {
setQuery(text)
} else {
setQuery("")
}
}
function inputChanged(event: React.ChangeEvent<HTMLInputElement>) {
const text = event.target.value
if (text.length) {
setQuery(text)
function fetchResults({ replace = false }: { replace?: boolean }) {
api
.search({
object: props.object,
query: query,
filters: filters,
locale: locale,
page: currentPage,
})
.then((response) => {
setTotalPages(response.data.total_pages)
setRecordCount(response.data.count)
if (replace) {
replaceResults(response.data.count, response.data.results)
} else {
setQuery('')
appendResults(response.data.results)
}
}
function fetchResults({ replace = false }: { replace?: boolean }) {
api.search({
object: props.object,
query: query,
filters: filters,
locale: locale,
page: currentPage
}).then(response => {
setTotalPages(response.data.total_pages)
setRecordCount(response.data.count)
})
.catch((error) => {
console.error(error)
})
}
if (replace) {
replaceResults(response.data.count, response.data.results)
} else {
appendResults(response.data.results)
}
}).catch(error => {
console.error(error)
})
function replaceResults(
count: number,
list: Weapon[] | Summon[] | Character[]
) {
if (count > 0) {
setResults(list)
} else {
setResults([])
}
}
function appendResults(list: Weapon[] | Summon[] | Character[]) {
setResults([...results, ...list])
}
function storeRecentResult(result: Character | Weapon | Summon) {
const key = `recent_${props.object}`
const cookie = getCookie(key)
const cookieObj: Character[] | Weapon[] | Summon[] = cookie
? JSON.parse(cookie as string)
: []
let recents: Character[] | Weapon[] | Summon[] = []
if (props.object === "weapons") {
recents = cloneDeep(cookieObj as Weapon[]) || []
if (!recents.find((item) => item.granblue_id === result.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)) {
recents.unshift(result as Summon)
}
}
function replaceResults(count: number, list: Weapon[] | Summon[] | Character[]) {
if (count > 0) {
setResults(list)
} else {
setResults([])
}
if (recents && recents.length > 5) recents.pop()
setCookie(`recent_${props.object}`, recents, { path: "/" })
sendData(result)
}
function sendData(result: Character | Weapon | Summon) {
props.send(result, props.fromPosition)
openChange()
}
function receiveFilters(filters: { [key: string]: number[] }) {
setCurrentPage(1)
setResults([])
setFilters(filters)
}
useEffect(() => {
// Current page changed
if (open && currentPage > 1) {
fetchResults({ replace: false })
} else if (open && currentPage == 1) {
fetchResults({ replace: true })
}
}, [currentPage])
function appendResults(list: Weapon[] | Summon[] | Character[]) {
setResults([...results, ...list])
}
useEffect(() => {
// Filters changed
const key = `recent_${props.object}`
const cookie = getCookie(key)
const cookieObj: Weapon[] | Summon[] | Character[] = cookie
? JSON.parse(cookie as string)
: []
function storeRecentResult(result: Character | Weapon | Summon) {
const key = `recent_${props.object}`
let recents: Character[] | Weapon[] | Summon[] = []
if (props.object === "weapons") {
recents = cloneDeep(cookies[key] as Weapon[]) || []
if (!recents.find(item => item.granblue_id === result.granblue_id)) {
recents.unshift(result as Weapon)
}
} else if (props.object === "summons") {
recents = cloneDeep(cookies[key] as Summon[]) || []
if (!recents.find(item => item.granblue_id === result.granblue_id)) {
recents.unshift(result as Summon)
}
}
if (recents && recents.length > 5) recents.pop()
setCookies(`recent_${props.object}`, recents, { path: '/' })
sendData(result)
}
function sendData(result: Character | Weapon | Summon) {
props.send(result, props.fromPosition)
openChange()
}
function receiveFilters(filters: { [key: string]: number[] }) {
if (open) {
if (firstLoad && cookieObj && cookieObj.length > 0) {
setResults(cookieObj)
setRecordCount(cookieObj.length)
setFirstLoad(false)
} else {
setCurrentPage(1)
setResults([])
setFilters(filters)
fetchResults({ replace: true })
}
}
}, [filters])
useEffect(() => {
// Current page changed
if (open && currentPage > 1) {
fetchResults({ replace: false })
} else if (open && currentPage == 1) {
fetchResults({ replace: true })
}
}, [currentPage])
useEffect(() => {
// Filters changed
const key = `recent_${props.object}`
if (open) {
if (firstLoad && cookies[key] && cookies[key].length > 0) {
setResults(cookies[key])
setRecordCount(cookies[key].length)
setFirstLoad(false)
} else {
setCurrentPage(1)
fetchResults({ replace: true })
}
}
}, [filters])
useEffect(() => {
// Query changed
if (open && query.length != 1) {
setCurrentPage(1)
fetchResults({ replace: true })
}
}, [query])
function renderResults() {
let jsx
switch(props.object) {
case 'weapons':
jsx = renderWeaponSearchResults()
break
case 'summons':
jsx = renderSummonSearchResults(results)
break
case 'characters':
jsx = renderCharacterSearchResults(results)
break
}
return (
<InfiniteScroll
dataLength={ (results && results.length > 0) ? results.length : 0}
next={ () => setCurrentPage(currentPage + 1) }
hasMore={totalPages > currentPage}
scrollableTarget="Results"
loader={<div className="footer">Loading...</div>}>
{jsx}
</InfiniteScroll>
)
useEffect(() => {
// Query changed
if (open && query.length != 1) {
setCurrentPage(1)
fetchResults({ replace: true })
}
}, [query])
function renderWeaponSearchResults() {
let jsx: React.ReactNode
const castResults: Weapon[] = results as Weapon[]
if (castResults && Object.keys(castResults).length > 0) {
jsx = castResults.map((result: Weapon) => {
return <WeaponResult
key={result.id}
data={result}
onClick={() => { storeRecentResult(result) }}
/>
})
}
function renderResults() {
let jsx
return jsx
}
function renderSummonSearchResults(results: { [key: string]: any }) {
let jsx: React.ReactNode
const castResults: Summon[] = results as Summon[]
if (castResults && Object.keys(castResults).length > 0) {
jsx = castResults.map((result: Summon) => {
return <SummonResult
key={result.id}
data={result}
onClick={() => { storeRecentResult(result) }}
/>
})
}
return jsx
}
function renderCharacterSearchResults(results: { [key: string]: any }) {
let jsx: React.ReactNode
const castResults: Character[] = results as Character[]
if (castResults && Object.keys(castResults).length > 0) {
jsx = castResults.map((result: Character) => {
return <CharacterResult
key={result.id}
data={result}
onClick={() => { storeRecentResult(result) }}
/>
})
}
return jsx
}
function openChange() {
if (open) {
setQuery('')
setFirstLoad(true)
setResults([])
setRecordCount(0)
setCurrentPage(1)
setOpen(false)
} else {
setOpen(true)
}
switch (props.object) {
case "weapons":
jsx = renderWeaponSearchResults()
break
case "summons":
jsx = renderSummonSearchResults(results)
break
case "characters":
jsx = renderCharacterSearchResults(results)
break
}
return (
<Dialog.Root open={open} onOpenChange={openChange}>
<Dialog.Trigger asChild>
{props.children}
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content className="Search Dialog">
<div id="Header">
<div id="Bar">
<label className="search_label" htmlFor="search_input">
<input
autoComplete="off"
type="text"
name="query"
className="Input"
id="search_input"
ref={searchInput}
value={query}
placeholder={props.placeholderText}
onChange={inputChanged}
/>
</label>
<Dialog.Close className="DialogClose" onClick={openChange}>
<CrossIcon />
</Dialog.Close>
</div>
{ (props.object === 'characters') ? <CharacterSearchFilterBar sendFilters={receiveFilters} /> : '' }
{ (props.object === 'weapons') ? <WeaponSearchFilterBar sendFilters={receiveFilters} /> : '' }
{ (props.object === 'summons') ? <SummonSearchFilterBar sendFilters={receiveFilters} /> : '' }
</div>
<div id="Results" ref={scrollContainer}>
<h5 className="total">{t('search.result_count', { "record_count": recordCount })}</h5>
{ (open) ? renderResults() : ''}
</div>
</Dialog.Content>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>
</Dialog.Root>
<InfiniteScroll
dataLength={results && results.length > 0 ? results.length : 0}
next={() => setCurrentPage(currentPage + 1)}
hasMore={totalPages > currentPage}
scrollableTarget="Results"
loader={<div className="footer">Loading...</div>}
>
{jsx}
</InfiniteScroll>
)
}
function renderWeaponSearchResults() {
let jsx: React.ReactNode
const castResults: Weapon[] = results as Weapon[]
if (castResults && Object.keys(castResults).length > 0) {
jsx = castResults.map((result: Weapon) => {
return (
<WeaponResult
key={result.id}
data={result}
onClick={() => {
storeRecentResult(result)
}}
/>
)
})
}
return jsx
}
function renderSummonSearchResults(results: { [key: string]: any }) {
let jsx: React.ReactNode
const castResults: Summon[] = results as Summon[]
if (castResults && Object.keys(castResults).length > 0) {
jsx = castResults.map((result: Summon) => {
return (
<SummonResult
key={result.id}
data={result}
onClick={() => {
storeRecentResult(result)
}}
/>
)
})
}
return jsx
}
function renderCharacterSearchResults(results: { [key: string]: any }) {
let jsx: React.ReactNode
const castResults: Character[] = results as Character[]
if (castResults && Object.keys(castResults).length > 0) {
jsx = castResults.map((result: Character) => {
return (
<CharacterResult
key={result.id}
data={result}
onClick={() => {
storeRecentResult(result)
}}
/>
)
})
}
return jsx
}
function openChange() {
if (open) {
setQuery("")
setFirstLoad(true)
setResults([])
setRecordCount(0)
setCurrentPage(1)
setOpen(false)
} else {
setOpen(true)
}
}
return (
<Dialog.Root open={open} onOpenChange={openChange}>
<Dialog.Trigger asChild>{props.children}</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content className="Search Dialog">
<div id="Header">
<div id="Bar">
<label className="search_label" htmlFor="search_input">
<input
autoComplete="off"
type="text"
name="query"
className="Input"
id="search_input"
ref={searchInput}
value={query}
placeholder={props.placeholderText}
onChange={inputChanged}
/>
</label>
<Dialog.Close className="DialogClose" onClick={openChange}>
<CrossIcon />
</Dialog.Close>
</div>
{props.object === "characters" ? (
<CharacterSearchFilterBar sendFilters={receiveFilters} />
) : (
""
)}
{props.object === "weapons" ? (
<WeaponSearchFilterBar sendFilters={receiveFilters} />
) : (
""
)}
{props.object === "summons" ? (
<SummonSearchFilterBar sendFilters={receiveFilters} />
) : (
""
)}
</div>
<div id="Results" ref={scrollContainer}>
<h5 className="total">
{t("search.result_count", { record_count: recordCount })}
</h5>
{open ? renderResults() : ""}
</div>
</Dialog.Content>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>
</Dialog.Root>
)
}
export default SearchModal
export default SearchModal

View file

@ -1,306 +1,324 @@
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import { useCookies } from 'react-cookie'
import { useRouter } from 'next/router'
import { Trans, useTranslation } from 'next-i18next'
import { AxiosResponse } from 'axios'
import React, { useEffect, useState } from "react"
import Link from "next/link"
import { setCookie } from "cookies-next"
import { useRouter } from "next/router"
import { Trans, useTranslation } from "next-i18next"
import { AxiosResponse } from "axios"
import * as Dialog from '@radix-ui/react-dialog'
import * as Dialog from "@radix-ui/react-dialog"
import api from '~utils/api'
import { accountState } from '~utils/accountState'
import api from "~utils/api"
import { accountState } from "~utils/accountState"
import Button from '~components/Button'
import Fieldset from '~components/Fieldset'
import Button from "~components/Button"
import Fieldset from "~components/Fieldset"
import CrossIcon from '~public/icons/Cross.svg'
import './index.scss'
import CrossIcon from "~public/icons/Cross.svg"
import "./index.scss"
interface Props {}
interface ErrorMap {
[index: string]: string
username: string
email: string
password: string
passwordConfirmation: string
[index: string]: string
username: string
email: string
password: string
passwordConfirmation: string
}
const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
const emailRegex =
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
const SignupModal = (props: Props) => {
const router = useRouter()
const { t } = useTranslation('common')
// Set up form states and error handling
const [formValid, setFormValid] = useState(false)
const [errors, setErrors] = useState<ErrorMap>({
username: '',
email: '',
password: '',
passwordConfirmation: ''
})
const router = useRouter()
const { t } = useTranslation("common")
// Cookies
const [cookies, setCookies] = useCookies()
// Set up form states and error handling
const [formValid, setFormValid] = useState(false)
const [errors, setErrors] = useState<ErrorMap>({
username: "",
email: "",
password: "",
passwordConfirmation: "",
})
// States
const [open, setOpen] = useState(false)
// Set up form refs
const usernameInput = React.createRef<HTMLInputElement>()
const emailInput = React.createRef<HTMLInputElement>()
const passwordInput = React.createRef<HTMLInputElement>()
const passwordConfirmationInput = React.createRef<HTMLInputElement>()
const form = [usernameInput, emailInput, passwordInput, passwordConfirmationInput]
// States
const [open, setOpen] = useState(false)
function register(event: React.FormEvent) {
event.preventDefault()
// Set up form refs
const usernameInput = React.createRef<HTMLInputElement>()
const emailInput = React.createRef<HTMLInputElement>()
const passwordInput = React.createRef<HTMLInputElement>()
const passwordConfirmationInput = React.createRef<HTMLInputElement>()
const form = [
usernameInput,
emailInput,
passwordInput,
passwordConfirmationInput,
]
const body = {
user: {
username: usernameInput.current?.value,
email: emailInput.current?.value,
password: passwordInput.current?.value,
password_confirmation: passwordConfirmationInput.current?.value,
language: router.locale
}
}
function register(event: React.FormEvent) {
event.preventDefault()
if (formValid)
api.endpoints.users.create(body)
.then(response => {
storeCookieInfo(response)
return response.data.user.user_id
})
.then(id => fetchUserInfo(id))
.then(infoResponse => storeUserInfo(infoResponse))
const body = {
user: {
username: usernameInput.current?.value,
email: emailInput.current?.value,
password: passwordInput.current?.value,
password_confirmation: passwordConfirmationInput.current?.value,
language: router.locale,
},
}
function storeCookieInfo(response: AxiosResponse) {
const user = response.data.user
const cookieObj = {
user_id: user.user_id,
username: user.username,
access_token: user.token
}
setCookies('account', cookieObj, { path: '/'})
}
function fetchUserInfo(id: string) {
return api.userInfo(id)
}
function storeUserInfo(response: AxiosResponse) {
const user = response.data.user
const cookieObj = {
picture: user.picture.picture,
element: user.picture.element,
language: user.language,
gender: user.gender
}
// TODO: Set language
setCookies('user', cookieObj, { path: '/'})
accountState.account.user = {
id: user.id,
username: user.username,
picture: user.picture.picture,
element: user.picture.element,
gender: user.gender
}
accountState.account.authorized = true
setOpen(false)
}
function handleNameChange(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault()
const fieldName = event.target.name
const value = event.target.value
if (value.length >= 3) {
api.check(fieldName, value)
.then((response) => {
processNameCheck(fieldName, value, response.data.available)
}, (error) => {
console.error(error)
})
} else {
validateName(fieldName, value)
}
}
function processNameCheck(fieldName: string, value: string, available: boolean) {
const newErrors = {...errors}
if (available) {
// Continue checking for errors
newErrors[fieldName] = ''
setErrors(newErrors)
setFormValid(true)
validateName(fieldName, value)
} else {
newErrors[fieldName] = t('modals.signup.errors.field_in_use', { field: fieldName})
setErrors(newErrors)
setFormValid(false)
}
}
function validateName(fieldName: string, value: string) {
let newErrors = {...errors}
switch(fieldName) {
case 'username':
if (value.length < 3)
newErrors.username = t('modals.signup.errors.username_too_short')
else if (value.length > 20)
newErrors.username = t('modals.signup.errors.username_too_long')
else
newErrors.username = ''
break
case 'email':
newErrors.email = emailRegex.test(value)
? ''
: t('modals.signup.errors.invalid_email')
break
default:
break
}
setFormValid(validateForm(newErrors))
}
function handlePasswordChange(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault()
const { name, value } = event.target
let newErrors = {...errors}
switch(name) {
case 'password':
newErrors.password = passwordInput.current?.value.includes(usernameInput.current?.value!)
? t('modals.signup.errors.password_contains_username')
: ''
break
case 'password':
newErrors.password = value.length < 8
? t('modals.signup.errors.password_too_short')
: ''
break
case 'confirm_password':
newErrors.passwordConfirmation = passwordInput.current?.value === passwordConfirmationInput.current?.value
? ''
: t('modals.signup.errors.passwords_dont_match')
break
default:
break
}
setFormValid(validateForm(newErrors))
}
function validateForm(errors: ErrorMap) {
let valid = true
Object.values(form).forEach(
(input) => input.current?.value.length == 0 && (valid = false)
)
Object.values(errors).forEach(
(error) => error.length > 0 && (valid = false)
)
return valid
}
function openChange(open: boolean) {
setOpen(open)
setErrors({
username: '',
email: '',
password: '',
passwordConfirmation: ''
if (formValid)
api.endpoints.users
.create(body)
.then((response) => {
storeCookieInfo(response)
return response.data.user.user_id
})
.then((id) => fetchUserInfo(id))
.then((infoResponse) => storeUserInfo(infoResponse))
}
function storeCookieInfo(response: AxiosResponse) {
const user = response.data.user
const cookieObj: AccountCookie = {
userId: user.user_id,
username: user.username,
token: user.token,
}
return (
<Dialog.Root open={open} onOpenChange={openChange}>
<Dialog.Trigger asChild>
<li className="MenuItem">
<span>{t('menu.signup')}</span>
</li>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content className="Signup Dialog" onOpenAutoFocus={ (event) => event.preventDefault() }>
<div className="DialogHeader">
<Dialog.Title className="DialogTitle">{t('modals.signup.title')}</Dialog.Title>
<Dialog.Close className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</Dialog.Close>
</div>
setCookie("account", cookieObj, { path: "/" })
}
<form className="form" onSubmit={register}>
<Fieldset
fieldName="username"
placeholder={t('modals.signup.placeholders.username')}
onChange={handleNameChange}
error={errors.username}
ref={usernameInput}
/>
function fetchUserInfo(id: string) {
return api.userInfo(id)
}
<Fieldset
fieldName="email"
placeholder={t('modals.signup.placeholders.email')}
onChange={handleNameChange}
error={errors.email}
ref={emailInput}
/>
function storeUserInfo(response: AxiosResponse) {
const user = response.data.user
<Fieldset
fieldName="password"
placeholder={t('modals.signup.placeholders.password')}
onChange={handlePasswordChange}
error={errors.password}
ref={passwordInput}
/>
const cookieObj: UserCookie = {
picture: user.picture.picture,
element: user.picture.element,
language: user.language,
gender: user.gender,
}
<Fieldset
fieldName="confirm_password"
placeholder={t('modals.signup.placeholders.password_confirm')}
onChange={handlePasswordChange}
error={errors.passwordConfirmation}
ref={passwordConfirmationInput}
/>
// TODO: Set language
setCookie("user", cookieObj, { path: "/" })
<Button>{t('modals.signup.buttons.confirm')}</Button>
accountState.account.user = {
id: user.id,
username: user.username,
picture: user.picture.picture,
element: user.picture.element,
gender: user.gender,
}
<Dialog.Description className="terms">
{/* <Trans i18nKey="modals.signup.agreement">
accountState.account.authorized = true
setOpen(false)
}
function handleNameChange(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault()
const fieldName = event.target.name
const value = event.target.value
if (value.length >= 3) {
api.check(fieldName, value).then(
(response) => {
processNameCheck(fieldName, value, response.data.available)
},
(error) => {
console.error(error)
}
)
} else {
validateName(fieldName, value)
}
}
function processNameCheck(
fieldName: string,
value: string,
available: boolean
) {
const newErrors = { ...errors }
if (available) {
// Continue checking for errors
newErrors[fieldName] = ""
setErrors(newErrors)
setFormValid(true)
validateName(fieldName, value)
} else {
newErrors[fieldName] = t("modals.signup.errors.field_in_use", {
field: fieldName,
})
setErrors(newErrors)
setFormValid(false)
}
}
function validateName(fieldName: string, value: string) {
let newErrors = { ...errors }
switch (fieldName) {
case "username":
if (value.length < 3)
newErrors.username = t("modals.signup.errors.username_too_short")
else if (value.length > 20)
newErrors.username = t("modals.signup.errors.username_too_long")
else newErrors.username = ""
break
case "email":
newErrors.email = emailRegex.test(value)
? ""
: t("modals.signup.errors.invalid_email")
break
default:
break
}
setFormValid(validateForm(newErrors))
}
function handlePasswordChange(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault()
const { name, value } = event.target
let newErrors = { ...errors }
switch (name) {
case "password":
newErrors.password = passwordInput.current?.value.includes(
usernameInput.current?.value!
)
? t("modals.signup.errors.password_contains_username")
: ""
break
case "password":
newErrors.password =
value.length < 8 ? t("modals.signup.errors.password_too_short") : ""
break
case "confirm_password":
newErrors.passwordConfirmation =
passwordInput.current?.value ===
passwordConfirmationInput.current?.value
? ""
: t("modals.signup.errors.passwords_dont_match")
break
default:
break
}
setFormValid(validateForm(newErrors))
}
function validateForm(errors: ErrorMap) {
let valid = true
Object.values(form).forEach(
(input) => input.current?.value.length == 0 && (valid = false)
)
Object.values(errors).forEach(
(error) => error.length > 0 && (valid = false)
)
return valid
}
function openChange(open: boolean) {
setOpen(open)
setErrors({
username: "",
email: "",
password: "",
passwordConfirmation: "",
})
}
return (
<Dialog.Root open={open} onOpenChange={openChange}>
<Dialog.Trigger asChild>
<li className="MenuItem">
<span>{t("menu.signup")}</span>
</li>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content
className="Signup Dialog"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div className="DialogHeader">
<Dialog.Title className="DialogTitle">
{t("modals.signup.title")}
</Dialog.Title>
<Dialog.Close className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</Dialog.Close>
</div>
<form className="form" onSubmit={register}>
<Fieldset
fieldName="username"
placeholder={t("modals.signup.placeholders.username")}
onChange={handleNameChange}
error={errors.username}
ref={usernameInput}
/>
<Fieldset
fieldName="email"
placeholder={t("modals.signup.placeholders.email")}
onChange={handleNameChange}
error={errors.email}
ref={emailInput}
/>
<Fieldset
fieldName="password"
placeholder={t("modals.signup.placeholders.password")}
onChange={handlePasswordChange}
error={errors.password}
ref={passwordInput}
/>
<Fieldset
fieldName="confirm_password"
placeholder={t("modals.signup.placeholders.password_confirm")}
onChange={handlePasswordChange}
error={errors.passwordConfirmation}
ref={passwordConfirmationInput}
/>
<Button>{t("modals.signup.buttons.confirm")}</Button>
<Dialog.Description className="terms">
{/* <Trans i18nKey="modals.signup.agreement">
By signing up, I agree to the <Link href="/privacy"><span>Privacy Policy</span></Link><Link href="/usage"><span>Usage Guidelines</span></Link>.
</Trans> */}
</Dialog.Description>
</form>
</Dialog.Content>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>
</Dialog.Root>
)
</Dialog.Description>
</form>
</Dialog.Content>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>
</Dialog.Root>
)
}
export default SignupModal
export default SignupModal

View file

@ -1,316 +1,286 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useCookies } from 'react-cookie'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { getCookie } from "cookies-next"
import { useSnapshot } from "valtio"
import { useTranslation } from "next-i18next"
import { AxiosResponse } from 'axios'
import debounce from 'lodash.debounce'
import { AxiosResponse } from "axios"
import debounce from "lodash.debounce"
import SummonUnit from '~components/SummonUnit'
import ExtraSummons from '~components/ExtraSummons'
import SummonUnit from "~components/SummonUnit"
import ExtraSummons from "~components/ExtraSummons"
import api from '~utils/api'
import { appState } from '~utils/appState'
import api from "~utils/api"
import { appState } from "~utils/appState"
import './index.scss'
import "./index.scss"
// Props
interface Props {
new: boolean
slug?: string
createParty: () => Promise<AxiosResponse<any, any>>
pushHistory?: (path: string) => void
new: boolean
summons?: GridSummon[]
createParty: () => Promise<AxiosResponse<any, any>>
pushHistory?: (path: string) => void
}
const SummonGrid = (props: Props) => {
// Constants
const numSummons: number = 4
// Constants
const numSummons: number = 4
const { t } = useTranslation('common')
// Cookies
const cookie = getCookie("account")
const accountData: AccountCookie = cookie
? JSON.parse(cookie as string)
: null
const headers = accountData
? { headers: { Authorization: `Bearer ${accountData.token}` } }
: {}
// Cookies
const [cookies, _] = useCookies(['account'])
const headers = (cookies.account != null) ? {
headers: {
'Authorization': `Bearer ${cookies.account.access_token}`
}
} : {}
// Localization
const { t } = useTranslation("common")
// Set up state for view management
const { party, grid } = useSnapshot(appState)
// Set up state for view management
const { party, grid } = useSnapshot(appState)
const [slug, setSlug] = useState()
const [slug, setSlug] = useState()
const [found, setFound] = useState(false)
const [loading, setLoading] = useState(true)
const [firstLoadComplete, setFirstLoadComplete] = useState(false)
// Create a temporary state to store previous weapon uncap value
const [previousUncapValues, setPreviousUncapValues] = useState<{
[key: number]: number
}>({})
// Create a temporary state to store previous weapon uncap value
const [previousUncapValues, setPreviousUncapValues] = useState<{[key: number]: number}>({})
// Set the editable flag only on first load
useEffect(() => {
// If user is logged in and matches
if (
(accountData && party.user && accountData.userId === party.user.id) ||
props.new
)
appState.party.editable = true
else appState.party.editable = false
}, [props.new, accountData, party])
// Fetch data from the server
useEffect(() => {
const shortcode = (props.slug) ? props.slug : slug
if (shortcode) fetchGrid(shortcode)
else appState.party.editable = true
}, [slug, props.slug])
// Initialize an array of current uncap values for each summon
useEffect(() => {
let initialPreviousUncapValues: { [key: number]: number } = {}
// Set the editable flag only on first load
useEffect(() => {
if (!loading && !firstLoadComplete) {
// If user is logged in and matches
if ((cookies.account && party.user && cookies.account.user_id === party.user.id) || props.new)
appState.party.editable = true
else
appState.party.editable = false
if (appState.grid.summons.mainSummon)
initialPreviousUncapValues[-1] =
appState.grid.summons.mainSummon.uncap_level
setFirstLoadComplete(true)
}
}, [props.new, cookies, party, loading, firstLoadComplete])
if (appState.grid.summons.friendSummon)
initialPreviousUncapValues[6] =
appState.grid.summons.friendSummon.uncap_level
// Initialize an array of current uncap values for each summon
useEffect(() => {
let initialPreviousUncapValues: {[key: number]: number} = {}
Object.values(appState.grid.summons.allSummons).map(
(o) => (initialPreviousUncapValues[o.position] = o.uncap_level)
)
if (appState.grid.summons.mainSummon)
initialPreviousUncapValues[-1] = appState.grid.summons.mainSummon.uncap_level
setPreviousUncapValues(initialPreviousUncapValues)
}, [
appState.grid.summons.mainSummon,
appState.grid.summons.friendSummon,
appState.grid.summons.allSummons,
])
if (appState.grid.summons.friendSummon)
initialPreviousUncapValues[6] = appState.grid.summons.friendSummon.uncap_level
// Methods: Adding an object from search
function receiveSummonFromSearch(
object: Character | Weapon | Summon,
position: number
) {
const summon = object as Summon
Object.values(appState.grid.summons.allSummons).map(o => initialPreviousUncapValues[o.position] = o.uncap_level)
setPreviousUncapValues(initialPreviousUncapValues)
}, [appState.grid.summons.mainSummon, appState.grid.summons.friendSummon, appState.grid.summons.allSummons])
// Methods: Fetching an object from the server
async function fetchGrid(shortcode: string) {
return api.endpoints.parties.getOneWithObject({ id: shortcode, object: 'summons', params: headers })
.then(response => processResult(response))
.catch(error => processError(error))
}
function processResult(response: AxiosResponse) {
// Store the response
const party: Party = response.data.party
// Store the important party and state-keeping values
if (!party.id) {
props.createParty().then((response) => {
const party = response.data.party
appState.party.id = party.id
appState.party.user = party.user
appState.party.favorited = party.favorited
appState.party.created_at = party.created_at
appState.party.updated_at = party.updated_at
setFound(true)
setLoading(false)
setSlug(party.shortcode)
// Populate the weapons in state
populateSummons(party.summons)
if (props.pushHistory) props.pushHistory(`/p/${party.shortcode}`)
saveSummon(party.id, summon, position).then((response) =>
storeGridSummon(response.data.grid_summon)
)
})
} else {
if (party.editable)
saveSummon(party.id, summon, position).then((response) =>
storeGridSummon(response.data.grid_summon)
)
}
}
function processError(error: any) {
if (error.response != null) {
if (error.response.status == 404) {
setFound(false)
setLoading(false)
}
} else {
console.error(error)
}
}
async function saveSummon(partyId: string, summon: Summon, position: number) {
let uncapLevel = 3
if (summon.uncap.ulb) uncapLevel = 5
else if (summon.uncap.flb) uncapLevel = 4
function populateSummons(list: Array<GridSummon>) {
list.forEach((gridObject: GridSummon) => {
if (gridObject.main)
appState.grid.summons.mainSummon = gridObject
else if (gridObject.friend)
appState.grid.summons.friendSummon = gridObject
else if (!gridObject.main && !gridObject.friend && gridObject.position != null)
appState.grid.summons.allSummons[gridObject.position] = gridObject
return await api.endpoints.summons.create(
{
summon: {
party_id: partyId,
summon_id: summon.id,
position: position,
main: position == -1,
friend: position == 6,
uncap_level: uncapLevel,
},
},
headers
)
}
function storeGridSummon(gridSummon: GridSummon) {
if (gridSummon.position == -1) appState.grid.summons.mainSummon = gridSummon
else if (gridSummon.position == 6)
appState.grid.summons.friendSummon = gridSummon
else appState.grid.summons.allSummons[gridSummon.position] = gridSummon
}
// Methods: Updating uncap level
// Note: Saves, but debouncing is not working properly
async function saveUncap(id: string, position: number, uncapLevel: number) {
storePreviousUncapValue(position)
try {
if (uncapLevel != previousUncapValues[position])
await api.updateUncap("summon", id, uncapLevel).then((response) => {
storeGridSummon(response.data.grid_summon)
})
} catch (error) {
console.error(error)
// Revert optimistic UI
updateUncapLevel(position, previousUncapValues[position])
// Remove optimistic key
let newPreviousValues = { ...previousUncapValues }
delete newPreviousValues[position]
setPreviousUncapValues(newPreviousValues)
}
}
// Methods: Adding an object from search
function receiveSummonFromSearch(object: Character | Weapon | Summon, position: number) {
const summon = object as Summon
function initiateUncapUpdate(
id: string,
position: number,
uncapLevel: number
) {
memoizeAction(id, position, uncapLevel)
if (!party.id) {
props.createParty()
.then(response => {
const party = response.data.party
appState.party.id = party.id
setSlug(party.shortcode)
// Optimistically update UI
updateUncapLevel(position, uncapLevel)
}
if (props.pushHistory) props.pushHistory(`/p/${party.shortcode}`)
const memoizeAction = useCallback(
(id: string, position: number, uncapLevel: number) => {
debouncedAction(id, position, uncapLevel)
},
[props, previousUncapValues]
)
saveSummon(party.id, summon, position)
.then(response => storeGridSummon(response.data.grid_summon))
})
} else {
if (party.editable)
saveSummon(party.id, summon, position)
.then(response => storeGridSummon(response.data.grid_summon))
}
}
const debouncedAction = useMemo(
() =>
debounce((id, position, number) => {
saveUncap(id, position, number)
}, 500),
[props, saveUncap]
)
async function saveSummon(partyId: string, summon: Summon, position: number) {
let uncapLevel = 3
if (summon.uncap.ulb) uncapLevel = 5
else if (summon.uncap.flb) uncapLevel = 4
return await api.endpoints.summons.create({
'summon': {
'party_id': partyId,
'summon_id': summon.id,
'position': position,
'main': (position == -1),
'friend': (position == 6),
'uncap_level': uncapLevel
}
}, headers)
}
const updateUncapLevel = (position: number, uncapLevel: number) => {
if (appState.grid.summons.mainSummon && position == -1)
appState.grid.summons.mainSummon.uncap_level = uncapLevel
else if (appState.grid.summons.friendSummon && position == 6)
appState.grid.summons.friendSummon.uncap_level = uncapLevel
else appState.grid.summons.allSummons[position].uncap_level = uncapLevel
}
function storeGridSummon(gridSummon: GridSummon) {
if (gridSummon.position == -1)
appState.grid.summons.mainSummon = gridSummon
else if (gridSummon.position == 6)
appState.grid.summons.friendSummon = gridSummon
else
appState.grid.summons.allSummons[gridSummon.position] = gridSummon
}
function storePreviousUncapValue(position: number) {
// Save the current value in case of an unexpected result
let newPreviousValues = { ...previousUncapValues }
// Methods: Updating uncap level
// Note: Saves, but debouncing is not working properly
async function saveUncap(id: string, position: number, uncapLevel: number) {
storePreviousUncapValue(position)
if (appState.grid.summons.mainSummon && position == -1)
newPreviousValues[position] = appState.grid.summons.mainSummon.uncap_level
else if (appState.grid.summons.friendSummon && position == 6)
newPreviousValues[position] =
appState.grid.summons.friendSummon.uncap_level
else
newPreviousValues[position] =
appState.grid.summons.allSummons[position].uncap_level
try {
if (uncapLevel != previousUncapValues[position])
await api.updateUncap('summon', id, uncapLevel)
.then(response => { storeGridSummon(response.data.grid_summon) })
} catch (error) {
console.error(error)
setPreviousUncapValues(newPreviousValues)
}
// Revert optimistic UI
updateUncapLevel(position, previousUncapValues[position])
// Render: JSX components
const mainSummonElement = (
<div className="LabeledUnit">
<div className="Label">{t("summons.main")}</div>
<SummonUnit
gridSummon={grid.summons.mainSummon}
editable={party.editable}
key="grid_main_summon"
position={-1}
unitType={0}
updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate}
/>
</div>
)
// Remove optimistic key
let newPreviousValues = {...previousUncapValues}
delete newPreviousValues[position]
setPreviousUncapValues(newPreviousValues)
}
}
function initiateUncapUpdate(id: string, position: number, uncapLevel: number) {
memoizeAction(id, position, uncapLevel)
// Optimistically update UI
updateUncapLevel(position, uncapLevel)
}
const memoizeAction = useCallback(
(id: string, position: number, uncapLevel: number) => {
debouncedAction(id, position, uncapLevel)
}, [props, previousUncapValues]
)
const debouncedAction = useMemo(() =>
debounce((id, position, number) => {
saveUncap(id, position, number)
}, 500), [props, saveUncap]
)
const updateUncapLevel = (position: number, uncapLevel: number) => {
if (appState.grid.summons.mainSummon && position == -1)
appState.grid.summons.mainSummon.uncap_level = uncapLevel
else if (appState.grid.summons.friendSummon && position == 6)
appState.grid.summons.friendSummon.uncap_level = uncapLevel
else
appState.grid.summons.allSummons[position].uncap_level = uncapLevel
}
function storePreviousUncapValue(position: number) {
// Save the current value in case of an unexpected result
let newPreviousValues = {...previousUncapValues}
if (appState.grid.summons.mainSummon && position == -1) newPreviousValues[position] = appState.grid.summons.mainSummon.uncap_level
else if (appState.grid.summons.friendSummon && position == 6) newPreviousValues[position] = appState.grid.summons.friendSummon.uncap_level
else newPreviousValues[position] = appState.grid.summons.allSummons[position].uncap_level
setPreviousUncapValues(newPreviousValues)
}
// Render: JSX components
const mainSummonElement = (
<div className="LabeledUnit">
<div className="Label">{t('summons.main')}</div>
<SummonUnit
gridSummon={grid.summons.mainSummon}
const friendSummonElement = (
<div className="LabeledUnit">
<div className="Label">{t("summons.friend")}</div>
<SummonUnit
gridSummon={grid.summons.friendSummon}
editable={party.editable}
key="grid_friend_summon"
position={6}
unitType={2}
updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate}
/>
</div>
)
const summonGridElement = (
<div id="LabeledGrid">
<div className="Label">{t("summons.summons")}</div>
<ul id="grid_summons">
{Array.from(Array(numSummons)).map((x, i) => {
return (
<li key={`grid_unit_${i}`}>
<SummonUnit
gridSummon={grid.summons.allSummons[i]}
editable={party.editable}
key="grid_main_summon"
position={-1}
unitType={0}
position={i}
unitType={1}
updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate}
/>
</div>
)
/>
</li>
)
})}
</ul>
</div>
)
const subAuraSummonElement = (
<ExtraSummons
grid={grid.summons.allSummons}
editable={party.editable}
exists={false}
offset={numSummons}
updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate}
/>
)
return (
<div>
<div id="SummonGrid">
{mainSummonElement}
{friendSummonElement}
{summonGridElement}
</div>
const friendSummonElement = (
<div className="LabeledUnit">
<div className="Label">{t('summons.friend')}</div>
<SummonUnit
gridSummon={grid.summons.friendSummon}
editable={party.editable}
key="grid_friend_summon"
position={6}
unitType={2}
updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate}
/>
</div>
)
const summonGridElement = (
<div id="LabeledGrid">
<div className="Label">{t('summons.summons')}</div>
<ul id="grid_summons">
{Array.from(Array(numSummons)).map((x, i) => {
return (<li key={`grid_unit_${i}`} >
<SummonUnit
gridSummon={grid.summons.allSummons[i]}
editable={party.editable}
position={i}
unitType={1}
updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate}
/>
</li>)
})}
</ul>
</div>
)
const subAuraSummonElement = (
<ExtraSummons
grid={grid.summons.allSummons}
editable={party.editable}
exists={false}
offset={numSummons}
updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate}
/>
)
return (
<div>
<div id="SummonGrid">
{ mainSummonElement }
{ friendSummonElement }
{ summonGridElement }
</div>
{ subAuraSummonElement }
</div>
)
{subAuraSummonElement}
</div>
)
}
export default SummonGrid

View file

@ -1,147 +1,154 @@
import React, { useEffect } from 'react'
import { useSnapshot } from 'valtio'
import { useCookies } from 'react-cookie'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import React from "react"
import { useSnapshot } from "valtio"
import { getCookie, deleteCookie } from "cookies-next"
import { useRouter } from "next/router"
import { useTranslation } from "next-i18next"
import clonedeep from 'lodash.clonedeep'
import clonedeep from "lodash.clonedeep"
import api from '~utils/api'
import { accountState, initialAccountState } from '~utils/accountState'
import { appState, initialAppState } from '~utils/appState'
import api from "~utils/api"
import { accountState, initialAccountState } from "~utils/accountState"
import { appState, initialAppState } from "~utils/appState"
import Header from '~components/Header'
import Button from '~components/Button'
import HeaderMenu from '~components/HeaderMenu'
import Header from "~components/Header"
import Button from "~components/Button"
import HeaderMenu from "~components/HeaderMenu"
const TopHeader = () => {
const { t } = useTranslation('common')
const { t } = useTranslation("common")
// Cookies
const [accountCookies, setAccountCookie, removeAccountCookie] = useCookies(['account'])
const [userCookies, setUserCookies, removeUserCookie] = useCookies(['user'])
const headers = (accountCookies.account != null) ? {
'Authorization': `Bearer ${accountCookies.account.access_token}`
} : {}
// Cookies
const accountCookie = getCookie("account")
const userCookie = getCookie("user")
const { account } = useSnapshot(accountState)
const { party } = useSnapshot(appState)
const router = useRouter()
const headers = {}
// accountCookies.account != null
// ? {
// Authorization: `Bearer ${accountCookies.account.access_token}`,
// }
// : {}
function copyToClipboard() {
const el = document.createElement('input')
el.value = window.location.href
el.id = 'url-input'
document.body.appendChild(el)
const { account } = useSnapshot(accountState)
const { party } = useSnapshot(appState)
const router = useRouter()
el.select()
document.execCommand('copy')
el.remove()
}
function copyToClipboard() {
const el = document.createElement("input")
el.value = window.location.href
el.id = "url-input"
document.body.appendChild(el)
function newParty() {
// Push the root URL
router.push('/')
el.select()
document.execCommand("copy")
el.remove()
}
// Clean state
const resetState = clonedeep(initialAppState)
Object.keys(resetState).forEach((key) => {
appState[key] = resetState[key]
})
function newParty() {
// Push the root URL
router.push("/")
// Set party to be editable
appState.party.editable = true
}
// Clean state
const resetState = clonedeep(initialAppState)
Object.keys(resetState).forEach((key) => {
appState[key] = resetState[key]
})
function logout() {
removeAccountCookie('account')
removeUserCookie('user')
// Set party to be editable
appState.party.editable = true
}
// Clean state
const resetState = clonedeep(initialAccountState)
Object.keys(resetState).forEach((key) => {
if (key !== 'language')
accountState[key] = resetState[key]
})
if (router.route != '/new')
appState.party.editable = false
function logout() {
deleteCookie("account")
deleteCookie("user")
router.push('/')
return false
}
// Clean state
const resetState = clonedeep(initialAccountState)
Object.keys(resetState).forEach((key) => {
if (key !== "language") accountState[key] = resetState[key]
})
function toggleFavorite() {
if (party.favorited)
unsaveFavorite()
else
saveFavorite()
}
if (router.route != "/new") appState.party.editable = false
function saveFavorite() {
if (party.id)
api.saveTeam({ id: party.id, params: headers })
.then((response) => {
if (response.status == 201)
appState.party.favorited = true
})
else
console.error("Failed to save team: No party ID")
}
router.push("/")
return false
}
function unsaveFavorite() {
if (party.id)
api.unsaveTeam({ id: party.id, params: headers })
.then((response) => {
if (response.status == 200)
appState.party.favorited = false
})
else
console.error("Failed to unsave team: No party ID")
}
function toggleFavorite() {
if (party.favorited) unsaveFavorite()
else saveFavorite()
}
const leftNav = () => {
return (
<div className="dropdown">
<Button icon="menu">{t('buttons.menu')}</Button>
{ (account.user) ?
<HeaderMenu authenticated={account.authorized} username={account.user.username} logout={logout} /> :
<HeaderMenu authenticated={account.authorized} />
}
</div>
)
}
function saveFavorite() {
if (party.id)
api.saveTeam({ id: party.id, params: headers }).then((response) => {
if (response.status == 201) appState.party.favorited = true
})
else console.error("Failed to save team: No party ID")
}
const saveButton = () => {
if (party.favorited)
return (<Button icon="save" active={true} onClick={toggleFavorite}>Saved</Button>)
else
return (<Button icon="save" onClick={toggleFavorite}>Save</Button>)
}
function unsaveFavorite() {
if (party.id)
api.unsaveTeam({ id: party.id, params: headers }).then((response) => {
if (response.status == 200) appState.party.favorited = false
})
else console.error("Failed to unsave team: No party ID")
}
const rightNav = () => {
return (
<div>
{ (router.route === '/p/[party]' && account.user && (!party.user || party.user.id !== account.user.id)) ?
saveButton() : ''
}
{ (router.route === '/p/[party]') ?
<Button icon="link" onClick={copyToClipboard}>{t('buttons.copy')}</Button> : ''
}
<Button icon="new" onClick={newParty}>{t('buttons.new')}</Button>
</div>
)
}
const leftNav = () => {
return (
<Header
position="top"
left={ leftNav() }
right={ rightNav() }
/>
<div className="dropdown">
<Button icon="menu">{t("buttons.menu")}</Button>
{account.user ? (
<HeaderMenu
authenticated={account.authorized}
username={account.user.username}
logout={logout}
/>
) : (
<HeaderMenu authenticated={account.authorized} />
)}
</div>
)
}
const saveButton = () => {
if (party.favorited)
return (
<Button icon="save" active={true} onClick={toggleFavorite}>
Saved
</Button>
)
else
return (
<Button icon="save" onClick={toggleFavorite}>
Save
</Button>
)
}
const rightNav = () => {
return (
<div>
{router.route === "/p/[party]" &&
account.user &&
(!party.user || party.user.id !== account.user.id)
? saveButton()
: ""}
{router.route === "/p/[party]" ? (
<Button icon="link" onClick={copyToClipboard}>
{t("buttons.copy")}
</Button>
) : (
""
)}
<Button icon="new" onClick={newParty}>
{t("buttons.new")}
</Button>
</div>
)
}
return <Header position="top" left={leftNav()} right={rightNav()} />
}
export default TopHeader
export default TopHeader

View file

@ -1,286 +1,246 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useCookies } from 'react-cookie'
import { useSnapshot } from 'valtio'
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { getCookie } from "cookies-next"
import { useSnapshot } from "valtio"
import { AxiosResponse } from 'axios'
import debounce from 'lodash.debounce'
import { AxiosResponse } from "axios"
import debounce from "lodash.debounce"
import WeaponUnit from '~components/WeaponUnit'
import ExtraWeapons from '~components/ExtraWeapons'
import WeaponUnit from "~components/WeaponUnit"
import ExtraWeapons from "~components/ExtraWeapons"
import api from '~utils/api'
import { appState } from '~utils/appState'
import api from "~utils/api"
import { appState } from "~utils/appState"
import './index.scss'
import "./index.scss"
// Props
interface Props {
new: boolean
slug?: string
createParty: (extra: boolean) => Promise<AxiosResponse<any, any>>
pushHistory?: (path: string) => void
new: boolean
weapons?: GridWeapon[]
createParty: (extra: boolean) => Promise<AxiosResponse<any, any>>
pushHistory?: (path: string) => void
}
const WeaponGrid = (props: Props) => {
// Constants
const numWeapons: number = 9
// Constants
const numWeapons: number = 9
// Cookies
const [cookies] = useCookies(['account'])
const headers = (cookies.account != null) ? {
headers: {
'Authorization': `Bearer ${cookies.account.access_token}`
}
} : {}
// Cookies
const cookie = getCookie("account")
const accountData: AccountCookie = cookie
? JSON.parse(cookie as string)
: null
const headers = accountData
? { headers: { Authorization: `Bearer ${accountData.token}` } }
: {}
// Set up state for view management
const { party, grid } = useSnapshot(appState)
// Set up state for view management
const { party, grid } = useSnapshot(appState)
const [slug, setSlug] = useState()
const [slug, setSlug] = useState()
const [found, setFound] = useState(false)
const [loading, setLoading] = useState(true)
const [firstLoadComplete, setFirstLoadComplete] = useState(false)
// Create a temporary state to store previous weapon uncap values
const [previousUncapValues, setPreviousUncapValues] = useState<{
[key: number]: number
}>({})
// Create a temporary state to store previous weapon uncap values
const [previousUncapValues, setPreviousUncapValues] = useState<{[key: number]: number}>({})
// Set the editable flag only on first load
useEffect(() => {
// If user is logged in and matches
if (
(accountData && party.user && accountData.userId === party.user.id) ||
props.new
)
appState.party.editable = true
else appState.party.editable = false
}, [props.new, accountData, party])
// Fetch data from the server
useEffect(() => {
const shortcode = (props.slug) ? props.slug : slug
if (shortcode) fetchGrid(shortcode)
else appState.party.editable = true
}, [slug, props.slug])
// Initialize an array of current uncap values for each weapon
useEffect(() => {
let initialPreviousUncapValues: { [key: number]: number } = {}
// Set the editable flag only on first load
useEffect(() => {
if (!loading && !firstLoadComplete) {
// If user is logged in and matches
if ((cookies.account && party.user && cookies.account.user_id === party.user.id) || props.new)
appState.party.editable = true
else
appState.party.editable = false
if (appState.grid.weapons.mainWeapon)
initialPreviousUncapValues[-1] =
appState.grid.weapons.mainWeapon.uncap_level
setFirstLoadComplete(true)
}
}, [props.new, cookies, party, loading, firstLoadComplete])
Object.values(appState.grid.weapons.allWeapons).map(
(o) => (initialPreviousUncapValues[o.position] = o.uncap_level)
)
// Initialize an array of current uncap values for each weapon
useEffect(() => {
let initialPreviousUncapValues: {[key: number]: number} = {}
setPreviousUncapValues(initialPreviousUncapValues)
}, [appState.grid.weapons.mainWeapon, appState.grid.weapons.allWeapons])
if (appState.grid.weapons.mainWeapon)
initialPreviousUncapValues[-1] = appState.grid.weapons.mainWeapon.uncap_level
// Methods: Adding an object from search
function receiveWeaponFromSearch(
object: Character | Weapon | Summon,
position: number
) {
const weapon = object as Weapon
if (position == 1) appState.party.element = weapon.element
Object.values(appState.grid.weapons.allWeapons).map(o => initialPreviousUncapValues[o.position] = o.uncap_level)
setPreviousUncapValues(initialPreviousUncapValues)
}, [appState.grid.weapons.mainWeapon, appState.grid.weapons.allWeapons])
// Methods: Fetching an object from the server
async function fetchGrid(shortcode: string) {
return api.endpoints.parties.getOneWithObject({ id: shortcode, object: 'weapons', params: headers })
.then(response => processResult(response))
.catch(error => processError(error))
}
function processResult(response: AxiosResponse) {
// Store the response
const party: Party = response.data.party
// Store the important party and state-keeping values
if (!party.id) {
props.createParty(party.extra).then((response) => {
const party = response.data.party
appState.party.id = party.id
appState.party.extra = party.extra
appState.party.user = party.user
appState.party.favorited = party.favorited
appState.party.created_at = party.created_at
appState.party.updated_at = party.updated_at
setSlug(party.shortcode)
setFound(true)
setLoading(false)
if (props.pushHistory) props.pushHistory(`/p/${party.shortcode}`)
// Populate the weapons in state
populateWeapons(party.weapons)
saveWeapon(party.id, weapon, position).then((response) =>
storeGridWeapon(response.data.grid_weapon)
)
})
} else {
saveWeapon(party.id, weapon, position).then((response) =>
storeGridWeapon(response.data.grid_weapon)
)
}
}
function processError(error: any) {
if (error.response != null) {
if (error.response.status == 404) {
setFound(false)
setLoading(false)
}
} else {
console.error(error)
}
async function saveWeapon(partyId: string, weapon: Weapon, position: number) {
let uncapLevel = 3
if (weapon.uncap.ulb) uncapLevel = 5
else if (weapon.uncap.flb) uncapLevel = 4
return await api.endpoints.weapons.create(
{
weapon: {
party_id: partyId,
weapon_id: weapon.id,
position: position,
mainhand: position == -1,
uncap_level: uncapLevel,
},
},
headers
)
}
function storeGridWeapon(gridWeapon: GridWeapon) {
if (gridWeapon.position == -1) {
appState.grid.weapons.mainWeapon = gridWeapon
appState.party.element = gridWeapon.object.element
} else {
// Store the grid unit at the correct position
appState.grid.weapons.allWeapons[gridWeapon.position] = gridWeapon
}
}
function populateWeapons(list: Array<GridWeapon>) {
list.forEach((gridObject: GridWeapon) => {
if (gridObject.mainhand) {
appState.grid.weapons.mainWeapon = gridObject
appState.party.element = gridObject.object.element
} else if (!gridObject.mainhand && gridObject.position != null) {
appState.grid.weapons.allWeapons[gridObject.position] = gridObject
}
// Methods: Updating uncap level
// Note: Saves, but debouncing is not working properly
async function saveUncap(id: string, position: number, uncapLevel: number) {
storePreviousUncapValue(position)
try {
if (uncapLevel != previousUncapValues[position])
await api.updateUncap("weapon", id, uncapLevel).then((response) => {
storeGridWeapon(response.data.grid_weapon)
})
} catch (error) {
console.error(error)
// Revert optimistic UI
updateUncapLevel(position, previousUncapValues[position])
// Remove optimistic key
let newPreviousValues = { ...previousUncapValues }
delete newPreviousValues[position]
setPreviousUncapValues(newPreviousValues)
}
// Methods: Adding an object from search
function receiveWeaponFromSearch(object: Character | Weapon | Summon, position: number) {
const weapon = object as Weapon
if (position == 1)
appState.party.element = weapon.element
}
if (!party.id) {
props.createParty(party.extra)
.then(response => {
const party = response.data.party
appState.party.id = party.id
setSlug(party.shortcode)
function initiateUncapUpdate(
id: string,
position: number,
uncapLevel: number
) {
memoizeAction(id, position, uncapLevel)
if (props.pushHistory) props.pushHistory(`/p/${party.shortcode}`)
// Optimistically update UI
updateUncapLevel(position, uncapLevel)
}
saveWeapon(party.id, weapon, position)
.then(response => storeGridWeapon(response.data.grid_weapon))
})
} else {
saveWeapon(party.id, weapon, position)
.then(response => storeGridWeapon(response.data.grid_weapon))
}
}
const memoizeAction = useCallback(
(id: string, position: number, uncapLevel: number) => {
debouncedAction(id, position, uncapLevel)
},
[props, previousUncapValues]
)
async function saveWeapon(partyId: string, weapon: Weapon, position: number) {
let uncapLevel = 3
if (weapon.uncap.ulb) uncapLevel = 5
else if (weapon.uncap.flb) uncapLevel = 4
return await api.endpoints.weapons.create({
'weapon': {
'party_id': partyId,
'weapon_id': weapon.id,
'position': position,
'mainhand': (position == -1),
'uncap_level': uncapLevel
}
}, headers)
}
const debouncedAction = useMemo(
() =>
debounce((id, position, number) => {
saveUncap(id, position, number)
}, 500),
[props, saveUncap]
)
function storeGridWeapon(gridWeapon: GridWeapon) {
if (gridWeapon.position == -1) {
appState.grid.weapons.mainWeapon = gridWeapon
appState.party.element = gridWeapon.object.element
} else {
// Store the grid unit at the correct position
appState.grid.weapons.allWeapons[gridWeapon.position] = gridWeapon
}
}
const updateUncapLevel = (position: number, uncapLevel: number) => {
if (appState.grid.weapons.mainWeapon && position == -1)
appState.grid.weapons.mainWeapon.uncap_level = uncapLevel
else appState.grid.weapons.allWeapons[position].uncap_level = uncapLevel
}
// Methods: Updating uncap level
// Note: Saves, but debouncing is not working properly
async function saveUncap(id: string, position: number, uncapLevel: number) {
storePreviousUncapValue(position)
function storePreviousUncapValue(position: number) {
// Save the current value in case of an unexpected result
let newPreviousValues = { ...previousUncapValues }
newPreviousValues[position] =
appState.grid.weapons.mainWeapon && position == -1
? appState.grid.weapons.mainWeapon.uncap_level
: appState.grid.weapons.allWeapons[position].uncap_level
setPreviousUncapValues(newPreviousValues)
}
try {
if (uncapLevel != previousUncapValues[position])
await api.updateUncap('weapon', id, uncapLevel)
.then(response => { storeGridWeapon(response.data.grid_weapon) })
} catch (error) {
console.error(error)
// Render: JSX components
const mainhandElement = (
<WeaponUnit
gridWeapon={appState.grid.weapons.mainWeapon}
editable={party.editable}
key="grid_mainhand"
position={-1}
unitType={0}
updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate}
/>
)
// Revert optimistic UI
updateUncapLevel(position, previousUncapValues[position])
// Remove optimistic key
let newPreviousValues = {...previousUncapValues}
delete newPreviousValues[position]
setPreviousUncapValues(newPreviousValues)
}
}
function initiateUncapUpdate(id: string, position: number, uncapLevel: number) {
memoizeAction(id, position, uncapLevel)
// Optimistically update UI
updateUncapLevel(position, uncapLevel)
}
const memoizeAction = useCallback(
(id: string, position: number, uncapLevel: number) => {
debouncedAction(id, position, uncapLevel)
}, [props, previousUncapValues]
)
const debouncedAction = useMemo(() =>
debounce((id, position, number) => {
saveUncap(id, position, number)
}, 500), [props, saveUncap]
)
const updateUncapLevel = (position: number, uncapLevel: number) => {
if (appState.grid.weapons.mainWeapon && position == -1)
appState.grid.weapons.mainWeapon.uncap_level = uncapLevel
else
appState.grid.weapons.allWeapons[position].uncap_level = uncapLevel
}
function storePreviousUncapValue(position: number) {
// Save the current value in case of an unexpected result
let newPreviousValues = {...previousUncapValues}
newPreviousValues[position] = (appState.grid.weapons.mainWeapon && position == -1) ?
appState.grid.weapons.mainWeapon.uncap_level : appState.grid.weapons.allWeapons[position].uncap_level
setPreviousUncapValues(newPreviousValues)
}
// Render: JSX components
const mainhandElement = (
<WeaponUnit
gridWeapon={appState.grid.weapons.mainWeapon}
editable={party.editable}
key="grid_mainhand"
position={-1}
unitType={0}
updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate}
/>
)
const weaponGridElement = (
Array.from(Array(numWeapons)).map((x, i) => {
return (
<li key={`grid_unit_${i}`} >
<WeaponUnit
gridWeapon={appState.grid.weapons.allWeapons[i]}
editable={party.editable}
position={i}
unitType={1}
updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate}
/>
</li>
)
})
)
const extraGridElement = (
<ExtraWeapons
grid={appState.grid.weapons.allWeapons}
editable={party.editable}
offset={numWeapons}
updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate}
/>
)
const weaponGridElement = Array.from(Array(numWeapons)).map((x, i) => {
return (
<div id="WeaponGrid">
<div id="MainGrid">
{ mainhandElement }
<ul className="grid_weapons">{ weaponGridElement }</ul>
</div>
{ (() => { return (party.extra) ? extraGridElement : '' })() }
</div>
<li key={`grid_unit_${i}`}>
<WeaponUnit
gridWeapon={appState.grid.weapons.allWeapons[i]}
editable={party.editable}
position={i}
unitType={1}
updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate}
/>
</li>
)
})
const extraGridElement = (
<ExtraWeapons
grid={appState.grid.weapons.allWeapons}
editable={party.editable}
offset={numWeapons}
updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate}
/>
)
return (
<div id="WeaponGrid">
<div id="MainGrid">
{mainhandElement}
<ul className="grid_weapons">{weaponGridElement}</ul>
</div>
{(() => {
return party.extra ? extraGridElement : ""
})()}
</div>
)
}
export default WeaponGrid

View file

@ -1,223 +1,261 @@
import React, { useState } from 'react'
import { useCookies } from 'react-cookie'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import { AxiosResponse } from 'axios'
import React, { useState } from "react"
import { getCookie } from "cookies-next"
import { useRouter } from "next/router"
import { useTranslation } from "next-i18next"
import { AxiosResponse } from "axios"
import * as Dialog from '@radix-ui/react-dialog'
import * as Dialog from "@radix-ui/react-dialog"
import AXSelect from '~components/AxSelect'
import ElementToggle from '~components/ElementToggle'
import WeaponKeyDropdown from '~components/WeaponKeyDropdown'
import Button from '~components/Button'
import AXSelect from "~components/AxSelect"
import ElementToggle from "~components/ElementToggle"
import WeaponKeyDropdown from "~components/WeaponKeyDropdown"
import Button from "~components/Button"
import api from '~utils/api'
import { appState } from '~utils/appState'
import api from "~utils/api"
import { appState } from "~utils/appState"
import CrossIcon from '~public/icons/Cross.svg'
import './index.scss'
import CrossIcon from "~public/icons/Cross.svg"
import "./index.scss"
interface GridWeaponObject {
weapon: {
element?: number
weapon_key1_id?: string
weapon_key2_id?: string
weapon_key3_id?: string
ax_modifier1?: number
ax_modifier2?: number
ax_strength1?: number
ax_strength2?: number
}
weapon: {
element?: number
weapon_key1_id?: string
weapon_key2_id?: string
weapon_key3_id?: string
ax_modifier1?: number
ax_modifier2?: number
ax_strength1?: number
ax_strength2?: number
}
}
interface Props {
gridWeapon: GridWeapon
children: React.ReactNode
gridWeapon: GridWeapon
children: React.ReactNode
}
const WeaponModal = (props: Props) => {
const router = useRouter()
const locale = (router.locale && ['en', 'ja'].includes(router.locale)) ? router.locale : 'en'
const { t } = useTranslation('common')
// Cookies
const [cookies] = useCookies(['account'])
const headers = (cookies.account != null) ? {
headers: {
'Authorization': `Bearer ${cookies.account.access_token}`
}
} : {}
// Refs
const weaponKey1Select = React.createRef<HTMLSelectElement>()
const weaponKey2Select = React.createRef<HTMLSelectElement>()
const weaponKey3Select = React.createRef<HTMLSelectElement>()
const router = useRouter()
const locale =
router.locale && ["en", "ja"].includes(router.locale) ? router.locale : "en"
const { t } = useTranslation("common")
// State
const [open, setOpen] = useState(false)
const [formValid, setFormValid] = useState(false)
// Cookies
const cookie = getCookie("account")
const accountData: AccountCookie = cookie
? JSON.parse(cookie as string)
: null
const headers = accountData
? { Authorization: `Bearer ${accountData.token}` }
: {}
const [element, setElement] = useState(-1)
const [primaryAxModifier, setPrimaryAxModifier] = useState(-1)
const [secondaryAxModifier, setSecondaryAxModifier] = useState(-1)
const [primaryAxValue, setPrimaryAxValue] = useState(0.0)
const [secondaryAxValue, setSecondaryAxValue] = useState(0.0)
function receiveAxValues(primaryAxModifier: number, primaryAxValue: number, secondaryAxModifier: number, secondaryAxValue: number) {
setPrimaryAxModifier(primaryAxModifier)
setSecondaryAxModifier(secondaryAxModifier)
// Refs
const weaponKey1Select = React.createRef<HTMLSelectElement>()
const weaponKey2Select = React.createRef<HTMLSelectElement>()
const weaponKey3Select = React.createRef<HTMLSelectElement>()
setPrimaryAxValue(primaryAxValue)
setSecondaryAxValue(secondaryAxValue)
// State
const [open, setOpen] = useState(false)
const [formValid, setFormValid] = useState(false)
const [element, setElement] = useState(-1)
const [primaryAxModifier, setPrimaryAxModifier] = useState(-1)
const [secondaryAxModifier, setSecondaryAxModifier] = useState(-1)
const [primaryAxValue, setPrimaryAxValue] = useState(0.0)
const [secondaryAxValue, setSecondaryAxValue] = useState(0.0)
function receiveAxValues(
primaryAxModifier: number,
primaryAxValue: number,
secondaryAxModifier: number,
secondaryAxValue: number
) {
setPrimaryAxModifier(primaryAxModifier)
setSecondaryAxModifier(secondaryAxModifier)
setPrimaryAxValue(primaryAxValue)
setSecondaryAxValue(secondaryAxValue)
}
function receiveAxValidity(isValid: boolean) {
setFormValid(isValid)
}
function receiveElementValue(element: string) {
setElement(parseInt(element))
}
function prepareObject() {
let object: GridWeaponObject = { weapon: {} }
if (props.gridWeapon.object.element == 0) object.weapon.element = element
if ([2, 3, 17, 24].includes(props.gridWeapon.object.series))
object.weapon.weapon_key1_id = weaponKey1Select.current?.value
if ([2, 3, 17].includes(props.gridWeapon.object.series))
object.weapon.weapon_key2_id = weaponKey2Select.current?.value
if (props.gridWeapon.object.series == 17)
object.weapon.weapon_key3_id = weaponKey3Select.current?.value
if (props.gridWeapon.object.ax > 0) {
object.weapon.ax_modifier1 = primaryAxModifier
object.weapon.ax_modifier2 = secondaryAxModifier
object.weapon.ax_strength1 = primaryAxValue
object.weapon.ax_strength2 = secondaryAxValue
}
function receiveAxValidity(isValid: boolean) {
setFormValid(isValid)
}
return object
}
function receiveElementValue(element: string) {
setElement(parseInt(element))
}
async function updateWeapon() {
const updateObject = prepareObject()
return await api.endpoints.grid_weapons
.update(props.gridWeapon.id, updateObject, headers)
.then((response) => processResult(response))
.catch((error) => processError(error))
}
function prepareObject() {
let object: GridWeaponObject = { weapon: {} }
function processResult(response: AxiosResponse) {
const gridWeapon: GridWeapon = response.data.grid_weapon
if (props.gridWeapon.object.element == 0)
object.weapon.element = element
if (gridWeapon.mainhand) appState.grid.weapons.mainWeapon = gridWeapon
else appState.grid.weapons.allWeapons[gridWeapon.position] = gridWeapon
if ([2, 3, 17, 24].includes(props.gridWeapon.object.series))
object.weapon.weapon_key1_id = weaponKey1Select.current?.value
setOpen(false)
}
if ([2, 3, 17].includes(props.gridWeapon.object.series))
object.weapon.weapon_key2_id = weaponKey2Select.current?.value
function processError(error: any) {
console.error(error)
}
if (props.gridWeapon.object.series == 17)
object.weapon.weapon_key3_id = weaponKey3Select.current?.value
if (props.gridWeapon.object.ax > 0) {
object.weapon.ax_modifier1 = primaryAxModifier
object.weapon.ax_modifier2 = secondaryAxModifier
object.weapon.ax_strength1 = primaryAxValue
object.weapon.ax_strength2 = secondaryAxValue
}
return object
}
async function updateWeapon() {
const updateObject = prepareObject()
return await api.endpoints.grid_weapons.update(props.gridWeapon.id, updateObject, headers)
.then(response => processResult(response))
.catch(error => processError(error))
}
function processResult(response: AxiosResponse) {
const gridWeapon: GridWeapon = response.data.grid_weapon
if (gridWeapon.mainhand)
appState.grid.weapons.mainWeapon = gridWeapon
else
appState.grid.weapons.allWeapons[gridWeapon.position] = gridWeapon
setOpen(false)
}
function processError(error: any) {
console.error(error)
}
const elementSelect = () => {
return (
<section>
<h3>{t('modals.weapon.subtitles.element')}</h3>
<ElementToggle
currentElement={props.gridWeapon.element}
sendValue={receiveElementValue}
/>
</section>
)
}
const keySelect = () => {
return (
<section>
<h3>{t('modals.weapon.subtitles.weapon_keys')}</h3>
{ ([2, 3, 17, 22].includes(props.gridWeapon.object.series)) ?
<WeaponKeyDropdown
currentValue={ (props.gridWeapon.weapon_keys) ? props.gridWeapon.weapon_keys[0] : undefined }
series={props.gridWeapon.object.series}
slot={0}
ref={weaponKey1Select} />
: ''}
{ ([2, 3, 17].includes(props.gridWeapon.object.series)) ?
<WeaponKeyDropdown
currentValue={ (props.gridWeapon.weapon_keys) ? props.gridWeapon.weapon_keys[1] : undefined }
series={props.gridWeapon.object.series}
slot={1}
ref={weaponKey2Select} />
: ''}
{ (props.gridWeapon.object.series == 17) ?
<WeaponKeyDropdown
currentValue={ (props.gridWeapon.weapon_keys) ? props.gridWeapon.weapon_keys[2] : undefined }
series={props.gridWeapon.object.series}
slot={2}
ref={weaponKey3Select} />
: ''}
</section>
)
}
const axSelect = () => {
return (
<section>
<h3>{t('modals.weapon.subtitles.ax_skills')}</h3>
<AXSelect
axType={props.gridWeapon.object.ax}
currentSkills={props.gridWeapon.ax}
sendValidity={receiveAxValidity}
sendValues={receiveAxValues}
/>
</section>
)
}
function openChange(open: boolean) {
setFormValid(false)
setOpen(open)
}
const elementSelect = () => {
return (
<Dialog.Root open={open} onOpenChange={openChange}>
<Dialog.Trigger asChild>
{ props.children }
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content className="Weapon Dialog" onOpenAutoFocus={ (event) => event.preventDefault() }>
<div className="DialogHeader">
<div className="DialogTop">
<Dialog.Title className="SubTitle">{t('modals.weapon.title')}</Dialog.Title>
<Dialog.Title className="DialogTitle">{props.gridWeapon.object.name[locale]}</Dialog.Title>
</div>
<Dialog.Close className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</Dialog.Close>
</div>
<div className="mods">
{ (props.gridWeapon.object.element == 0) ? elementSelect() : '' }
{ ([2, 3, 17, 24].includes(props.gridWeapon.object.series)) ? keySelect() : '' }
{ (props.gridWeapon.object.ax > 0) ? axSelect() : '' }
<Button onClick={updateWeapon} disabled={props.gridWeapon.object.ax > 0 && !formValid}>{t('modals.weapon.buttons.confirm')}</Button>
</div>
</Dialog.Content>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>
</Dialog.Root>
<section>
<h3>{t("modals.weapon.subtitles.element")}</h3>
<ElementToggle
currentElement={props.gridWeapon.element}
sendValue={receiveElementValue}
/>
</section>
)
}
const keySelect = () => {
return (
<section>
<h3>{t("modals.weapon.subtitles.weapon_keys")}</h3>
{[2, 3, 17, 22].includes(props.gridWeapon.object.series) ? (
<WeaponKeyDropdown
currentValue={
props.gridWeapon.weapon_keys
? props.gridWeapon.weapon_keys[0]
: undefined
}
series={props.gridWeapon.object.series}
slot={0}
ref={weaponKey1Select}
/>
) : (
""
)}
{[2, 3, 17].includes(props.gridWeapon.object.series) ? (
<WeaponKeyDropdown
currentValue={
props.gridWeapon.weapon_keys
? props.gridWeapon.weapon_keys[1]
: undefined
}
series={props.gridWeapon.object.series}
slot={1}
ref={weaponKey2Select}
/>
) : (
""
)}
{props.gridWeapon.object.series == 17 ? (
<WeaponKeyDropdown
currentValue={
props.gridWeapon.weapon_keys
? props.gridWeapon.weapon_keys[2]
: undefined
}
series={props.gridWeapon.object.series}
slot={2}
ref={weaponKey3Select}
/>
) : (
""
)}
</section>
)
}
const axSelect = () => {
return (
<section>
<h3>{t("modals.weapon.subtitles.ax_skills")}</h3>
<AXSelect
axType={props.gridWeapon.object.ax}
currentSkills={props.gridWeapon.ax}
sendValidity={receiveAxValidity}
sendValues={receiveAxValues}
/>
</section>
)
}
function openChange(open: boolean) {
setFormValid(false)
setOpen(open)
}
return (
<Dialog.Root open={open} onOpenChange={openChange}>
<Dialog.Trigger asChild>{props.children}</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content
className="Weapon Dialog"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div className="DialogHeader">
<div className="DialogTop">
<Dialog.Title className="SubTitle">
{t("modals.weapon.title")}
</Dialog.Title>
<Dialog.Title className="DialogTitle">
{props.gridWeapon.object.name[locale]}
</Dialog.Title>
</div>
<Dialog.Close className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</Dialog.Close>
</div>
<div className="mods">
{props.gridWeapon.object.element == 0 ? elementSelect() : ""}
{[2, 3, 17, 24].includes(props.gridWeapon.object.series)
? keySelect()
: ""}
{props.gridWeapon.object.ax > 0 ? axSelect() : ""}
<Button
onClick={updateWeapon}
disabled={props.gridWeapon.object.ax > 0 && !formValid}
>
{t("modals.weapon.buttons.confirm")}
</Button>
</div>
</Dialog.Content>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>
</Dialog.Root>
)
}
export default WeaponModal
export default WeaponModal

95
package-lock.json generated
View file

@ -15,6 +15,7 @@
"@svgr/webpack": "^6.2.0",
"axios": "^0.25.0",
"classnames": "^2.3.1",
"cookies-next": "^2.1.1",
"i18next": "^21.6.13",
"i18next-browser-languagedetector": "^6.1.3",
"i18next-http-backend": "^1.3.2",
@ -25,7 +26,6 @@
"next-i18next": "^10.5.0",
"next-usequerystate": "^1.7.0",
"react": "17.0.2",
"react-cookie": "^4.1.1",
"react-dom": "^17.0.2",
"react-i18next": "^11.15.5",
"react-infinite-scroll-component": "^6.1.0",
@ -3353,11 +3353,6 @@
"node": ">=10.13.0"
}
},
"node_modules/@types/cookie": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz",
"integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow=="
},
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
@ -4050,6 +4045,26 @@
"node": ">= 0.6"
}
},
"node_modules/cookies-next": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/cookies-next/-/cookies-next-2.1.1.tgz",
"integrity": "sha512-AZGZPdL1hU3jCjN2UMJTGhLOYzNUN9Gm+v8BdptYIHUdwz397Et1p+sZRfvAl8pKnnmMdX2Pk9xDRKCGBum6GA==",
"dependencies": {
"@types/cookie": "^0.4.1",
"@types/node": "^16.10.2",
"cookie": "^0.4.0"
}
},
"node_modules/cookies-next/node_modules/@types/cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q=="
},
"node_modules/cookies-next/node_modules/@types/node": {
"version": "16.18.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.3.tgz",
"integrity": "sha512-jh6m0QUhIRcZpNv7Z/rpN+ZWXOicUUQbSoWks7Htkbb9IjFQj4kzcX/xFCkjstCj5flMsN8FiSvt+q+Tcs4Llg=="
},
"node_modules/core-js": {
"version": "3.21.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz",
@ -6333,19 +6348,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react-cookie": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-4.1.1.tgz",
"integrity": "sha512-ffn7Y7G4bXiFbnE+dKhHhbP+b8I34mH9jqnm8Llhj89zF4nPxPutxHT1suUqMeCEhLDBI7InYwf1tpaSoK5w8A==",
"dependencies": {
"@types/hoist-non-react-statics": "^3.0.1",
"hoist-non-react-statics": "^3.0.0",
"universal-cookie": "^4.0.0"
},
"peerDependencies": {
"react": ">= 16.3.0"
}
},
"node_modules/react-dom": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
@ -7184,15 +7186,6 @@
"node": ">=4"
}
},
"node_modules/universal-cookie": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-4.0.4.tgz",
"integrity": "sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw==",
"dependencies": {
"@types/cookie": "^0.3.3",
"cookie": "^0.4.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@ -9691,11 +9684,6 @@
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="
},
"@types/cookie": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz",
"integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow=="
},
"@types/hoist-non-react-statics": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
@ -10218,6 +10206,28 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz",
"integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA=="
},
"cookies-next": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/cookies-next/-/cookies-next-2.1.1.tgz",
"integrity": "sha512-AZGZPdL1hU3jCjN2UMJTGhLOYzNUN9Gm+v8BdptYIHUdwz397Et1p+sZRfvAl8pKnnmMdX2Pk9xDRKCGBum6GA==",
"requires": {
"@types/cookie": "^0.4.1",
"@types/node": "^16.10.2",
"cookie": "^0.4.0"
},
"dependencies": {
"@types/cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q=="
},
"@types/node": {
"version": "16.18.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.3.tgz",
"integrity": "sha512-jh6m0QUhIRcZpNv7Z/rpN+ZWXOicUUQbSoWks7Htkbb9IjFQj4kzcX/xFCkjstCj5flMsN8FiSvt+q+Tcs4Llg=="
}
}
},
"core-js": {
"version": "3.21.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz",
@ -11886,16 +11896,6 @@
"object-assign": "^4.1.1"
}
},
"react-cookie": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-4.1.1.tgz",
"integrity": "sha512-ffn7Y7G4bXiFbnE+dKhHhbP+b8I34mH9jqnm8Llhj89zF4nPxPutxHT1suUqMeCEhLDBI7InYwf1tpaSoK5w8A==",
"requires": {
"@types/hoist-non-react-statics": "^3.0.1",
"hoist-non-react-statics": "^3.0.0",
"universal-cookie": "^4.0.0"
}
},
"react-dom": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
@ -12468,15 +12468,6 @@
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz",
"integrity": "sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ=="
},
"universal-cookie": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-4.0.4.tgz",
"integrity": "sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw==",
"requires": {
"@types/cookie": "^0.3.3",
"cookie": "^0.4.0"
}
},
"uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",

View file

@ -20,6 +20,7 @@
"@svgr/webpack": "^6.2.0",
"axios": "^0.25.0",
"classnames": "^2.3.1",
"cookies-next": "^2.1.1",
"i18next": "^21.6.13",
"i18next-browser-languagedetector": "^6.1.3",
"i18next-http-backend": "^1.3.2",
@ -30,7 +31,6 @@
"next-i18next": "^10.5.0",
"next-usequerystate": "^1.7.0",
"react": "17.0.2",
"react-cookie": "^4.1.1",
"react-dom": "^17.0.2",
"react-i18next": "^11.15.5",
"react-infinite-scroll-component": "^6.1.0",

View file

@ -1,310 +1,431 @@
import React, { useCallback, useEffect, useState } from 'react'
import Head from 'next/head'
import React, { useCallback, useEffect, useState } from "react"
import Head from "next/head"
import { useCookies } from 'react-cookie'
import { queryTypes, useQueryState } from 'next-usequerystate'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import InfiniteScroll from 'react-infinite-scroll-component'
import { getCookie } from "cookies-next"
import { queryTypes, useQueryState } from "next-usequerystate"
import { useRouter } from "next/router"
import { useTranslation } from "next-i18next"
import InfiniteScroll from "react-infinite-scroll-component"
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import clonedeep from 'lodash.clonedeep'
import { serverSideTranslations } from "next-i18next/serverSideTranslations"
import api from '~utils/api'
import { elements, allElement } from '~utils/Element'
import api from "~utils/api"
import useDidMountEffect from "~utils/useDidMountEffect"
import { elements, allElement } from "~utils/Element"
import GridRep from '~components/GridRep'
import GridRepCollection from '~components/GridRepCollection'
import FilterBar from '~components/FilterBar'
import GridRep from "~components/GridRep"
import GridRepCollection from "~components/GridRepCollection"
import FilterBar from "~components/FilterBar"
const emptyUser = {
id: '',
username: '',
granblueId: 0,
picture: {
picture: '',
element: ''
},
private: false,
gender: 0
import type { NextApiRequest, NextApiResponse } from "next"
interface Props {
user?: User
teams?: { count: number; total_pages: number; results: Party[] }
raids: Raid[]
sortedRaids: Raid[][]
}
const ProfileRoute: React.FC = () => {
// Set up cookies
const [cookies] = useCookies(['account'])
const headers = (cookies.account) ? {
'Authorization': `Bearer ${cookies.account.access_token}`
} : {}
const ProfileRoute: React.FC<Props> = (props: Props) => {
// Set up cookies
const cookie = getCookie("account")
const accountData: AccountCookie = cookie
? JSON.parse(cookie as string)
: null
const headers = accountData
? { Authorization: `Bearer ${accountData.token}` }
: {}
// Set up router
const router = useRouter()
const { username } = router.query
// Set up router
const router = useRouter()
const { username } = router.query
// Import translations
const { t } = useTranslation('common')
// Import translations
const { t } = useTranslation("common")
// Set up app-specific states
const [found, setFound] = useState(false)
const [loading, setLoading] = useState(true)
const [raidsLoading, setRaidsLoading] = useState(true)
const [scrolled, setScrolled] = useState(false)
// Set up app-specific states
const [raidsLoading, setRaidsLoading] = useState(true)
const [scrolled, setScrolled] = useState(false)
// Set up page-specific states
const [parties, setParties] = useState<Party[]>([])
const [raids, setRaids] = useState<Raid[]>()
const [raid, setRaid] = useState<Raid>()
const [user, setUser] = useState<User>(emptyUser)
// Set up page-specific states
const [parties, setParties] = useState<Party[]>([])
const [raids, setRaids] = useState<Raid[]>()
const [raid, setRaid] = useState<Raid>()
// Set up infinite scrolling-related states
const [recordCount, setRecordCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
// Set up infinite scrolling-related states
const [recordCount, setRecordCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
// Set up filter-specific query states
// Recency is in seconds
const [element, setElement] = useQueryState("element", {
defaultValue: -1,
parse: (query: string) => parseElement(query),
serialize: value => serializeElement(value)
})
const [raidSlug, setRaidSlug] = useQueryState("raid", { defaultValue: "all" })
const [recency, setRecency] = useQueryState("recency", queryTypes.integer.withDefault(-1))
// Set up filter-specific query states
// Recency is in seconds
const [element, setElement] = useQueryState("element", {
defaultValue: -1,
parse: (query: string) => parseElement(query),
serialize: (value) => serializeElement(value),
})
const [raidSlug, setRaidSlug] = useQueryState("raid", { defaultValue: "all" })
const [recency, setRecency] = useQueryState(
"recency",
queryTypes.integer.withDefault(-1)
)
// Define transformers for element
function parseElement(query: string) {
let element: TeamElement | undefined =
(query === 'all') ?
allElement : elements.find(element => element.name.en.toLowerCase() === query)
return (element) ? element.id : -1
// Define transformers for element
function parseElement(query: string) {
let element: TeamElement | undefined =
query === "all"
? allElement
: elements.find((element) => element.name.en.toLowerCase() === query)
return element ? element.id : -1
}
function serializeElement(value: number | undefined) {
let name = ""
if (value != undefined) {
if (value == -1) name = allElement.name.en.toLowerCase()
else name = elements[value].name.en.toLowerCase()
}
function serializeElement(value: number | undefined) {
let name = ''
return name
}
if (value != undefined) {
if (value == -1)
name = allElement.name.en.toLowerCase()
else
name = elements[value].name.en.toLowerCase()
}
return name
// Set the initial parties from props
useEffect(() => {
if (props.teams) {
setTotalPages(props.teams.total_pages)
setRecordCount(props.teams.count)
replaceResults(props.teams.count, props.teams.results)
}
setCurrentPage(1)
}, [])
// Add scroll event listener for shadow on FilterBar on mount
useEffect(() => {
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll);
}, [])
// Add scroll event listener for shadow on FilterBar on mount
useEffect(() => {
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [])
// Handle errors
const handleError = useCallback((error: any) => {
if (error.response != null) {
console.error(error)
} else {
console.error("There was an error.")
}
}, [])
const fetchProfile = useCallback(({ replace }: { replace: boolean }) => {
const filters = {
params: {
element: (element != -1) ? element : undefined,
raid: (raid) ? raid.id : undefined,
recency: (recency != -1) ? recency : undefined,
page: currentPage
}
}
if (username && !Array.isArray(username))
api.endpoints.users.getOne({ id: username , params: {...filters, ...{ headers: headers }}})
.then(response => {
setUser({
id: response.data.user.id,
username: response.data.user.username,
granblueId: response.data.user.granblue_id,
picture: response.data.user.picture,
private: response.data.user.private,
gender: response.data.user.gender
})
setTotalPages(response.data.parties.total_pages)
setRecordCount(response.data.parties.count)
if (replace)
replaceResults(response.data.parties.count, response.data.parties.results)
else
appendResults(response.data.parties.results)
})
.then(() => {
setFound(true)
setLoading(false)
})
.catch(error => handleError(error))
}, [currentPage, parties, element, raid, recency])
function replaceResults(count: number, list: Party[]) {
if (count > 0) {
setParties(list.sort((a, b) => (a.created_at > b.created_at) ? -1 : 1))
} else {
setParties([])
}
// Handle errors
const handleError = useCallback((error: any) => {
if (error.response != null) {
console.error(error)
} else {
console.error("There was an error.")
}
}, [])
function appendResults(list: Party[]) {
setParties([...parties, ...list])
}
// Fetch all raids on mount, then find the raid in the URL if present
useEffect(() => {
api.endpoints.raids.getAll()
.then(response => {
const cleanRaids: Raid[] = response.data.map((r: any) => r.raid)
setRaids(cleanRaids)
setRaidsLoading(false)
const raid = cleanRaids.find(r => r.slug === raidSlug)
setRaid(raid)
return raid
})
}, [setRaids])
// When the element, raid or recency filter changes,
// fetch all teams again.
useEffect(() => {
if (!raidsLoading) {
setCurrentPage(1)
fetchProfile({ replace: true })
}
}, [element, raid, recency])
useEffect(() => {
// Current page changed
if (currentPage > 1)
fetchProfile({ replace: false })
else if (currentPage == 1)
fetchProfile({ replace: true })
}, [currentPage])
// Receive filters from the filter bar
function receiveFilters({ element, raidSlug, recency }: {element?: number, raidSlug?: string, recency?: number}) {
if (element == 0)
setElement(0)
else if (element)
setElement(element)
if (raids && raidSlug) {
const raid = raids.find(raid => raid.slug === raidSlug)
setRaid(raid)
setRaidSlug(raidSlug)
}
if (recency) setRecency(recency)
}
// Methods: Navigation
function handleScroll() {
if (window.pageYOffset > 90)
setScrolled(true)
else
setScrolled(false)
}
function goTo(shortcode: string) {
router.push(`/p/${shortcode}`)
}
// TODO: Add save functions
function renderParties() {
return parties.map((party, i) => {
return <GridRep
id={party.id}
shortcode={party.shortcode}
name={party.name}
createdAt={new Date(party.created_at)}
raid={party.raid}
grid={party.weapons}
favorited={party.favorited}
key={`party-${i}`}
onClick={goTo}
/>
})
}
return (
<div id="Profile">
<Head>
<title>@{user.username}&apos;s Teams</title>
<meta property="og:title" content={`@${user.username}\'s Teams`} />
<meta property="og:description" content={`Browse @${user.username}\'s Teams and filter raid, element or recency`} />
<meta property="og:url" content={`https://app.granblue.team/${user.username}`} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:domain" content="app.granblue.team" />
<meta name="twitter:title" content={`@${user.username}\'s Teams`} />
<meta name="twitter:description" content={`Browse @${user.username}\''s Teams and filter raid, element or recency`} />
</Head>
<FilterBar
onFilter={receiveFilters}
scrolled={scrolled}
element={element}
raidSlug={ (raidSlug) ? raidSlug : undefined }
recency={recency}>
<div className="UserInfo">
<img
alt={user.picture.picture}
className={`profile ${user.picture.element}`}
srcSet={`/profile/${user.picture.picture}.png,
/profile/${user.picture.picture}@2x.png 2x`}
src={`/profile/${user.picture.picture}.png`}
/>
<h1>{user.username}</h1>
</div>
</FilterBar>
<section>
<InfiniteScroll
dataLength={ (parties && parties.length > 0) ? parties.length : 0}
next={ () => setCurrentPage(currentPage + 1) }
hasMore={totalPages > currentPage}
loader={ <div id="NotFound"><h2>Loading...</h2></div> }>
<GridRepCollection loading={loading}>
{ renderParties() }
</GridRepCollection>
</InfiniteScroll>
{ (parties.length == 0) ?
<div id="NotFound">
<h2>{ (loading) ? t('teams.loading') : t('teams.not_found') }</h2>
</div>
: '' }
</section>
</div>
)
}
export async function getStaticPaths() {
return {
paths: [
// Object variant:
{ params: { username: 'string' } },
],
fallback: true,
}
}
export async function getStaticProps({ locale }: { locale: string }) {
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
// Will be passed to the page component as props
const fetchProfile = useCallback(
({ replace }: { replace: boolean }) => {
const filters = {
params: {
element: element != -1 ? element : undefined,
raid: raid ? raid.id : undefined,
recency: recency != -1 ? recency : undefined,
page: currentPage,
},
}
if (username && !Array.isArray(username)) {
api.endpoints.users
.getOne({
id: username,
params: { ...filters, ...{ headers: headers } },
})
.then((response) => {
setTotalPages(response.data.parties.total_pages)
setRecordCount(response.data.parties.count)
if (replace)
replaceResults(
response.data.parties.count,
response.data.parties.results
)
else appendResults(response.data.parties.results)
})
.catch((error) => handleError(error))
}
},
[currentPage, parties, element, raid, recency]
)
function replaceResults(count: number, list: Party[]) {
if (count > 0) {
setParties(list.sort((a, b) => (a.created_at > b.created_at ? -1 : 1)))
} else {
setParties([])
}
}
function appendResults(list: Party[]) {
setParties([...parties, ...list])
}
// Fetch all raids on mount, then find the raid in the URL if present
useEffect(() => {
api.endpoints.raids.getAll().then((response) => {
const cleanRaids: Raid[] = response.data.map((r: any) => r.raid)
setRaids(cleanRaids)
setRaidsLoading(false)
const raid = cleanRaids.find((r) => r.slug === raidSlug)
setRaid(raid)
return raid
})
}, [setRaids])
// When the element, raid or recency filter changes,
// fetch all teams again.
useDidMountEffect(() => {
setCurrentPage(1)
fetchProfile({ replace: true })
}, [element, raid, recency])
// When the page changes, fetch all teams again.
useDidMountEffect(() => {
// Current page changed
if (currentPage > 1) fetchProfile({ replace: false })
else if (currentPage == 1) fetchProfile({ replace: true })
}, [currentPage])
// Receive filters from the filter bar
function receiveFilters({
element,
raidSlug,
recency,
}: {
element?: number
raidSlug?: string
recency?: number
}) {
if (element == 0) setElement(0)
else if (element) setElement(element)
if (raids && raidSlug) {
const raid = raids.find((raid) => raid.slug === raidSlug)
setRaid(raid)
setRaidSlug(raidSlug)
}
if (recency) setRecency(recency)
}
// Methods: Navigation
function handleScroll() {
if (window.pageYOffset > 90) setScrolled(true)
else setScrolled(false)
}
function goTo(shortcode: string) {
router.push(`/p/${shortcode}`)
}
// TODO: Add save functions
function renderParties() {
return parties.map((party, i) => {
return (
<GridRep
id={party.id}
shortcode={party.shortcode}
name={party.name}
createdAt={new Date(party.created_at)}
raid={party.raid}
grid={party.weapons}
favorited={party.favorited}
key={`party-${i}`}
onClick={goTo}
/>
)
})
}
return (
<div id="Profile">
<Head>
<title>@{props.user?.username}&apos;s Teams</title>
<meta
property="og:title"
content={`@${props.user?.username}\'s Teams`}
/>
<meta
property="og:description"
content={`Browse @${props.user?.username}\'s Teams and filter raid, element or recency`}
/>
<meta
property="og:url"
content={`https://app.granblue.team/${props.user?.username}`}
/>
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:domain" content="app.granblue.team" />
<meta
name="twitter:title"
content={`@${props.user?.username}\'s Teams`}
/>
<meta
name="twitter:description"
content={`Browse @${props.user?.username}\''s Teams and filter raid, element or recency`}
/>
</Head>
<FilterBar
onFilter={receiveFilters}
scrolled={scrolled}
element={element}
raidSlug={raidSlug ? raidSlug : undefined}
recency={recency}
>
<div className="UserInfo">
<img
alt={props.user?.picture.picture}
className={`profile ${props.user?.picture.element}`}
srcSet={`/profile/${props.user?.picture.picture}.png,
/profile/${props.user?.picture.picture}@2x.png 2x`}
src={`/profile/${props.user?.picture.picture}.png`}
/>
<h1>{props.user?.username}</h1>
</div>
</FilterBar>
<section>
<InfiniteScroll
dataLength={parties && parties.length > 0 ? parties.length : 0}
next={() => setCurrentPage(currentPage + 1)}
hasMore={totalPages > currentPage}
loader={
<div id="NotFound">
<h2>Loading...</h2>
</div>
}
>
<GridRepCollection>{renderParties()}</GridRepCollection>
</InfiniteScroll>
{parties.length == 0 ? (
<div id="NotFound">
<h2>{t("teams.not_found")}</h2>
</div>
) : (
""
)}
</section>
</div>
)
}
export const getServerSidePaths = async () => {
return {
paths: [
// Object variant:
{ params: { party: "string" } },
],
fallback: true,
}
}
// prettier-ignore
export const getServerSideProps = async ({ req, res, locale, query }: { req: NextApiRequest, res: NextApiResponse, locale: string, query: { [index: string]: string } }) => {
// Cookies
const cookie = getCookie("account", { req, res })
const accountData: AccountCookie = cookie
? JSON.parse(cookie as string)
: null
const headers = accountData
? { headers: { Authorization: `Bearer ${accountData.token}` } }
: {}
let { raids, sortedRaids } = await api.endpoints.raids
.getAll(headers)
.then((response) => organizeRaids(response.data.map((r: any) => r.raid)))
// Extract recency filter
const recencyParam: number = parseInt(query.recency)
// Extract element filter
const elementParam: string = query.element
const teamElement: TeamElement | undefined =
elementParam === "all"
? allElement
: elements.find(
(element) => element.name.en.toLowerCase() === elementParam
)
// Extract raid filter
const raidParam: string = query.raid
const raid: Raid | undefined = raids.find((r) => r.slug === raidParam)
// Create filter object
const filters: {
raid?: string
element?: number
recency?: number
} = {}
if (recencyParam) filters.recency = recencyParam
if (teamElement && teamElement.id > -1) filters.element = teamElement.id
if (raid) filters.raid = raid.id
// Fetch initial set of parties here
let user: User | null = null
let teams: Party[] | null = null
if (query.username) {
const response = await api.endpoints.users.getOne({
id: query.username,
params: {
params: filters,
...headers
}
})
user = response.data.user
teams = response.data.parties
}
return {
props: {
user: user,
teams: teams,
raids: raids,
sortedRaids: sortedRaids,
...(await serverSideTranslations(locale, ["common"])),
// Will be passed to the page component as props
},
}
}
const organizeRaids = (raids: Raid[]) => {
// Set up empty raid for "All raids"
const all = {
id: "0",
name: {
en: "All raids",
ja: "全て",
},
slug: "all",
level: 0,
group: 0,
element: 0,
}
const numGroups = Math.max.apply(
Math,
raids.map((raid) => raid.group)
)
let groupedRaids = []
for (let i = 0; i <= numGroups; i++) {
groupedRaids[i] = raids.filter((raid) => raid.group == i)
}
return {
raids: raids,
sortedRaids: groupedRaids,
}
}
export default ProfileRoute

View file

@ -1,41 +1,40 @@
import { useEffect } from 'react'
import { useCookies, CookiesProvider } from 'react-cookie'
import { appWithTranslation } from 'next-i18next'
import { useEffect } from "react"
import { getCookie } from "cookies-next"
import { appWithTranslation } from "next-i18next"
import type { AppProps } from 'next/app'
import Layout from '~components/Layout'
import type { AppProps } from "next/app"
import Layout from "~components/Layout"
import { accountState } from '~utils/accountState'
import { accountState } from "~utils/accountState"
import '../styles/globals.scss'
import "../styles/globals.scss"
function MyApp({ Component, pageProps }: AppProps) {
const [cookies] = useCookies(['account'])
const cookie = getCookie("account")
const cookieData: AccountCookie = cookie ? JSON.parse(cookie as string) : null
useEffect(() => {
if (cookies.account) {
console.log(`Logged in as user "${cookies.account.username}"`)
useEffect(() => {
if (cookie) {
console.log(`Logged in as user "${cookieData.username}"`)
accountState.account.authorized = true
accountState.account.user = {
id: cookies.account.user_id,
username: cookies.account.username,
picture: '',
element: '',
gender: 0
}
} else {
console.log(`You are not currently logged in.`)
}
}, [cookies.account])
accountState.account.authorized = true
accountState.account.user = {
id: cookieData.userId,
username: cookieData.username,
picture: "",
element: "",
gender: 0,
}
} else {
console.log(`You are not currently logged in.`)
}
}, [cookieData])
return (
<CookiesProvider>
<Layout>
<Component {...pageProps} />
</Layout>
</CookiesProvider>
)
return (
<Layout>
<Component {...pageProps} />
</Layout>
)
}
export default appWithTranslation(MyApp)

View file

@ -1,53 +1,100 @@
import React from 'react'
import { useRouter } from 'next/router'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import React from "react"
import { getCookie } from "cookies-next"
import { serverSideTranslations } from "next-i18next/serverSideTranslations"
import Party from '~components/Party'
import Party from "~components/Party"
const PartyRoute: React.FC = () => {
const { party: slug } = useRouter().query
import api from "~utils/api"
return (
<div id="Content">
<Party slug={slug as string} />
</div>
)
import type { NextApiRequest, NextApiResponse } from "next"
// function renderNotFound() {
// return (
// <div id="NotFound">
// <h2>There&apos;s no grid here.</h2>
// <Button type="new">New grid</Button>
// </div>
// )
// }
// if (!found && !loading) {
// return renderNotFound()
// } else if (found && !loading) {
// return render()
// } else {
// return (<div />)
// }
interface Props {
party: Party
raids: Raid[]
sortedRaids: Raid[][]
}
export async function getStaticPaths() {
return {
paths: [
// Object variant:
{ params: { party: 'string' } },
],
fallback: true,
}
const PartyRoute: React.FC<Props> = (props: Props) => {
return (
<div id="Content">
<Party team={props.party} raids={props.sortedRaids} />
</div>
)
}
export async function getStaticProps({ locale }: { locale: string }) {
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
// Will be passed to the page component as props
},
}
export const getServerSidePaths = async () => {
return {
paths: [
// Object variant:
{ params: { party: "string" } },
],
fallback: true,
}
}
export default PartyRoute
// prettier-ignore
export const getServerSideProps = async ({ req, res, locale, query }: { req: NextApiRequest, res: NextApiResponse, locale: string, query: { [index: string]: string } }) => {
// Cookies
const cookie = getCookie("account", { req, res })
const accountData: AccountCookie = cookie
? JSON.parse(cookie as string)
: null
const headers = accountData
? { headers: { Authorization: `Bearer ${accountData.token}` } }
: {}
let { raids, sortedRaids } = await api.endpoints.raids
.getAll()
.then((response) => organizeRaids(response.data.map((r: any) => r.raid)))
let party: Party | null = null
if (query.party) {
let response = await api.endpoints.parties.getOne({ id: query.party, params: headers })
party = response.data.party
} else {
console.log("No party code")
}
return {
props: {
party: party,
raids: raids,
sortedRaids: sortedRaids,
...(await serverSideTranslations(locale, ["common"])),
// Will be passed to the page component as props
},
}
}
const organizeRaids = (raids: Raid[]) => {
// Set up empty raid for "All raids"
const all = {
id: "0",
name: {
en: "All raids",
ja: "全て",
},
slug: "all",
level: 0,
group: 0,
element: 0,
}
const numGroups = Math.max.apply(
Math,
raids.map((raid) => raid.group)
)
let groupedRaids = []
for (let i = 0; i <= numGroups; i++) {
groupedRaids[i] = raids.filter((raid) => raid.group == i)
}
return {
raids: raids,
sortedRaids: groupedRaids,
}
}
export default PartyRoute

View file

@ -1,306 +1,425 @@
import React, { useCallback, useEffect, useState } from 'react'
import Head from 'next/head'
import React, { useCallback, useEffect, useState } from "react"
import Head from "next/head"
import { useCookies } from 'react-cookie'
import { queryTypes, useQueryState } from 'next-usequerystate'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import InfiniteScroll from 'react-infinite-scroll-component'
import { getCookie } from "cookies-next"
import { queryTypes, useQueryState } from "next-usequerystate"
import { useRouter } from "next/router"
import { useTranslation } from "next-i18next"
import InfiniteScroll from "react-infinite-scroll-component"
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import clonedeep from 'lodash.clonedeep'
import { serverSideTranslations } from "next-i18next/serverSideTranslations"
import clonedeep from "lodash.clonedeep"
import api from '~utils/api'
import { elements, allElement } from '~utils/Element'
import api from "~utils/api"
import useDidMountEffect from "~utils/useDidMountEffect"
import { elements, allElement } from "~utils/Element"
import GridRep from '~components/GridRep'
import GridRepCollection from '~components/GridRepCollection'
import FilterBar from '~components/FilterBar'
import GridRep from "~components/GridRep"
import GridRepCollection from "~components/GridRepCollection"
import FilterBar from "~components/FilterBar"
const SavedRoute: React.FC = () => {
// Set up cookies
const [cookies] = useCookies(['account'])
const headers = (cookies.account) ? {
'Authorization': `Bearer ${cookies.account.access_token}`
} : {}
import type { NextApiRequest, NextApiResponse } from "next"
// Set up router
const router = useRouter()
// Import translations
const { t } = useTranslation('common')
// Set up app-specific states
const [loading, setLoading] = useState(true)
const [raidsLoading, setRaidsLoading] = useState(true)
const [scrolled, setScrolled] = useState(false)
// Set up page-specific states
const [parties, setParties] = useState<Party[]>([])
const [raids, setRaids] = useState<Raid[]>()
const [raid, setRaid] = useState<Raid>()
// Set up infinite scrolling-related states
const [recordCount, setRecordCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
// Set up filter-specific query states
// Recency is in seconds
const [element, setElement] = useQueryState("element", {
defaultValue: -1,
parse: (query: string) => parseElement(query),
serialize: value => serializeElement(value)
})
const [raidSlug, setRaidSlug] = useQueryState("raid", { defaultValue: "all" })
const [recency, setRecency] = useQueryState("recency", queryTypes.integer.withDefault(-1))
// Define transformers for element
function parseElement(query: string) {
let element: TeamElement | undefined =
(query === 'all') ?
allElement : elements.find(element => element.name.en.toLowerCase() === query)
return (element) ? element.id : -1
}
function serializeElement(value: number | undefined) {
let name = ''
if (value != undefined) {
if (value == -1)
name = allElement.name.en.toLowerCase()
else
name = elements[value].name.en.toLowerCase()
}
return name
}
// Add scroll event listener for shadow on FilterBar on mount
useEffect(() => {
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll);
}, [])
// Handle errors
const handleError = useCallback((error: any) => {
if (error.response != null) {
console.error(error)
} else {
console.error("There was an error.")
}
}, [])
const fetchTeams = useCallback(({ replace }: { replace: boolean }) => {
const filters = {
params: {
element: (element != -1) ? element : undefined,
raid: (raid) ? raid.id : undefined,
recency: (recency != -1) ? recency : undefined,
page: currentPage
}
}
api.savedTeams({...filters, ...{ headers: headers }})
.then(response => {
setTotalPages(response.data.total_pages)
setRecordCount(response.data.count)
if (replace)
replaceResults(response.data.count, response.data.results)
else
appendResults(response.data.results)
})
.then(() => {
setLoading(false)
})
.catch(error => handleError(error))
}, [currentPage, parties, element, raid, recency])
function replaceResults(count: number, list: Party[]) {
if (count > 0) {
setParties(list)
} else {
setParties([])
}
}
function appendResults(list: Party[]) {
setParties([...parties, ...list])
}
// Fetch all raids on mount, then find the raid in the URL if present
useEffect(() => {
api.endpoints.raids.getAll()
.then(response => {
const cleanRaids: Raid[] = response.data.map((r: any) => r.raid)
setRaids(cleanRaids)
setRaidsLoading(false)
const raid = cleanRaids.find(r => r.slug === raidSlug)
setRaid(raid)
return raid
})
}, [setRaids])
// When the element, raid or recency filter changes,
// fetch all teams again.
useEffect(() => {
if (!raidsLoading) {
setCurrentPage(1)
fetchTeams({ replace: true })
}
}, [element, raid, recency])
useEffect(() => {
// Current page changed
if (currentPage > 1)
fetchTeams({ replace: false })
else if (currentPage == 1)
fetchTeams({ replace: true })
}, [currentPage])
// Receive filters from the filter bar
function receiveFilters({ element, raidSlug, recency }: {element?: number, raidSlug?: string, recency?: number}) {
if (element == 0)
setElement(0)
else if (element)
setElement(element)
if (raids && raidSlug) {
const raid = raids.find(raid => raid.slug === raidSlug)
setRaid(raid)
setRaidSlug(raidSlug)
}
if (recency) setRecency(recency)
}
// Methods: Favorites
function toggleFavorite(teamId: string, favorited: boolean) {
if (favorited)
unsaveFavorite(teamId)
else
saveFavorite(teamId)
}
function saveFavorite(teamId: string) {
api.saveTeam({ id: teamId, params: headers })
.then((response) => {
if (response.status == 201) {
const index = parties.findIndex(p => p.id === teamId)
const party = parties[index]
party.favorited = true
let clonedParties = clonedeep(parties)
clonedParties[index] = party
setParties(clonedParties)
}
})
}
function unsaveFavorite(teamId: string) {
api.unsaveTeam({ id: teamId, params: headers })
.then((response) => {
if (response.status == 200) {
const index = parties.findIndex(p => p.id === teamId)
const party = parties[index]
party.favorited = false
let clonedParties = clonedeep(parties)
clonedParties.splice(index, 1)
setParties(clonedParties)
}
})
}
// Methods: Navigation
function handleScroll() {
if (window.pageYOffset > 90)
setScrolled(true)
else
setScrolled(false)
}
function goTo(shortcode: string) {
router.push(`/p/${shortcode}`)
}
function renderParties() {
return parties.map((party, i) => {
return <GridRep
id={party.id}
shortcode={party.shortcode}
name={party.name}
createdAt={new Date(party.created_at)}
raid={party.raid}
grid={party.weapons}
user={party.user}
favorited={party.favorited}
key={`party-${i}`}
displayUser={true}
onClick={goTo}
onSave={toggleFavorite} />
})
}
return (
<div id="Teams">
<Head>
<title>{t('saved.title')}</title>
<meta property="og:title" content="Your saved Teams" />
<meta property="og:url" content="https://app.granblue.team/saved" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:domain" content="app.granblue.team" />
<meta name="twitter:title" content="Your saved Teams" />
</Head>
<FilterBar
onFilter={receiveFilters}
scrolled={scrolled}
element={element}
raidSlug={ (raidSlug) ? raidSlug : undefined }
recency={recency}>
<h1>{t('saved.title')}</h1>
</FilterBar>
<section>
<InfiniteScroll
dataLength={ (parties && parties.length > 0) ? parties.length : 0}
next={ () => setCurrentPage(currentPage + 1) }
hasMore={totalPages > currentPage}
loader={ <div id="NotFound"><h2>Loading...</h2></div> }>
<GridRepCollection loading={loading}>
{ renderParties() }
</GridRepCollection>
</InfiniteScroll>
{ (parties.length == 0) ?
<div id="NotFound">
<h2>{ (loading) ? t('saved.loading') : t('saved.not_found') }</h2>
</div>
: '' }
</section>
</div>
)
interface Props {
teams?: { count: number; total_pages: number; results: Party[] }
raids: Raid[]
sortedRaids: Raid[][]
}
export async function getStaticProps({ locale }: { locale: string }) {
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
// Will be passed to the page component as props
const SavedRoute: React.FC<Props> = (props: Props) => {
// Set up cookies
const cookie = getCookie("account")
const accountData: AccountCookie = cookie
? JSON.parse(cookie as string)
: null
const headers = accountData
? { Authorization: `Bearer ${accountData.token}` }
: {}
// Set up router
const router = useRouter()
// Import translations
const { t } = useTranslation("common")
// Set up app-specific states
const [raidsLoading, setRaidsLoading] = useState(true)
const [scrolled, setScrolled] = useState(false)
// Set up page-specific states
const [parties, setParties] = useState<Party[]>([])
const [raids, setRaids] = useState<Raid[]>()
const [raid, setRaid] = useState<Raid>()
// Set up infinite scrolling-related states
const [recordCount, setRecordCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
// Set up filter-specific query states
// Recency is in seconds
const [element, setElement] = useQueryState("element", {
defaultValue: -1,
parse: (query: string) => parseElement(query),
serialize: (value) => serializeElement(value),
})
const [raidSlug, setRaidSlug] = useQueryState("raid", { defaultValue: "all" })
const [recency, setRecency] = useQueryState(
"recency",
queryTypes.integer.withDefault(-1)
)
// Define transformers for element
function parseElement(query: string) {
let element: TeamElement | undefined =
query === "all"
? allElement
: elements.find((element) => element.name.en.toLowerCase() === query)
return element ? element.id : -1
}
function serializeElement(value: number | undefined) {
let name = ""
if (value != undefined) {
if (value == -1) name = allElement.name.en.toLowerCase()
else name = elements[value].name.en.toLowerCase()
}
return name
}
// Set the initial parties from props
useEffect(() => {
if (props.teams) {
setTotalPages(props.teams.total_pages)
setRecordCount(props.teams.count)
replaceResults(props.teams.count, props.teams.results)
}
setCurrentPage(1)
}, [])
// Add scroll event listener for shadow on FilterBar on mount
useEffect(() => {
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [])
// Handle errors
const handleError = useCallback((error: any) => {
if (error.response != null) {
console.error(error)
} else {
console.error("There was an error.")
}
}, [])
const fetchTeams = useCallback(
({ replace }: { replace: boolean }) => {
const filters = {
params: {
element: element != -1 ? element : undefined,
raid: raid ? raid.id : undefined,
recency: recency != -1 ? recency : undefined,
page: currentPage,
},
}
api
.savedTeams({ ...filters, ...{ headers: headers } })
.then((response) => {
setTotalPages(response.data.total_pages)
setRecordCount(response.data.count)
if (replace)
replaceResults(response.data.count, response.data.results)
else appendResults(response.data.results)
})
.catch((error) => handleError(error))
},
[currentPage, parties, element, raid, recency]
)
function replaceResults(count: number, list: Party[]) {
if (count > 0) {
setParties(list)
} else {
setParties([])
}
}
function appendResults(list: Party[]) {
setParties([...parties, ...list])
}
// Fetch all raids on mount, then find the raid in the URL if present
useEffect(() => {
api.endpoints.raids.getAll().then((response) => {
const cleanRaids: Raid[] = response.data.map((r: any) => r.raid)
setRaids(cleanRaids)
setRaidsLoading(false)
const raid = cleanRaids.find((r) => r.slug === raidSlug)
setRaid(raid)
return raid
})
}, [setRaids])
// When the element, raid or recency filter changes,
// fetch all teams again.
useDidMountEffect(() => {
setCurrentPage(1)
fetchTeams({ replace: true })
}, [element, raid, recency])
// When the page changes, fetch all teams again.
useDidMountEffect(() => {
// Current page changed
if (currentPage > 1) fetchTeams({ replace: false })
else if (currentPage == 1) fetchTeams({ replace: true })
}, [currentPage])
// Receive filters from the filter bar
function receiveFilters({
element,
raidSlug,
recency,
}: {
element?: number
raidSlug?: string
recency?: number
}) {
if (element == 0) setElement(0)
else if (element) setElement(element)
if (raids && raidSlug) {
const raid = raids.find((raid) => raid.slug === raidSlug)
setRaid(raid)
setRaidSlug(raidSlug)
}
if (recency) setRecency(recency)
}
// Methods: Favorites
function toggleFavorite(teamId: string, favorited: boolean) {
if (favorited) unsaveFavorite(teamId)
else saveFavorite(teamId)
}
function saveFavorite(teamId: string) {
api.saveTeam({ id: teamId, params: headers }).then((response) => {
if (response.status == 201) {
const index = parties.findIndex((p) => p.id === teamId)
const party = parties[index]
party.favorited = true
let clonedParties = clonedeep(parties)
clonedParties[index] = party
setParties(clonedParties)
}
})
}
function unsaveFavorite(teamId: string) {
api.unsaveTeam({ id: teamId, params: headers }).then((response) => {
if (response.status == 200) {
const index = parties.findIndex((p) => p.id === teamId)
const party = parties[index]
party.favorited = false
let clonedParties = clonedeep(parties)
clonedParties.splice(index, 1)
setParties(clonedParties)
}
})
}
// Methods: Navigation
function handleScroll() {
if (window.pageYOffset > 90) setScrolled(true)
else setScrolled(false)
}
function goTo(shortcode: string) {
router.push(`/p/${shortcode}`)
}
function renderParties() {
return parties.map((party, i) => {
return (
<GridRep
id={party.id}
shortcode={party.shortcode}
name={party.name}
createdAt={new Date(party.created_at)}
raid={party.raid}
grid={party.weapons}
user={party.user}
favorited={party.favorited}
key={`party-${i}`}
displayUser={true}
onClick={goTo}
onSave={toggleFavorite}
/>
)
})
}
return (
<div id="Teams">
<Head>
<title>{t("saved.title")}</title>
<meta property="og:title" content="Your saved Teams" />
<meta property="og:url" content="https://app.granblue.team/saved" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:domain" content="app.granblue.team" />
<meta name="twitter:title" content="Your saved Teams" />
</Head>
<FilterBar
onFilter={receiveFilters}
scrolled={scrolled}
element={element}
raidSlug={raidSlug ? raidSlug : undefined}
recency={recency}
>
<h1>{t("saved.title")}</h1>
</FilterBar>
<section>
<InfiniteScroll
dataLength={parties && parties.length > 0 ? parties.length : 0}
next={() => setCurrentPage(currentPage + 1)}
hasMore={totalPages > currentPage}
loader={
<div id="NotFound">
<h2>Loading...</h2>
</div>
}
>
<GridRepCollection>{renderParties()}</GridRepCollection>
</InfiniteScroll>
{parties.length == 0 ? (
<div id="NotFound">
<h2>{t("saved.not_found")}</h2>
</div>
) : (
""
)}
</section>
</div>
)
}
export default SavedRoute
export const getServerSidePaths = async () => {
return {
paths: [
// Object variant:
{ params: { party: "string" } },
],
fallback: true,
}
}
// prettier-ignore
export const getServerSideProps = async ({ req, res, locale, query }: { req: NextApiRequest, res: NextApiResponse, locale: string, query: { [index: string]: string } }) => {
// Cookies
const cookie = getCookie("account", { req, res })
const accountData: AccountCookie = cookie
? JSON.parse(cookie as string)
: null
const headers = accountData
? { headers: { Authorization: `Bearer ${accountData.token}` } }
: {}
let { raids, sortedRaids } = await api.endpoints.raids
.getAll(headers)
.then((response) => organizeRaids(response.data.map((r: any) => r.raid)))
// Extract recency filter
const recencyParam: number = parseInt(query.recency)
// Extract element filter
const elementParam: string = query.element
const teamElement: TeamElement | undefined =
elementParam === "all"
? allElement
: elements.find(
(element) => element.name.en.toLowerCase() === elementParam
)
// Extract raid filter
const raidParam: string = query.raid
const raid: Raid | undefined = raids.find((r) => r.slug === raidParam)
// Create filter object
const filters: {
raid?: string
element?: number
recency?: number
} = {}
if (recencyParam) filters.recency = recencyParam
if (teamElement && teamElement.id > -1) filters.element = teamElement.id
if (raid) filters.raid = raid.id
// Fetch initial set of parties here
const response = await api.savedTeams({
params: filters,
...headers
})
return {
props: {
teams: response.data,
raids: raids,
sortedRaids: sortedRaids,
...(await serverSideTranslations(locale, ["common"])),
// Will be passed to the page component as props
},
}
}
const organizeRaids = (raids: Raid[]) => {
// Set up empty raid for "All raids"
const all = {
id: "0",
name: {
en: "All raids",
ja: "全て",
},
slug: "all",
level: 0,
group: 0,
element: 0,
}
const numGroups = Math.max.apply(
Math,
raids.map((raid) => raid.group)
)
let groupedRaids = []
for (let i = 0; i <= numGroups; i++) {
groupedRaids[i] = raids.filter((raid) => raid.group == i)
}
return {
raids: raids,
sortedRaids: groupedRaids,
}
}
export default SavedRoute

View file

@ -1,308 +1,433 @@
import React, { useCallback, useEffect, useState } from 'react'
import Head from 'next/head'
import React, { useCallback, useEffect, useState } from "react"
import Head from "next/head"
import { useCookies } from 'react-cookie'
import { queryTypes, useQueryState } from 'next-usequerystate'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import InfiniteScroll from 'react-infinite-scroll-component'
import { getCookie } from "cookies-next"
import { queryTypes, useQueryState } from "next-usequerystate"
import { useRouter } from "next/router"
import { useTranslation } from "next-i18next"
import InfiniteScroll from "react-infinite-scroll-component"
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import clonedeep from 'lodash.clonedeep'
import { serverSideTranslations } from "next-i18next/serverSideTranslations"
import clonedeep from "lodash.clonedeep"
import api from '~utils/api'
import { elements, allElement } from '~utils/Element'
import api from "~utils/api"
import useDidMountEffect from "~utils/useDidMountEffect"
import { elements, allElement } from "~utils/Element"
import GridRep from '~components/GridRep'
import GridRepCollection from '~components/GridRepCollection'
import FilterBar from '~components/FilterBar'
import GridRep from "~components/GridRep"
import GridRepCollection from "~components/GridRepCollection"
import FilterBar from "~components/FilterBar"
const TeamsRoute: React.FC = () => {
// Set up cookies
const [cookies] = useCookies(['account'])
const headers = (cookies.account) ? {
'Authorization': `Bearer ${cookies.account.access_token}`
} : {}
import type { NextApiRequest, NextApiResponse } from "next"
// Set up router
const router = useRouter()
// Import translations
const { t } = useTranslation('common')
// Set up app-specific states
const [loading, setLoading] = useState(true)
const [raidsLoading, setRaidsLoading] = useState(true)
const [scrolled, setScrolled] = useState(false)
// Set up page-specific states
const [parties, setParties] = useState<Party[]>([])
const [raids, setRaids] = useState<Raid[]>()
const [raid, setRaid] = useState<Raid>()
// Set up infinite scrolling-related states
const [recordCount, setRecordCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
// Set up filter-specific query states
// Recency is in seconds
const [element, setElement] = useQueryState("element", {
defaultValue: -1,
parse: (query: string) => parseElement(query),
serialize: value => serializeElement(value)
})
const [raidSlug, setRaidSlug] = useQueryState("raid", { defaultValue: "all" })
const [recency, setRecency] = useQueryState("recency", queryTypes.integer.withDefault(-1))
// Define transformers for element
function parseElement(query: string) {
let element: TeamElement | undefined =
(query === 'all') ?
allElement : elements.find(element => element.name.en.toLowerCase() === query)
return (element) ? element.id : -1
}
function serializeElement(value: number | undefined) {
let name = ''
if (value != undefined) {
if (value == -1)
name = allElement.name.en.toLowerCase()
else
name = elements[value].name.en.toLowerCase()
}
return name
}
// Add scroll event listener for shadow on FilterBar on mount
useEffect(() => {
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll);
}, [])
// Handle errors
const handleError = useCallback((error: any) => {
if (error.response != null) {
console.error(error)
} else {
console.error("There was an error.")
}
}, [])
const fetchTeams = useCallback(({ replace }: { replace: boolean }) => {
const filters = {
params: {
element: (element != -1) ? element : undefined,
raid: (raid) ? raid.id : undefined,
recency: (recency != -1) ? recency : undefined,
page: currentPage
}
}
api.endpoints.parties.getAll({...filters, ...{ headers: headers }})
.then(response => {
setTotalPages(response.data.total_pages)
setRecordCount(response.data.count)
if (replace)
replaceResults(response.data.count, response.data.results)
else
appendResults(response.data.results)
})
.then(() => {
setLoading(false)
})
.catch(error => handleError(error))
}, [currentPage, parties, element, raid, recency])
function replaceResults(count: number, list: Party[]) {
if (count > 0) {
setParties(list.sort((a, b) => (a.created_at > b.created_at) ? -1 : 1))
} else {
setParties([])
}
}
function appendResults(list: Party[]) {
setParties([...parties, ...list])
}
// Fetch all raids on mount, then find the raid in the URL if present
useEffect(() => {
api.endpoints.raids.getAll()
.then(response => {
const cleanRaids: Raid[] = response.data.map((r: any) => r.raid)
setRaids(cleanRaids)
setRaidsLoading(false)
const raid = cleanRaids.find(r => r.slug === raidSlug)
setRaid(raid)
return raid
})
}, [setRaids])
// When the element, raid or recency filter changes,
// fetch all teams again.
useEffect(() => {
if (!raidsLoading) {
setCurrentPage(1)
fetchTeams({ replace: true })
}
}, [element, raid, recency])
useEffect(() => {
// Current page changed
if (currentPage > 1)
fetchTeams({ replace: false })
else if (currentPage == 1)
fetchTeams({ replace: true })
}, [currentPage])
// Receive filters from the filter bar
function receiveFilters({ element, raidSlug, recency }: {element?: number, raidSlug?: string, recency?: number}) {
if (element == 0)
setElement(0)
else if (element)
setElement(element)
if (raids && raidSlug) {
const raid = raids.find(raid => raid.slug === raidSlug)
setRaid(raid)
setRaidSlug(raidSlug)
}
if (recency) setRecency(recency)
}
// Methods: Favorites
function toggleFavorite(teamId: string, favorited: boolean) {
if (favorited)
unsaveFavorite(teamId)
else
saveFavorite(teamId)
}
function saveFavorite(teamId: string) {
api.saveTeam({ id: teamId, params: headers })
.then((response) => {
if (response.status == 201) {
const index = parties.findIndex(p => p.id === teamId)
const party = parties[index]
party.favorited = true
let clonedParties = clonedeep(parties)
clonedParties[index] = party
setParties(clonedParties)
}
})
}
function unsaveFavorite(teamId: string) {
api.unsaveTeam({ id: teamId, params: headers })
.then((response) => {
if (response.status == 200) {
const index = parties.findIndex(p => p.id === teamId)
const party = parties[index]
party.favorited = false
let clonedParties = clonedeep(parties)
clonedParties[index] = party
setParties(clonedParties)
}
})
}
// Methods: Navigation
function handleScroll() {
if (window.pageYOffset > 90)
setScrolled(true)
else
setScrolled(false)
}
function goTo(shortcode: string) {
router.push(`/p/${shortcode}`)
}
function renderParties() {
return parties.map((party, i) => {
return <GridRep
id={party.id}
shortcode={party.shortcode}
name={party.name}
createdAt={new Date(party.created_at)}
raid={party.raid}
grid={party.weapons}
user={party.user}
favorited={party.favorited}
key={`party-${i}`}
displayUser={true}
onClick={goTo}
onSave={toggleFavorite} />
})
}
return (
<div id="Teams">
<Head>
<title>{ t('teams.title') }</title>
<meta property="og:title" content="Discover Teams" />
<meta property="og:description" content="Find different Granblue Fantasy teams by raid, element or recency" />
<meta property="og:url" content="https://app.granblue.team/teams" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:domain" content="app.granblue.team" />
<meta name="twitter:title" content="Discover Teams" />
<meta name="twitter:description" content="Find different Granblue Fantasy teams by raid, element or recency" />
</Head>
<FilterBar
onFilter={receiveFilters}
scrolled={scrolled}
element={element}
raidSlug={ (raidSlug) ? raidSlug : undefined }
recency={recency}>
<h1>{t('teams.title')}</h1>
</FilterBar>
<section>
<InfiniteScroll
dataLength={ (parties && parties.length > 0) ? parties.length : 0}
next={ () => setCurrentPage(currentPage + 1) }
hasMore={totalPages > currentPage}
loader={ <div id="NotFound"><h2>Loading...</h2></div> }>
<GridRepCollection loading={loading}>
{ renderParties() }
</GridRepCollection>
</InfiniteScroll>
{ (parties.length == 0) ?
<div id="NotFound">
<h2>{ (loading) ? t('teams.loading') : t('teams.not_found') }</h2>
</div>
: '' }
</section>
</div>
)
interface Props {
teams?: { count: number; total_pages: number; results: Party[] }
raids: Raid[]
sortedRaids: Raid[][]
}
export async function getStaticProps({ locale }: { locale: string }) {
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
// Will be passed to the page component as props
const TeamsRoute: React.FC<Props> = (props: Props) => {
// Set up cookies
const cookie = getCookie("account")
const accountData: AccountCookie = cookie
? JSON.parse(cookie as string)
: null
const headers = accountData
? { Authorization: `Bearer ${accountData.token}` }
: {}
// Set up router
const router = useRouter()
// Import translations
const { t } = useTranslation("common")
// Set up app-specific states
const [raidsLoading, setRaidsLoading] = useState(true)
const [scrolled, setScrolled] = useState(false)
// Set up page-specific states
const [parties, setParties] = useState<Party[]>([])
const [raids, setRaids] = useState<Raid[]>()
const [raid, setRaid] = useState<Raid>()
// Set up infinite scrolling-related states
const [recordCount, setRecordCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
// Set up filter-specific query states
// Recency is in seconds
const [element, setElement] = useQueryState("element", {
defaultValue: -1,
parse: (query: string) => parseElement(query),
serialize: (value) => serializeElement(value),
})
const [raidSlug, setRaidSlug] = useQueryState("raid", { defaultValue: "all" })
const [recency, setRecency] = useQueryState(
"recency",
queryTypes.integer.withDefault(-1)
)
// Define transformers for element
function parseElement(query: string) {
let element: TeamElement | undefined =
query === "all"
? allElement
: elements.find((element) => element.name.en.toLowerCase() === query)
return element ? element.id : -1
}
function serializeElement(value: number | undefined) {
let name = ""
if (value != undefined) {
if (value == -1) name = allElement.name.en.toLowerCase()
else name = elements[value].name.en.toLowerCase()
}
return name
}
// Set the initial parties from props
useEffect(() => {
if (props.teams) {
setTotalPages(props.teams.total_pages)
setRecordCount(props.teams.count)
replaceResults(props.teams.count, props.teams.results)
}
setCurrentPage(1)
}, [])
// Add scroll event listener for shadow on FilterBar on mount
useEffect(() => {
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [])
// Handle errors
const handleError = useCallback((error: any) => {
if (error.response != null) {
console.error(error)
} else {
console.error("There was an error.")
}
}, [])
const fetchTeams = useCallback(
({ replace }: { replace: boolean }) => {
const filters = {
params: {
element: element != -1 ? element : undefined,
raid: raid ? raid.id : undefined,
recency: recency != -1 ? recency : undefined,
page: currentPage,
},
}
api.endpoints.parties
.getAll({ ...filters, ...{ headers: headers } })
.then((response) => {
setTotalPages(response.data.total_pages)
setRecordCount(response.data.count)
if (replace)
replaceResults(response.data.count, response.data.results)
else appendResults(response.data.results)
})
.catch((error) => handleError(error))
},
[currentPage, parties, element, raid, recency]
)
function replaceResults(count: number, list: Party[]) {
if (count > 0) {
setParties(list.sort((a, b) => (a.created_at > b.created_at ? -1 : 1)))
} else {
setParties([])
}
}
function appendResults(list: Party[]) {
setParties([...parties, ...list])
}
// Fetch all raids on mount, then find the raid in the URL if present
useEffect(() => {
api.endpoints.raids.getAll().then((response) => {
const cleanRaids: Raid[] = response.data.map((r: any) => r.raid)
setRaids(cleanRaids)
setRaidsLoading(false)
const raid = cleanRaids.find((r) => r.slug === raidSlug)
setRaid(raid)
return raid
})
}, [setRaids])
// When the element, raid or recency filter changes,
// fetch all teams again.
useDidMountEffect(() => {
setCurrentPage(1)
fetchTeams({ replace: true })
}, [element, raid, recency])
// When the page changes, fetch all teams again.
useDidMountEffect(() => {
// Current page changed
if (currentPage > 1) fetchTeams({ replace: false })
else if (currentPage == 1) fetchTeams({ replace: true })
}, [currentPage])
// Receive filters from the filter bar
function receiveFilters({
element,
raidSlug,
recency,
}: {
element?: number
raidSlug?: string
recency?: number
}) {
if (element == 0) setElement(0)
else if (element) setElement(element)
if (raids && raidSlug) {
const raid = raids.find((raid) => raid.slug === raidSlug)
setRaid(raid)
setRaidSlug(raidSlug)
}
if (recency) setRecency(recency)
}
// Methods: Favorites
function toggleFavorite(teamId: string, favorited: boolean) {
if (favorited) unsaveFavorite(teamId)
else saveFavorite(teamId)
}
function saveFavorite(teamId: string) {
api.saveTeam({ id: teamId, params: headers }).then((response) => {
if (response.status == 201) {
const index = parties.findIndex((p) => p.id === teamId)
const party = parties[index]
party.favorited = true
let clonedParties = clonedeep(parties)
clonedParties[index] = party
setParties(clonedParties)
}
})
}
function unsaveFavorite(teamId: string) {
api.unsaveTeam({ id: teamId, params: headers }).then((response) => {
if (response.status == 200) {
const index = parties.findIndex((p) => p.id === teamId)
const party = parties[index]
party.favorited = false
let clonedParties = clonedeep(parties)
clonedParties[index] = party
setParties(clonedParties)
}
})
}
// Methods: Navigation
function handleScroll() {
if (window.pageYOffset > 90) setScrolled(true)
else setScrolled(false)
}
function goTo(shortcode: string) {
router.push(`/p/${shortcode}`)
}
function renderParties() {
return parties.map((party, i) => {
return (
<GridRep
id={party.id}
shortcode={party.shortcode}
name={party.name}
createdAt={new Date(party.created_at)}
raid={party.raid}
grid={party.weapons}
user={party.user}
favorited={party.favorited}
key={`party-${i}`}
displayUser={true}
onClick={goTo}
onSave={toggleFavorite}
/>
)
})
}
return (
<div id="Teams">
<Head>
<title>{t("teams.title")}</title>
<meta property="og:title" content="Discover Teams" />
<meta
property="og:description"
content="Find different Granblue Fantasy teams by raid, element or recency"
/>
<meta property="og:url" content="https://app.granblue.team/teams" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:domain" content="app.granblue.team" />
<meta name="twitter:title" content="Discover Teams" />
<meta
name="twitter:description"
content="Find different Granblue Fantasy teams by raid, element or recency"
/>
</Head>
<FilterBar
onFilter={receiveFilters}
scrolled={scrolled}
element={element}
raidSlug={raidSlug ? raidSlug : undefined}
recency={recency}
>
<h1>{t("teams.title")}</h1>
</FilterBar>
<section>
<InfiniteScroll
dataLength={parties && parties.length > 0 ? parties.length : 0}
next={() => setCurrentPage(currentPage + 1)}
hasMore={totalPages > currentPage}
loader={
<div id="NotFound">
<h2>Loading...</h2>
</div>
}
>
<GridRepCollection>{renderParties()}</GridRepCollection>
</InfiniteScroll>
{parties.length == 0 ? (
<div id="NotFound">
<h2>{t("teams.not_found")}</h2>
</div>
) : (
""
)}
</section>
</div>
)
}
export default TeamsRoute
export const getServerSidePaths = async () => {
return {
paths: [
// Object variant:
{ params: { party: "string" } },
],
fallback: true,
}
}
// prettier-ignore
export const getServerSideProps = async ({ req, res, locale, query }: { req: NextApiRequest, res: NextApiResponse, locale: string, query: { [index: string]: string } }) => {
// Cookies
const cookie = getCookie("account", { req, res })
const accountData: AccountCookie = cookie
? JSON.parse(cookie as string)
: null
const headers = accountData
? { headers: { Authorization: `Bearer ${accountData.token}` } }
: {}
let { raids, sortedRaids } = await api.endpoints.raids
.getAll(headers)
.then((response) => organizeRaids(response.data.map((r: any) => r.raid)))
// Extract recency filter
const recencyParam: number = parseInt(query.recency)
// Extract element filter
const elementParam: string = query.element
const teamElement: TeamElement | undefined =
elementParam === "all"
? allElement
: elements.find(
(element) => element.name.en.toLowerCase() === elementParam
)
// Extract raid filter
const raidParam: string = query.raid
const raid: Raid | undefined = raids.find((r) => r.slug === raidParam)
// Create filter object
const filters: {
raid?: string
element?: number
recency?: number
} = {}
if (recencyParam) filters.recency = recencyParam
if (teamElement && teamElement.id > -1) filters.element = teamElement.id
if (raid) filters.raid = raid.id
// Fetch initial set of parties here
const response = await api.endpoints.parties.getAll({
params: filters,
...headers
})
return {
props: {
teams: response.data,
raids: raids,
sortedRaids: sortedRaids,
...(await serverSideTranslations(locale, ["common"])),
// Will be passed to the page component as props
},
}
}
const organizeRaids = (raids: Raid[]) => {
// Set up empty raid for "All raids"
const all = {
id: "0",
name: {
en: "All raids",
ja: "全て",
},
slug: "all",
level: 0,
group: 0,
element: 0,
}
const numGroups = Math.max.apply(
Math,
raids.map((raid) => raid.group)
)
let groupedRaids = []
for (let i = 0; i <= numGroups; i++) {
groupedRaids[i] = raids.filter((raid) => raid.group == i)
}
return {
raids: raids,
sortedRaids: groupedRaids,
}
}
export default TeamsRoute

View file

@ -1,242 +1,248 @@
{
"ax": {
"no_skill": "No AX Skill",
"errors": {
"value_too_low": "{{name}} must be at least {{minValue}}{{suffix}}",
"value_too_high": "{{name}} cannot be greater than {{maxValue}}{{suffix}}",
"value_not_whole": "{{name}} must be a whole number",
"value_empty": "{{name}} must have a value"
}
"ax": {
"no_skill": "No AX Skill",
"errors": {
"value_too_low": "{{name}} must be at least {{minValue}}{{suffix}}",
"value_too_high": "{{name}} cannot be greater than {{maxValue}}{{suffix}}",
"value_not_whole": "{{name}} must be a whole number",
"value_empty": "{{name}} must have a value"
}
},
"buttons": {
"cancel": "Cancel",
"copy": "Copy link",
"delete": "Delete team",
"show_info": "Edit info",
"hide_info": "Hide info",
"save_info": "Save info",
"menu": "Menu",
"new": "New",
"wiki": "View more on gbf.wiki"
},
"filters": {
"labels": {
"element": "Element",
"series": "Series",
"proficiency": "Proficiency",
"rarity": "Rarity"
}
},
"header": {
"anonymous": "Anonymous",
"untitled_team": "Untitled team by {{username}}",
"new_team": "New team",
"byline": "{{partyName}} by {{username}}"
},
"rarities": {
"sr": "SR",
"ssr": "SSR"
},
"elements": {
"null": "Null",
"wind": "Wind",
"fire": "Fire",
"water": "Water",
"earth": "Earth",
"dark": "Dark",
"light": "Light",
"full": {
"all": "All elements",
"null": "Null",
"wind": "Wind",
"fire": "Fire",
"water": "Water",
"earth": "Earth",
"dark": "Dark",
"light": "Light"
}
},
"proficiencies": {
"sabre": "Sabre",
"dagger": "Dagger",
"spear": "Spear",
"axe": "Axe",
"staff": "Staff",
"gun": "Gun",
"melee": "Melee",
"bow": "Bow",
"harp": "Harp",
"katana": "Katana"
},
"series": {
"seraphic": "Seraphic",
"grand": "Grand",
"opus": "Dark Opus",
"draconic": "Draconic",
"primal": "Primal",
"olden_primal": "Olden Primal",
"beast": "Beast",
"omega": "Omega",
"militis": "Militis",
"xeno": "Xeno",
"astral": "Astral",
"rose": "Rose",
"hollowsky": "Hollowsky",
"ultima": "Ultima",
"bahamut": "Bahamut",
"epic": "Epic",
"ennead": "Ennead",
"cosmos": "Cosmos",
"ancestral": "Ancestral",
"superlative": "Superlative",
"vintage": "Vintage",
"class_champion": "Class Champion",
"sephira": "Sephira",
"new_world": "New World Foundation"
},
"recency": {
"all_time": "All time",
"last_day": "Last day",
"last_week": "Last week",
"last_month": "Last month",
"last_3_months": "Last 3 months",
"last_6_months": "Last 6 months",
"last_year": "Last year"
},
"summons": {
"main": "Main Summon",
"friend": "Friend Summon",
"summons": "Summons",
"subaura": "Sub Aura Summons"
},
"modals": {
"about": {
"title": "About"
},
"buttons": {
"cancel": "Cancel",
"copy": "Copy link",
"delete": "Delete team",
"show_info": "Edit info",
"hide_info": "Hide info",
"save_info": "Save info",
"menu": "Menu",
"new": "New",
"wiki": "View more on gbf.wiki"
"delete_team": {
"title": "Delete team",
"description": "Are you sure you want to permanently delete this team?",
"buttons": {
"confirm": "Yes, delete",
"cancel": "Nevermind"
}
},
"filters": {
"labels": {
"element": "Element",
"series": "Series",
"proficiency": "Proficiency",
"rarity": "Rarity"
}
"login": {
"title": "Log in",
"buttons": {
"confirm": "Log in"
},
"errors": {
"empty_email": "Please enter your email",
"empty_password": "Please enter your password",
"invalid_email": "That email address is not valid",
"invalid_credentials": "Your email address or password is incorrect"
},
"placeholders": {
"email": "Email address",
"password": "Password"
}
},
"rarities": {
"sr": "SR",
"ssr": "SSR"
},
"elements": {
"null": "Null",
"wind": "Wind",
"fire": "Fire",
"water": "Water",
"earth": "Earth",
"dark": "Dark",
"light": "Light",
"full": {
"all": "All elements",
"null": "Null",
"wind": "Wind",
"fire": "Fire",
"water": "Water",
"earth": "Earth",
"dark": "Dark",
"light": "Light"
}
},
"proficiencies": {
"sabre": "Sabre",
"dagger": "Dagger",
"spear": "Spear",
"axe": "Axe",
"staff": "Staff",
"gun": "Gun",
"melee": "Melee",
"bow": "Bow",
"harp": "Harp",
"katana": "Katana"
},
"series": {
"seraphic": "Seraphic",
"grand": "Grand",
"opus": "Dark Opus",
"draconic": "Draconic",
"primal": "Primal",
"olden_primal": "Olden Primal",
"beast": "Beast",
"omega": "Omega",
"militis": "Militis",
"xeno": "Xeno",
"astral": "Astral",
"rose": "Rose",
"hollowsky": "Hollowsky",
"ultima": "Ultima",
"bahamut": "Bahamut",
"epic": "Epic",
"ennead": "Ennead",
"cosmos": "Cosmos",
"ancestral": "Ancestral",
"superlative": "Superlative",
"vintage": "Vintage",
"class_champion": "Class Champion",
"sephira": "Sephira",
"new_world": "New World Foundation"
},
"recency": {
"all_time": "All time",
"last_day": "Last day",
"last_week": "Last week",
"last_month": "Last month",
"last_3_months": "Last 3 months",
"last_6_months": "Last 6 months",
"last_year": "Last year"
},
"summons": {
"main": "Main Summon",
"friend": "Friend Summon",
"summons": "Summons",
"subaura": "Sub Aura Summons"
},
"modals": {
"about": {
"title": "About"
},
"delete_team": {
"title": "Delete team",
"description": "Are you sure you want to permanently delete this team?",
"buttons": {
"confirm": "Yes, delete",
"cancel": "Nevermind"
}
},
"login": {
"title": "Log in",
"buttons": {
"confirm": "Log in"
},
"errors": {
"empty_email": "Please enter your email",
"empty_password": "Please enter your password",
"invalid_email": "That email address is not valid",
"invalid_credentials": "Your email address or password is incorrect"
},
"placeholders": {
"email": "Email address",
"password": "Password"
}
},
"settings": {
"title": "Account Settings",
"labels": {
"picture": "Picture",
"language": "Language",
"gender": "Main Character",
"private": "Private"
},
"descriptions": {
"private": "Hide your profile and prevent your grids from showing up in collections"
},
"gender": {
"gran": "Gran",
"djeeta": "Djeeta"
},
"language": {
"english": "English",
"japanese": "Japanese"
},
"buttons": {
"confirm": "Save settings"
}
},
"signup": {
"title": "Create an account",
"buttons": {
"confirm": "Sign up"
},
"agreement": "By signing up, I agree to the <br/><2>Privacy Policy</2> and <1>Usage Guidelines</1>.",
"errors": {
"field_in_use": "This {{field}} is already in use",
"empty_email": "Please enter your email",
"invalid_email": "That email address is not valid",
"username_too_short": "Username must be at least 3 characters",
"username_too_long": "Username must be less than 20 characters",
"empty_password": "Please enter your password",
"password_contains_username": "Your password should not contain your username",
"password_too_short": "Password must be at least 8 characters",
"mismatched_passwords": "Your passwords don't match"
},
"placeholders": {
"username": "Username",
"email": "Email address",
"password": "Password",
"password_confirm": "Password (again)"
}
},
"weapon": {
"title": "Modify Weapon",
"buttons": {
"confirm": "Save weapon"
},
"subtitles": {
"element": "Element",
"ax_skills": "AX Skills",
"weapon_keys": "Weapon Keys"
}
}
},
"menu": {
"about": "About",
"guides": "Guides",
"settings": {
"title": "Account Settings",
"labels": {
"picture": "Picture",
"language": "Language",
"login": "Log in",
"saved": "Saved",
"settings": "Settings",
"signup": "Sign up",
"teams": "Teams",
"logout": "Logout"
"gender": "Main Character",
"private": "Private"
},
"descriptions": {
"private": "Hide your profile and prevent your grids from showing up in collections"
},
"gender": {
"gran": "Gran",
"djeeta": "Djeeta"
},
"language": {
"english": "English",
"japanese": "Japanese"
},
"buttons": {
"confirm": "Save settings"
}
},
"party": {
"segmented_control": {
"class": "Class",
"characters": "Characters",
"weapons": "Weapons",
"summons": "Summons"
}
"signup": {
"title": "Create an account",
"buttons": {
"confirm": "Sign up"
},
"agreement": "By signing up, I agree to the <br/><2>Privacy Policy</2> and <1>Usage Guidelines</1>.",
"errors": {
"field_in_use": "This {{field}} is already in use",
"empty_email": "Please enter your email",
"invalid_email": "That email address is not valid",
"username_too_short": "Username must be at least 3 characters",
"username_too_long": "Username must be less than 20 characters",
"empty_password": "Please enter your password",
"password_contains_username": "Your password should not contain your username",
"password_too_short": "Password must be at least 8 characters",
"mismatched_passwords": "Your passwords don't match"
},
"placeholders": {
"username": "Username",
"email": "Email address",
"password": "Password",
"password_confirm": "Password (again)"
}
},
"saved": {
"title": "Your saved Teams",
"loading": "Loading saved teams...",
"not_found": "You haven't saved any teams"
"weapon": {
"title": "Modify Weapon",
"buttons": {
"confirm": "Save weapon"
},
"subtitles": {
"element": "Element",
"ax_skills": "AX Skills",
"weapon_keys": "Weapon Keys"
}
}
},
"menu": {
"about": "About",
"guides": "Guides",
"language": "Language",
"login": "Log in",
"saved": "Saved",
"settings": "Settings",
"signup": "Sign up",
"teams": "Teams",
"logout": "Logout"
},
"party": {
"segmented_control": {
"class": "Class",
"characters": "Characters",
"weapons": "Weapons",
"summons": "Summons"
}
},
"saved": {
"title": "Your saved Teams",
"loading": "Loading saved teams...",
"not_found": "You haven't saved any teams"
},
"search": {
"recent": "Recently added",
"result_count": "{{record_count}} results",
"errors": {
"start_typing": "Start typing the name of a {{object}}",
"min_length": "Type at least 3 characters",
"no_results": "No results found for '{{query}}'",
"end_results": "No more results"
},
"search": {
"recent": "Recently added",
"result_count": "{{record_count}} results",
"errors": {
"start_typing": "Start typing the name of a {{object}}",
"min_length": "Type at least 3 characters",
"no_results": "No results found for '{{query}}'",
"end_results": "No more results"
},
"placeholders": {
"weapon": "Search for a weapon...",
"summon": "Search for a summon...",
"character": "Search for a character..."
}
},
"teams": {
"title": "Discover Teams",
"loading": "Loading teams...",
"not_found": "No teams found"
},
"extra_weapons": "Additional Weapons",
"coming_soon": "Coming Soon",
"no_title": "Untitled",
"no_raid": "No raid",
"no_user": "Anonymous"
"placeholders": {
"weapon": "Search for a weapon...",
"summon": "Search for a summon...",
"character": "Search for a character..."
}
},
"teams": {
"title": "Discover Teams",
"loading": "Loading teams...",
"not_found": "No teams found"
},
"extra_weapons": "Additional Weapons",
"coming_soon": "Coming Soon",
"no_title": "Untitled",
"no_raid": "No raid",
"no_user": "Anonymous"
}

View file

@ -1,244 +1,249 @@
{
"ax": {
"no_skill": "EXスキルなし",
"errors": {
"value_too_low": "{{name}}は最低{{minValue}}{{suffix}}を入力してください",
"value_too_high": "{{name}}は最大{{maxValue}}を入力してください",
"value_not_whole": "{{name}}は整数でなければなりません",
"value_empty": "{{name}}を入力してください"
}
"ax": {
"no_skill": "EXスキルなし",
"errors": {
"value_too_low": "{{name}}は最低{{minValue}}{{suffix}}を入力してください",
"value_too_high": "{{name}}は最大{{maxValue}}を入力してください",
"value_not_whole": "{{name}}は整数でなければなりません",
"value_empty": "{{name}}を入力してください"
}
},
"buttons": {
"cancel": "キャンセルs",
"copy": "リンクをコピー",
"delete": "編成を削除",
"show_info": "詳細を編集",
"save_info": "詳細を保存",
"hide_info": "詳細を非表示",
"menu": "メニュー",
"new": "作成",
"wiki": "gbf.wikiで詳しく見る"
},
"filters": {
"labels": {
"element": "属性",
"series": "シリーズ",
"proficiency": "武器種",
"rarity": "レアリティ"
}
},
"header": {
"anonymous": "無名",
"untitled_team": "{{username}}さんからの無題編成",
"new_team": "新編成",
"byline": "{{username}}さんからの{{partyName}}"
},
"rarities": {
"sr": "SR",
"ssr": "SSR"
},
"elements": {
"null": "無",
"wind": "風",
"fire": "火",
"water": "水",
"earth": "土",
"dark": "闇",
"light": "光",
"full": {
"all": "全属性",
"null": "無属性",
"wind": "風属性",
"fire": "火属性",
"water": "水属性",
"earth": "土属性",
"dark": "闇属性",
"light": "光属性"
}
},
"proficiencies": {
"sabre": "剣",
"dagger": "短剣",
"spear": "槍",
"axe": "斧",
"staff": "杖",
"gun": "銃",
"melee": "拳",
"bow": "弓",
"harp": "琴",
"katana": "刀"
},
"series": {
"seraphic": "セラフィックウェポン",
"grand": "リミテッドシリーズ",
"opus": "終末の神器",
"draconic": "ドラコニックウェポン",
"primal": "プライマルシリーズ",
"olden_primal": "オールド・プライマルシリーズ",
"beast": "四象武器",
"omega": "マグナシリーズ",
"militis": "ミーレスシリーズ",
"xeno": "六道武器",
"astral": "アストラルウェポン",
"rose": "ローズシリーズ",
"hollowsky": "虚ろなる神器",
"ultima": "オメガウェポン",
"bahamut": "バハムートウェポン",
"epic": "エピックウェポン",
"ennead": "エニアドシリーズ",
"cosmos": "コスモスシリーズ",
"ancestral": "アンセスタルシリーズ",
"superlative": "スペリオシリーズ",
"vintage": "ヴィンテージシリーズ",
"class_champion": "英雄武器",
"sephira": "セフィラン・オールドウェポン",
"new_world": "新世界の礎"
},
"recency": {
"all_time": "全ての期間",
"last_day": "1日",
"last_week": "7日",
"last_month": "1ヶ月",
"last_3_months": "3ヶ月",
"last_6_months": "6ヶ月",
"last_year": "1年"
},
"summons": {
"main": "メイン",
"friend": "フレンド",
"summons": "召喚石",
"subaura": "サブ加護召喚石"
},
"modals": {
"about": {
"title": "このサイトについて"
},
"buttons": {
"cancel": "キャンセルs",
"copy": "リンクをコピー",
"delete": "編成を削除",
"show_info": "詳細を編集",
"save_info": "詳細を保存",
"hide_info": "詳細を非表示",
"menu": "メニュー",
"new": "作成",
"wiki": "gbf.wikiで詳しく見る"
"delete_team": {
"title": "編成を削除しますか",
"description": "編成を削除する操作は取り消せません。",
"buttons": {
"confirm": "削除",
"cancel": "キャンセル"
}
},
"filters": {
"labels": {
"element": "属性",
"series": "シリーズ",
"proficiency": "武器種",
"rarity": "レアリティ"
}
"login": {
"title": "ログイン",
"buttons": {
"confirm": "ログイン"
},
"errors": {
"empty_email": "メールアドレスを入力して下さい",
"empty_password": "パスワードを入力して下さい",
"invalid_email": "メールアドレスは有効ではありません",
"invalid_credentials": "パスワードまたはメールアドレスが違います"
},
"placeholders": {
"email": "メールアドレス",
"password": "パスワード"
}
},
"rarities": {
"sr": "SR",
"ssr": "SSR"
},
"elements": {
"null": "無",
"wind": "風",
"fire": "火",
"water": "水",
"earth": "土",
"dark": "闇",
"light": "光",
"full": {
"all": "全属性",
"null": "無属性",
"wind": "風属性",
"fire": "火属性",
"water": "水属性",
"earth": "土属性",
"dark": "闇属性",
"light": "光属性"
}
},
"proficiencies": {
"sabre": "剣",
"dagger": "短剣",
"spear": "槍",
"axe": "斧",
"staff": "杖",
"gun": "銃",
"melee": "拳",
"bow": "弓",
"harp": "琴",
"katana": "刀"
},
"series": {
"seraphic": "セラフィックウェポン",
"grand": "リミテッドシリーズ",
"opus": "終末の神器",
"draconic": "ドラコニックウェポン",
"primal": "プライマルシリーズ",
"olden_primal": "オールド・プライマルシリーズ",
"beast": "四象武器",
"omega": "マグナシリーズ",
"militis": "ミーレスシリーズ",
"xeno": "六道武器",
"astral": "アストラルウェポン",
"rose": "ローズシリーズ",
"hollowsky": "虚ろなる神器",
"ultima": "オメガウェポン",
"bahamut": "バハムートウェポン",
"epic": "エピックウェポン",
"ennead": "エニアドシリーズ",
"cosmos": "コスモスシリーズ",
"ancestral": "アンセスタルシリーズ",
"superlative": "スペリオシリーズ",
"vintage": "ヴィンテージシリーズ",
"class_champion": "英雄武器",
"sephira": "セフィラン・オールドウェポン",
"new_world": "新世界の礎"
},
"recency": {
"all_time": "全ての期間",
"last_day": "1日",
"last_week": "7日",
"last_month": "1ヶ月",
"last_3_months": "3ヶ月",
"last_6_months": "6ヶ月",
"last_year": "1年"
},
"summons": {
"main": "メイン",
"friend": "フレンド",
"summons": "召喚石",
"subaura": "サブ加護召喚石"
},
"modals": {
"about": {
"title": "このサイトについて"
},
"delete_team": {
"title": "編成を削除しますか",
"description": "編成を削除する操作は取り消せません。",
"buttons": {
"confirm": "削除",
"cancel": "キャンセル"
}
},
"login": {
"title": "ログイン",
"buttons": {
"confirm": "ログイン"
},
"errors": {
"empty_email": "メールアドレスを入力して下さい",
"empty_password": "パスワードを入力して下さい",
"invalid_email": "メールアドレスは有効ではありません",
"invalid_credentials": "パスワードまたはメールアドレスが違います"
},
"placeholders": {
"email": "メールアドレス",
"password": "パスワード"
}
},
"settings": {
"title": "アカウント設定",
"labels": {
"picture": "プロフィール画像",
"language": "言語",
"gender": "主人公",
"private": "プライベート"
},
"descriptions": {
"private": "プロフィールを隠し、編成をコレクションに表示されないようにします"
},
"gender": {
"gran": "グラン",
"djeeta": "ジータ"
},
"language": {
"english": "英語",
"japanese": "日本語"
},
"buttons": {
"confirm": "設定を保存する"
}
},
"signup": {
"title": "アカウント登録",
"buttons": {
"confirm": "登録する"
},
"agreement": "続行することで<1>利用規約</1>に同意し、<br/><1>プライバシーポリシー</1>を読んだものとみなされます。",
"errors": {
"field_in_use": "入力された{{field}}は既に登録済みです",
"empty_email": "メールアドレスを入力して下さい",
"invalid_email": "メールアドレスは有効ではありません",
"username_too_short": "ユーザーネームは3文字以上で入力してください",
"username_too_long": "ユーザーネームは20文字以内で入力してください",
"empty_password": "パスワードを入力して下さい",
"password_contains_username": "パスワードにはユーザー名を含めないでください",
"password_too_short": "パスワードは8文字以上で入力してください",
"mismatched_passwords": "パスワードとパスワード確認を確かめてください",
"invalid_credentials": "パスワードまたはメールアドレスが違います"
},
"placeholders": {
"username": "ユーザー名",
"email": "メールアドレス",
"password": "パスワード",
"password_confirm": "パスワード確認"
}
},
"weapon": {
"title": "武器変更",
"buttons": {
"confirm": "武器を変更する"
},
"subtitles": {
"element": "属性",
"ax_skills": "EXスキル",
"weapon_keys": "武器スキル"
}
}
},
"menu": {
"about": "このサイトについて",
"guides": "攻略",
"settings": {
"title": "アカウント設定",
"labels": {
"picture": "プロフィール画像",
"language": "言語",
"login": "ログイン",
"saved": "保存した編成",
"settings": "アカウント設定",
"signup": "登録",
"teams": "編成一覧",
"logout": "ログアウト"
"gender": "主人公",
"private": "プライベート"
},
"descriptions": {
"private": "プロフィールを隠し、編成をコレクションに表示されないようにします"
},
"gender": {
"gran": "グラン",
"djeeta": "ジータ"
},
"language": {
"english": "英語",
"japanese": "日本語"
},
"buttons": {
"confirm": "設定を保存する"
}
},
"party": {
"segmented_control": {
"class": "ジョブ",
"characters": "キャラ",
"weapons": "武器",
"summons": "召喚石"
}
"signup": {
"title": "アカウント登録",
"buttons": {
"confirm": "登録する"
},
"agreement": "続行することで<1>利用規約</1>に同意し、<br/><1>プライバシーポリシー</1>を読んだものとみなされます。",
"errors": {
"field_in_use": "入力された{{field}}は既に登録済みです",
"empty_email": "メールアドレスを入力して下さい",
"invalid_email": "メールアドレスは有効ではありません",
"username_too_short": "ユーザーネームは3文字以上で入力してください",
"username_too_long": "ユーザーネームは20文字以内で入力してください",
"empty_password": "パスワードを入力して下さい",
"password_contains_username": "パスワードにはユーザー名を含めないでください",
"password_too_short": "パスワードは8文字以上で入力してください",
"mismatched_passwords": "パスワードとパスワード確認を確かめてください",
"invalid_credentials": "パスワードまたはメールアドレスが違います"
},
"placeholders": {
"username": "ユーザー名",
"email": "メールアドレス",
"password": "パスワード",
"password_confirm": "パスワード確認"
}
},
"saved": {
"title": "保存した編成",
"loading": "ロード中...",
"not_found": "編成はまだ保存していません"
"weapon": {
"title": "武器変更",
"buttons": {
"confirm": "武器を変更する"
},
"subtitles": {
"element": "属性",
"ax_skills": "EXスキル",
"weapon_keys": "武器スキル"
}
}
},
"menu": {
"about": "このサイトについて",
"guides": "攻略",
"language": "言語",
"login": "ログイン",
"saved": "保存した編成",
"settings": "アカウント設定",
"signup": "登録",
"teams": "編成一覧",
"logout": "ログアウト"
},
"party": {
"segmented_control": {
"class": "ジョブ",
"characters": "キャラ",
"weapons": "武器",
"summons": "召喚石"
}
},
"saved": {
"title": "保存した編成",
"loading": "ロード中...",
"not_found": "編成はまだ保存していません"
},
"search": {
"recent": "最近追加した",
"result_count": "{{record_count}}件",
"errors": {
"start_typing": "{{object}}名を入力してください",
"min_length": "3文字以上を入力してください",
"no_results": "'{{query}}'の検索結果が見つかりませんでした",
"end_results": "検索結果これ以上ありません"
},
"search": {
"recent": "最近追加した",
"result_count": "{{record_count}}件",
"errors": {
"start_typing": "{{object}}名を入力してください",
"min_length": "3文字以上を入力してください",
"no_results": "'{{query}}'の検索結果が見つかりませんでした",
"end_results": "検索結果これ以上ありません"
},
"placeholders": {
"weapon": "武器を検索...",
"summon": "召喚石を検索...",
"character": "キャラを検索..."
}
},
"teams": {
"title": "編成一覧",
"loading": "ロード中...",
"not_found": "編成は見つかりませんでした"
},
"extra_weapons": "Additional<br/>Weapons",
"coming_soon": "開発中",
"no_title": "無題",
"no_raid": "マルチなし",
"no_user": "無名"
"placeholders": {
"weapon": "武器を検索...",
"summon": "召喚石を検索...",
"character": "キャラを検索..."
}
},
"teams": {
"title": "編成一覧",
"loading": "ロード中...",
"not_found": "編成は見つかりませんでした"
},
"extra_weapons": "Additional<br/>Weapons",
"coming_soon": "開発中",
"no_title": "無題",
"no_raid": "マルチなし",
"no_user": "無名"
}

5
types/AccountCookie.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
interface AccountCookie {
userId: string
username: string
token: string
}

27
types/Party.d.ts vendored
View file

@ -1,14 +1,15 @@
interface Party {
id: string
name: string
raid: Raid
shortcode: string
extra: boolean
favorited: boolean
characters: Array<GridCharacter>
weapons: Array<GridWeapon>
summons: Array<GridSummon>
user: User
created_at: string
updated_at: string
}
id: string
name: string
description: string
raid: Raid
shortcode: string
extra: boolean
favorited: boolean
characters: Array<GridCharacter>
weapons: Array<GridWeapon>
summons: Array<GridSummon>
user: User
created_at: string
updated_at: string
}

6
types/UserCookie.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
interface UserCookie {
picture: string
element: string
language: string
gender: number
}

View file

@ -1,130 +1,130 @@
interface RaidGroup {
name: {
[key: string]: string
en: string
ja: string
}
name: {
[key: string]: string
en: string
ja: string
}
}
export const raidGroups: RaidGroup[] = [
{
name: {
en: 'Assorted',
ja: 'その他'
}
{
name: {
en: "Assorted",
ja: "その他",
},
{
name: {
en: 'Guild Wars',
ja: '星の古戦場'
}
},
{
name: {
en: "Guild Wars",
ja: "星の古戦場",
},
{
name: {
en: 'Omega',
ja: 'マグナ'
}
},
{
name: {
en: "Omega",
ja: "マグナ",
},
{
name: {
en: 'T1 Summons',
ja: '召喚石マルチ1'
}
},
{
name: {
en: "T1 Summons",
ja: "召喚石マルチ1",
},
{
name: {
en: 'T2 Summons',
ja: '召喚石マルチ2'
}
},
{
name: {
en: "T2 Summons",
ja: "召喚石マルチ2",
},
{
name: {
en: 'Primarchs',
ja: '四大天使'
}
},
{
name: {
en: "Primarchs",
ja: "四大天使",
},
{
name: {
en: 'Nightmare',
ja: 'HELL'
}
},
{
name: {
en: "Nightmare",
ja: "HELL",
},
{
name: {
en: 'Omega (Impossible)',
ja: 'マグナHL'
}
},
{
name: {
en: "Omega (Impossible)",
ja: "マグナHL",
},
{
name: {
en: 'Omega II',
ja: 'マグナII'
}
},
{
name: {
en: "Omega II",
ja: "マグナII",
},
{
name: {
en: 'Tier 1 Summons (Impossible)',
ja: '旧召喚石HL'
}
},
{
name: {
en: "Tier 1 Summons (Impossible)",
ja: "旧召喚石HL",
},
{
name: {
en: 'Tier 3 Summons',
ja: 'エピックHL'
}
},
{
name: {
en: "Tier 3 Summons",
ja: "エピックHL",
},
{
name: {
en: 'Ennead',
ja: 'エニアド'
}
},
{
name: {
en: "Ennead",
ja: "エニアド",
},
{
name: {
en: 'Malice',
ja: 'マリス'
}
},
{
name: {
en: "Malice",
ja: "マリス",
},
{
name: {
en: '6-Star Raids',
ja: '★★★★★★'
}
},
{
name: {
en: "6-Star Raids",
ja: "★★★★★★",
},
{
name: {
en: 'Six-Dragons',
ja: '六竜HL'
}
},
{
name: {
en: "Six-Dragons",
ja: "六竜HL",
},
{
name: {
en: 'Nightmare (Impossible)',
ja: '高級HELL'
}
},
{
name: {
en: "Nightmare (Impossible)",
ja: "高級HELL",
},
{
name: {
en: 'Arcarum: Replicard Sandbox',
ja: 'アーカルム レプリカルド・サンドボックス'
}
},
{
name: {
en: "Arcarum: Replicard Sandbox",
ja: "アーカルム レプリカルド・サンドボックス",
},
{
name: {
en: 'Astrals',
ja: '星の民'
}
},
{
name: {
en: "Astrals",
ja: "星の民",
},
{
name: {
en: '10-Star Raids',
ja: '★★★★★★★★★★'
}
},
{
name: {
en: "Disaster",
ja: "災害",
},
{
name: {
en: 'Super Ultimate',
ja: 'スーパーアルティメット'
}
}
},
{
name: {
en: "Super Ultimate",
ja: "スーパーアルティメット",
},
},
]

View file

@ -0,0 +1,12 @@
import React, { useEffect, useRef } from "react"
const useDidMountEffect = (func: any, deps: any) => {
const didMount = useRef(false)
useEffect(() => {
if (didMount.current) func()
else didMount.current = true
}, deps)
}
export default useDidMountEffect