diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..2badae6b3fc04489905ce10c5ef15eb8f857f035 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +REACT_APP_API_URL="http://localhost:5000" \ No newline at end of file diff --git a/.gitignore b/.gitignore index f21726c760680e64debfd1abbf4924a22e1fb12c..1787a6ddbfe06e31c9fed57c218ca390b70d5766 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ # misc .DS_Store +.env .env.local .env.development.local .env.test.local diff --git a/public/favicon.ico b/public/favicon.ico index a11777cc471a4344702741ab1c8a588998b1311a..024bc75ef8673d4c4be9d578c42c05be84569963 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..a72cfc2ec3abcdf83a52fd53644a13f9844d6224 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/index.html b/public/index.html index dd1ccfd4cd30a29aaa08b295d99be29cdeb29cf9..8b2f9d1cb04b63987cab6a5b0645beb0aeafff57 100644 --- a/public/index.html +++ b/public/index.html @@ -19,11 +19,12 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - <title>React App</title> + <title>TACS</title> </head> <body> <noscript>You need to enable JavaScript to run this app.</noscript> <div id="root"></div> + <div id="form"></div> <!-- This HTML file is a template. If you open it directly in the browser, you will see an empty page. diff --git a/src/App.css b/src/App.css index b0f08dddfc38b5c27752c025dbfe8113491b93d2..7d7f9508468c0da993d1991880bbf8fde6c93677 100644 --- a/src/App.css +++ b/src/App.css @@ -51,7 +51,7 @@ div.header button:hover { div.fade-main { position: fixed; top: 0; - z-index: 1000; + z-index: 1020; height: 100vh; width: 100vw; margin: auto; @@ -60,22 +60,22 @@ div.fade-main { } div.sticky { - position: fixed; - top: 0px; - right: 0px; - height: 100px; - width: 100px; - margin-right: 150px; + position: fixed; + top: 0px; + right: 0px; + height: 100px; + width: 100px; + margin-right: 150px; } .close { - position: fixed; - color: #f1f1f1; - height: 85px; - font-size: 100px; - font-weight: bold; - transition: transform 0.4s ease-in-out; - line-height: 70%; + position: fixed; + color: #f1f1f1; + height: 85px; + font-size: 100px; + font-weight: bold; + transition: transform 0.4s ease-in-out; + line-height: 70%; } .close:hover, @@ -138,3 +138,62 @@ div.login button:hover { background-color: darkblue; cursor: pointer; } + +.login .formDate { + width: 30%; +} + +.login label.formDate { + color: white; + width: 10%; + margin-left: 10%; + font-size: 180%; +} + +.login .formTime { + width: 30%; + margin-right: 20%; +} + +/* Editing text button in the toolbar */ +.leaflet-draw-toolbar .leaflet-draw-draw-textbox { + background-image: url("icons/button-textbox.png"); + background-size: 30px 30px; +} + +/* Editing tooltips */ +.leaflet-tooltip { + font-size: 18px; + /* Overriding tooltip layout by making it invisible */ + background-color: transparent; + box-shadow: none; + border: none; + padding: 0; +} + +/* remove the small triangle on tooltip */ +.leaflet-tooltip-bottom:before { + border: 0; +} + +/* Editing editable tooltips */ +.editable { + display: block; + cursor: text; /* the cursor icon doesn't change by default when hovering on top of the text; overriding */ + min-width: 154px; + min-height: 18px; + color: #fff; + font-weight: bold; + /* text borders */ + text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, + 1px 1px 0 #000; +} + +/* placeholder text for tooltip */ +[contenteditable="true"]:empty:before { + content: attr(placeholder); + display: block; /* For Firefox */ + color: #777; + text-shadow: none; + font-weight: normal; +} diff --git a/src/App.js b/src/App.js index 7342088361a7e2c36e0cf4cd71d5a6b7e269817b..b2d8e87008ca309f10a6c6f85a8211ea6b313321 100644 --- a/src/App.js +++ b/src/App.js @@ -1,36 +1,62 @@ -import React, { Component } from 'react'; -import '../node_modules/leaflet-draw/dist/leaflet.draw.css' -import './App.css'; -import UserMap from './components/UserMap'; -import Header from './components/Header'; +import React, { Component } from "react"; +import "../node_modules/leaflet-draw/dist/leaflet.draw.css"; +import "./App.css"; +import UserMap from "./components/UserMap"; +import Header from "./components/Header"; class App extends Component { constructor() { super(); - // set initial state this.state = { lat: 62.2416479, lng: 25.7597186, zoom: 13, - mapUrl: 'https://tiles.kartat.kapsi.fi/taustakartta/{z}/{x}/{y}.jpg' + mapUrl: "https://tiles.kartat.kapsi.fi/taustakartta/{z}/{x}/{y}.jpg", + currentGameId: null }; this.handleLayerChange = this.handleLayerChange.bind(this); + this.handleGameChange = this.handleGameChange.bind(this); } // Toggles through the list and changes the mapUrl state handleLayerChange = () => { - const maps = ['https://tiles.kartat.kapsi.fi/taustakartta/{z}/{x}/{y}.jpg', 'https://tiles.kartat.kapsi.fi/peruskartta/{z}/{x}/{y}.jpg','https://tiles.kartat.kapsi.fi/ortokuva/{z}/{x}/{y}.jpg']; - this.setState({mapUrl: maps.indexOf(this.state.mapUrl) < maps.length - 1 ? maps[maps.indexOf(this.state.mapUrl) + 1] : maps[0]}) + const maps = [ + "https://tiles.kartat.kapsi.fi/taustakartta/{z}/{x}/{y}.jpg", + "https://tiles.kartat.kapsi.fi/peruskartta/{z}/{x}/{y}.jpg", + "https://tiles.kartat.kapsi.fi/ortokuva/{z}/{x}/{y}.jpg" + ]; + this.setState({ + mapUrl: + maps.indexOf(this.state.mapUrl) < maps.length - 1 + ? maps[maps.indexOf(this.state.mapUrl) + 1] + : maps[0] + }); + }; + + // function to be sent to Header -> GameList to get changed game ID + handleGameChange = gameId => { + this.setState({ + currentGameId: gameId + }); }; render() { const initialPosition = [this.state.lat, this.state.lng]; return ( <div> - <UserMap position={initialPosition} zoom={this.state.zoom} mapUrl={this.state.mapUrl} />, - <Header handleLayerChange={this.handleLayerChange} /> + <UserMap + position={initialPosition} + zoom={this.state.zoom} + mapUrl={this.state.mapUrl} + currentGameId={this.state.currentGameId} + /> + , + <Header + handleLayerChange={this.handleLayerChange} + handleGameChange={this.handleGameChange} + /> </div> ); } diff --git a/src/components/DrawTools.js b/src/components/DrawTools.js index 0fbf85c1f2ad0fdf391b0b07dcee8350363e3fad..3de6eddbc6bd7478d33e7b8c49daeb2885646aca 100644 --- a/src/components/DrawTools.js +++ b/src/components/DrawTools.js @@ -1,79 +1,397 @@ -import React, {Component} from 'react'; -import { EditControl } from "react-leaflet-draw" +import React, { Component } from "react"; +import { EditControl } from "react-leaflet-draw"; +import L from "leaflet"; +import "leaflet-draw"; import { - FeatureGroup, -} from 'react-leaflet' + FeatureGroup, + Circle, + Marker, + Polygon, + Polyline, + Rectangle, + Tooltip +} from "react-leaflet"; + +// an empty icon for textboxes +let noIcon = L.divIcon({ + className: "", + iconSize: [20, 20], + iconAnchor: [10, 20] +}); + +// class for text field +L.Draw.MarkerTextBox = L.Draw.Marker.extend({ + options: { + icon: noIcon, + repeatMode: false, + interactive: true + }, + initialize: function(map, options) { + this.type = "textbox"; // important to have a unique type, so that it won't get mixed up with other elements + this.featureTypeCode = "textbox"; + L.Draw.Feature.prototype.initialize.call(this, map, options); + } +}); + +// Overriding default toolbar +// Just adding one new button though lol +L.DrawToolbar.include({ + getModeHandlers: function(map) { + return [ + { + enabled: this.options.polyline, + handler: new L.Draw.Polyline(map, this.options.polyline), + title: L.drawLocal.draw.toolbar.buttons.polyline + }, + { + enabled: this.options.polygon, + handler: new L.Draw.Polygon(map, this.options.polygon), + title: L.drawLocal.draw.toolbar.buttons.polygon + }, + { + enabled: this.options.rectangle, + handler: new L.Draw.Rectangle(map, this.options.rectangle), + title: L.drawLocal.draw.toolbar.buttons.rectangle + }, + { + enabled: this.options.circle, + handler: new L.Draw.Circle(map, this.options.circle), + title: L.drawLocal.draw.toolbar.buttons.circle + }, + { + enabled: this.options.marker, + handler: new L.Draw.Marker(map, this.options.marker), + title: L.drawLocal.draw.toolbar.buttons.marker + }, + { + enabled: this.options.marker, + handler: new L.Draw.MarkerTextBox(map, this.options.marker), + title: "Write text" + } + ]; + } +}); class DrawTools extends Component { - constructor(props){ - super(props); - this.state = { - geoJSONAll: [] // property for all GeoJSON data in the map - } + constructor(props) { + super(props); + this.state = { + geoJSONAll: [], // property for all GeoJSON data in the map + editModeActive: false + }; + } + + _onCreated = e => { + // check if a drawn polyline has just one point in it + if (e.layerType === "polyline" && e.layer.getLatLngs().length === 1) { + e.layer.remove(); + return; + } + + if (e.layerType === "textbox") { + // have to create tooltip as a DOM element to allow text selecting. maybe + let tooltip = L.DomUtil.create("div", "editable"); + + // need ids for tooltips to be able to add a blur event to them + tooltip.innerHTML = + '<div contenteditable="true" placeholder="Click out to save" id="' + + e.layer._leaflet_id + + '"></div>'; + + e.layer.bindTooltip(tooltip, { + permanent: true, + direction: "bottom", + interactive: true + }); + + // disable dragging when cursor is over marker (tooltip) + // clicking on tooltip fires the marker's click handler, hence e.layer.on + e.layer.on("mouseover", function() { + e.layer._map.dragging.disable(); + }); + + // enable dragging again when cursor is out of marker (tooltip) + e.layer.on("mouseout", function() { + e.layer._map.dragging.enable(); + }); + + // show placeholder text again upon emptying textbox + e.layer.on("keyup", function() { + // when the text area is emptied, a <br> appears + // manually removing it so that the placeholder text can show + if ( + tooltip.innerHTML === + '<div placeholder="Click out to save" contenteditable="true" id ="' + + e.layer._leaflet_id + + "><br></div>" || + tooltip.innerHTML === + '<div placeholder="Click out to save" contenteditable="true" id ="' + + e.layer._leaflet_id + + "><div><br></div></div>" + ) { + tooltip.innerHTML = + '<div placeholder="Click out to save" contenteditable="true" id ="' + + e.layer._leaflet_id + + "></div>"; + } + }); + + // blur event listener can't be given straight to a layer + // getting element by ID and adding an event listener to the element + document + .getElementById(e.layer._leaflet_id) + .addEventListener( + "blur", + this.makeGeoJSON.bind(this, e.layerType, e.layer) + ); // can't put this.makeGeoJSON(e) straight, as it calls the function + document.getElementById(e.layer._leaflet_id).focus(); + + console.log(e.layer); + + return; // only sending the textbox to database until text has been written + } // end if (e.layerType === "textbox") + + this.makeGeoJSON(e.layerType, e.layer); + }; // end _onCreated + + // turn layer to GeoJSON data + makeGeoJSON = (layerType, layer) => { + // setting the format in which the data will be sent + let geoJSON = { + data: layer.toGeoJSON(), + mapDrawingId: layer.options.id + }; + + // setting properties + if (layerType === "textbox") { + if (layer._tooltip._content.innerText) { + geoJSON.data.properties.text = layer._tooltip._content.innerText; + } else { + return; + } + } else if (layerType === "circle") { + geoJSON.data.properties.radius = layer._mRadius; // layer.options.radius doesn't update for some reason; need to use _mRadius instead + } else if (layerType === "rectangle") { + // rectangle is a simple true/false property to recognize a rectangle + // so that Polygons with this property can be inserted into map with rectangle functionalities instead of Polygon's + geoJSON.data.properties.rectangle = true; + } + geoJSON.data.properties.color = layer.options.color; + + // send item to database, and receive an ID for the layer + this.props.sendGeoJSON(geoJSON, false); + }; + + _onEditDeleteStart = () => { + this.setState({ editModeActive: true }); + }; + + _onEditDeleteStop = () => { + this.setState({ editModeActive: false }); + }; + + _onEdited = e => { + // layers are saved in a rather curious format. they're not in an array, so need to make an array first + let editedLayers = e.layers; + let idsToEdit = []; + editedLayers.eachLayer(function(layer) { + idsToEdit.push(layer); + }); + + idsToEdit.map(layer => { + // checking the contents of the layer to determine its type, as it's not explicitly stated + if (layer.options.bounds) { + this.makeGeoJSON("rectangle", layer); + } else if (layer.options.radius) { + this.makeGeoJSON("circle", layer); + } else if (layer.options.text) { + this.makeGeoJSON("textbox", layer); + } else { + this.makeGeoJSON(null, layer); + } + }); + }; + + _onDeleted = e => { + // layers are saved in a rather curious format. they're not in an array, so need to make an array first + let deletedLayers = e.layers; + let idsToDelete = []; + deletedLayers.eachLayer(function(layer) { + idsToDelete.push(layer); + }); + + idsToDelete.map(layer => { + let geoJSON = { + data: layer.toGeoJSON(), + mapDrawingId: layer.options.id + }; + + this.props.sendGeoJSON(geoJSON, true); + }); + }; + + shouldComponentUpdate() { + // disable re-rendering when edit mode is active + return !this.state.editModeActive; + } + + render() { + return ( + // "It's important to wrap EditControl component into FeatureGroup component from react-leaflet. + // The elements you draw will be added to this FeatureGroup layer, + // when you hit edit button only items in this layer will be edited." + <FeatureGroup> + <EditControl + position="topright" + onCreated={this._onCreated} + onEdited={this._onEdited} + onEditStart={this._onEditDeleteStart} + onEditStop={this._onEditDeleteStop} + onDeleted={this._onDeleted} + onDeleteStart={this._onEditDeleteStart} + onDeleteStop={this._onEditDeleteStop} + draw={{ + circle: { + repeatMode: false, // allows using the tool again after finishing the previous shape + shapeOptions: { + color: "#f9f10c", + opacity: 1 // affects the outline only. for some reason it wasn't at full opacity, so this is needed for more clarity + } + }, + rectangle: { + repeatMode: false + }, + polygon: { + repeatMode: true, + allowIntersection: false, // Restricts shapes to simple polygons + drawError: { + color: "#e1e100", // Color the shape will turn when intersects + message: "<strong>Oh snap!<strong> you can't draw that!" // Message that will show when intersect + }, + shapeOptions: { + color: "#ed2572", + opacity: 1 + } + }, + polyline: { + repeatMode: true, + shapeOptions: { + color: "#ed2572", + opacity: 1 + } + }, + marker: { + repeatMode: false + }, + circlemarker: false + }} + /> + + {/* iterate through every element fetched from back-end */} + {this.props.geoJSONLayer.features.map(feature => { + let id = feature.mapDrawingId; + let coords = feature.data.geometry.coordinates; + let type = feature.data.geometry.type; + let color = feature.data.properties.color; + let radius = feature.data.properties.radius; + let text = feature.data.properties.text; + let rectangle = feature.data.properties.rectangle; + + if (type === "Point") { + // GeoJSON saves latitude first, not longitude like usual. swapping + let position = [coords[1], coords[0]]; + if (radius) { + return ( + // keys are required to be able to edit + <Circle + key={Math.random()} + center={position} + id={id} + radius={radius} + color={color} + /> + ); + } else if (text) { + return ( + <Marker + key={Math.random()} + position={position} + id={id} + color={color} + icon={noIcon} + > + <Tooltip + direction="bottom" + permanent + className="editable" + interactive={true} + > + <div class="editable"> + <div + contenteditable="true" + placeholder="Click out to save" + > + {text} + </div> + </div> + </Tooltip> + </Marker> + ); + } else { + // unknown if color changes anything. need to test + return ( + <Marker + key={Math.random()} + position={position} + id={id} + color={color} + /> + ); + } + } else if (rectangle) { + // instead of an array of four coordinates, rectangles only have two corners + let bounds = coords[0].map(coord => { + return [coord[1], coord[0]]; + }); + return ( + <Rectangle + key={Math.random()} + bounds={bounds} + id={id} + color={color} + /> + ); + } else if (type === "Polygon") { + // Polygon coordinates are wrapped under a one element array, for some reason + let positions = coords[0].map(coord => { + return [coord[1], coord[0]]; + }); + return ( + <Polygon + key={Math.random()} + positions={positions} + id={id} + color={color} + /> + ); + } else if (type === "LineString") { + // Polyline coordinates are a normal array, unlike Polygon + let positions = coords.map(coord => { + return [coord[1], coord[0]]; + }); + return ( + <Polyline + key={Math.random()} + positions={positions} + id={id} + color={color} + /> + ); + } + })} + </FeatureGroup> + ); } - - _onCreated = (e) => { - let type = e.layerType; // from the example; isn't used right now, but may be relevant in the future - let layer = e.layer; - this.makeGeoJSON(e.layer); - } - - // turn layer to GeoJSON data and add it to an array of all GeoJSON data of the current map - makeGeoJSON = (e) => { - let geoJSON = e.toGeoJSON(); - let newGeoJSONAll = this.state.geoJSONAll; - newGeoJSONAll.push(geoJSON); - this.setState({geoJSONAll: newGeoJSONAll}); - console.log(JSON.stringify(geoJSON, null, 4)); // printing GeoJSON data of the previous object create - console.log("newGeoJSONAll.length: " + newGeoJSONAll.length); - } - - render() { - return ( - // "It's important to wrap EditControl component into FeatureGroup component from react-leaflet. The elements you draw will be added to this FeatureGroup layer, when you hit edit button only items in this layer will be edited." - <FeatureGroup> - <EditControl - position='topright' - onCreated={this._onCreated} - draw={{ - circle: { - repeatMode: true, // allows using the tool again after finishing the previous shape - shapeOptions: { - color: '#f9f10c', - opacity: 100 - } - }, - rectangle: { - repeatMode: true - }, - polygon: { - repeatMode: true, - allowIntersection: false, // Restricts shapes to simple polygons - drawError: { - color: '#e1e100', // Color the shape will turn when intersects - message: '<strong>Oh snap!<strong> you can\'t draw that!' // Message that will show when intersect - }, - shapeOptions: { - color: '#ed2572', - opacity: 100 - } - }, - polyline: { - repeatMode: true, - shapeOptions: { - color: '#ed2572', - opacity: 100 - } - }, - marker: { - repeatMode: true - }, - circlemarker: false - }} - /> - </FeatureGroup> - ) - } } -export default DrawTools; \ No newline at end of file +export default DrawTools; diff --git a/src/components/EditGameForm.js b/src/components/EditGameForm.js new file mode 100644 index 0000000000000000000000000000000000000000..fbc37f8995faaaec41f56c51a6b1b6cdd6ba5d4b --- /dev/null +++ b/src/components/EditGameForm.js @@ -0,0 +1,226 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import { Map, TileLayer } from "react-leaflet"; + +export class EditGameForm extends React.Component { + constructor(props) { + super(props); + + this.state = { + gamename: "", + description: "", + startDate: "", + startTime: "", + endDate: "", + endTime: "", + zoom: 13, + mapCenter: { + lat: 62.2416479, + lng: 25.7597186 + } + }; + + this.handleMapDrag = this.handleMapDrag.bind(this); + } + + handleError = error => { + this.setState({ errorMsg: error }); + }; + + handleChange = e => { + const { name, value } = e.target; + this.setState({ [name]: value }); + }; + + // show/hide this form + handleView = e => { + this.props.toggleView(this.props.view); + }; + + // remove view with ESC + handleEsc = e => { + if (e.keyCode === 27) { + this.handleView(); + } + }; + + handleMapDrag = e => { + this.setState({ + mapCenter: e.target.getCenter() + }); + }; + + handleMapScroll = e => { + this.setState({ + zoom: e.target.getZoom() + }); + }; + + handleGameSave = e => { + let startDate = + this.state.startDate + "T" + this.state.startTime + ":00.000Z"; + let endDate = this.state.endDate + "T" + this.state.endTime + ":00.000Z"; + + const gameObject = { + name: this.state.gamename, + desc: this.state.description, + map: "", + startdate: startDate, + enddate: endDate, + center: this.state.mapCenter + }; + + e.preventDefault(); + + let token = sessionStorage.getItem("token"); + + // Send Game info to the server + fetch(`${process.env.REACT_APP_API_URL}/game/edit/${this.props.gameId}`, { + method: "PUT", + headers: { + Authorization: "Bearer " + token, + Accept: "application/json", + "Content-Type": "application/json" + }, + body: JSON.stringify(gameObject) + }) + .then(res => res.json()) + .then(result => { + alert(result.message); + this.handleView(); + }) + .catch(error => console.log("Error: ", error)); + }; + + componentDidMount() { + document.addEventListener("keyup", this.handleEsc); + this.getGameInfo(this.props.gameId); + } + + componentWillUnmount() { + document.removeEventListener("keyup", this.handleEsc); + } + + getGameInfo(gameId) { + fetch(`${process.env.REACT_APP_API_URL}/game/${gameId}`) + .then(response => response.json()) + .then(json => this.handleGameInfo(json)) + .catch(error => console.log(error)); + } + + handleGameInfo(json) { + this.setState({ + gamename: json.name, + description: json.desc, + startDate: json.startdate.substring(0, 10), + startTime: json.startdate.substring(11, 16), + endDate: json.enddate.substring(0, 10), + endTime: json.enddate.substring(11, 16), + zoom: 13, + mapCenter: { + lat: json.center.lat, + lng: json.center.lng + } + }); + } + + render() { + return ReactDOM.createPortal( + <div className="fade-main"> + <div className="sticky"> + <span + id="closeEditGameFormX" + className="close" + onClick={this.handleView} + > + × + </span> + </div> + <div className=""> + <form onSubmit={this.handleGameSave}> + <h1>Demo Game Creation</h1> + <br /> + <input + placeholder="Game name" + name="gamename" + value={this.state.gamename} + onChange={this.handleChange} + id="editGameNameInput" + /> + <br /> + <input + placeholder="Description" + type="text" + name="description" + value={this.state.description} + onChange={this.handleChange} + id="editGameDescriptionInput" + /> + <br /> + <label className="">Start:</label> + <input + className="formDate" + type="date" + name="startDate" + value={this.state.startDate} + onChange={this.handleChange} + id="editGameDateStartInput" + /> + <input + className="formTime" + type="time" + name="startTime" + value={this.state.startTime} + onChange={this.handleChange} + rid="editGameTimeStartInput" + /> + <br /> + <label className="">End:</label> + <input + className="formDate" + type="date" + name="endDate" + value={this.state.endDate} + onChange={this.handleChange} + min={this.state.startDate} + id="editGameDateEndInput" + /> + <input + className="formTime" + type="time" + name="endTime" + value={this.state.endTime} + onChange={this.handleChange} + id="editGameTimeEndInput" + /> + <br /> + <label>Map things</label> + <br /> + <Map + id="editGameCenterMap" + className="" + center={[this.state.mapCenter.lat, this.state.mapCenter.lng]} + zoom={this.state.zoom} + style={{ height: "400px", width: "400px" }} + onmoveend={this.handleMapDrag} + onzoomend={this.handleMapScroll} + > + <TileLayer + attribution="Maanmittauslaitoksen kartta" + url=" https://tiles.kartat.kapsi.fi/taustakartta/{z}/{x}/{y}.jpg" + /> + </Map> + <br /> + <button id="editGameSubmitButton" type="submit"> + Save changes + </button> + <h2>{this.state.errorMsg}</h2> + </form> + </div> + </div>, + document.getElementById("form") + ); + } +} + +export default EditGameForm; diff --git a/src/components/GameList.js b/src/components/GameList.js new file mode 100644 index 0000000000000000000000000000000000000000..f3fc6d3c71a350b960302f428ed66df3271a8e87 --- /dev/null +++ b/src/components/GameList.js @@ -0,0 +1,99 @@ +import React, { Fragment } from "react"; +import EditGameForm from "./EditGameForm"; + +class GameList extends React.Component { + constructor(props) { + super(props); + this.state = { + games: [], + selectedGame: null, + editForm: false + }; + + this.toggleView = this.toggleView.bind(this); + } + + componentDidMount() { + this.getGames(); + } + + getGames() { + fetch(`${process.env.REACT_APP_API_URL}/game/listgames`) + .then(response => response.json()) + .then(games => { + this.setState({ + games: games, + selectedGame: games !== undefined && games[0].id + }); + // taking the initialized gameID to UserMap.js (GameList.js -> Header.js -> App.js -> UserMap.js) + this.props.handleGameChange(games[0].id); + }) + .catch(error => { + console.log(error); + }); + } + + handleChange = e => { + this.setState( + { + selectedGame: e.target.value + }, + () => { + // taking the changed gameID to UserMap.js (GameList.js -> Header.js -> App.js -> UserMap.js) + this.props.handleGameChange(this.state.selectedGame); + } + ); + }; + + handleEditClick = e => { + if (this.state.selectedGame === null) { + alert("No game selected"); + } else { + this.setState({ + editForm: true + }); + } + }; + + toggleView = e => { + this.setState({ + editForm: !this.state.editForm + }); + this.getGames(); + }; + + render() { + let items = []; + + for (let i = 0; i < this.state.games.length; i++) { + const element = this.state.games[i]; + items.push( + <option key={element.id} value={element.id}> + {element.name} + </option> + ); + } + + return ( + <Fragment> + <label>Game: </label> + <select id="changeActiveGameList" onChange={this.handleChange}> + {items} + </select> + {sessionStorage.getItem("token") && ( + <button id="editGameButton" onClick={this.handleEditClick}> + Edit game + </button> + )} + {this.state.editForm && this.state.selectedGame !== null && ( + <EditGameForm + gameId={this.state.selectedGame} + toggleView={this.toggleView} + /> + )} + </Fragment> + ); + } +} + +export default GameList; diff --git a/src/components/Header.js b/src/components/Header.js index dfe41df38dfd877773b63aee334363518e12a6e8..13457f78a81c6381d8f0b630049491bb91102218 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -1,43 +1,46 @@ -import React from 'react'; +import React from "react"; -import LoginForm from './LoginForm'; -import RegisterForm from './RegisterForm'; +import LoginForm from "./LoginForm"; +import RegisterForm from "./RegisterForm"; +import GameList from "./GameList"; +import NewGameForm from "./NewGameForm"; class Header extends React.Component { + constructor(props) { + super(props); + } + state = { - login: false, - register: false, + form: "", // Popup form (login, register etc.) username: null, token: null }; // toggles the login/register view toggleView = view => { - this.setState(prevState => { - return { - [view]: view === 'login' ? !prevState.login : !prevState.register - }; + this.setState({ + form: view }); }; handleState = data => { - sessionStorage.setItem('name', data.name); - sessionStorage.setItem('token', data.token); + sessionStorage.setItem("name", data.name); + sessionStorage.setItem("token", data.token); this.setState({ username: data.name, token: data.token }); }; handleLogout = () => { this.setState({ username: null, token: null }); - sessionStorage.removeItem('token'); + sessionStorage.removeItem("token"); }; // verifies the token (if it exists) on element mount with backend server componentDidMount() { - let token = sessionStorage.getItem('token'); + let token = sessionStorage.getItem("token"); if (token) { - fetch(`${process.env.REACT_APP_URL}/user/verify`, { + fetch(`${process.env.REACT_APP_API_URL}/user/verify`, { headers: { - Authorization: 'Bearer ' + token + Authorization: "Bearer " + token } }) .then(res => res.json()) @@ -46,7 +49,7 @@ class Header extends React.Component { // if token is still valid, login user if (result === true) { this.setState({ - username: sessionStorage.getItem('name'), + username: sessionStorage.getItem("name"), token: token }); // logout user if token has expired / is invalid @@ -64,36 +67,60 @@ class Header extends React.Component { render() { return ( <div> - <div className='header'> + <div className="header"> {!this.state.username && ( - <button id="registerButton" onClick={() => this.toggleView('register')}> + <button + id="registerButton" + onClick={() => this.toggleView("register")} + > register </button> )} {!this.state.username && ( - <button id="loginButton" onClick={() => this.toggleView('login')}>login</button> + <button id="loginButton" onClick={() => this.toggleView("login")}> + login + </button> )} {this.state.username && ( - <button id="logoutButton" onClick={this.handleLogout}>logout</button> + <button + id="newGameButton" + onClick={() => this.toggleView("newgame")} + > + New Game + </button> + )} + {this.state.username && ( + <button id="logoutButton" onClick={this.handleLogout}> + logout + </button> )} {this.state.username && <button>{this.state.username}</button>} - <button id="changeLayerButton" onClick={this.props.handleLayerChange}>change layer</button> + <button id="changeLayerButton" onClick={this.props.handleLayerChange}> + change layer + </button> + <GameList handleGameChange={this.props.handleGameChange} /> </div> - {this.state.register && ( + {this.state.form === "register" && ( <RegisterForm - view='register' + view="" handleState={this.handleState} toggleView={this.toggleView} /> )} - {this.state.login && ( + {this.state.form === "login" && ( <LoginForm - view='login' + view="" + handleState={this.handleState} + toggleView={this.toggleView} + /> + )} + {this.state.form === "newgame" && ( + <NewGameForm + view="" handleState={this.handleState} toggleView={this.toggleView} /> )} - </div> ); } diff --git a/src/components/LoginForm.js b/src/components/LoginForm.js index dbf1c833d38183336d0d372c975699694ed5db65..61f3dec0a63cc4391c5f7a328303e9db9513d726 100644 --- a/src/components/LoginForm.js +++ b/src/components/LoginForm.js @@ -1,13 +1,13 @@ -import React from 'react'; +import React from "react"; export class LoginForm extends React.Component { constructor(props) { super(props); this.state = { - errorMsg: '', - username: '', - password: '' + errorMsg: "", + username: "", + password: "" }; } @@ -39,10 +39,10 @@ export class LoginForm extends React.Component { // Send login info to the server fetch(`${process.env.REACT_APP_API_URL}/user/login`, { - method: 'POST', + method: "POST", headers: { - Accept: 'application/json', - 'Content-Type': 'application/json' + Accept: "application/json", + "Content-Type": "application/json" }, body: JSON.stringify({ name: name, @@ -69,47 +69,48 @@ export class LoginForm extends React.Component { }; componentDidMount() { - document.addEventListener('keyup', this.handleEsc); + document.addEventListener("keyup", this.handleEsc); } componentWillUnmount() { - document.removeEventListener('keyup', this.handleEsc); + document.removeEventListener("keyup", this.handleEsc); } render() { return ( - <div className='fade-main'> - <div className='sticky'> + <div className="fade-main"> + <div className="sticky"> <span - id='closeLoginFormX' - className='close' + id="closeLoginFormX" + className="close" onClick={this.handleView} > × </span> </div> - <div className='login'> + <div className="login"> <form onSubmit={this.handleLogin}> <h1>demo login</h1> <br /> <input - placeholder='Enter Username' - name='username' + placeholder="Enter Username" + name="username" value={this.state.username} onChange={this.handleChange} - id='loginUsernameInput' + id="loginUsernameInput" + autofocus /> <br /> <input - placeholder='Enter password' - type='password' - name='password' + placeholder="Enter password" + type="password" + name="password" value={this.state.password} onChange={this.handleChange} - id='loginPasswordInput' + id="loginPasswordInput" /> <br /> - <button id='submitLoginButton' type='submit'> + <button id="submitLoginButton" type="submit"> login </button> <h2>{this.state.errorMsg}</h2> diff --git a/src/components/NewGameForm.js b/src/components/NewGameForm.js new file mode 100644 index 0000000000000000000000000000000000000000..e84940d00c6619d3793d5b9cc5746ebf3269e36c --- /dev/null +++ b/src/components/NewGameForm.js @@ -0,0 +1,199 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import { Map, TileLayer } from "react-leaflet"; + +export class NewGameForm extends React.Component { + constructor(props) { + super(props); + + this.state = { + gamename: "", + description: "", + startDate: "", + startTime: "", + endDate: "", + endTime: "", + zoom: 13, + mapCenter: { + lat: 62.2416479, + lng: 25.7597186 + } + }; + + this.handleMapDrag = this.handleMapDrag.bind(this); + } + + handleError = error => { + this.setState({ errorMsg: error }); + }; + + handleChange = e => { + const { name, value } = e.target; + this.setState({ [name]: value }); + }; + + // show/hide this form + handleView = e => { + this.props.toggleView(this.props.view); + }; + + // remove view with ESC + handleEsc = e => { + if (e.keyCode === 27) { + this.handleView(); + } + }; + + handleMapDrag = e => { + this.setState({ + mapCenter: e.target.getCenter() + }); + }; + + handleMapScroll = e => { + this.setState({ + zoom: e.target.getZoom() + }); + }; + + handleGameCreation = e => { + let startDate = + this.state.startDate + "T" + this.state.startTime + ":00.000Z"; + let endDate = this.state.endDate + "T" + this.state.endTime + ":00.000Z"; + + const gameObject = { + name: this.state.gamename, + desc: this.state.description, + map: "", + startdate: startDate, + enddate: endDate, + center: this.state.mapCenter + }; + + e.preventDefault(); + + let token = sessionStorage.getItem("token"); + + // Send Game info to the server + fetch(`${process.env.REACT_APP_API_URL}/game/new`, { + method: "POST", + headers: { + Authorization: "Bearer " + token, + Accept: "application/json", + "Content-Type": "application/json" + }, + body: JSON.stringify(gameObject) + }) + .then(res => res.json()) + .then(result => { + this.handleView(); + }) + .catch(error => console.log("Error: ", error)); + }; + + componentDidMount() { + document.addEventListener("keyup", this.handleEsc); + } + + componentWillUnmount() { + document.removeEventListener("keyup", this.handleEsc); + } + + render() { + return ReactDOM.createPortal( + <div className="fade-main"> + <div className="sticky"> + <span + id="closeNewGameFormX" + className="close" + onClick={this.handleView} + > + × + </span> + </div> + <div className=""> + <form onSubmit={this.handleGameCreation}> + <h1>Demo Game Creation</h1> + <br /> + <input + placeholder="Game name" + name="gamename" + value={this.state.gamename} + onChange={this.handleChange} + id="newGameNameInput" + /> + <br /> + <input + placeholder="Description" + type="text" + name="description" + value={this.state.description} + onChange={this.handleChange} + id="newGameDescriptionInput" + /> + <br /> + <label className="">Start:</label> + <input + className="formDate" + type="date" + name="startDate" + value={this.state.startDate} + onChange={this.handleChange} + id="newGameDateStartInput" + /> + <input + className="formTime" + type="time" + name="startTime" + onChange={this.handleChange} + id="newGameTimeStartInput" + /> + <br /> + <label className="">End:</label> + <input + className="formDate" + type="date" + name="endDate" + value={this.state.endDate} + onChange={this.handleChange} + min={this.state.startDate} + id="newGameDateEndInput" + /> + <input + className="formTime" + type="time" + name="endTime" + onChange={this.handleChange} + id="newGameTimeEndInput" + /> + <br /> + <label>Map things</label> + <br /> + <Map + id="newGameCenterMap" + className="" + center={[this.state.mapCenter.lat, this.state.mapCenter.lng]} + zoom={this.state.zoom} + style={{ height: "400px", width: "400px" }} + onmoveend={this.handleMapDrag} + onzoomend={this.handleMapScroll} + > + <TileLayer + attribution="Maanmittauslaitoksen kartta" + url=" https://tiles.kartat.kapsi.fi/taustakartta/{z}/{x}/{y}.jpg" + /> + </Map> + <br /> + <button id="newGameSubmitButton" type="submit"> + Submit + </button> + <h2>{this.state.errorMsg}</h2> + </form> + </div> + </div>, + document.getElementById("form") + ); + } +} + +export default NewGameForm; diff --git a/src/components/Player.js b/src/components/Player.js new file mode 100644 index 0000000000000000000000000000000000000000..195cc40acbdc959c08b58da9b290bf44577f9e9f --- /dev/null +++ b/src/components/Player.js @@ -0,0 +1,96 @@ +import React, { Component } from "react"; +import { Marker, Popup } from "react-leaflet"; + +class Player extends Component { + constructor(props) { + super(props); + this.state = { + players: null + }; + } + + getPlayers() { + fetch( + `${process.env.REACT_APP_API_URL}/tracking/players/${ + this.props.currentGameId + }`, + { + method: "GET", + headers: { + Authorization: "Bearer " + sessionStorage.getItem("token") + } + } + ) + .then(res => res.json()) // no brackets over res.json() or it breaks (what) + .then(data => { + // don't do anything if data is not an array, as it breaks the map function at render + if (Array.isArray(data)) { + this.setState({ + players: data + }); + } + }) + .catch(error => { + console.log(error); + }); + } + + componentDidMount() { + // update components every 10 seconds + this.interval = setInterval(() => this.setState(this.getPlayers()), 5000); + } + + shouldComponentUpdate(nextProps, nextState) { + // do not update component until players have been fetched and game ID is available + if (this.state.players === null) { + this.getPlayers(); + return false; + } else if (this.props.currentGameId === null) { + return false; + } else { + return true; + } + } + + componentDidUpdate() { + // check if game ID has changed + if (this.state.currentGameId !== this.props.currentGameId) { + this.setState({ + currentGameId: this.props.currentGameId + }); + } + } + + componentWillUnmount() { + clearInterval(this.interval); + } + + render() { + return ( + <div> + {this.state.players !== null && + this.state.players.map(player => { + return ( + <Marker + key={Math.random()} + position={[player.coordinates.lat, player.coordinates.lng]} + factionId={player.factionId} + gamepersonId={player.gamepersonId} + gamepersonRole={player.gamepersonRole} + > + <Popup> + <b>factionId:</b> {player.factionId} + <br /> + <b>gamepersonId:</b> {player.gamepersonId} + <br /> + <b>gamepersonRole:</b> {player.gamepersonRole} + </Popup> + </Marker> + ); + })} + </div> + ); + } +} + +export default Player; diff --git a/src/components/RegisterForm.js b/src/components/RegisterForm.js index 727fbde10465c456e9ea4452205bf3a75be1fc0a..3ef5ca7594ad2e8177d7a37037047b144000a68e 100644 --- a/src/components/RegisterForm.js +++ b/src/components/RegisterForm.js @@ -1,14 +1,14 @@ -import React from 'react'; +import React from "react"; export class RegisterForm extends React.Component { constructor(props) { super(props); this.state = { - errorMsg: '', - username: '', - password: '', - password2: '' + errorMsg: "", + username: "", + password: "", + password2: "" }; } @@ -41,14 +41,14 @@ export class RegisterForm extends React.Component { e.preventDefault(); if (this.state.password !== this.state.password2) { - this.handleError('Passwords do not match'); + this.handleError("Passwords do not match"); } else { // Send register info to the server fetch(`${process.env.REACT_APP_API_URL}/user/register`, { - method: 'POST', + method: "POST", headers: { - Accept: 'application/json', - 'Content-Type': 'application/json' + Accept: "application/json", + "Content-Type": "application/json" }, body: JSON.stringify({ name: name, @@ -76,56 +76,54 @@ export class RegisterForm extends React.Component { }; componentDidMount() { - document.addEventListener('keyup', this.handleEsc); + document.addEventListener("keyup", this.handleEsc); } componentWillUnmount() { - document.removeEventListener('keyup', this.handleEsc); + document.removeEventListener("keyup", this.handleEsc); } render() { return ( - <div className='fade-main'> - <div className='sticky'> - <span - id='closeRegisterFormX' - className='close' - onClick={this.handleView} - > + <div className="fade-main"> + <div className="sticky"> + <span className="close" onClick={this.handleView}> × </span> </div> - <div className='login'> + <div className="login"> <form onSubmit={this.handleRegister}> <h1>register new user</h1> <br /> <input - placeholder='Enter Username' - name='username' + placeholder="Enter Username" + name="username" value={this.state.username} onChange={this.handleChange} - id='registerUsernameInput' + id="registerUsernameInput" + autoFocus + //required /> <br /> <input - placeholder='Enter password' - type='password' - name='password' + placeholder="Enter password" + type="password" + name="password" value={this.state.password} onChange={this.handleChange} - id='registerPasswordInput' + id="registerUsernameInput" /> <br /> <input - placeholder='Verify password' - type='password' - name='password2' + placeholder="Verify password" + type="password" + name="password2" value={this.state.password2} onChange={this.handleChange} - id='registerPasswordVerifyInput' + id="registerPasswordVerifyInput" /> <br /> - <button id='submitRegisterButton' type='submit'> + <button id="submitRegisterButton" type="submit"> register </button> <h2>{this.state.errorMsg}</h2> diff --git a/src/components/UserMap.js b/src/components/UserMap.js index 52cce7ac28be2f1bbaa3a8aaeafd523e5b047cb3..d1e3f86927e264a3e337574b29597eaa0941b744 100644 --- a/src/components/UserMap.js +++ b/src/components/UserMap.js @@ -1,114 +1,205 @@ -import React, {Component} from 'react'; -import { - Map, - TileLayer, - ZoomControl, - Marker, - Popup -} from 'react-leaflet' -import DrawTools from './DrawTools.js' +import React, { Component } from "react"; +import { Map, TileLayer, ZoomControl, Marker, Popup } from "react-leaflet"; +import DrawTools from "./DrawTools.js"; +import Player from "./Player.js"; class UserMap extends Component { - constructor(props){ + constructor(props) { super(props); + this.state = { ownLat: null, ownLng: null, - mapUrl: 'https://tiles.kartat.kapsi.fi/taustakartta/{z}/{x}/{y}.jpg' - } + mapUrl: "https://tiles.kartat.kapsi.fi/taustakartta/{z}/{x}/{y}.jpg", + geoJSONLayer: { + type: "FeatureCollection", + features: [] + }, + currentGameId: null + }; + this.sendGeoJSON = this.sendGeoJSON.bind(this); + this.setCurrentPosition = this.setCurrentPosition.bind(this); this.watchPositionId = null; } - componentDidMount(){ - this.getCurrentPosition((position) => { + componentDidMount() { + this.getCurrentPosition(position => { this.setCurrentPosition(position); }); } - componentWillUnmount(){ - if(this.watchPositionId != null){ + componentDidUpdate() { + // check if game ID has changed and fetch that game's drawings + if (this.state.currentGameId !== this.props.currentGameId) { + this.setState({ + currentGameId: this.props.currentGameId + }); + this.fetchGeoJSON(); + } + } + + // Sends the players drawings to the backend (and database) + sendGeoJSON(layerToDatabase, isDeleted) { + // isDeleted is used to determine the drawing's drawingIsActive status + // otherwise the fetch functions are the same in both if and else. any smarter way to do this? + if (isDeleted === true) { + fetch( + `${process.env.REACT_APP_API_URL}/draw/mapdrawing/${ + this.props.currentGameId + }`, + { + method: "PUT", + headers: { + Authorization: "Bearer " + sessionStorage.getItem("token"), + Accept: "application/json", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + type: "FeatureCollection", + drawingIsActive: false, + mapDrawingId: layerToDatabase.mapDrawingId, + data: layerToDatabase.data + }) + } + ); + } else { + fetch( + `${process.env.REACT_APP_API_URL}/draw/mapdrawing/${ + this.props.currentGameId + }`, + { + method: "PUT", + headers: { + Authorization: "Bearer " + sessionStorage.getItem("token"), + Accept: "application/json", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + type: "FeatureCollection", + drawingIsActive: true, + mapDrawingId: layerToDatabase.mapDrawingId, + data: layerToDatabase.data + }) + } + ); + } + + // get the layers again to stop updating with old objects + this.fetchGeoJSON(); + } + + // Get the drawings from the backend and add them to the state, so they can be drawn + fetchGeoJSON() { + fetch( + `${process.env.REACT_APP_API_URL}/draw/map/${this.props.currentGameId}`, + { + method: "GET", + headers: { + Authorization: "Bearer " + sessionStorage.getItem("token"), + Accept: "application/json", + "Content-Type": "application/json" + } + } + ) + .then(res => res.json()) + .then(data => { + let newFeatures = []; + data.map(item => { + newFeatures.push(item); + }); + + this.setState({ + geoJSONLayer: { + type: "FeatureCollection", + features: [...newFeatures] + } + }); + }) + .catch(error => { + console.log(error); + }); + } + + componentWillUnmount() { + if (this.watchPositionId != null) { navigator.geolocation.clearWatch(this.watchPositionId); } } - setCurrentPosition(position){ + setCurrentPosition(position) { this.setState({ ownLat: position.coords.latitude, - ownLng: position.coords.longitude, + ownLng: position.coords.longitude }); } - getCurrentPosition(callback){ - if(!navigator.geolocation){ + getCurrentPosition(callback) { + if (!navigator.geolocation) { console.log("Can't get geolocation :/"); - } - else{ + } else { // Position tracking options const options = { enableHighAccuracy: true, timeout: 30000, maximumAge: 0 - } - - if(this.watchPositionId != null){navigator.geolocation.clearWatch(this.watchPositionId);} - - this.watchPositionId = navigator.geolocation.watchPosition((position) =>{ - //success - if(position != null){ - callback(position); - } - }, (error) =>{ - console.log(error); - // disable tracking - if(this.watchPositionId != null){ - navigator.geolocation.clearWatch(this.watchPositionId); - } - }, options); - } - } + }; - positionToGeoJSON(position){ - let geoJSON = { - type: "Feature", - properties: {}, - geometry: { - type: "Point", - coordinates: [position.coords.longitude, position.coords.latitude] + if (this.watchPositionId != null) { + navigator.geolocation.clearWatch(this.watchPositionId); } - } - return JSON.stringify(geoJSON); + this.watchPositionId = navigator.geolocation.watchPosition( + position => { + //success + if (position != null) { + callback(position); + } + }, + error => { + console.log(error); + // disable tracking + if (this.watchPositionId != null) { + navigator.geolocation.clearWatch(this.watchPositionId); + } + }, + options + ); + } } render() { return ( <Map - className='map' + className="map" center={this.props.position} zoom={this.props.zoom} - minZoom='7' - maxZoom='17' - zoomControl={false}> + minZoom="7" + maxZoom="17" + zoomControl={false} + > <TileLayer attribution='© <a href="https://www.maanmittauslaitos.fi/">Maanmittauslaitos</a>' url={this.props.mapUrl} /> - <ZoomControl position='topright' /> - <DrawTools position={this.props.position} /> - <Marker position={this.props.position}> - <Popup> - Se on perjantai, my dudes <br /> - </Popup> - </Marker> - {this.state.ownLat !== null && <Marker position={[this.state.ownLat, this.state.ownLng]}> - <Popup> - User's real position.<br /> - </Popup> - </Marker>} + <ZoomControl position="topright" /> + <DrawTools + position={this.props.position} + sendGeoJSON={this.sendGeoJSON} + geoJSONLayer={this.state.geoJSONLayer} + currentGameId={this.props.currentGameId} + /> + {this.state.ownLat !== null && ( + <Marker position={[this.state.ownLat, this.state.ownLng]}> + <Popup> + User's real position. + <br /> + </Popup> + </Marker> + )} + <Player currentGameId={this.state.currentGameId} /> </Map> ); } } - -export default UserMap; \ No newline at end of file +export default UserMap; diff --git a/src/icons/button-textbox.png b/src/icons/button-textbox.png new file mode 100644 index 0000000000000000000000000000000000000000..e36a2872bdf8f68df996d35159335a77211ead7a Binary files /dev/null and b/src/icons/button-textbox.png differ diff --git a/src/icons/color-icon.png b/src/icons/color-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..28e54479f3d7fbfecc52694f51a7c6d2dbec1a34 Binary files /dev/null and b/src/icons/color-icon.png differ diff --git a/src/icons/nil.png b/src/icons/nil.png new file mode 100644 index 0000000000000000000000000000000000000000..ae058ee7bffad41634a20cd463fc94219f0de69b Binary files /dev/null and b/src/icons/nil.png differ