src/components/structural/header/Header.js
import React, { Component, Fragment } from "react";
import Reference from "../../reference/Reference.js";
import Collection from "../../collection/Collection.js";
import SceneConfigMenu from "./SceneConfigMenu.js";
import Sidebar from "./Sidebar.js";
import MyrTour from "./MyrTour.js";
import ProjectView from "./ProjectView.js";
import CourseSelect from "../../courses/CourseSelect.js";
import WelcomeScreen from "../WelcomeScreen.js";
import { GoogleLogin, GoogleLogout } from "react-google-login";
import sockets from "socket.io-client";
import * as layoutTypes from "../../../constants/LayoutTypes.js";
import {
Button,
ButtonBase,
Icon,
MenuItem,
Tooltip,
Drawer,
IconButton,
FormControl,
TextField,
Snackbar,
Popover,
Avatar,
createMuiTheme,
MuiThemeProvider
} from "@material-ui/core";
import { save } from "../../../actions/projectActions.js";
const exitBtnStyle = {
position: "absolute",
top: 0,
right: 0,
};
const resetButtonStyle = {
top: 2,
paddingLeft: 10,
paddingRight: 10,
color: "white",
opacity: 1
};
/**
* React component class for the header
*/
class Header extends Component {
constructor(props) {
super(props);
this.state = {
logMenuOpen: false,
availProj: [],
sampleProj: [],
collectionOpen: false,
projectsOpen: false,
projectTab: "a",
snackOpen: false,
lastMsgTime: 0,
anchorEl: null,
navAwayModal: false,
needsNewId: false, // this explicitly tells us to make a new id
spinnerOpen: false,
referenceOpen: false,
coursesOpen: false,
tourOpen: false,
welcomeOpen: false,
updateCollection: false,
fetchCollection: false,
socket: sockets(),
savedSettings: [],
googleUser: undefined
};
this.state.socket.on("update", () => {
let editor = window.ace.edit("ace-editor");
if (editor.getSession().getValue() === this.props.scene.code || window.confirm("A new version of the scene is available, would you like to load it?")) {
this.props.actions.fetchScene(this.props.projectId);
}
});
}
/**
* When the component is done rendering, we want to:
*/
componentDidMount() {
this.props.projectActions.asyncExampleProj();
this.props.courseActions.fetchCourses();
if (this.props.courseName) {
this.props.courseActions.fetchCourse(this.props.courseName);
}
else if (this.props.refExName) {
this.props.referenceExampleActions.fetchReferenceExample(this.props.refExName);
}
else if (this.props.collection) {
this.setState({ fetchCollection: true });
}
// Render project if we have projectId. This should only happen if coming from viewer
const { match } = this.props;
const projectId = (match && match.params && match.params.id) || "";
if (this.props.match && projectId) {
this.setState({ spinnerOpen: true });
this.props.actions.fetchScene(projectId);
this.setState({ spinnerOpen: false });
this.state.socket.emit("scene", projectId);
}
// Bind to keyboard to listen for shortcuts
document.addEventListener("keydown", this.handleKeyDown.bind(this));
}
/**
* Catches certain keyboard shortcuts
*
* @param {event} e - event from the keystroke.
*/
handleKeyDown(e) {
//metaKey is cmd and windows key in some browsers
if (this.props.layoutType !== layoutTypes.REFERENCE) {
if ((e.ctrlKey || e.metaKey) && (e.key === "Enter" || e.key === "Return")) {
//ctrl/cmd + enter renders the scene
e.preventDefault();
this.clear();
this.closeSnackBar();
this.handleRender();
} else if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === "s" || e.key === "S")) {
//ctrl/cmd + shift + s saves the scene with a new ID
e.preventDefault();
this.setState({ needsNewId: true });
this.handleSaveOpen();
} else if ((e.ctrlKey || e.metaKey) && (e.key === "s" || e.key === "S")) {
//ctrl/cmd + s saves the scene
e.preventDefault();
this.handleSave();
this.handleSaveClose();
}
}
}
/**
* Removes listener for real time sync process
*/
componentWillUnmount() {
}
/**
* When we update, check to see if there is a new message by comparing the local state to
* props.message.time
*/
componentDidUpdate() {
if (this.state.lastMsgTime !== this.props.message.time && this.props.message.text !== "") {
this.setState({ snackOpen: true, lastMsgTime: this.props.message.time });
}
if (this.state.updateCollection && this.props.user) {
this.props.collectionActions.asyncCollection(this.props.collection, this.props.user.uid);
this.setState({ updateCollection: false });
}
if (this.state.fetchCollection && this.props.user) {
this.props.collectionActions.asyncCollection(this.props.collection, this.props.user.uid);
this.setState({ fetchCollection: false });
}
if (this.state.savedSettings.length === 0 && this.props.scene.id !== 0) {
this.setState({ savedSettings: this.buildSettingsArr() });
window.addEventListener("beforeunload", (event) => {
let finalSettings = this.buildSettingsArr();
if (!this.settingsEqual(finalSettings)) {
event.preventDefault();
event.returnValue = "You may have unsaved changes!";
}
});
}
}
/**
* Flatten the sceneSettings from props (object) into an array for comparason
* @returns {array} Array of the scene settings
*/
buildSettingsArr = () => {
const sceneSettings = this.props.scene.settings;
return [sceneSettings.floorColor,
sceneSettings.showCoordHelper, sceneSettings.showFloor,
sceneSettings.skyColor, sceneSettings.viewOnly];
};
/**
* Compare two arrays of setting and determine whether is the settings are equal or not
* @param {array} newSettings Settings to compare
* @returns {boolean} If settings are equal or not
*/
settingsEqual = (newSettings) => {
for (let i = 0; i < newSettings.length; ++i) {
if (newSettings[i] !== this.state.savedSettings[i]) {
return false;
}
}
return true;
}
/**
* The logout function runs when the user click to logout of the application.
*/
logout = () => {
// sync with application state
this.props.logging.logout();
this.props.projectActions.syncUserProj([]);
this.setState({ logMenuOpen: false });
}
/**
* The login function runs when the user click to login of the application.
*/
login = (googleAuth) => {
//googleAuth.getAuthResponse().id_token;
googleAuth.profileObj["uid"] = googleAuth.getAuthResponse().id_token;
this.props.logging.login(googleAuth.profileObj);
this.setState({ logMenuOpen: false, googleUser: googleAuth });
this.props.projectActions.asyncUserProj(this.props.user.uid);
this.props.collectionActions.asyncCollections(this.props.user.uid);
this.props.userActions.asyncUserSettings(this.props.user.uid);
this.setRefreshTime(googleAuth.tokenObj.expires_at);
//send uid to google analyrica
window.gtag("config", "UA-122925714-1", { "user_id": this.props.user.googleId });
}
/**
* Google auth token object has a expiration time that needed to be refresh after certain time period
* This function set the timeout and will refresh the token after it reach the time
* @param {number} time The time when the token will expired
*/
setRefreshTime = (time) => {
const oneMinute = 60 * 1000;
let expiryTime = Math.max(
oneMinute * 5, //Default of 5 minutes
time - Date.now() - oneMinute * 5 // give 5 mins of breathing room
);
setTimeout(this.refreshToken, expiryTime);
}
/**
* Refresh token when the time expires, update the token, and set the refresh time again
*/
refreshToken = () => {
this.state.googleUser.reloadAuthResponse().then((authResponse) => {
this.props.logging.refreshToken(authResponse.id_token);
this.setRefreshTime(authResponse.expires_at);
});
}
/**
* This function produces the DOM elements to display logging functionality
*/
loginBtn = () => {
return (
<div id="user" >
{this.props.user && this.props.user.name ?
<GoogleLogout
clientId={process.env.REACT_APP_GOOGLE_CLIENTID}
buttonText="Logout"
render={renderProps => (
<Fragment>
<Avatar
id="login"
src={this.props.user.imageUrl}
open={this.state.logMenuOpen}
onClick={() => this.setState({ logMenuOpen: !this.state.logMenuOpen })}
label="logout"
style={{ marginTop: 5 }} />
<Popover
open={this.state.logMenuOpen}
anchorEl={document.getElementById("user")}
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
onClose={this.handleLogClick} >
<MenuItem primarytext="Log Out" onClick={renderProps.onClick}>Log Out</MenuItem>
</Popover>
</Fragment>
)}
onLogoutSuccess={this.logout}
onFailure={(err) => console.error("Could not logout: ", err)}
/>
:
<GoogleLogin
clientId={process.env.REACT_APP_GOOGLE_CLIENTID}
buttonText="Login"
isSignedIn={true}
render={renderProps => (
<Button
type="button"
variant="outlined"
size="small"
color="primary"
onClick={renderProps.onClick}
style={{
color: "white",
margin: 4,
padding: 2,
border: "1px solid #fff"
}}>
Log In
</Button>
)}
onSuccess={this.login}
onFailure={(err) => { console.error("Error logging in: ", err); }}
/>
}
</div>
);
}
/**
* This function handles when the user wants to toggle the logging menu
*/
handleLogClick = (event) => {
event.preventDefault();
this.setState({
logMenuOpen: !this.state.logMenuOpen,
});
};
/**
* This sets the components current state to the input from the scene name form
*/
handleNameChange = (event) => {
this.props.sceneActions.nameScene(event.target.value);
}
/**
* This sets the components current state to the input from the scene description form
*/
handleDescChange = (event) => {
this.props.sceneActions.setDesc(event.target.value);
}
/**
* This function produces the form for inputting the scene's name and description
*/
sceneName = () => {
let sceneName = this.props.scene.name;
let sceneDesc = this.props.scene.desc;
return (
<FormControl className="mt-2" aria-describedby="name-helper-text">
<TextField id="name-helper"
value={sceneName ? sceneName : ""}
label="Scene Name"
placeholder={sceneName ? sceneName : "Untitled Scene"}
onBlur={this.handleNameChange}
onChange={this.handleNameChange} />
<TextField
value={sceneDesc ? sceneDesc : ""}
onChange={this.handleDescChange}
onBlur={this.handleDescChange}
label="Description"
margin="normal"
/>
</FormControl>
);
}
/**
* handeRender gets the information from Ace Editor and calls the action: render()
*/
handleRender = () => {
try {
let editor = window.ace.edit("ace-editor");
this.props.actions.render(editor.getSession().getValue(), this.props.user ? this.props.user.uid : "anon");
} catch (error) {
this.props.actions.render(this.props.text, this.props.user ? this.props.user.uid : "anon");
}
}
/**
* This function will determine which projectId to use when saving.
*
* @returns - projectId
*/
getProjectId = () => {
const { match } = this.props;
let projectId = (match && match.params && match.params.id) || null;
return projectId;
}
/**
* @return Return a elements with spinner like effects if the spinnerOpen is true
* Use for when saving or loading a scene
*/
spinner = () => {
if (this.state.spinnerOpen) {
return (
<span className='spinner'>
<div className='cube1'></div>
<div className='cube2'></div>
</span>
);
} else {
return null;
}
}
/**
* When the user clicks save it will upload the information to Firebase
*/
handleSave = (newCollectionID = undefined) => {
let editor, text;
if (!this.props.viewOnly) {
//If in editor mode, gets text directly from editor
editor = window.ace.edit("ace-editor");
text = editor.getSession().getValue();
} else {
//Otherwise, gets text from state (should be up to date since it is refreshed on editor unmount)
text = this.props.text;
}
if (this.props.user && this.props.user.uid && text) {
this.setState({ spinnerOpen: true });
let scene = document.querySelector("a-scene");
// Access the scene and screen shot, with perspective view in a lossy jpeg format
let img = scene.components.screenshot.getCanvas("perspective").toDataURL("image/jpeg", 0.1);
let newScene = {
name: (this.props.scene.name ? this.props.scene.name : "Untitled Scene"),
desc: this.props.scene.desc,
code: text,
uid: this.props.user.uid,
settings: {
...this.props.scene.settings,
collectionID: newCollectionID || this.props.scene.settings.collectionID
},
updateTime: Date.now(),
createTime: (this.props.scene.createTime ? this.props.scene.createTime : Date.now())
};
save(this.props.user.uid, newScene, img, this.props.projectId).then((projectId) => {
if (!projectId) {
console.error("Could not save the scene");
}
this.props.actions.updateSavedText(text);
// If we have a new projectId reload page with it
if (projectId !== this.props.projectId) {
this.setState({ spinnerOpen: false });
window.location.assign(`${window.origin}/scene/${projectId}`);
this.props.projectActions.asyncUserProj(this.props.user.uid);
}
if (!this.state.viewOnly) {
this.props.actions.refresh(text, this.props.user ? this.props.user.uid : "anon");
}
this.setState({ spinnerOpen: false, saveOpen: false });
this.state.socket.emit("save");
return true;
});
} else if (!text) {
alert("There is no code to save for this scene. Try adding some in the editor!");
} else {
// TODO: Don't use alert
alert("We were unable to save your project. Are you currently logged in?");
}
if (!this.state.viewOnly) {
this.props.actions.refresh(text, this.props.user ? this.props.user.uid : "anon");
}
this.setState({ savedSettings: this.buildSettingsArr() });
}
/**
* resets the current scene
*/
clear = () => {
try {
let editor = window.ace.edit("ace-editor");
this.props.actions.refresh(editor.getSession().getValue(), this.props.user ? this.props.user.uid : "anon");
} catch (error) {
this.props.actions.refresh(this.props.text, this.props.user ? this.props.user.uid : "anon");
}
}
/**
* toggles the save drawer
*/
handleSaveToggle = () => this.setState({ saveOpen: !this.state.saveOpen });
/**
* forces save drawer closed
*/
handleSaveClose = () => this.setState({ saveOpen: false });
/**
* forces save drawer closed
*/
handleSaveOpen = () => this.setState({ saveOpen: true });
renameScene = () => {
this.setState({ needsNewId: true });
this.handleSaveOpen();
}
/**
* creates the save drawer
*/
saveDrawer = () => {
return (
<Drawer
variant="persistent"
className="side-drawer"
open={this.state.saveOpen}
onClose={this.handleSaveToggle} >
<IconButton variant="contained"
color="default"
style={exitBtnStyle}
onClick={this.handleSaveToggle}>
<Icon className="material-icons">close</Icon>
</IconButton>
{this.sceneName()}
<Button
variant="contained"
size="small"
color="primary"
onClick={() => this.handleSave(false)}
className="">
<Icon className="material-icons">save</Icon> Save
</Button>
</Drawer>
);
}
/**
* toggles the load project drawer
*/
handleProjectToggle = () => {
this.setState({ projectsOpen: !this.state.projectsOpen });
this.setState({ projectTab: "a" });
};
/**
* toggles the load welcome menu
*/
handleWelcomeToggle = () => {
this.setState({ welcomeOpen: !this.state.welcomeOpen });
};
/**
* toggles the load courses drawer
*/
handleCoursesToggle = () => {
this.setState({ coursesOpen: !this.state.coursesOpen });
};
/**
* toggles the load tour
*/
handleTourToggle = () => {
this.setState({ tourOpen: !this.state.tourOpen });
};
/**
* toggles the load collection drawer
*/
handleCollectionToggle = () => {
this.setState({ collectionOpen: !this.state.collectionOpen });
};
/**
* close the collection drawer
*/
handleCollectionClose = () => {
this.setState({ collectionOpen: false });
};
/**
* toggles the load reference drawer
*/
handleReferenceToggle = () => {
this.setState({ referenceOpen: !this.state.referenceOpen });
};
/**
* Handles when collection is deleted
*/
handleCollectionDelete = (collectionID) => {
if (this.props.scene.settings.collectionID === collectionID) {
this.props.sceneActions.removeCollectionID(this.props.scene);
}
}
/**
* Return a collection component
*/
loadCollection = () => {
return (
<Collection
openCollection={this.props.collection}
collections={this.props.collections}
collectionActions={this.props.collectionActions}
user={this.props.user}
open={this.state.collectionOpen}
handleCollectionToggle={this.handleCollectionToggle}
handleCollectionClose={this.handleCollectionClose}
deleteCallback={this.handleCollectionDelete} />
);
}
/**
* closes the snackbar that displays the message from render
*/
closeSnackBar = () => {
this.setState({ snackOpen: false });
}
/**
* Display the snackbar that displays the message from render
*/
renderSnackBar = () => {
return (
<Snackbar
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
open={this.state.snackOpen}
autoHideDuration={6000}
onClose={this.closeSnackBar}
ContentProps={{
"aria-describedby": "message-id",
}}
message={<span id="message-id">{this.props.message.text}</span>}
action={[
<Button key="undo" color="secondary" size="small" onClick={this.closeSnackBar}>
Dismiss
</Button>
]}
/>
);
}
/**
* creates the header and links the buttons
*/
render() {
const style = {
play: {
margin: 5,
background: "linear-gradient(45deg, #38e438 30%, #58e458 90%)",
},
play_disabled: {
margin: 5,
padding: 0,
background: "#222",
border: "2px solid",
borderColor: "#777",
},
clear: {
margin: 5,
marginRight: 10,
padding: 0,
background: "linear-gradient(45deg, #FE3B3B 30%, #FF3B3B 90%)",
},
clear_disabled: {
margin: 5,
marginRight: 10,
padding: 0,
background: "#222",
border: "2px solid",
borderColor: "#777",
},
default: {
margin: 2,
color: "#fff",
},
disabled: {
margin: 2,
color: "#777",
},
};
const theme = createMuiTheme({
palette: {
primary: {
main: "#3f51b5",
}
}
});
const referenceMode = this.props.layoutType === layoutTypes.REFERENCE;
return (
<header className="App-header align-items-center ">
<div className="col-9 d-flex justify-content-start" style={{ paddingLeft: 0 }}>
<Sidebar scene={this.props.scene} nameScene={this.props.sceneActions.nameScene} >
<Button
variant="contained"
onClick={() => { window.location.assign(window.origin); }}
color="primary"
className="sidebar-btn">
<Icon className="material-icons">add</Icon>
Start New
</Button>
<Button
variant="contained"
onClick={this.props.actions.recover}
color="primary"
className="sidebar-btn"
disabled={referenceMode}>
<Icon className="material-icons">replay</Icon>
Recover
</Button>
<Button
variant="contained"
onClick={this.handleSaveToggle}
color="primary"
className="sidebar-btn"
disabled={referenceMode}>
<Icon className="material-icons">save</Icon>
Save Project
</Button>
<Button
variant="contained"
onClick={this.handleProjectToggle}
color="primary"
className="sidebar-btn">
<Icon className="material-icons">perm_media</Icon>
Open Project
</Button>
<Button
variant="contained"
onClick={this.handleCollectionToggle}
color="primary"
className="sidebar-btn">
<Icon className="material-icons">assignment</Icon>
Collections
</Button>
<Button
variant="contained"
onClick={this.handleWelcomeToggle}
color="primary"
className="sidebar-btn">
<Icon className="material-icons">wb_iridescent</Icon>
Show Welcome Screen
</Button>
</Sidebar>
<h1 className="mr-2 d-none d-sm-block"
style={{ cursor: "pointer" }}
onClick={() => { window.location.assign(window.origin); }} >
MYR
</h1>
<MuiThemeProvider theme={theme}>
<Tooltip title="Render" placement="bottom-start">
<Button
id="play-btn"
variant={referenceMode ? "outlined" : "contained"}
size="small"
onClick={() => {
this.clear();
this.postpone(this.handleRender);
}}
color="primary"
className="header-btn"
style={referenceMode ? style.play_disabled : style.play}
disabled={referenceMode}>
<Icon className="material-icons" style={referenceMode ? { color: "#777" } : { color: "#222" }}>play_arrow</Icon>
</Button>
</Tooltip>
<WelcomeScreen
handleWelcomeToggle={this.handleWelcomeToggle}
welcomeOpen={this.state.welcomeOpen}
deleteFunc={this.props.projectActions.deleteProj}
userProjs={this.props.projects.userProjs}
exampleProjs={this.props.projects.exampleProjs}
courses={this.props.courses.courses}
handleTourToggle={this.handleTourToggle}
renameScene={this.renameScene} />
<Tooltip title="Stop" placement="bottom-start">
<Button
id="stop-btn"
variant={referenceMode ? "outlined" : "contained"}
size="small"
onClick={this.clear}
color="primary"
className="header-btn"
style={referenceMode ? style.clear_disabled : style.clear}
disabled={referenceMode}>
<Icon className="material-icons" style={referenceMode ? { color: "#777" } : { color: "#222" }}>stop</Icon>
</Button>
</Tooltip>
</MuiThemeProvider>
<Tooltip title="New Scene" placement="bottom-start">
<IconButton
id="new-btn"
onClick={() => { window.location.assign(window.origin); }}
style={style.default}
className="header-btn d-none d-md-block" >
<Icon className="material-icons">add_circle_outline</Icon>
</IconButton>
</Tooltip>
<Tooltip title="Save" placement="bottom-start">
<IconButton
id="save-btn"
onClick={this.handleSaveToggle}
className="header-btn d-none d-sm-block"
style={referenceMode ? style.disabled : style.default}
disabled={referenceMode}>
<Icon className="material-icons">save</Icon>
</IconButton>
</Tooltip>
<Tooltip title="reset position" placement="bottom-start">
<ButtonBase
id="reset-btn"
onClick={() => this.props.sceneActions.setCamera(0, 1.6, 3)}
className="header-btn d-none d-md-block"
style= {resetButtonStyle}
disabled={referenceMode}>
<Icon className ="material-icons">settings_backup_restore</Icon>
</ButtonBase>
</Tooltip>
<ProjectView
deleteFunc={this.props.projectActions.deleteProj}
userProjs={this.props.projects.userProjs}
exampleProjs={this.props.projects.exampleProjs}
projectsOpen={this.state.projectsOpen}
handleProjectToggle={this.handleProjectToggle}
tab={this.state.projectTab}
user={this.props.user}
renameScene={this.renameScene} />
<MyrTour
tourOpen={this.state.tourOpen}
handleTourToggle={this.handleTourToggle}
viewOnly={this.props.scene.settings.viewOnly}
changeView={this.props.sceneActions.changeView}
layoutType={this.props.layoutType}
referenceOpen={this.state.referenceOpen}
handleReferenceToggle={this.handleReferenceToggle} />
</div>
<div className="col-3 d-flex justify-content-end">
<Reference
layoutType={this.props.layoutType}
referenceOpen={this.state.referenceOpen}
handleReferenceToggle={this.handleReferenceToggle} />
<SceneConfigMenu
scene={this.props.scene}
sceneActions={this.props.sceneActions}
collectionActions={this.props.collectionActions}
user={this.props.user}
settings={this.props.settings}
userActions={this.props.userActions}
handleRender={this.handleRender}
handleSave={this.handleSave}
handleSaveClose={this.handleSaveClose}
layoutType={this.props.layoutType}
displayCollectionConfig={!this.props.collection}
/>
<CourseSelect
coursesOpen={this.state.coursesOpen}
handleCoursesToggle={this.handleCoursesToggle}
courses={this.props.courses.courses} />
<this.loginBtn />
</div>
<this.saveDrawer />
<this.renderSnackBar />
<this.spinner />
<this.loadCollection />
</header >
);
}
/**
* You can pass functions into this in order to have
* multiple setState/state actions dispatched within an event handler
* Currently only used for render button
*
* @param {function} f Function to call after the timeout
*/
postpone(f) {
window.setTimeout(f, 0);
}
}
export default Header;