Merge pull request #8 from jedmund/radix

Add valtio state management and Radix components
This commit is contained in:
Justin Edmund 2022-02-23 14:15:59 -08:00 committed by GitHub
commit c1879d2277
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1006 additions and 676 deletions

View file

@ -1,10 +1,21 @@
.AboutModal p { .About.Dialog {
font-size: $font-regular; width: $unit * 60;
line-height: 1.24;
margin: 0;
margin-bottom: $unit * 2;
&:last-of-type { section {
margin-bottom: 0; margin-bottom: $unit;
h2 {
margin-bottom: $unit * 3;
}
}
.DialogDescription {
font-size: $font-regular;
line-height: 1.24;
margin-bottom: $unit;
&:last-of-type {
margin-bottom: 0;
}
} }
} }

View file

@ -1,35 +1,55 @@
import React from 'react' import React from 'react'
import { createPortal } from 'react-dom' import * as Dialog from '@radix-ui/react-dialog'
import api from '~utils/api'
import Modal from '~components/Modal'
import Overlay from '~components/Overlay'
import CrossIcon from '~public/icons/Cross.svg'
import './index.scss' import './index.scss'
interface Props { const AboutModal = () => {
close: () => void
}
const AboutModal = (props: Props) => {
return ( return (
createPortal( <Dialog.Root>
<div> <Dialog.Trigger asChild>
<Modal <li className="MenuItem">About</li>
title="About" </Dialog.Trigger>
styleName="AboutModal" <Dialog.Portal>
close={ () => {} } <Dialog.Content className="About Dialog" onOpenAutoFocus={ (event) => event.preventDefault() }>
> <div className="DialogHeader">
<div> <Dialog.Title className="DialogTitle">About</Dialog.Title>
<p>Siero is a tool to save and share parties for <a href="https://game.granbluefantasy.jp">Granblue Fantasy.</a></p> <Dialog.Close className="DialogClose" asChild>
<p>All you need to do to get started is start adding things. Siero will make a URL and you can share your party with the world.</p> <span>
<p>If you want to save your parties for safe keeping or to edit them later, you can make a free account.</p> <CrossIcon />
</span>
</Dialog.Close>
</div> </div>
</Modal>
<Overlay onClick={props.close} /> <section>
</div>, <Dialog.Description className="DialogDescription">
document.body Granblue.team is a tool to save and share team compositions for <a href="https://game.granbluefantasy.jp">Granblue Fantasy.</a>
) </Dialog.Description>
<Dialog.Description className="DialogDescription">
Start adding things to a team and a URL will be created for you to share it wherever you like, no account needed.
</Dialog.Description>
<Dialog.Description className="DialogDescription">
You can make an account to save any teams you find for future reference, or to keep all of your teams together in one place.
</Dialog.Description>
</section>
<section>
<Dialog.Title className="DialogTitle">Credits</Dialog.Title>
<Dialog.Description className="DialogDescription">
Granblue.team was built by <a href="https://twitter.com/jedmund">@jedmund</a> with a lot of help from <a href="https://twitter.com/lalalalinna">@lalalalinna</a> and <a href="https://twitter.com/tarngerine">@tarngerine</a>.
</Dialog.Description>
</section>
<section>
<Dialog.Title className="DialogTitle">Open Source</Dialog.Title>
<Dialog.Description className="DialogDescription">
This app is open source. You can contribute on Github.
</Dialog.Description>
</section>
</Dialog.Content>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>
</Dialog.Root>
) )
} }

View file

@ -0,0 +1,86 @@
import React, { MouseEventHandler, useContext, useEffect, useState } from 'react'
import { useCookies } from 'react-cookie'
import { useRouter } from 'next/router'
import AppContext from '~context/AppContext'
import * as AlertDialog from '@radix-ui/react-alert-dialog';
import Header from '~components/Header'
import Button from '~components/Button'
import { ButtonType } from '~utils/enums'
import CrossIcon from '~public/icons/Cross.svg'
const BottomHeader = () => {
const { editable, setEditable, authenticated, setAuthenticated } = useContext(AppContext)
const [username, setUsername] = useState(undefined)
const [cookies, _, removeCookie] = useCookies(['user'])
const router = useRouter()
useEffect(() => {
if (cookies.user) {
setAuthenticated(true)
setUsername(cookies.user.username)
} else {
setAuthenticated(false)
}
}, [cookies, setUsername, setAuthenticated])
function deleteTeam(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
// TODO: Implement deleting teams
console.log("Deleting team...")
}
const leftNav = () => {
return (
<Button icon="edit" click={() => {}}>Add more info</Button>
)
}
const rightNav = () => {
if (editable && router.route === '/p/[party]') {
return (
<AlertDialog.Root>
<AlertDialog.Trigger className="Button destructive">
<span className='icon'>
<CrossIcon />
</span>
<span className="text">Delete team</span>
</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay className="Overlay" />
<AlertDialog.Content className="Dialog">
<AlertDialog.Title className="DialogTitle">
Delete team
</AlertDialog.Title>
<AlertDialog.Description className="DialogDescription">
Are you sure you want to permanently delete this team?
</AlertDialog.Description>
<div className="actions">
<AlertDialog.Cancel className="Button modal">Nevermind</AlertDialog.Cancel>
<AlertDialog.Action className="Button modal destructive" onClick={(e) => deleteTeam(e)}>Yes, delete</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
)
} else {
return (<div />)
}
}
return (
<Header
position="bottom"
left={ leftNav() }
right={ rightNav() }
/>
)
}
export default BottomHeader

View file

@ -16,7 +16,33 @@
color: $grey-00; color: $grey-00;
.icon svg { .icon svg {
fill: $grey-50; fill: $grey-00;
}
.icon.stroke svg {
fill: none;
stroke: $grey-00;
}
}
&.destructive:hover {
background: $error;
color: white;
.icon svg {
fill: white;
}
}
&.modal:hover {
background: $grey-90;
}
&.modal.destructive {
color: $error;
&:hover {
color: darken($error, 10)
} }
} }
@ -28,6 +54,11 @@
height: 12px; height: 12px;
width: 12px; width: 12px;
} }
&.stroke svg {
fill: none;
stroke: $grey-50;
}
} }
&.btn-blue { &.btn-blue {

View file

@ -1,15 +1,22 @@
import React from 'react' import React from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import Link from 'next/link'
import AddIcon from '~public/icons/Add.svg' import AddIcon from '~public/icons/Add.svg'
import CrossIcon from '~public/icons/Cross.svg'
import EditIcon from '~public/icons/Edit.svg'
import LinkIcon from '~public/icons/Link.svg'
import MenuIcon from '~public/icons/Menu.svg' import MenuIcon from '~public/icons/Menu.svg'
import './index.scss' import './index.scss'
import { ButtonType } from '~utils/enums'
interface Props { interface Props {
color: string
disabled: boolean disabled: boolean
type: string | null icon: string | null
type: ButtonType
click: any click: any
} }
@ -19,9 +26,9 @@ interface State {
class Button extends React.Component<Props, State> { class Button extends React.Component<Props, State> {
static defaultProps: Props = { static defaultProps: Props = {
color: 'grey',
disabled: false, disabled: false,
type: null, icon: null,
type: ButtonType.Base,
click: () => {} click: () => {}
} }
@ -34,17 +41,25 @@ class Button extends React.Component<Props, State> {
render() { render() {
let icon let icon
if (this.props.type === 'new') { if (this.props.icon === 'new') {
icon = <span className='icon'> icon = <span className='icon'>
<AddIcon /> <AddIcon />
</span> </span>
} else if (this.props.type === 'menu') { } else if (this.props.icon === 'menu') {
icon = <span className='icon'> icon = <span className='icon'>
<MenuIcon /> <MenuIcon />
</span> </span>
} else if (this.props.type === 'link') { } else if (this.props.icon === 'link') {
icon = <span className='icon stroke'>
<LinkIcon />
</span>
} else if (this.props.icon === 'cross') {
icon = <span className='icon'> icon = <span className='icon'>
<img alt="" src="/icons/Link.svg" /> <CrossIcon />
</span>
} else if (this.props.icon === 'edit') {
icon = <span className='icon'>
<EditIcon />
</span> </span>
} }
@ -52,7 +67,7 @@ class Button extends React.Component<Props, State> {
Button: true, Button: true,
'btn-pressed': this.state.isPressed, 'btn-pressed': this.state.isPressed,
'btn-disabled': this.props.disabled, 'btn-disabled': this.props.disabled,
[`btn-${this.props.color}`]: true 'destructive': this.props.type == ButtonType.Destructive
}) })
return <button className={classes} disabled={this.props.disabled} onClick={this.props.click}> return <button className={classes} disabled={this.props.disabled} onClick={this.props.click}>

View file

@ -1,18 +1,17 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react' import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useCookies } from 'react-cookie' import { useCookies } from 'react-cookie'
import { useModal as useModal } from '~utils/useModal' import { useSnapshot } from 'valtio'
import { AxiosResponse } from 'axios' import { AxiosResponse } from 'axios'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
import AppContext from '~context/AppContext'
import PartyContext from '~context/PartyContext'
import CharacterUnit from '~components/CharacterUnit' import CharacterUnit from '~components/CharacterUnit'
import SearchModal from '~components/SearchModal' import SearchModal from '~components/SearchModal'
import api from '~utils/api' import api from '~utils/api'
import state from '~utils/state'
import './index.scss' import './index.scss'
// Props // Props
@ -35,44 +34,28 @@ const CharacterGrid = (props: Props) => {
} : {} } : {}
// Set up state for view management // Set up state for view management
const { party, grid } = useSnapshot(state)
const [slug, setSlug] = useState()
const [found, setFound] = useState(false) const [found, setFound] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const { id, setId } = useContext(PartyContext)
const { slug, setSlug } = useContext(PartyContext)
const { editable, setEditable } = useContext(AppContext)
// Set up states for Grid data
const [characters, setCharacters] = useState<GridArray<GridCharacter>>({})
// Set up states for Search
const { open, openModal, closeModal } = useModal()
const [itemPositionForSearch, setItemPositionForSearch] = useState(0)
// Create a temporary state to store previous character uncap values // Create a temporary state to store previous character uncap values
const [previousUncapValues, setPreviousUncapValues] = useState<{[key: number]: number}>({}) const [previousUncapValues, setPreviousUncapValues] = useState<{[key: number]: number}>({})
// Create a state dictionary to store pure objects for Search
const [searchGrid, setSearchGrid] = useState<GridArray<Character>>({})
// Fetch data from the server // Fetch data from the server
useEffect(() => { useEffect(() => {
const shortcode = (props.slug) ? props.slug : slug const shortcode = (props.slug) ? props.slug : slug
if (shortcode) fetchGrid(shortcode) if (shortcode) fetchGrid(shortcode)
else setEditable(true) else state.party.editable = true
}, [slug, props.slug]) }, [slug, props.slug])
// Initialize an array of current uncap values for each characters // Initialize an array of current uncap values for each characters
useEffect(() => { useEffect(() => {
let initialPreviousUncapValues: {[key: number]: number} = {} let initialPreviousUncapValues: {[key: number]: number} = {}
Object.values(characters).map(o => initialPreviousUncapValues[o.position] = o.uncap_level) Object.values(state.grid.characters).map(o => initialPreviousUncapValues[o.position] = o.uncap_level)
setPreviousUncapValues(initialPreviousUncapValues) setPreviousUncapValues(initialPreviousUncapValues)
}, [characters]) }, [state.grid.characters])
// Update search grid whenever characters are updated
useEffect(() => {
let newSearchGrid = Object.values(characters).map((o) => o.character)
setSearchGrid(newSearchGrid)
}, [characters])
// Methods: Fetching an object from the server // Methods: Fetching an object from the server
async function fetchGrid(shortcode: string) { async function fetchGrid(shortcode: string) {
@ -90,11 +73,12 @@ const CharacterGrid = (props: Props) => {
const loggedInUser = (cookies.user) ? cookies.user.user_id : '' const loggedInUser = (cookies.user) ? cookies.user.user_id : ''
if (partyUser != undefined && loggedInUser != undefined && partyUser === loggedInUser) { if (partyUser != undefined && loggedInUser != undefined && partyUser === loggedInUser) {
setEditable(true) party.editable = true
} }
// Store the important party and state-keeping values // Store the important party and state-keeping values
setId(party.id) state.party.id = party.id
setFound(true) setFound(true)
setLoading(false) setLoading(false)
@ -114,60 +98,48 @@ const CharacterGrid = (props: Props) => {
} }
function populateCharacters(list: [GridCharacter]) { function populateCharacters(list: [GridCharacter]) {
let characters: GridArray<GridCharacter> = {}
list.forEach((object: GridCharacter) => { list.forEach((object: GridCharacter) => {
if (object.position != null) if (object.position != null)
characters[object.position] = object state.grid.characters[object.position] = object
}) })
setCharacters(characters)
} }
// Methods: Adding an object from search // Methods: Adding an object from search
function openSearchModal(position: number) {
setItemPositionForSearch(position)
openModal()
}
function receiveCharacterFromSearch(object: Character | Weapon | Summon, position: number) { function receiveCharacterFromSearch(object: Character | Weapon | Summon, position: number) {
const character = object as Character const character = object as Character
if (!id) { if (!party.id) {
props.createParty() props.createParty()
.then(response => { .then(response => {
const party = response.data.party const party = response.data.party
setId(party.id) state.party.id = party.id
setSlug(party.shortcode) setSlug(party.shortcode)
if (props.pushHistory) props.pushHistory(`/p/${party.shortcode}`) if (props.pushHistory) props.pushHistory(`/p/${party.shortcode}`)
saveCharacter(party.id, character, position) saveCharacter(party.id, character, position)
.then(response => storeGridCharacter(response.data.grid_character)) .then(response => storeGridCharacter(response.data.grid_character))
.catch(error => console.error(error))
}) })
} else { } else {
saveCharacter(id, character, position) saveCharacter(party.id, character, position)
.then(response => storeGridCharacter(response.data.grid_character)) .then(response => storeGridCharacter(response.data.grid_character))
.catch(error => console.error(error))
} }
} }
async function saveCharacter(partyId: string, character: Character, position: number) { async function saveCharacter(partyId: string, character: Character, position: number) {
return await api.endpoints.characters.create({ return await api.endpoints.characters.create({
'character': { 'character': {
'party_id': partyId, 'party_id': partyId,
'character_id': character.id, 'character_id': character.id,
'position': position, 'position': position,
'mainhand': (position == -1),
'uncap_level': characterUncapLevel(character) 'uncap_level': characterUncapLevel(character)
} }
}, headers) }, headers)
} }
function storeGridCharacter(gridCharacter: GridCharacter) { function storeGridCharacter(gridCharacter: GridCharacter) {
// Store the grid unit at the correct position state.grid.characters[gridCharacter.position] = gridCharacter
let newCharacters = Object.assign({}, characters)
newCharacters[gridCharacter.position] = gridCharacter
setCharacters(newCharacters)
} }
// Methods: Helpers // Methods: Helpers
@ -209,39 +181,37 @@ const CharacterGrid = (props: Props) => {
} }
} }
const initiateUncapUpdate = useCallback( function initiateUncapUpdate(id: string, position: number, uncapLevel: number) {
(id: string, position: number, uncapLevel: number) => { memoizeAction(id, position, uncapLevel)
memoizeAction(id, position, uncapLevel)
// Optimistically update UI // Optimistically update UI
updateUncapLevel(position, uncapLevel) updateUncapLevel(position, uncapLevel)
}, [previousUncapValues, characters] }
)
const memoizeAction = useCallback( const memoizeAction = useCallback(
(id: string, position: number, uncapLevel: number) => { (id: string, position: number, uncapLevel: number) => {
debouncedAction(id, position, uncapLevel) debouncedAction(id, position, uncapLevel)
}, [characters] }, [props, previousUncapValues]
) )
const debouncedAction = useMemo(() => const debouncedAction = useMemo(() =>
debounce((id, position, number) => { debounce((id, position, number) => {
saveUncap(id, position, number) saveUncap(id, position, number)
}, 500), [characters, saveUncap] }, 500), [props, saveUncap]
) )
const updateUncapLevel = (position: number, uncapLevel: number) => { const updateUncapLevel = (position: number, uncapLevel: number) => {
let newCharacters = {...characters} state.grid.characters[position].uncap_level = uncapLevel
newCharacters[position].uncap_level = uncapLevel
setCharacters(newCharacters)
} }
function storePreviousUncapValue(position: number) { function storePreviousUncapValue(position: number) {
// Save the current value in case of an unexpected result // Save the current value in case of an unexpected result
let newPreviousValues = {...previousUncapValues} let newPreviousValues = {...previousUncapValues}
newPreviousValues[position] = characters[position].uncap_level
setPreviousUncapValues(newPreviousValues) if (grid.characters[position]) {
newPreviousValues[position] = grid.characters[position].uncap_level
setPreviousUncapValues(newPreviousValues)
}
} }
// Render: JSX components // Render: JSX components
@ -252,26 +222,15 @@ const CharacterGrid = (props: Props) => {
return ( return (
<li key={`grid_unit_${i}`} > <li key={`grid_unit_${i}`} >
<CharacterUnit <CharacterUnit
gridCharacter={characters[i]} gridCharacter={grid.characters[i]}
editable={editable} editable={party.editable}
position={i} position={i}
onClick={() => { openSearchModal(i) }} updateObject={receiveCharacterFromSearch}
updateUncap={initiateUncapUpdate} updateUncap={initiateUncapUpdate}
/> />
</li> </li>
) )
})} })}
{open ? (
<SearchModal
grid={searchGrid}
close={closeModal}
send={receiveCharacterFromSearch}
fromPosition={itemPositionForSearch}
object="characters"
placeholderText="Search for a character..."
/>
) : null}
</ul> </ul>
</div> </div>
) )

View file

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import SearchModal from '~components/SearchModal'
import UncapIndicator from '~components/UncapIndicator' import UncapIndicator from '~components/UncapIndicator'
import PlusIcon from '~public/icons/Add.svg' import PlusIcon from '~public/icons/Add.svg'
@ -10,7 +11,7 @@ interface Props {
gridCharacter: GridCharacter | undefined gridCharacter: GridCharacter | undefined
position: number position: number
editable: boolean editable: boolean
onClick: () => void updateObject: (object: Character | Weapon | Summon, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void updateUncap: (id: string, position: number, uncap: number) => void
} }
@ -24,7 +25,7 @@ const CharacterUnit = (props: Props) => {
}) })
const gridCharacter = props.gridCharacter const gridCharacter = props.gridCharacter
const character = gridCharacter?.character const character = gridCharacter?.object
useEffect(() => { useEffect(() => {
generateImageUrl() generateImageUrl()
@ -34,7 +35,7 @@ const CharacterUnit = (props: Props) => {
let imgSrc = "" let imgSrc = ""
if (props.gridCharacter) { if (props.gridCharacter) {
const character = props.gridCharacter.character! const character = props.gridCharacter.object!
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-main/${character.granblue_id}_01.jpg` imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-main/${character.granblue_id}_01.jpg`
} }
@ -49,10 +50,17 @@ const CharacterUnit = (props: Props) => {
return ( return (
<div> <div>
<div className={classes}> <div className={classes}>
<div className="CharacterImage" onClick={ (props.editable) ? props.onClick : () => {} }> <SearchModal
<img alt={character?.name.en} className="grid_image" src={imageUrl} /> placeholderText="Search for a character..."
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' } fromPosition={props.position}
</div> object="characters"
send={props.updateObject}>
<div className="CharacterImage">
<img alt={character?.name.en} className="grid_image" src={imageUrl} />
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' }
</div>
</SearchModal>
{ (gridCharacter && character) ? { (gridCharacter && character) ?
<UncapIndicator <UncapIndicator
type="character" type="character"

View file

@ -2,14 +2,6 @@ import React from 'react'
import SummonUnit from '~components/SummonUnit' import SummonUnit from '~components/SummonUnit'
import './index.scss' import './index.scss'
// GridType
export enum GridType {
Class,
Character,
Weapon,
Summon
}
// Props // Props
interface Props { interface Props {
grid: GridArray<GridSummon> grid: GridArray<GridSummon>
@ -17,7 +9,7 @@ interface Props {
exists: boolean exists: boolean
found?: boolean found?: boolean
offset: number offset: number
onClick: (position: number) => void updateObject: (object: Character | Weapon | Summon, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void updateUncap: (id: string, position: number, uncap: number) => void
} }
@ -37,7 +29,7 @@ const ExtraSummons = (props: Props) => {
position={props.offset + i} position={props.offset + i}
unitType={1} unitType={1}
gridSummon={props.grid[props.offset + i]} gridSummon={props.grid[props.offset + i]}
onClick={() => { props.onClick(props.offset + i) }} updateObject={props.updateObject}
updateUncap={props.updateUncap} updateUncap={props.updateUncap}
/> />
</li> </li>

View file

@ -3,21 +3,13 @@ import WeaponUnit from '~components/WeaponUnit'
import './index.scss' import './index.scss'
// GridType
export enum GridType {
Class,
Character,
Weapon,
Summon
}
// Props // Props
interface Props { interface Props {
grid: GridArray<GridWeapon> grid: GridArray<GridWeapon>
editable: boolean editable: boolean
found?: boolean found?: boolean
offset: number offset: number
onClick: (position: number) => void updateObject: (object: Character | Weapon | Summon, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void updateUncap: (id: string, position: number, uncap: number) => void
} }
@ -37,7 +29,7 @@ const ExtraWeapons = (props: Props) => {
position={props.offset + i} position={props.offset + i}
unitType={1} unitType={1}
gridWeapon={props.grid[props.offset + i]} gridWeapon={props.grid[props.offset + i]}
onClick={() => { props.onClick(props.offset + i)}} updateObject={props.updateObject}
updateUncap={props.updateUncap} updateUncap={props.updateUncap}
/> />
</li> </li>

View file

@ -19,9 +19,9 @@ const GridRep = (props: Props) => {
for (const [key, value] of Object.entries(props.grid)) { for (const [key, value] of Object.entries(props.grid)) {
if (value.position == -1) if (value.position == -1)
setMainhand(value.weapon) setMainhand(value.object)
else if (!value.mainhand && value.position != null) else if (!value.mainhand && value.position != null)
newWeapons[value.position] = value.weapon newWeapons[value.position] = value.object
} }
setWeapons(newWeapons) setWeapons(newWeapons)

View file

@ -1,8 +1,13 @@
#Header { .Header {
display: flex; display: flex;
height: 34px; height: 34px;
width: 100%; width: 100%;
&.bottom {
position: sticky;
bottom: $unit * 2;
}
#right { #right {
display: flex; display: flex;
gap: 8px; gap: 8px;

View file

@ -1,82 +1,19 @@
import React, { useContext, useEffect, useState } from 'react' import React from 'react'
import { useCookies } from 'react-cookie'
import { useRouter } from 'next/router'
import AppContext from '~context/AppContext'
import Button from '~components/Button'
import HeaderMenu from '~components/HeaderMenu'
import './index.scss' import './index.scss'
interface Props {} interface Props {
position: 'top' | 'bottom'
left: JSX.Element,
right: JSX.Element
}
const Header = (props: Props) => { const Header = (props: Props) => {
const { editable, setEditable, authenticated, setAuthenticated } = useContext(AppContext)
const [username, setUsername] = useState(undefined)
const [cookies, _, removeCookie] = useCookies(['user'])
const router = useRouter()
useEffect(() => {
if (cookies.user) {
setAuthenticated(true)
setUsername(cookies.user.username)
console.log(`Logged in as user "${cookies.user.username}"`)
} else {
setAuthenticated(false)
console.log('You are currently not logged in.')
}
}, [cookies, setUsername, setAuthenticated])
function copyToClipboard() {
const el = document.createElement('input')
el.value = window.location.href
el.id = 'url-input'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
el.remove()
}
function newParty() {
router.push('/')
}
function logout() {
removeCookie('user')
setAuthenticated(false)
if (editable) setEditable(false)
// How can we log out without navigating to root
router.push('/')
return false
}
return ( return (
<nav id="Header"> <nav className={`Header ${props.position}`}>
<div id="left"> <div id="left">{ props.left }</div>
<div className="dropdown">
<Button type="menu">Menu</Button>
{ (username) ?
<HeaderMenu authenticated={authenticated} username={username} logout={logout} /> :
<HeaderMenu authenticated={authenticated} />
}
</div>
</div>
<div className="push" /> <div className="push" />
<div id="right"> <div id="right">{ props.right }</div>
{ (editable && router.route === '/p/[slug]') ?
<Button color="red" type="link" click={() => {}}>Delete</Button> : ''
}
{ (router.route === '/p/[slug]') ?
<Button type="link" click={copyToClipboard}>Copy link</Button> : ''
}
<Button type="new" click={newParty}>New</Button>
</div>
</nav> </nav>
) )
} }

View file

@ -38,7 +38,7 @@ const HeaderMenu = (props: Props) => {
</div> </div>
<div className="MenuGroup"> <div className="MenuGroup">
<li className="MenuItem"> <li className="MenuItem">
<Link href='/teans'>Teams</Link> <Link href='/teams'>Teams</Link>
</li> </li>
<li className="MenuItem"> <li className="MenuItem">
@ -46,10 +46,7 @@ const HeaderMenu = (props: Props) => {
</li> </li>
</div> </div>
<div className="MenuGroup"> <div className="MenuGroup">
<li className="MenuItem" onClick={openAboutModal}>About</li> <AboutModal />
{aboutOpen ? (
<AboutModal close={closeAboutModal} />
) : null}
<li className="MenuItem">Settings</li> <li className="MenuItem">Settings</li>
<li className="MenuItem" onClick={props.logout}>Logout</li> <li className="MenuItem" onClick={props.logout}>Logout</li>
</div> </div>
@ -62,14 +59,11 @@ const HeaderMenu = (props: Props) => {
return ( return (
<ul className="Menu unauth"> <ul className="Menu unauth">
<div className="MenuGroup"> <div className="MenuGroup">
<li className="MenuItem" onClick={openAboutModal}>About</li> <AboutModal />
{aboutOpen ? (
<AboutModal close={closeAboutModal} />
) : null}
</div> </div>
<div className="MenuGroup"> <div className="MenuGroup">
<li className="MenuItem"> <li className="MenuItem">
<Link href='/teans'>Teams</Link> <Link href='/teams'>Teams</Link>
</li> </li>
<li className="MenuItem"> <li className="MenuItem">

View file

@ -1,5 +1,6 @@
import type { ReactElement } from 'react' import type { ReactElement } from 'react'
import Header from '~components/Header' import TopHeader from '~components/TopHeader'
import BottomHeader from '~components/BottomHeader'
interface Props { interface Props {
children: ReactElement children: ReactElement
@ -8,8 +9,9 @@ interface Props {
const Layout = ({children}: Props) => { const Layout = ({children}: Props) => {
return ( return (
<> <>
<Header /> <TopHeader />
<main>{children}</main> <main>{children}</main>
<BottomHeader />
</> </>
) )
} }

View file

@ -22,9 +22,9 @@
overflow-y: auto; overflow-y: auto;
padding: $unit * 3; padding: $unit * 3;
position: relative; position: relative;
z-index: 10; z-index: 21;
#ModalTop { #ModalHeader {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;

View file

@ -1,12 +1,12 @@
.Overlay { // .Overlay {
background: black; // background: black;
position: absolute; // position: absolute;
opacity: 0.28; // opacity: 0.28;
height: 100%; // height: 100%;
width: 100%; // width: 100%;
top: 0; // top: 0;
left: 0; // left: 0;
z-index: 2;
} // }

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useSnapshot } from 'valtio'
import { useCookies } from 'react-cookie' import { useCookies } from 'react-cookie'
import PartyContext from '~context/PartyContext'
import PartySegmentedControl from '~components/PartySegmentedControl' import PartySegmentedControl from '~components/PartySegmentedControl'
import WeaponGrid from '~components/WeaponGrid' import WeaponGrid from '~components/WeaponGrid'
@ -9,18 +9,11 @@ import SummonGrid from '~components/SummonGrid'
import CharacterGrid from '~components/CharacterGrid' import CharacterGrid from '~components/CharacterGrid'
import api from '~utils/api' import api from '~utils/api'
import { TeamElement } from '~utils/enums' import state from '~utils/state'
import { GridType, TeamElement } from '~utils/enums'
import './index.scss' import './index.scss'
// GridType
enum GridType {
Class,
Character,
Weapon,
Summon
}
// Props // Props
interface Props { interface Props {
slug?: string slug?: string
@ -37,12 +30,8 @@ const Party = (props: Props) => {
} : {} } : {}
// Set up states // Set up states
const { party } = useSnapshot(state)
const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon) const [currentTab, setCurrentTab] = useState<GridType>(GridType.Weapon)
const [id, setId] = useState('')
const [slug, setSlug] = useState('')
const [element, setElement] = useState<TeamElement>(TeamElement.Any)
const [editable, setEditable] = useState(false)
const [hasExtra, setHasExtra] = useState(false)
// Methods: Creating a new party // Methods: Creating a new party
async function createParty(extra: boolean = false) { async function createParty(extra: boolean = false) {
@ -58,10 +47,12 @@ const Party = (props: Props) => {
// Methods: Updating the party's extra flag // Methods: Updating the party's extra flag
function checkboxChanged(event: React.ChangeEvent<HTMLInputElement>) { function checkboxChanged(event: React.ChangeEvent<HTMLInputElement>) {
setHasExtra(event.target.checked) if (party.id) {
api.endpoints.parties.update(id, { state.party.extra = event.target.checked
'party': { 'is_extra': event.target.checked } api.endpoints.parties.update(party.id, {
}, headers) 'party': { 'is_extra': event.target.checked }
}, headers)
}
} }
// Methods: Navigating with segmented control // Methods: Navigating with segmented control
@ -130,10 +121,8 @@ const Party = (props: Props) => {
return ( return (
<div> <div>
<PartyContext.Provider value={{ id, setId, slug, setSlug, element, setElement, editable, setEditable, hasExtra, setHasExtra }}> { navigation }
{ navigation } { currentGrid() }
{ currentGrid() }
</PartyContext.Provider>
</div> </div>
) )
} }

View file

@ -1,19 +1,14 @@
import React, { useContext } from 'react' import React, { useContext } from 'react'
import './index.scss' import './index.scss'
import PartyContext from '~context/PartyContext' import state from '~utils/state'
import SegmentedControl from '~components/SegmentedControl' import SegmentedControl from '~components/SegmentedControl'
import Segment from '~components/Segment' import Segment from '~components/Segment'
import ToggleSwitch from '~components/ToggleSwitch' import ToggleSwitch from '~components/ToggleSwitch'
// GridType import { GridType } from '~utils/enums'
export enum GridType { import { useSnapshot } from 'valtio'
Class,
Character,
Weapon,
Summon
}
interface Props { interface Props {
selectedTab: GridType selectedTab: GridType
@ -22,10 +17,10 @@ interface Props {
} }
const PartySegmentedControl = (props: Props) => { const PartySegmentedControl = (props: Props) => {
const { editable, element, hasExtra } = useContext(PartyContext) const { party } = useSnapshot(state)
function getElement() { function getElement() {
switch(element) { switch(party.element) {
case 1: return "wind"; break case 1: return "wind"; break
case 2: return "fire"; break case 2: return "fire"; break
case 3: return "water"; break case 3: return "water"; break
@ -40,8 +35,8 @@ const PartySegmentedControl = (props: Props) => {
Extra Extra
<ToggleSwitch <ToggleSwitch
name="ExtraSwitch" name="ExtraSwitch"
editable={editable} editable={party.editable}
checked={hasExtra} checked={party.extra}
onChange={props.onCheckboxChange} onChange={props.onCheckboxChange}
/> />
</div> </div>
@ -80,7 +75,7 @@ const PartySegmentedControl = (props: Props) => {
{ {
(() => { (() => {
if (editable && props.selectedTab == GridType.Weapon) { if (party.editable && props.selectedTab == GridType.Weapon) {
return extraToggle return extraToggle
} }
})() })()

View file

@ -1,11 +1,11 @@
.ModalContainer .Modal.SearchModal { .Modal.Search {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 420px; min-height: 420px;
min-width: 600px; min-width: 600px;
padding: 0; padding: 0;
#ModalTop { #ModalHeader {
background: $grey-90; background: $grey-90;
gap: $unit; gap: $unit;
margin: 0; margin: 0;
@ -13,12 +13,28 @@
position: sticky; position: sticky;
top: 0; top: 0;
button {
background: transparent;
border: none;
height: 42px;
padding: 0;
svg {
height: 24px;
width: 24px;
vertical-align: middle;
}
}
label { label {
width: 100%; width: 100%;
.Input { .Input {
border: 1px solid $grey-70;
border-radius: $unit / 2;
box-sizing: border-box; box-sizing: border-box;
padding: 12px 8px; font-size: $font-regular;
padding: $unit * 1.5;
text-align: left; text-align: left;
width: 100%; width: 100%;
} }
@ -26,13 +42,13 @@
} }
} }
.SearchModal #results_container { .Search.Modal #results_container {
margin: 0; margin: 0;
max-height: 330px; max-height: 330px;
padding: 0 12px 12px 12px; padding: 0 12px 12px 12px;
} }
.SearchModal #NoResults { .Search.Modal #NoResults {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -40,7 +56,7 @@
flex-grow: 1; flex-grow: 1;
} }
.SearchModal #NoResults h2 { .Search.Modal #NoResults h2 {
color: #ccc; color: #ccc;
font-size: $font-large; font-size: $font-large;
font-weight: 500; font-weight: 500;

View file

@ -1,10 +1,11 @@
import React from 'react' import React, { useEffect, useState } from 'react'
import { useSnapshot } from 'valtio'
import { createPortal } from 'react-dom' import state from '~utils/state'
import api from '~utils/api' import api from '~utils/api'
import Modal from '~components/Modal' import * as Dialog from '@radix-ui/react-dialog'
import Overlay from '~components/Overlay'
import CharacterResult from '~components/CharacterResult' import CharacterResult from '~components/CharacterResult'
import WeaponResult from '~components/WeaponResult' import WeaponResult from '~components/WeaponResult'
import SummonResult from '~components/SummonResult' import SummonResult from '~components/SummonResult'
@ -13,138 +14,143 @@ import './index.scss'
import PlusIcon from '~public/icons/Add.svg' import PlusIcon from '~public/icons/Add.svg'
interface Props { interface Props {
close: () => void
send: (object: Character | Weapon | Summon, position: number) => any send: (object: Character | Weapon | Summon, position: number) => any
grid: GridArray<Character|Weapon|Summon>
placeholderText: string placeholderText: string
fromPosition: number fromPosition: number
object: 'weapons' | 'characters' | 'summons' object: 'weapons' | 'characters' | 'summons',
children: React.ReactNode
} }
interface State { const SearchModal = (props: Props) => {
query: string, let { grid } = useSnapshot(state)
results: { [key: string]: any }
loading: boolean
message: string
totalResults: number
}
class SearchModal extends React.Component<Props, State> { let searchInput = React.createRef<HTMLInputElement>()
searchInput: React.RefObject<HTMLInputElement>
constructor(props: Props) { const [objects, setObjects] = useState<{[id: number]: GridCharacter | GridWeapon | GridSummon}>()
super(props) const [open, setOpen] = useState(false)
this.state = { const [query, setQuery] = useState('')
query: '', const [results, setResults] = useState({})
results: {}, const [loading, setLoading] = useState(false)
loading: false, const [message, setMessage] = useState('')
message: '', const [totalResults, setTotalResults] = useState(0)
totalResults: 0
}
this.searchInput = React.createRef<HTMLInputElement>()
}
componentDidMount() { useEffect(() => {
if (this.searchInput.current) { setObjects(grid[props.object])
this.searchInput.current.focus() }, [grid, props.object])
}
}
filterExclusions = (o: Character | Weapon | Summon) => { useEffect(() => {
if (this.props.grid[this.props.fromPosition] && if (searchInput.current)
o.granblue_id == this.props.grid[this.props.fromPosition].granblue_id) { searchInput.current.focus()
return null }, [searchInput])
} else return o
}
fetchResults = (query: string) => { function inputChanged(event: React.ChangeEvent<HTMLInputElement>) {
const excludes = Object.values(this.props.grid).filter(this.filterExclusions).map((o) => { return o.name.en }).join(',') const text = event.target.value
if (text.length) {
setQuery(text)
setLoading(true)
setMessage('')
api.search(this.props.object, query, excludes) if (text.length > 2)
.then((response) => { fetchResults()
const data = response.data
const totalResults = data.length
this.setState({
results: data,
totalResults: totalResults,
loading: false
})
}, (error) => {
this.setState({
loading: false,
message: error
})
})
}
inputChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
const query = event.target.value
if (query.length) {
this.setState({ query, loading: true, message: '' }, () => {
this.fetchResults(query)
})
} else { } else {
this.setState({ query, results: {}, message: '' }) setQuery('')
setResults({})
setMessage('')
} }
} }
function fetchResults() {
// Exclude objects in grid from search results
// unless the object is in the position that the user clicked
// so that users can replace object versions with
// compatible other objects.
const excludes = (objects) ? Object.values(objects)
.filter(filterExclusions)
.map((o) => { return (o.object) ? o.object.name.en : undefined }).join(',') : ''
sendData = (result: Character | Weapon | Summon) => { api.search(props.object, query, excludes)
this.props.send(result, this.props.fromPosition) .then(response => {
this.props.close() setResults(response.data)
setTotalResults(response.data.length)
setLoading(false)
})
.catch(error => {
setMessage(error)
setLoading(false)
})
} }
renderSearchResults = () => { function filterExclusions(gridObject: GridCharacter | GridWeapon | GridSummon) {
const { results } = this.state if (objects && gridObject.object &&
gridObject.object.granblue_id === objects[props.fromPosition]?.object.granblue_id)
switch(this.props.object) { return null
else return gridObject
}
function sendData(result: Character | Weapon | Summon) {
props.send(result, props.fromPosition)
setOpen(false)
}
function renderResults() {
switch(props.object) {
case 'weapons': case 'weapons':
return this.renderWeaponSearchResults(results) return renderWeaponSearchResults(results)
break
case 'summons': case 'summons':
return this.renderSummonSearchResults(results) return renderSummonSearchResults(results)
break
case 'characters': case 'characters':
return this.renderCharacterSearchResults(results) return renderCharacterSearchResults(results)
break
} }
} }
renderWeaponSearchResults = (results: { [key: string]: any }) => { function renderWeaponSearchResults(results: { [key: string]: any }) {
return ( const elements = results.map((result: Weapon) => {
<ul id="results_container"> return <WeaponResult
{ results.map( (result: Weapon) => { key={result.id}
return <WeaponResult key={result.id} data={result} onClick={() => { this.sendData(result) }} /> data={result}
})} onClick={() => { sendData(result) }}
</ul> />
) })
return (<ul id="results_container">{elements}</ul>)
} }
renderSummonSearchResults = (results: { [key: string]: any }) => { function renderSummonSearchResults(results: { [key: string]: any }) {
return ( const elements = results.map((result: Summon) => {
<ul id="results_container"> return <SummonResult
{ results.map( (result: Summon) => { key={result.id}
return <SummonResult key={result.id} data={result} onClick={() => { this.sendData(result) }} /> data={result}
})} onClick={() => { sendData(result) }}
</ul> />
) })
return (<ul id="results_container">{elements}</ul>)
} }
renderCharacterSearchResults = (results: { [key: string]: any }) => { function renderCharacterSearchResults(results: { [key: string]: any }) {
return ( const elements = results.map((result: Character) => {
<ul id="results_container"> return <CharacterResult
{ results.map( (result: Character) => { key={result.id}
return <CharacterResult key={result.id} data={result} onClick={() => { this.sendData(result) }} /> data={result}
})} onClick={() => { sendData(result) }}
</ul> />
) })
return (<ul id="results_container">{elements}</ul>)
} }
renderEmptyState = () => { function renderEmptyState() {
let string = '' let string = ''
if (this.state.query === '') { if (query === '') {
string = `No ${this.props.object}` string = `No ${props.object}`
} else if (query.length < 3) {
string = `Type at least 3 characters`
} else { } else {
string = `No results found for '${this.state.query}'` string = `No results found for '${query}'`
} }
return ( return (
@ -153,48 +159,46 @@ class SearchModal extends React.Component<Props, State> {
</div> </div>
) )
} }
render() { function resetAndClose() {
const { query, loading } = this.state setQuery('')
setResults({})
let content: JSX.Element setOpen(true)
if (Object.entries(this.state.results).length == 0) {
content = this.renderEmptyState()
} else {
content = this.renderSearchResults()
}
return (
createPortal(
<div>
<div className="ModalContainer">
<div className="Modal SearchModal" key="search_modal">
<div id="ModalTop">
<label className="search_label" htmlFor="search_input">
<input
autoComplete="off"
type="text"
name="query"
className="Input"
id="search_input"
ref={this.searchInput}
value={query}
placeholder={this.props.placeholderText}
onChange={this.inputChanged}
/>
</label>
<PlusIcon onClick={this.props.close} />
</div>
{content}
</div>
</div>
<Overlay onClick={this.props.close} />
</div>,
document.body
)
)
} }
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
{props.children}
</Dialog.Trigger>
<Dialog.Portal>
<div className="ModalContainer">
<Dialog.Content className="Search Modal">
<div id="ModalHeader">
<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 onClick={resetAndClose}>
<PlusIcon />
</Dialog.Close>
</div>
{ ((Object.entries(results).length == 0) ? renderEmptyState() : renderResults()) }
</Dialog.Content>
</div>
<Dialog.Overlay className="Overlay" />
</Dialog.Portal>
</Dialog.Root>
)
} }
export default SearchModal export default SearchModal

View file

@ -1,19 +1,17 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react' import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useCookies } from 'react-cookie' import { useCookies } from 'react-cookie'
import { useModal as useModal } from '~utils/useModal' import { useSnapshot } from 'valtio'
import { AxiosResponse } from 'axios' import { AxiosResponse } from 'axios'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
import AppContext from '~context/AppContext'
import PartyContext from '~context/PartyContext'
import SearchModal from '~components/SearchModal'
import SummonUnit from '~components/SummonUnit' import SummonUnit from '~components/SummonUnit'
import ExtraSummons from '~components/ExtraSummons' import ExtraSummons from '~components/ExtraSummons'
import api from '~utils/api' import api from '~utils/api'
import state from '~utils/state'
import './index.scss' import './index.scss'
// Props // Props
@ -36,55 +34,37 @@ const SummonGrid = (props: Props) => {
} : {} } : {}
// Set up state for view management // Set up state for view management
const { party, grid } = useSnapshot(state)
const [slug, setSlug] = useState()
const [found, setFound] = useState(false) const [found, setFound] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const { id, setId } = useContext(PartyContext)
const { slug, setSlug } = useContext(PartyContext)
const { editable, setEditable } = useContext(AppContext)
// Set up states for Grid data
const [summons, setSummons] = useState<GridArray<GridSummon>>({})
const [mainSummon, setMainSummon] = useState<GridSummon>()
const [friendSummon, setFriendSummon] = useState<GridSummon>()
// Set up states for Search
const { open, openModal, closeModal } = useModal()
const [itemPositionForSearch, setItemPositionForSearch] = useState(0)
// Create a temporary state to store previous weapon uncap value // Create a temporary state to store previous weapon uncap value
const [previousUncapValues, setPreviousUncapValues] = useState<{[key: number]: number}>({}) const [previousUncapValues, setPreviousUncapValues] = useState<{[key: number]: number}>({})
// Create a state dictionary to store pure objects for Search
const [searchGrid, setSearchGrid] = useState<GridArray<Summon>>({})
// Fetch data from the server // Fetch data from the server
useEffect(() => { useEffect(() => {
const shortcode = (props.slug) ? props.slug : slug const shortcode = (props.slug) ? props.slug : slug
if (shortcode) fetchGrid(shortcode) if (shortcode) fetchGrid(shortcode)
else setEditable(true) else state.party.editable = true
}, [slug, props.slug]) }, [slug, props.slug])
// Initialize an array of current uncap values for each summon // Initialize an array of current uncap values for each summon
useEffect(() => { useEffect(() => {
let initialPreviousUncapValues: {[key: number]: number} = {} let initialPreviousUncapValues: {[key: number]: number} = {}
if (mainSummon) initialPreviousUncapValues[-1] = mainSummon.uncap_level
if (friendSummon) initialPreviousUncapValues[6] = friendSummon.uncap_level if (state.grid.summons.mainSummon)
Object.values(summons).map(o => initialPreviousUncapValues[o.position] = o.uncap_level) initialPreviousUncapValues[-1] = state.grid.summons.mainSummon.uncap_level
if (state.grid.summons.friendSummon)
initialPreviousUncapValues[6] = state.grid.summons.friendSummon.uncap_level
Object.values(state.grid.summons.allSummons).map(o => initialPreviousUncapValues[o.position] = o.uncap_level)
setPreviousUncapValues(initialPreviousUncapValues) setPreviousUncapValues(initialPreviousUncapValues)
}, [summons, mainSummon, friendSummon]) }, [state.grid.summons.mainSummon, state.grid.summons.friendSummon, state.grid.summons.allSummons])
// Update search grid whenever any summon is updated
useEffect(() => {
let newSearchGrid = Object.values(summons).map((o) => o.summon)
if (mainSummon)
newSearchGrid.unshift(mainSummon.summon)
if (friendSummon)
newSearchGrid.unshift(friendSummon.summon)
setSearchGrid(newSearchGrid)
}, [summons, mainSummon, friendSummon])
// Methods: Fetching an object from the server // Methods: Fetching an object from the server
async function fetchGrid(shortcode: string) { async function fetchGrid(shortcode: string) {
@ -102,11 +82,12 @@ const SummonGrid = (props: Props) => {
const loggedInUser = (cookies.user) ? cookies.user.user_id : '' const loggedInUser = (cookies.user) ? cookies.user.user_id : ''
if (partyUser != undefined && loggedInUser != undefined && partyUser === loggedInUser) { if (partyUser != undefined && loggedInUser != undefined && partyUser === loggedInUser) {
setEditable(true) state.party.editable = true
} }
// Store the important party and state-keeping values // Store the important party and state-keeping values
setId(party.id) state.party.id = party.id
setFound(true) setFound(true)
setLoading(false) setLoading(false)
@ -126,42 +107,34 @@ const SummonGrid = (props: Props) => {
} }
function populateSummons(list: [GridSummon]) { function populateSummons(list: [GridSummon]) {
let summons: GridArray<GridSummon> = {} list.forEach((gridObject: GridSummon) => {
if (gridObject.main)
list.forEach((object: GridSummon) => { state.grid.summons.mainSummon = gridObject
if (object.main) else if (gridObject.friend)
setMainSummon(object) state.grid.summons.friendSummon = gridObject
else if (object.friend) else if (!gridObject.main && !gridObject.friend && gridObject.position != null)
setFriendSummon(object) state.grid.summons.allSummons[gridObject.position] = gridObject
else if (!object.main && !object.friend && object.position != null)
summons[object.position] = object
}) })
setSummons(summons)
} }
// Methods: Adding an object from search // Methods: Adding an object from search
function openSearchModal(position: number) {
setItemPositionForSearch(position)
openModal()
}
function receiveSummonFromSearch(object: Character | Weapon | Summon, position: number) { function receiveSummonFromSearch(object: Character | Weapon | Summon, position: number) {
const summon = object as Summon const summon = object as Summon
if (!id) { if (!party.id) {
props.createParty() props.createParty()
.then(response => { .then(response => {
const party = response.data.party const party = response.data.party
setId(party.id) state.party.id = party.id
setSlug(party.shortcode) setSlug(party.shortcode)
if (props.pushHistory) props.pushHistory(`/p/${party.shortcode}`) if (props.pushHistory) props.pushHistory(`/p/${party.shortcode}`)
saveSummon(party.id, summon, position) saveSummon(party.id, summon, position)
.then(response => storeGridSummon(response.data.grid_summon)) .then(response => storeGridSummon(response.data.grid_summon))
}) })
} else { } else {
saveSummon(id, summon, position) saveSummon(party.id, summon, position)
.then(response => storeGridSummon(response.data.grid_summon)) .then(response => storeGridSummon(response.data.grid_summon))
} }
} }
@ -184,16 +157,12 @@ const SummonGrid = (props: Props) => {
} }
function storeGridSummon(gridSummon: GridSummon) { function storeGridSummon(gridSummon: GridSummon) {
if (gridSummon.position == -1) { if (gridSummon.position == -1)
setMainSummon(gridSummon) state.grid.summons.mainSummon = gridSummon
} else if (gridSummon.position == 6) { else if (gridSummon.position == 6)
setFriendSummon(gridSummon) state.grid.summons.friendSummon = gridSummon
} else { else
// Store the grid unit at the correct position state.grid.summons.allSummons[gridSummon.position] = gridSummon
let newSummons = Object.assign({}, summons)
newSummons[gridSummon.position] = gridSummon
setSummons(newSummons)
}
} }
// Methods: Updating uncap level // Methods: Updating uncap level
@ -218,40 +187,41 @@ const SummonGrid = (props: Props) => {
} }
} }
const initiateUncapUpdate = useCallback( function initiateUncapUpdate(id: string, position: number, uncapLevel: number) {
(id: string, position: number, uncapLevel: number) => { memoizeAction(id, position, uncapLevel)
memoizeAction(id, position, uncapLevel)
// Optimistically update UI // Optimistically update UI
updateUncapLevel(position, uncapLevel) updateUncapLevel(position, uncapLevel)
}, [previousUncapValues, summons] }
)
const memoizeAction = useCallback( const memoizeAction = useCallback(
(id: string, position: number, uncapLevel: number) => { (id: string, position: number, uncapLevel: number) => {
debouncedAction(id, position, uncapLevel) debouncedAction(id, position, uncapLevel)
}, [summons, mainSummon, friendSummon] }, [props, previousUncapValues]
) )
const debouncedAction = useMemo(() => const debouncedAction = useMemo(() =>
debounce((id, position, number) => { debounce((id, position, number) => {
saveUncap(id, position, number) saveUncap(id, position, number)
}, 500), [summons, mainSummon, friendSummon, saveUncap] }, 500), [props, saveUncap]
) )
const updateUncapLevel = (position: number, uncapLevel: number) => { const updateUncapLevel = (position: number, uncapLevel: number) => {
let newSummons = Object.assign({}, summons) if (state.grid.summons.mainSummon && position == -1)
newSummons[position].uncap_level = uncapLevel state.grid.summons.mainSummon.uncap_level = uncapLevel
setSummons(newSummons) else if (state.grid.summons.friendSummon && position == 6)
state.grid.summons.friendSummon.uncap_level = uncapLevel
else
state.grid.summons.allSummons[position].uncap_level = uncapLevel
} }
function storePreviousUncapValue(position: number) { function storePreviousUncapValue(position: number) {
// Save the current value in case of an unexpected result // Save the current value in case of an unexpected result
let newPreviousValues = {...previousUncapValues} let newPreviousValues = {...previousUncapValues}
if (mainSummon && position == -1) newPreviousValues[position] = mainSummon.uncap_level if (state.grid.summons.mainSummon && position == -1) newPreviousValues[position] = state.grid.summons.mainSummon.uncap_level
else if (friendSummon && position == 6) newPreviousValues[position] = friendSummon.uncap_level else if (state.grid.summons.friendSummon && position == 6) newPreviousValues[position] = state.grid.summons.friendSummon.uncap_level
else newPreviousValues[position] = summons[position].uncap_level else newPreviousValues[position] = state.grid.summons.allSummons[position].uncap_level
setPreviousUncapValues(newPreviousValues) setPreviousUncapValues(newPreviousValues)
} }
@ -261,12 +231,12 @@ const SummonGrid = (props: Props) => {
<div className="LabeledUnit"> <div className="LabeledUnit">
<div className="Label">Main Summon</div> <div className="Label">Main Summon</div>
<SummonUnit <SummonUnit
gridSummon={mainSummon} gridSummon={grid.summons.mainSummon}
editable={editable} editable={party.editable}
key="grid_main_summon" key="grid_main_summon"
position={-1} position={-1}
unitType={0} unitType={0}
onClick={() => { openSearchModal(-1) }} updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate} updateUncap={initiateUncapUpdate}
/> />
</div> </div>
@ -276,12 +246,12 @@ const SummonGrid = (props: Props) => {
<div className="LabeledUnit"> <div className="LabeledUnit">
<div className="Label">Friend Summon</div> <div className="Label">Friend Summon</div>
<SummonUnit <SummonUnit
gridSummon={friendSummon} gridSummon={grid.summons.friendSummon}
editable={editable} editable={party.editable}
key="grid_friend_summon" key="grid_friend_summon"
position={6} position={6}
unitType={2} unitType={2}
onClick={() => { openSearchModal(6) }} updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate} updateUncap={initiateUncapUpdate}
/> />
</div> </div>
@ -293,11 +263,11 @@ const SummonGrid = (props: Props) => {
{Array.from(Array(numSummons)).map((x, i) => { {Array.from(Array(numSummons)).map((x, i) => {
return (<li key={`grid_unit_${i}`} > return (<li key={`grid_unit_${i}`} >
<SummonUnit <SummonUnit
gridSummon={summons[i]} gridSummon={grid.summons.allSummons[i]}
editable={editable} editable={party.editable}
position={i} position={i}
unitType={1} unitType={1}
onClick={() => { openSearchModal(i) }} updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate} updateUncap={initiateUncapUpdate}
/> />
</li>) </li>)
@ -307,11 +277,11 @@ const SummonGrid = (props: Props) => {
) )
const subAuraSummonElement = ( const subAuraSummonElement = (
<ExtraSummons <ExtraSummons
grid={summons} grid={grid.summons.allSummons}
editable={editable} editable={party.editable}
exists={false} exists={false}
offset={numSummons} offset={numSummons}
onClick={openSearchModal} updateObject={receiveSummonFromSearch}
updateUncap={initiateUncapUpdate} updateUncap={initiateUncapUpdate}
/> />
) )
@ -324,17 +294,6 @@ const SummonGrid = (props: Props) => {
</div> </div>
{ subAuraSummonElement } { subAuraSummonElement }
{open ? (
<SearchModal
grid={searchGrid}
close={closeModal}
send={receiveSummonFromSearch}
fromPosition={itemPositionForSearch}
object="summons"
placeholderText="Search for a summon..."
/>
) : null}
</div> </div>
) )
} }

View file

@ -12,7 +12,7 @@
background: #e9e9e9; background: #e9e9e9;
border-radius: 6px; border-radius: 6px;
display: inline-block; display: inline-block;
height: 72px; height: auto;
width: 120px; width: 120px;
} }

View file

@ -1,18 +1,19 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import SearchModal from '~components/SearchModal'
import UncapIndicator from '~components/UncapIndicator' import UncapIndicator from '~components/UncapIndicator'
import PlusIcon from '~public/icons/Add.svg' import PlusIcon from '~public/icons/Add.svg'
import './index.scss' import './index.scss'
interface Props { interface Props {
onClick: () => void
updateUncap: (id: string, position: number, uncap: number) => void
gridSummon: GridSummon | undefined gridSummon: GridSummon | undefined
unitType: 0 | 1 | 2
position: number position: number
editable: boolean editable: boolean
unitType: 0 | 1 | 2 updateObject: (object: Character | Weapon | Summon, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void
} }
const SummonUnit = (props: Props) => { const SummonUnit = (props: Props) => {
@ -28,7 +29,7 @@ const SummonUnit = (props: Props) => {
}) })
const gridSummon = props.gridSummon const gridSummon = props.gridSummon
const summon = gridSummon?.summon const summon = gridSummon?.object
useEffect(() => { useEffect(() => {
generateImageUrl() generateImageUrl()
@ -37,7 +38,7 @@ const SummonUnit = (props: Props) => {
function generateImageUrl() { function generateImageUrl() {
let imgSrc = "" let imgSrc = ""
if (props.gridSummon) { if (props.gridSummon) {
const summon = props.gridSummon.summon! const summon = props.gridSummon.object!
// Generate the correct source for the summon // Generate the correct source for the summon
if (props.unitType == 0 || props.unitType == 2) if (props.unitType == 0 || props.unitType == 2)
@ -57,19 +58,27 @@ const SummonUnit = (props: Props) => {
return ( return (
<div> <div>
<div className={classes}> <div className={classes}>
<div className="SummonImage" onClick={ (props.editable) ? props.onClick : () => {} }> <SearchModal
<img alt={summon?.name.en} className="grid_image" src={imageUrl} /> placeholderText="Search for a summon..."
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' } fromPosition={props.position}
</div> object="summons"
send={props.updateObject}>
<div className="SummonImage">
<img alt={summon?.name.en} className="grid_image" src={imageUrl} />
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' }
</div>
</SearchModal>
{ (gridSummon) ? { (gridSummon) ?
<UncapIndicator <UncapIndicator
type="summon" type="summon"
ulb={summon?.uncap.ulb || false} ulb={gridSummon.object.uncap.ulb || false}
flb={summon?.uncap.flb || false} flb={gridSummon.object.uncap.flb || false}
uncapLevel={gridSummon?.uncap_level} uncapLevel={gridSummon.uncap_level}
updateUncap={passUncapData} updateUncap={passUncapData}
special={false} special={false}
/> : '' } /> : ''
}
<h3 className="SummonName">{summon?.name.en}</h3> <h3 className="SummonName">{summon?.name.en}</h3>
</div> </div>
</div> </div>

View file

View file

@ -0,0 +1,89 @@
import React, { useContext, useEffect, useState } from 'react'
import { useCookies } from 'react-cookie'
import { useRouter } from 'next/router'
import AppContext from '~context/AppContext'
import Header from '~components/Header'
import Button from '~components/Button'
import HeaderMenu from '~components/HeaderMenu'
const TopHeader = () => {
const { editable, setEditable, authenticated, setAuthenticated } = useContext(AppContext)
const [username, setUsername] = useState(undefined)
const [cookies, _, removeCookie] = useCookies(['user'])
const router = useRouter()
useEffect(() => {
if (cookies.user) {
setAuthenticated(true)
setUsername(cookies.user.username)
console.log(`Logged in as user "${cookies.user.username}"`)
} else {
setAuthenticated(false)
console.log('You are currently not logged in.')
}
}, [cookies, setUsername, setAuthenticated])
function copyToClipboard() {
const el = document.createElement('input')
el.value = window.location.href
el.id = 'url-input'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
el.remove()
}
function newParty() {
router.push('/')
}
function logout() {
removeCookie('user')
setAuthenticated(false)
if (editable) setEditable(false)
// TODO: How can we log out without navigating to root
router.push('/')
return false
}
const leftNav = () => {
return (
<div className="dropdown">
<Button icon="menu">Menu</Button>
{ (username) ?
<HeaderMenu authenticated={authenticated} username={username} logout={logout} /> :
<HeaderMenu authenticated={authenticated} />
}
</div>
)
}
const rightNav = () => {
return (
<div>
{ (router.route === '/p/[party]') ?
<Button icon="link" click={copyToClipboard}>Copy link</Button> : ''
}
<Button icon="new" click={newParty}>New</Button>
</div>
)
}
return (
<Header
position="top"
left={ leftNav() }
right={ rightNav() }
/>
)
}
export default TopHeader

View file

@ -1,19 +1,17 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react' import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useCookies } from 'react-cookie' import { useCookies } from 'react-cookie'
import { useModal as useModal } from '~utils/useModal' import { useSnapshot } from 'valtio'
import { AxiosResponse } from 'axios' import { AxiosResponse } from 'axios'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
import AppContext from '~context/AppContext'
import PartyContext from '~context/PartyContext'
import SearchModal from '~components/SearchModal'
import WeaponUnit from '~components/WeaponUnit' import WeaponUnit from '~components/WeaponUnit'
import ExtraWeapons from '~components/ExtraWeapons' import ExtraWeapons from '~components/ExtraWeapons'
import api from '~utils/api' import api from '~utils/api'
import state from '~utils/state'
import './index.scss' import './index.scss'
// Props // Props
@ -36,58 +34,33 @@ const WeaponGrid = (props: Props) => {
} : {} } : {}
// Set up state for view management // Set up state for view management
const { party, grid } = useSnapshot(state)
const [slug, setSlug] = useState()
const [found, setFound] = useState(false) const [found, setFound] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// Set up the party context
const { setEditable: setAppEditable } = useContext(AppContext)
const { id, setId } = useContext(PartyContext)
const { slug, setSlug } = useContext(PartyContext)
const { editable, setEditable } = useContext(PartyContext)
const { hasExtra, setHasExtra } = useContext(PartyContext)
const { setElement } = useContext(PartyContext)
// Set up states for Grid data
const [weapons, setWeapons] = useState<GridArray<GridWeapon>>({})
const [mainWeapon, setMainWeapon] = useState<GridWeapon>()
// Set up states for Search
const { open, openModal, closeModal } = useModal()
const [itemPositionForSearch, setItemPositionForSearch] = useState(0)
// Create a temporary state to store previous weapon uncap values // Create a temporary state to store previous weapon uncap values
const [previousUncapValues, setPreviousUncapValues] = useState<{[key: number]: number}>({}) const [previousUncapValues, setPreviousUncapValues] = useState<{[key: number]: number}>({})
// Create a state dictionary to store pure objects for Search
const [searchGrid, setSearchGrid] = useState<GridArray<Weapon>>({})
// Fetch data from the server // Fetch data from the server
useEffect(() => { useEffect(() => {
const shortcode = (props.slug) ? props.slug : slug const shortcode = (props.slug) ? props.slug : slug
if (shortcode) fetchGrid(shortcode) if (shortcode) fetchGrid(shortcode)
else { else state.party.editable = true
setEditable(true)
setAppEditable(true)
}
}, [slug, props.slug]) }, [slug, props.slug])
// Initialize an array of current uncap values for each weapon // Initialize an array of current uncap values for each weapon
useEffect(() => { useEffect(() => {
let initialPreviousUncapValues: {[key: number]: number} = {} let initialPreviousUncapValues: {[key: number]: number} = {}
if (mainWeapon) initialPreviousUncapValues[-1] = mainWeapon.uncap_level
Object.values(weapons).map(o => initialPreviousUncapValues[o.position] = o.uncap_level) if (state.grid.weapons.mainWeapon)
initialPreviousUncapValues[-1] = state.grid.weapons.mainWeapon.uncap_level
Object.values(state.grid.weapons.allWeapons).map(o => initialPreviousUncapValues[o.position] = o.uncap_level)
setPreviousUncapValues(initialPreviousUncapValues) setPreviousUncapValues(initialPreviousUncapValues)
}, [mainWeapon, weapons]) }, [state.grid.weapons.mainWeapon, state.grid.weapons.allWeapons])
// Update search grid whenever weapons or the mainhand are updated
useEffect(() => {
let newSearchGrid = Object.values(weapons).map((o) => o.weapon)
if (mainWeapon)
newSearchGrid.unshift(mainWeapon.weapon)
setSearchGrid(newSearchGrid)
}, [weapons, mainWeapon])
// Methods: Fetching an object from the server // Methods: Fetching an object from the server
async function fetchGrid(shortcode: string) { async function fetchGrid(shortcode: string) {
@ -105,13 +78,13 @@ const WeaponGrid = (props: Props) => {
const loggedInUser = (cookies.user) ? cookies.user.user_id : '' const loggedInUser = (cookies.user) ? cookies.user.user_id : ''
if (partyUser != undefined && loggedInUser != undefined && partyUser === loggedInUser) { if (partyUser != undefined && loggedInUser != undefined && partyUser === loggedInUser) {
setEditable(true) state.party.editable = true
setAppEditable(true)
} }
// Store the important party and state-keeping values // Store the important party and state-keeping values
setId(party.id) state.party.id = party.id
setHasExtra(party.is_extra) state.party.extra = party.is_extra
setFound(true) setFound(true)
setLoading(false) setLoading(false)
@ -131,43 +104,36 @@ const WeaponGrid = (props: Props) => {
} }
function populateWeapons(list: [GridWeapon]) { function populateWeapons(list: [GridWeapon]) {
let weapons: GridArray<GridWeapon> = {} list.forEach((gridObject: GridWeapon) => {
if (gridObject.mainhand) {
list.forEach((object: GridWeapon) => { state.grid.weapons.mainWeapon = gridObject
if (object.mainhand) { state.party.element = gridObject.object.element
setMainWeapon(object) } else if (!gridObject.mainhand && gridObject.position != null) {
setElement(object.weapon.element) state.grid.weapons.allWeapons[gridObject.position] = gridObject
} else if (!object.mainhand && object.position != null) {
weapons[object.position] = object
} }
}) })
setWeapons(weapons)
} }
// Methods: Adding an object from search // Methods: Adding an object from search
function openSearchModal(position: number) {
setItemPositionForSearch(position)
openModal()
}
function receiveWeaponFromSearch(object: Character | Weapon | Summon, position: number) { function receiveWeaponFromSearch(object: Character | Weapon | Summon, position: number) {
const weapon = object as Weapon const weapon = object as Weapon
setElement(weapon.element) if (position == 1)
state.party.element = weapon.element
if (!id) { if (!party.id) {
props.createParty(hasExtra) props.createParty(party.extra)
.then(response => { .then(response => {
const party = response.data.party const party = response.data.party
setId(party.id) state.party.id = party.id
setSlug(party.shortcode) setSlug(party.shortcode)
if (props.pushHistory) props.pushHistory(`/p/${party.shortcode}`) if (props.pushHistory) props.pushHistory(`/p/${party.shortcode}`)
saveWeapon(party.id, weapon, position) saveWeapon(party.id, weapon, position)
.then(response => storeGridWeapon(response.data.grid_weapon)) .then(response => storeGridWeapon(response.data.grid_weapon))
}) })
} else { } else {
saveWeapon(id, weapon, position) saveWeapon(party.id, weapon, position)
.then(response => storeGridWeapon(response.data.grid_weapon)) .then(response => storeGridWeapon(response.data.grid_weapon))
} }
} }
@ -190,12 +156,11 @@ const WeaponGrid = (props: Props) => {
function storeGridWeapon(gridWeapon: GridWeapon) { function storeGridWeapon(gridWeapon: GridWeapon) {
if (gridWeapon.position == -1) { if (gridWeapon.position == -1) {
setMainWeapon(gridWeapon) state.grid.weapons.mainWeapon = gridWeapon
state.party.element = gridWeapon.object.element
} else { } else {
// Store the grid unit at the correct position // Store the grid unit at the correct position
let newWeapons = Object.assign({}, weapons) state.grid.weapons.allWeapons[gridWeapon.position] = gridWeapon
newWeapons[gridWeapon.position] = gridWeapon
setWeapons(newWeapons)
} }
} }
@ -241,32 +206,29 @@ const WeaponGrid = (props: Props) => {
) )
const updateUncapLevel = (position: number, uncapLevel: number) => { const updateUncapLevel = (position: number, uncapLevel: number) => {
if (mainWeapon && position == -1) { if (state.grid.weapons.mainWeapon && position == -1)
mainWeapon.uncap_level = uncapLevel state.grid.weapons.mainWeapon.uncap_level = uncapLevel
setMainWeapon(mainWeapon) else
} else { state.grid.weapons.allWeapons[position].uncap_level = uncapLevel
let newWeapons = Object.assign({}, weapons)
newWeapons[position].uncap_level = uncapLevel
setWeapons(newWeapons)
}
} }
function storePreviousUncapValue(position: number) { function storePreviousUncapValue(position: number) {
// Save the current value in case of an unexpected result // Save the current value in case of an unexpected result
let newPreviousValues = {...previousUncapValues} let newPreviousValues = {...previousUncapValues}
newPreviousValues[position] = (mainWeapon && position == -1) ? mainWeapon.uncap_level : weapons[position].uncap_level newPreviousValues[position] = (state.grid.weapons.mainWeapon && position == -1) ?
state.grid.weapons.mainWeapon.uncap_level : state.grid.weapons.allWeapons[position].uncap_level
setPreviousUncapValues(newPreviousValues) setPreviousUncapValues(newPreviousValues)
} }
// Render: JSX components // Render: JSX components
const mainhandElement = ( const mainhandElement = (
<WeaponUnit <WeaponUnit
gridWeapon={mainWeapon} gridWeapon={grid.weapons.mainWeapon}
editable={editable} editable={party.editable}
key="grid_mainhand" key="grid_mainhand"
position={-1} position={-1}
unitType={0} unitType={0}
onClick={() => { openSearchModal(-1) }} updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate} updateUncap={initiateUncapUpdate}
/> />
) )
@ -276,11 +238,11 @@ const WeaponGrid = (props: Props) => {
return ( return (
<li key={`grid_unit_${i}`} > <li key={`grid_unit_${i}`} >
<WeaponUnit <WeaponUnit
gridWeapon={weapons[i]} gridWeapon={grid.weapons.allWeapons[i]}
editable={editable} editable={party.editable}
position={i} position={i}
unitType={1} unitType={1}
onClick={() => { openSearchModal(i) }} updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate} updateUncap={initiateUncapUpdate}
/> />
</li> </li>
@ -290,10 +252,10 @@ const WeaponGrid = (props: Props) => {
const extraGridElement = ( const extraGridElement = (
<ExtraWeapons <ExtraWeapons
grid={weapons} grid={state.grid.weapons.allWeapons}
editable={editable} editable={party.editable}
offset={numWeapons} offset={numWeapons}
onClick={openSearchModal} updateObject={receiveWeaponFromSearch}
updateUncap={initiateUncapUpdate} updateUncap={initiateUncapUpdate}
/> />
) )
@ -305,18 +267,7 @@ const WeaponGrid = (props: Props) => {
<ul className="grid_weapons">{ weaponGridElement }</ul> <ul className="grid_weapons">{ weaponGridElement }</ul>
</div> </div>
{ (() => { return (hasExtra) ? extraGridElement : '' })() } { (() => { return (party.extra) ? extraGridElement : '' })() }
{open ? (
<SearchModal
grid={searchGrid}
close={closeModal}
send={receiveWeaponFromSearch}
fromPosition={itemPositionForSearch}
object="weapons"
placeholderText="Search for a weapon..."
/>
) : null}
</div> </div>
) )
} }

View file

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import SearchModal from '~components/SearchModal'
import UncapIndicator from '~components/UncapIndicator' import UncapIndicator from '~components/UncapIndicator'
import PlusIcon from '~public/icons/Add.svg' import PlusIcon from '~public/icons/Add.svg'
@ -11,7 +12,7 @@ interface Props {
unitType: 0 | 1 unitType: 0 | 1
position: number position: number
editable: boolean editable: boolean
onClick: () => void updateObject: (object: Character | Weapon | Summon, position: number) => void
updateUncap: (id: string, position: number, uncap: number) => void updateUncap: (id: string, position: number, uncap: number) => void
} }
@ -27,7 +28,7 @@ const WeaponUnit = (props: Props) => {
}) })
const gridWeapon = props.gridWeapon const gridWeapon = props.gridWeapon
const weapon = gridWeapon?.weapon const weapon = gridWeapon?.object
useEffect(() => { useEffect(() => {
generateImageUrl() generateImageUrl()
@ -36,7 +37,7 @@ const WeaponUnit = (props: Props) => {
function generateImageUrl() { function generateImageUrl() {
let imgSrc = "" let imgSrc = ""
if (props.gridWeapon) { if (props.gridWeapon) {
const weapon = props.gridWeapon.weapon! const weapon = props.gridWeapon.object!
if (props.unitType == 0) if (props.unitType == 0)
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${weapon.granblue_id}.jpg` imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-main/${weapon.granblue_id}.jpg`
@ -55,15 +56,22 @@ const WeaponUnit = (props: Props) => {
return ( return (
<div> <div>
<div className={classes}> <div className={classes}>
<div className="WeaponImage" onClick={ (props.editable) ? props.onClick : () => {} }> <SearchModal
<img alt={weapon?.name.en} className="grid_image" src={imageUrl} /> placeholderText="Search for a weapon..."
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' } fromPosition={props.position}
</div> object="weapons"
send={props.updateObject}>
<div className="WeaponImage">
<img alt={weapon?.name.en} className="grid_image" src={imageUrl} />
{ (props.editable) ? <span className='icon'><PlusIcon /></span> : '' }
</div>
</SearchModal>
{ (gridWeapon) ? { (gridWeapon) ?
<UncapIndicator <UncapIndicator
type="weapon" type="weapon"
ulb={gridWeapon.weapon.uncap.ulb || false} ulb={gridWeapon.object.uncap.ulb || false}
flb={gridWeapon.weapon.uncap.flb || false} flb={gridWeapon.object.uncap.flb || false}
uncapLevel={gridWeapon.uncap_level} uncapLevel={gridWeapon.uncap_level}
updateUncap={passUncapData} updateUncap={passUncapData}
special={false} special={false}

98
package-lock.json generated
View file

@ -6,6 +6,7 @@
"": { "": {
"name": "grid-web", "name": "grid-web",
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^0.1.5",
"@radix-ui/react-dialog": "^0.1.5", "@radix-ui/react-dialog": "^0.1.5",
"@radix-ui/react-dropdown-menu": "^0.1.4", "@radix-ui/react-dropdown-menu": "^0.1.4",
"@radix-ui/react-hover-card": "^0.1.3", "@radix-ui/react-hover-card": "^0.1.3",
@ -22,7 +23,8 @@
"react-cookie": "^4.1.1", "react-cookie": "^4.1.1",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-i18next": "^11.15.3", "react-i18next": "^11.15.3",
"sass": "^1.49.0" "sass": "^1.49.0",
"valtio": "^1.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/lodash.debounce": "^4.0.6", "@types/lodash.debounce": "^4.0.6",
@ -31,6 +33,7 @@
"@types/react-dom": "^17.0.11", "@types/react-dom": "^17.0.11",
"eslint": "8.7.0", "eslint": "8.7.0",
"eslint-config-next": "12.0.8", "eslint-config-next": "12.0.8",
"eslint-plugin-valtio": "^0.4.1",
"typescript": "4.5.5" "typescript": "4.5.5"
} }
}, },
@ -2284,6 +2287,24 @@
"@babel/runtime": "^7.13.10" "@babel/runtime": "^7.13.10"
} }
}, },
"node_modules/@radix-ui/react-alert-dialog": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-0.1.5.tgz",
"integrity": "sha512-Lq9h3GSvw752e7dFll3UWvm4uWiTlYAXLFX6wr/VQPRoa7XaQO8/1NBu4ikLHAecGEd/uDGZLY3aP7ovGPQYtg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "0.1.0",
"@radix-ui/react-compose-refs": "0.1.0",
"@radix-ui/react-context": "0.1.1",
"@radix-ui/react-dialog": "0.1.5",
"@radix-ui/react-primitive": "0.1.3",
"@radix-ui/react-slot": "0.1.2"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": "^16.8 || ^17.0"
}
},
"node_modules/@radix-ui/react-arrow": { "node_modules/@radix-ui/react-arrow": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.3.tgz",
@ -4293,6 +4314,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/eslint-plugin-valtio": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-valtio/-/eslint-plugin-valtio-0.4.1.tgz",
"integrity": "sha512-mORVREchU66YRWa0svret65i9U6gSliNThPH2GJEJlNHE/J1sYdcEcuobKAAMKlz5WpflC38nslkRxBKpiU/rA==",
"dev": true
},
"node_modules/eslint-scope": { "node_modules/eslint-scope": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.0.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.0.tgz",
@ -5762,6 +5789,11 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true "dev": true
}, },
"node_modules/proxy-compare": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.0.2.tgz",
"integrity": "sha512-3qUXJBariEj3eO90M3Rgqq3+/P5Efl0t/dl9g/1uVzIQmO3M+ql4hvNH3mYdu8H+1zcKv07YvL55tsY74jmH1A=="
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
@ -6667,6 +6699,37 @@
"integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
"dev": true "dev": true
}, },
"node_modules/valtio": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-1.3.0.tgz",
"integrity": "sha512-wsE6EDIkt+CNZPNHOxNVzoi026Fyt6ZRT750etZCAvrndcdT3N7Z+SSV4kJQdCwl5gNxsnU4BhP1wFS7cu21oA==",
"dependencies": {
"proxy-compare": "2.0.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@babel/helper-module-imports": ">=7.12",
"@babel/types": ">=7.13",
"babel-plugin-macros": ">=3.0",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@babel/helper-module-imports": {
"optional": true
},
"@babel/types": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/void-elements": { "node_modules/void-elements": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@ -8284,6 +8347,20 @@
"@babel/runtime": "^7.13.10" "@babel/runtime": "^7.13.10"
} }
}, },
"@radix-ui/react-alert-dialog": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-0.1.5.tgz",
"integrity": "sha512-Lq9h3GSvw752e7dFll3UWvm4uWiTlYAXLFX6wr/VQPRoa7XaQO8/1NBu4ikLHAecGEd/uDGZLY3aP7ovGPQYtg==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "0.1.0",
"@radix-ui/react-compose-refs": "0.1.0",
"@radix-ui/react-context": "0.1.1",
"@radix-ui/react-dialog": "0.1.5",
"@radix-ui/react-primitive": "0.1.3",
"@radix-ui/react-slot": "0.1.2"
}
},
"@radix-ui/react-arrow": { "@radix-ui/react-arrow": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.3.tgz",
@ -9813,6 +9890,12 @@
"dev": true, "dev": true,
"requires": {} "requires": {}
}, },
"eslint-plugin-valtio": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-valtio/-/eslint-plugin-valtio-0.4.1.tgz",
"integrity": "sha512-mORVREchU66YRWa0svret65i9U6gSliNThPH2GJEJlNHE/J1sYdcEcuobKAAMKlz5WpflC38nslkRxBKpiU/rA==",
"dev": true
},
"eslint-scope": { "eslint-scope": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.0.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.0.tgz",
@ -10820,6 +10903,11 @@
} }
} }
}, },
"proxy-compare": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.0.2.tgz",
"integrity": "sha512-3qUXJBariEj3eO90M3Rgqq3+/P5Efl0t/dl9g/1uVzIQmO3M+ql4hvNH3mYdu8H+1zcKv07YvL55tsY74jmH1A=="
},
"punycode": { "punycode": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
@ -11429,6 +11517,14 @@
"integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
"dev": true "dev": true
}, },
"valtio": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-1.3.0.tgz",
"integrity": "sha512-wsE6EDIkt+CNZPNHOxNVzoi026Fyt6ZRT750etZCAvrndcdT3N7Z+SSV4kJQdCwl5gNxsnU4BhP1wFS7cu21oA==",
"requires": {
"proxy-compare": "2.0.2"
}
},
"void-elements": { "void-elements": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",

View file

@ -11,6 +11,7 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^0.1.5",
"@radix-ui/react-dialog": "^0.1.5", "@radix-ui/react-dialog": "^0.1.5",
"@radix-ui/react-dropdown-menu": "^0.1.4", "@radix-ui/react-dropdown-menu": "^0.1.4",
"@radix-ui/react-hover-card": "^0.1.3", "@radix-ui/react-hover-card": "^0.1.3",
@ -27,7 +28,8 @@
"react-cookie": "^4.1.1", "react-cookie": "^4.1.1",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-i18next": "^11.15.3", "react-i18next": "^11.15.3",
"sass": "^1.49.0" "sass": "^1.49.0",
"valtio": "^1.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/lodash.debounce": "^4.0.6", "@types/lodash.debounce": "^4.0.6",
@ -36,6 +38,7 @@
"@types/react-dom": "^17.0.11", "@types/react-dom": "^17.0.11",
"eslint": "8.7.0", "eslint": "8.7.0",
"eslint-config-next": "12.0.8", "eslint-config-next": "12.0.8",
"eslint-plugin-valtio": "^0.4.1",
"typescript": "4.5.5" "typescript": "4.5.5"
} }
} }

View file

@ -2,6 +2,7 @@ import React, { useContext, useEffect, useState } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import Party from '~components/Party' import Party from '~components/Party'
import * as AlertDialog from '@radix-ui/react-alert-dialog'
const PartyRoute: React.FC = () => { const PartyRoute: React.FC = () => {
const router = useRouter() const router = useRouter()

3
public/icons/Cross.svg Normal file
View file

@ -0,0 +1,3 @@
<svg viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
<path d="M10.9425 11.8186C11.1844 12.0605 11.5766 12.0605 11.8186 11.8186C12.0605 11.5766 12.0605 11.1844 11.8186 10.9425L7.8761 7L11.8186 3.05755C12.0605 2.81562 12.0605 2.42338 11.8186 2.18145C11.5766 1.93952 11.1844 1.93952 10.9425 2.18145L7 6.1239L3.05755 2.18145C2.81562 1.93952 2.42338 1.93952 2.18145 2.18145C1.93952 2.42338 1.93952 2.81562 2.18145 3.05755L6.1239 7L2.18145 10.9425C1.93952 11.1844 1.93952 11.5766 2.18145 11.8186C2.42338 12.0605 2.81562 12.0605 3.05755 11.8186L7 7.8761L10.9425 11.8186Z" />
</svg>

After

Width:  |  Height:  |  Size: 583 B

View file

@ -3,25 +3,35 @@
html { html {
background: $background-color; background: $background-color;
font-size: 62.5%; font-size: 62.5%;
padding: $unit * 2;
} }
body { body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
font-family: system-ui, -apple-system, Helvetica Neue, Helvetica, Arial, sans-serif; box-sizing: border-box;
font-family: system-ui, -apple-system, 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 1.4rem; font-size: 1.4rem;
height: 100vh;
padding: $unit * 2 !important;
&.no-scroll { &.no-scroll {
overflow: hidden; overflow: hidden;
} }
} }
#__next {
height: 100%;
}
main {
min-height: 90%;
}
a { a {
text-decoration: none; text-decoration: none;
} }
button, input { button, input {
font-family: system-ui, -apple-system, Helvetica Neue, Helvetica, Arial, sans-serif; font-family: system-ui, -apple-system, 'Helvetica Neue', Helvetica, Arial, sans-serif;
} }
h1, h2, h3, p { h1, h2, h3, p {
@ -48,3 +58,80 @@ h1 {
} }
.Overlay {
background: rgba(0, 0, 0, 0.6);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 20;
}
.Dialog {
animation: 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal none running openModal;
background: white;
border-radius: $unit;
display: flex;
flex-direction: column;
gap: $unit * 3;
height: auto;
min-width: $unit * 48;
min-height: $unit * 12;
padding: $unit * 3;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 21;
.DialogHeader {
display: flex;
}
.DialogClose {
background: transparent;
height: 21px;
width: 21px;
&:hover {
cursor: pointer;
svg {
fill: $grey-00;
}
}
svg {
fill: $grey-10;
}
}
.DialogTitle {
font-size: $font-large;
flex-grow: 1;
}
.DialogDescription {
flex-grow: 1;
}
.actions {
display: flex;
justify-content: flex-end;
width: 100%;
}
}
@keyframes openModal {
0% {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
100% {
// opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}

View file

@ -1,4 +1,6 @@
interface Character { interface Character {
type: 'character'
id: string id: string
granblue_id: string granblue_id: string
element: number element: number

View file

@ -1,6 +1,6 @@
interface GridCharacter { interface GridCharacter {
id: string id: string
position: number position: number
character: Character object: Character
uncap_level: number uncap_level: number
} }

View file

@ -1,8 +1,8 @@
interface GridSummon { interface GridSummon {
id: string id: string
main: boolean main: boolean
friend: boolean friend: boolean
position: number position: number
summon: Summon object: Summon
uncap_level: number uncap_level: number
} }

View file

@ -2,6 +2,6 @@ interface GridWeapon {
id: string id: string
mainhand: boolean mainhand: boolean
position: number position: number
weapon: Weapon object: Weapon
uncap_level: number uncap_level: number
} }

2
types/Summon.d.ts vendored
View file

@ -1,4 +1,6 @@
interface Summon { interface Summon {
type: 'summon'
id: string id: string
granblue_id: number granblue_id: number
element: number element: number

2
types/Weapon.d.ts vendored
View file

@ -1,4 +1,6 @@
interface Weapon { interface Weapon {
type: 'weapon'
id: string id: string
granblue_id: number granblue_id: number
element: number element: number

View file

@ -1,3 +1,8 @@
export enum ButtonType {
Base,
Destructive
}
export enum GridType { export enum GridType {
Class, Class,
Character, Character,

57
utils/state.tsx Normal file
View file

@ -0,0 +1,57 @@
import { proxy } from "valtio";
interface State {
app: {
authenticated: boolean
},
party: {
id: string | undefined,
editable: boolean,
element: number,
extra: boolean
},
grid: {
weapons: {
mainWeapon: GridWeapon | undefined,
allWeapons: GridArray<GridWeapon>
},
summons: {
mainSummon: GridSummon | undefined,
friendSummon: GridSummon | undefined,
allSummons: GridArray<GridSummon>
},
characters: GridArray<GridCharacter>
},
search: {
sourceItem: GridCharacter | GridWeapon | GridSummon | undefined
}
}
const state: State = {
app: {
authenticated: false
},
party: {
id: undefined,
editable: false,
element: 0,
extra: false
},
grid: {
weapons: {
mainWeapon: undefined,
allWeapons: {}
},
summons: {
mainSummon: undefined,
friendSummon: undefined,
allSummons: {}
},
characters: {}
},
search: {
sourceItem: undefined
}
}
export default proxy(state)