aboutsummaryrefslogtreecommitdiff
path: root/diplomacy/web/src/gui/core
diff options
context:
space:
mode:
authorPhilip Paquette <pcpaquette@gmail.com>2018-09-26 07:48:55 -0400
committerPhilip Paquette <pcpaquette@gmail.com>2019-04-18 11:14:24 -0400
commit6187faf20384b0c5a4966343b2d4ca47f8b11e45 (patch)
tree151ccd21aea20180432c13fe4b58240d3d9e98b6 /diplomacy/web/src/gui/core
parent96b7e2c03ed98705754f13ae8efa808b948ee3a8 (diff)
Release v1.0.0 - Diplomacy Game Engine - AGPL v3+ License
Diffstat (limited to 'diplomacy/web/src/gui/core')
-rw-r--r--diplomacy/web/src/gui/core/content.jsx51
-rw-r--r--diplomacy/web/src/gui/core/fancybox.jsx59
-rw-r--r--diplomacy/web/src/gui/core/forms.jsx116
-rw-r--r--diplomacy/web/src/gui/core/layouts.jsx55
-rw-r--r--diplomacy/web/src/gui/core/page.jsx434
-rw-r--r--diplomacy/web/src/gui/core/table.jsx112
-rw-r--r--diplomacy/web/src/gui/core/tabs.jsx96
-rw-r--r--diplomacy/web/src/gui/core/widgets.jsx102
8 files changed, 1025 insertions, 0 deletions
diff --git a/diplomacy/web/src/gui/core/content.jsx b/diplomacy/web/src/gui/core/content.jsx
new file mode 100644
index 0000000..416ba9e
--- /dev/null
+++ b/diplomacy/web/src/gui/core/content.jsx
@@ -0,0 +1,51 @@
+// ==============================================================================
+// Copyright (C) 2019 - Philip Paquette, Steven Bocco
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option) any
+// later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+// details.
+//
+// You should have received a copy of the GNU Affero General Public License along
+// with this program. If not, see <https://www.gnu.org/licenses/>.
+// ==============================================================================
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export class Content extends React.Component {
+ // PROPERTIES:
+ // page: pointer to parent Page object
+ // data: data for current content
+
+ // Each derived class must implement this static method.
+ static builder(page, data) {
+ return {
+ // page title (string)
+ title: `${data ? 'with data' : 'without data'}`,
+ // page navigation links: array of couples
+ // (navigation title, navigation callback ( onClick=() => callback() ))
+ navigation: [],
+ // page content: React component (e.g. <MyComponent/>, or <div class="content">...</div>, etc).
+ component: null
+ };
+ }
+
+ getPage() {
+ return this.props.page;
+ }
+
+ componentDidMount() {
+ window.scrollTo(0, 0);
+ }
+}
+
+
+Content.propTypes = {
+ page: PropTypes.object.isRequired,
+ data: PropTypes.object
+};
diff --git a/diplomacy/web/src/gui/core/fancybox.jsx b/diplomacy/web/src/gui/core/fancybox.jsx
new file mode 100644
index 0000000..4d1013d
--- /dev/null
+++ b/diplomacy/web/src/gui/core/fancybox.jsx
@@ -0,0 +1,59 @@
+// ==============================================================================
+// Copyright (C) 2019 - Philip Paquette, Steven Bocco
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option) any
+// later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+// details.
+//
+// You should have received a copy of the GNU Affero General Public License along
+// with this program. If not, see <https://www.gnu.org/licenses/>.
+// ==============================================================================
+import React from 'react';
+import {Button} from "./widgets";
+import PropTypes from 'prop-types';
+
+const TIMES = '\u00D7';
+
+export class FancyBox extends React.Component {
+ // open-tag (<FancyBox></FancyBox>)
+ // PROPERTIES
+ // title
+ // onClose
+ render() {
+ return (
+ <div className={'fancy-wrapper'} onClick={this.props.onClose}>
+ <div className={'fancy-box container'} onClick={(event) => {
+ if (!event)
+ event = window.event;
+ if (event.hasOwnProperty('cancelBubble'))
+ event.cancelBubble = true;
+ if (event.stopPropagation)
+ event.stopPropagation();
+ }}>
+ <div className={'row fancy-bar'}>
+ <div className={'col-11 align-self-center fancy-title'}>{this.props.title}</div>
+ <div className={'col-1 fancy-button'}>
+ <Button title={TIMES} color={'danger'} onClick={this.props.onClose}/>
+ </div>
+ </div>
+ <div className={'row'}>
+ <div className={'col fancy-content'}>{this.props.children}</div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+
+FancyBox.propTypes = {
+ title: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ children: PropTypes.oneOfType([PropTypes.array, PropTypes.object])
+};
diff --git a/diplomacy/web/src/gui/core/forms.jsx b/diplomacy/web/src/gui/core/forms.jsx
new file mode 100644
index 0000000..76d188c
--- /dev/null
+++ b/diplomacy/web/src/gui/core/forms.jsx
@@ -0,0 +1,116 @@
+// ==============================================================================
+// Copyright (C) 2019 - Philip Paquette, Steven Bocco
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option) any
+// later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+// details.
+//
+// You should have received a copy of the GNU Affero General Public License along
+// with this program. If not, see <https://www.gnu.org/licenses/>.
+// ==============================================================================
+import React from "react";
+import {Button} from "./widgets";
+import {UTILS} from "../../diplomacy/utils/utils";
+
+export class Forms {
+ static createOnChangeCallback(component, callback) {
+ return (event) => {
+ const value = UTILS.html.isCheckBox(event.target) ? event.target.checked : event.target.value;
+ const fieldName = UTILS.html.isRadioButton(event.target) ? event.target.name : event.target.id;
+ const update = {[fieldName]: value};
+ const state = Object.assign({}, component.state, update);
+ if (callback)
+ callback(state);
+ component.setState(state);
+ };
+ }
+
+ static createOnSubmitCallback(component, callback, resetState) {
+ return (event) => {
+ if (callback)
+ callback(Object.assign({}, component.state));
+ if (resetState)
+ component.setState(resetState);
+ event.preventDefault();
+ };
+ }
+
+ static createOnResetCallback(component, onChangeCallback, resetState) {
+ return (event) => {
+ if (onChangeCallback)
+ onChangeCallback(resetState);
+ component.setState(resetState);
+ if (event && event.preventDefault)
+ event.preventDefault();
+ };
+ }
+
+ static getValue(fieldValues, fieldName, defaultValue) {
+ return fieldValues.hasOwnProperty(fieldName) ? fieldValues[fieldName] : defaultValue;
+ }
+
+ static createReset(title, large, onReset) {
+ return <Button key={'reset'} title={title || 'reset'} onClick={onReset} pickEvent={true} large={large}/>;
+ }
+
+ static createSubmit(title, large, onSubmit) {
+ return <Button key={'submit'} title={title || 'submit'} onClick={onSubmit} pickEvent={true} large={large}/>;
+ }
+
+ static createButton(title, fn, color, large) {
+ const wrapFn = (event) => {
+ fn();
+ event.preventDefault();
+ };
+ return <Button large={large} key={title} color={color} title={title} onClick={wrapFn} pickEvent={true}/>;
+ }
+
+ static createCheckbox(id, title, value, onChange) {
+ const input = <input className={'form-check-input'} key={id} type={'checkbox'} id={id} checked={value}
+ onChange={onChange}/>;
+ const label = <label className={'form-check-label'} key={`label-${id}`} htmlFor={id}>{title}</label>;
+ return [input, label];
+ }
+
+ static createRadio(name, value, title, currentValue, onChange) {
+ const id = `[${name}][${value}]`;
+ const input = <input className={'form-check-input'} key={id} type={'radio'}
+ name={name} value={value} checked={currentValue === value}
+ id={id} onChange={onChange}/>;
+ const label = <label className={'form-check-label'} key={`label-${id}`} htmlFor={id}>{title || value}</label>;
+ return [input, label];
+ }
+
+ static createRow(label, input) {
+ return (
+ <div className={'form-group row'}>
+ {label}
+ <div className={'col'}>{input}</div>
+ </div>
+ );
+ }
+
+ static createLabel(htmFor, title, className) {
+ return <label className={className} htmlFor={htmFor}>{title}</label>;
+ }
+
+ static createColLabel(htmlFor, title) {
+ return Forms.createLabel(htmlFor, title, 'col');
+ }
+
+ static createSelectOptions(values, none) {
+ const options = values.slice();
+ const components = options.map((option, index) => <option key={index} value={option}>{option}</option>);
+ if (none) {
+ components.splice(0, 0, [<option key={-1} value={''}>{none === true ? '(none)' : `${none}`}</option>]);
+ }
+ return components;
+ }
+}
+
diff --git a/diplomacy/web/src/gui/core/layouts.jsx b/diplomacy/web/src/gui/core/layouts.jsx
new file mode 100644
index 0000000..78189e4
--- /dev/null
+++ b/diplomacy/web/src/gui/core/layouts.jsx
@@ -0,0 +1,55 @@
+// ==============================================================================
+// Copyright (C) 2019 - Philip Paquette, Steven Bocco
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option) any
+// later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+// details.
+//
+// You should have received a copy of the GNU Affero General Public License along
+// with this program. If not, see <https://www.gnu.org/licenses/>.
+// ==============================================================================
+import React from 'react';
+import PropTypes from 'prop-types';
+
+class Div extends React.Component {
+ getClassName() {
+ return '';
+ }
+
+ render() {
+ return (
+ <div className={this.getClassName() + (this.props.className ? ' ' + this.props.className : '')}>
+ {this.props.children}
+ </div>
+ );
+ }
+}
+
+Div.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.oneOfType([PropTypes.array, PropTypes.object])
+};
+
+export class Bar extends Div {
+ getClassName() {
+ return 'bar';
+ }
+}
+
+export class Row extends Div {
+ getClassName() {
+ return 'row';
+ }
+}
+
+export class Col extends Div {
+ getClassName() {
+ return 'col';
+ }
+}
diff --git a/diplomacy/web/src/gui/core/page.jsx b/diplomacy/web/src/gui/core/page.jsx
new file mode 100644
index 0000000..5ca09fd
--- /dev/null
+++ b/diplomacy/web/src/gui/core/page.jsx
@@ -0,0 +1,434 @@
+// ==============================================================================
+// Copyright (C) 2019 - Philip Paquette, Steven Bocco
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option) any
+// later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+// details.
+//
+// You should have received a copy of the GNU Affero General Public License along
+// with this program. If not, see <https://www.gnu.org/licenses/>.
+// ==============================================================================
+/** Main class to use to create app GUI. **/
+
+import React from "react";
+import {ContentConnection} from "../diplomacy/contents/content_connection";
+import {ContentGames} from "../diplomacy/contents/content_games";
+import {ContentGame} from "../diplomacy/contents/content_game";
+import {UTILS} from "../../diplomacy/utils/utils";
+import {Diplog} from "../../diplomacy/utils/diplog";
+import {STRINGS} from "../../diplomacy/utils/strings";
+import {Game} from "../../diplomacy/engine/game";
+import Octicon, {Person} from '@githubprimer/octicons-react';
+import $ from "jquery";
+import {FancyBox} from "./fancybox";
+import {DipStorage} from "../diplomacy/utils/dipStorage";
+
+const CONTENTS = {
+ connection: ContentConnection,
+ games: ContentGames,
+ game: ContentGame
+};
+
+export class Page extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.connection = null;
+ this.channel = null;
+ this.availableMaps = null;
+ this.state = {
+ // fancybox,
+ fancyTitle: null,
+ onFancyBox: null,
+ // Page messages
+ error: null,
+ info: null,
+ success: null,
+ title: null,
+ // Page content parameters
+ contentName: 'connection',
+ contentData: null,
+ // Games.
+ games: {}, // Games found.
+ myGames: {} // Games locally stored.
+ };
+ this.loadPage = this.loadPage.bind(this);
+ this.loadConnection = this.loadConnection.bind(this);
+ this.loadGames = this.loadGames.bind(this);
+ this.loadGame = this.loadGame.bind(this);
+ this.loadGameFromDisk = this.loadGameFromDisk.bind(this);
+ this.logout = this.logout.bind(this);
+ this.error = this.error.bind(this);
+ this.info = this.info.bind(this);
+ this.success = this.success.bind(this);
+ this.unloadFancyBox = this.unloadFancyBox.bind(this);
+ }
+
+ static wrapMessage(message) {
+ return message ? `(${UTILS.date()}) ${message}` : '';
+ }
+
+ static __sort_games(games) {
+ // Sort games with not-joined games first, else compare game ID.
+ games.sort((a, b) => (((a.role ? 1 : 0) - (b.role ? 1 : 0)) || a.game_id.localeCompare(b.game_id)));
+ return games;
+ }
+
+ copyState(updatedFields) {
+ return Object.assign({}, this.state, updatedFields || {});
+ }
+
+ //// Methods to check page type.
+
+ __page_is(contentName, contentData) {
+ return this.state.contentName === contentName && (!contentData || this.state.contentData === contentData);
+ }
+
+ pageIsConnection(contentData) {
+ return this.__page_is('connection', contentData);
+ }
+
+ pageIsGames(contentData) {
+ return this.__page_is('games', contentData);
+ }
+
+ pageIsGame(contentData) {
+ return this.__page_is('game', contentData);
+ }
+
+ //// Methods to load a global fancybox.
+
+ loadFancyBox(title, callback) {
+ this.setState({fancyTitle: title, onFancyBox: callback});
+ }
+
+ unloadFancyBox() {
+ this.setState({fancyTitle: null, onFancyBox: null});
+ }
+
+ //// Methods to load a page.
+
+ loadPage(contentName, contentData, messages) {
+ messages = messages || {};
+ messages.error = Page.wrapMessage(messages.error);
+ messages.info = Page.wrapMessage(messages.info);
+ messages.success = Page.wrapMessage(messages.success);
+ Diplog.printMessages(messages);
+ this.setState(this.copyState({
+ error: messages.error,
+ info: messages.info,
+ success: messages.success,
+ contentName: contentName,
+ contentData: contentData,
+ title: null,
+ fancyTitle: null,
+ onFancyBox: null
+ }));
+ }
+
+ loadConnection(contentData, messages) {
+ this.loadPage('connection', contentData, messages);
+ }
+
+ loadGames(contentData, messages) {
+ this.loadPage('games', contentData, messages);
+ }
+
+ loadGame(gameInfo, messages) {
+ this.loadPage('game', gameInfo, messages);
+ }
+
+ loadGameFromDisk() {
+ const input = $(document.createElement('input'));
+ input.attr("type", "file");
+ input.trigger('click');
+ input.change(event => {
+ const file = event.target.files[0];
+ if (!file.name.match(/\.json$/i)) {
+ this.error(`Invalid JSON filename ${file.name}`);
+ } else {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const savedData = JSON.parse(reader.result);
+ const gameObject = {};
+ gameObject.game_id = `(local) ${savedData.id}`;
+ gameObject.map_name = savedData.map;
+ gameObject.rules = savedData.rules;
+ const state_history = {};
+ const message_history = {};
+ const order_history = {};
+ const result_history = {};
+ for (let savedPhase of savedData.phases) {
+ const gameState = savedPhase.state;
+ const phaseOrders = savedPhase.orders || {};
+ const phaseResults = savedPhase.results || {};
+ const phaseMessages = {};
+ if (savedPhase.messages) {
+ for (let message of savedPhase.messages) {
+ phaseMessages[message.time_sent] = message;
+ }
+ }
+ if (!gameState.name)
+ gameState.name = savedPhase.name;
+ state_history[gameState.name] = gameState;
+ order_history[gameState.name] = phaseOrders;
+ message_history[gameState.name] = phaseMessages;
+ result_history[gameState.name] = phaseResults;
+ }
+ gameObject.state_history = state_history;
+ gameObject.message_history = message_history;
+ gameObject.order_history = order_history;
+ gameObject.state_history = state_history;
+ gameObject.result_history = result_history;
+ gameObject.messages = [];
+ gameObject.role = STRINGS.OBSERVER_TYPE;
+ gameObject.status = STRINGS.COMPLETED;
+ gameObject.timestamp_created = 0;
+ gameObject.deadline = 0;
+ gameObject.n_controls = 0;
+ gameObject.registration_password = '';
+ const game = new Game(gameObject);
+ this.loadGame(game);
+ };
+ reader.readAsText(file);
+ }
+ });
+ }
+
+ //// Methods to sign out channel and go back to connection page.
+
+ __disconnect() {
+ // Clear local data and go back to connection page.
+ this.connection.close();
+ this.connection = null;
+ this.channel = null;
+ this.availableMaps = null;
+ const message = Page.wrapMessage(`Disconnected from channel and server.`);
+ Diplog.success(message);
+ this.setState(this.copyState({
+ error: null,
+ info: null,
+ success: message,
+ contentName: 'connection',
+ contentData: null,
+ // When disconnected, remove all games previously loaded.
+ games: {},
+ myGames: {}
+ }));
+ }
+
+ logout() {
+ // Disconnect channel and go back to connection page.
+ if (this.channel) {
+ this.channel.logout()
+ .then(() => this.__disconnect())
+ .catch(error => this.error(`Error while disconnecting: ${error.toString()}.`));
+ } else {
+ this.__disconnect();
+ }
+ }
+
+ //// Methods to be used to set page title and messages.
+
+ setTitle(title) {
+ this.setState({title: title});
+ }
+
+ error(message) {
+ message = Page.wrapMessage(message);
+ Diplog.error(message);
+ this.setState({error: message});
+ }
+
+ info(message) {
+ message = Page.wrapMessage(message);
+ Diplog.info(message);
+ this.setState({info: message});
+ }
+
+ success(message) {
+ message = Page.wrapMessage(message);
+ Diplog.success(message);
+ this.setState({success: message});
+ }
+
+ warn(message) {
+ this.info(message);
+ }
+
+ //// Methods to manage games.
+
+ updateMyGames(gamesToAdd) {
+ // Update state myGames with given games. This method does not update local storage.
+ const myGames = Object.assign({}, this.state.myGames);
+ let gamesFound = null;
+ for (let gameToAdd of gamesToAdd) {
+ myGames[gameToAdd.game_id] = gameToAdd;
+ if (this.state.games.hasOwnProperty(gameToAdd.game_id)) {
+ if (!gamesFound)
+ gamesFound = Object.assign({}, this.state.games);
+ gamesFound[gameToAdd.game_id] = gameToAdd;
+ }
+ }
+ if (!gamesFound)
+ gamesFound = this.state.games;
+ this.setState({myGames: myGames, games: gamesFound});
+ }
+
+ getMyGames() {
+ return Page.__sort_games(Object.values(this.state.myGames));
+ }
+
+ getGamesFound() {
+ return Page.__sort_games(Object.values(this.state.games));
+ }
+
+ addGamesFound(gamesToAdd) {
+ const gamesFound = {};
+ for (let game of gamesToAdd) {
+ gamesFound[game.game_id] = (
+ this.state.myGames.hasOwnProperty(game.game_id) ?
+ this.state.myGames[game.game_id] : game
+ );
+ }
+ this.setState({games: gamesFound});
+ }
+
+ leaveGame(gameID) {
+ if (this.state.myGames.hasOwnProperty(gameID)) {
+ const game = this.state.myGames[gameID];
+ if (game.client) {
+ game.client.leave()
+ .then(() => {
+ this.disconnectGame(gameID);
+ this.loadGames(null, {info: `Game ${gameID} left.`});
+ })
+ .catch(error => this.error(`Error when leaving game ${gameID}: ${error.toString()}`));
+ }
+ }
+ }
+
+ disconnectGame(gameID) {
+ if (this.state.myGames.hasOwnProperty(gameID)) {
+ const game = this.state.myGames[gameID];
+ if (game.client)
+ game.client.clearAllCallbacks();
+ this.channel.getGamesInfo({games: [gameID]})
+ .then(gamesInfo => {
+ this.updateMyGames(gamesInfo);
+ })
+ .catch(error => this.error(`Error while leaving game ${gameID}: ${error.toString()}`));
+ }
+ }
+
+ addToMyGames(game) {
+ // Update state myGames with given game **and** update local storage.
+ const myGames = Object.assign({}, this.state.myGames);
+ const gamesFound = this.state.games.hasOwnProperty(game.game_id) ? Object.assign({}, this.state.games) : this.state.games;
+ myGames[game.game_id] = game;
+ if (gamesFound.hasOwnProperty(game.game_id))
+ gamesFound[game.game_id] = game;
+ DipStorage.addUserGame(this.channel.username, game.game_id);
+ this.setState({myGames: myGames, games: gamesFound});
+ }
+
+ removeFromMyGames(gameID) {
+ if (this.state.myGames.hasOwnProperty(gameID)) {
+ const games = Object.assign({}, this.state.myGames);
+ delete games[gameID];
+ DipStorage.removeUserGame(this.channel.username, gameID);
+ this.setState({myGames: games});
+ }
+ }
+
+ hasMyGame(gameID) {
+ return this.state.myGames.hasOwnProperty(gameID);
+ }
+
+ //// Render method.
+
+ render() {
+ const content = CONTENTS[this.state.contentName].builder(this, this.state.contentData);
+ const hasNavigation = UTILS.javascript.hasArray(content.navigation);
+
+ // NB: I currently don't find a better way to update document title from content details.
+ const successMessage = this.state.success || '-';
+ const infoMessage = this.state.info || '-';
+ const errorMessage = this.state.error || '-';
+ const title = this.state.title || content.title;
+ document.title = title + ' | Diplomacy';
+
+ return (
+ <div className="page container-fluid" id={this.state.contentName}>
+ <div className={'top-msg row'}>
+ <div title={successMessage !== '-' ? successMessage : ''}
+ className={'col-sm-4 msg success ' + (this.state.success ? 'with-msg' : 'no-msg')}
+ onClick={() => this.success()}>
+ {successMessage}
+ </div>
+ <div title={infoMessage !== '-' ? infoMessage : ''}
+ className={'col-sm-4 msg info ' + (this.state.info ? 'with-msg' : 'no-msg')}
+ onClick={() => this.info()}>
+ {infoMessage}
+ </div>
+ <div title={errorMessage !== '-' ? errorMessage : ''}
+ className={'col-sm-4 msg error ' + (this.state.error ? 'with-msg' : 'no-msg')}
+ onClick={() => this.error()}>
+ {errorMessage}
+ </div>
+ </div>
+ {((hasNavigation || this.channel) && (
+ <div className={'title row'}>
+ <div className={'col align-self-center'}><strong>{title}</strong></div>
+ <div className={'col-sm-1'}>
+ {(!hasNavigation && (
+ <div className={'float-right'}>
+ <strong>
+ <u className={'mr-2'}>{this.channel.username}</u>
+ <Octicon icon={Person}/>
+ </strong>
+ </div>
+ )) || (
+ <div className="dropdown float-right">
+ <button className="btn btn-secondary dropdown-toggle" type="button"
+ id="dropdownMenuButton" data-toggle="dropdown"
+ aria-haspopup="true" aria-expanded="false">
+ {(this.channel && this.channel.username && (
+ <span>
+ <u className={'mr-2'}>{this.channel.username}</u>
+ <Octicon icon={Person}/>
+ </span>
+ )) || 'Menu'}
+ </button>
+ <div className="dropdown-menu dropdown-menu-right"
+ aria-labelledby="dropdownMenuButton">
+ {content.navigation.map((nav, index) => {
+ const navTitle = nav[0];
+ const navAction = nav[1];
+ return <a key={index} className="dropdown-item"
+ onClick={navAction}>{navTitle}</a>;
+ })}
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ )) || (
+ <div className={'title'}><strong>{title}</strong></div>
+ )}
+ {content.component}
+ {this.state.onFancyBox && (
+ <FancyBox title={this.state.fancyTitle} onClose={this.unloadFancyBox}>
+ {this.state.onFancyBox()}
+ </FancyBox>
+ )}
+ </div>
+ );
+ }
+}
diff --git a/diplomacy/web/src/gui/core/table.jsx b/diplomacy/web/src/gui/core/table.jsx
new file mode 100644
index 0000000..cb729e7
--- /dev/null
+++ b/diplomacy/web/src/gui/core/table.jsx
@@ -0,0 +1,112 @@
+// ==============================================================================
+// Copyright (C) 2019 - Philip Paquette, Steven Bocco
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option) any
+// later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+// details.
+//
+// You should have received a copy of the GNU Affero General Public License along
+// with this program. If not, see <https://www.gnu.org/licenses/>.
+// ==============================================================================
+//// Tables.
+
+import React from "react";
+import PropTypes from 'prop-types';
+
+class DefaultWrapper {
+ constructor(data) {
+ this.data = data;
+ this.get = this.get.bind(this);
+ }
+
+ get(fieldName) {
+ return this.data[fieldName];
+ }
+}
+
+function defaultWrapper(data) {
+ return new DefaultWrapper(data);
+}
+
+export class Table extends React.Component {
+ // className
+ // caption
+ // columns : {name: [title, order]}
+ // data: [objects with expected column names]
+ // wrapper: (optional) function to use to wrap one data entry into an object before accessing fields.
+ // Must return an instance with a method get(name).
+ // If provided: wrapper(data_entry).get(field_name)
+ // else: data_entry[field_name]
+
+ constructor(props) {
+ super(props);
+ if (!this.props.wrapper)
+ this.props.wrapper = defaultWrapper;
+ }
+
+ static getHeader(columns) {
+ const header = [];
+ for (let entry of Object.entries(columns)) {
+ const name = entry[0];
+ const title = entry[1][0];
+ const order = entry[1][1];
+ header.push([order, name, title]);
+ }
+ header.sort((a, b) => {
+ let t = a[0] - b[0];
+ if (t === 0)
+ t = a[1].localeCompare(b[1]);
+ if (t === 0)
+ t = a[2].localeCompare(b[2]);
+ return t;
+ });
+ return header;
+ }
+
+ static getHeaderLine(header) {
+ return (
+ <thead className={'thead-light'}>
+ <tr>{header.map((column, colIndex) => <th key={colIndex}>{column[2]}</th>)}</tr>
+ </thead>
+ );
+ }
+
+ static getBodyRow(header, row, rowIndex, wrapper) {
+ const wrapped = wrapper(row);
+ return (<tr key={rowIndex}>
+ {header.map((headerColumn, colIndex) => <td className={'align-middle'}
+ key={colIndex}>{wrapped.get(headerColumn[1])}</td>)}
+ </tr>);
+ }
+
+ static getBodyLines(header, data, wrapper) {
+ return (<tbody>{data.map((row, rowIndex) => Table.getBodyRow(header, row, rowIndex, wrapper))}</tbody>);
+ }
+
+ render() {
+ const header = Table.getHeader(this.props.columns);
+ return (
+ <div className={'table-responsive'}>
+ <table className={this.props.className}>
+ <caption>{this.props.caption} ({this.props.data.length})</caption>
+ {Table.getHeaderLine(header)}
+ {Table.getBodyLines(header, this.props.data, this.props.wrapper)}
+ </table>
+ </div>
+ );
+ }
+}
+
+Table.propTypes = {
+ wrapper: PropTypes.func,
+ columns: PropTypes.object,
+ className: PropTypes.string,
+ caption: PropTypes.string,
+ data: PropTypes.array
+};
diff --git a/diplomacy/web/src/gui/core/tabs.jsx b/diplomacy/web/src/gui/core/tabs.jsx
new file mode 100644
index 0000000..6123219
--- /dev/null
+++ b/diplomacy/web/src/gui/core/tabs.jsx
@@ -0,0 +1,96 @@
+// ==============================================================================
+// Copyright (C) 2019 - Philip Paquette, Steven Bocco
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option) any
+// later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+// details.
+//
+// You should have received a copy of the GNU Affero General Public License along
+// with this program. If not, see <https://www.gnu.org/licenses/>.
+// ==============================================================================
+import React from "react";
+import {Action} from "./widgets";
+import PropTypes from 'prop-types';
+
+export class Tab extends React.Component {
+ render() {
+ const style = {
+ display: this.props.display ? 'block' : 'none'
+ };
+ const id = this.props.id ? {id: this.props.id} : {};
+ return (
+ <div className={'tab mb-4 ' + this.props.className} style={style} {...id}>
+ {this.props.children}
+ </div>
+ );
+ }
+}
+
+Tab.propTypes = {
+ display: PropTypes.bool,
+ className: PropTypes.string,
+ id: PropTypes.string,
+ children: PropTypes.oneOfType([PropTypes.array, PropTypes.object])
+};
+
+Tab.defaultProps = {
+ display: false,
+ className: '',
+ id: ''
+};
+
+export class Tabs extends React.Component {
+ /** PROPERTIES
+ * active: index of active menu (must be > menu.length).
+ * highlights: dictionary mapping a menu indice to a highlight message
+ * onChange: callback(index): receive index of menu to display.
+ * **/
+
+ generateTabAction(tabTitle, tabId, isActive, onChange, highlight) {
+ return <Action isActive={isActive}
+ title={tabTitle}
+ onClick={() => onChange(tabId)}
+ highlight={highlight}
+ key={tabId}/>;
+ }
+
+ render() {
+ if (!this.props.menu.length)
+ throw new Error(`No tab menu given.`);
+ if (this.props.menu.length !== this.props.titles.length)
+ throw new Error(`Menu length (${this.props.menu.length}) != titles length (${this.props.titles.length})`);
+ if (this.props.active && !this.props.menu.includes(this.props.active))
+ throw new Error(`Invalid active tab name, got ${this.props.active}, expected one of: ${this.props.menu.join(', ')}`);
+ const active = this.props.active || this.props.menu[0];
+ return (
+ <div className={'tabs mb-3'}>
+ <nav className={'tabs-bar nav nav-tabs justify-content-center mb-3'}>
+ {this.props.menu.map((tabName, index) => this.generateTabAction(
+ this.props.titles[index], tabName, active === tabName, this.props.onChange,
+ (this.props.highlights.hasOwnProperty(tabName) && this.props.highlights[tabName]) || null
+ ))}
+ </nav>
+ {this.props.children}
+ </div>
+ );
+ }
+}
+
+Tabs.propTypes = {
+ menu: PropTypes.arrayOf(PropTypes.string).isRequired, // tab names
+ titles: PropTypes.arrayOf(PropTypes.string).isRequired, // tab titles
+ onChange: PropTypes.func.isRequired, // callback(tab name)
+ children: PropTypes.array.isRequired,
+ active: PropTypes.string, // current active tab name
+ highlights: PropTypes.object, // {tab name => highligh message (optional)}
+};
+
+Tabs.defaultProps = {
+ highlights: {}
+};
diff --git a/diplomacy/web/src/gui/core/widgets.jsx b/diplomacy/web/src/gui/core/widgets.jsx
new file mode 100644
index 0000000..62a5eb4
--- /dev/null
+++ b/diplomacy/web/src/gui/core/widgets.jsx
@@ -0,0 +1,102 @@
+// ==============================================================================
+// Copyright (C) 2019 - Philip Paquette, Steven Bocco
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option) any
+// later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+// details.
+//
+// You should have received a copy of the GNU Affero General Public License along
+// with this program. If not, see <https://www.gnu.org/licenses/>.
+// ==============================================================================
+import React from "react";
+import PropTypes from 'prop-types';
+
+export class Button extends React.Component {
+ /** Bootstrap button.
+ * Bootstrap classes:
+ * - btn
+ * - btn-primary
+ * - mx-1 (margin-left 1px, margin-right 1px)
+ * Props: title (str), onClick (function).
+ * **/
+ // title
+ // onClick
+ // pickEvent = false
+ // large = false
+ // small = false
+
+ constructor(props) {
+ super(props);
+ this.onClick = this.onClick.bind(this);
+ }
+
+ onClick(event) {
+ if (this.props.onClick)
+ this.props.onClick(this.props.pickEvent ? event : null);
+ }
+
+ render() {
+ return (
+ <button
+ className={`btn btn-${this.props.color || 'secondary'}` + (this.props.large ? ' btn-block' : '') + (this.props.small ? ' btn-sm' : '')}
+ disabled={this.props.disabled}
+ onClick={this.onClick}>
+ <strong>{this.props.title}</strong>
+ </button>
+ );
+ }
+}
+
+Button.propTypes = {
+ title: PropTypes.string.isRequired,
+ onClick: PropTypes.func.isRequired,
+ color: PropTypes.string,
+ large: PropTypes.bool,
+ small: PropTypes.bool,
+ pickEvent: PropTypes.bool,
+ disabled: PropTypes.bool
+};
+
+Button.defaultPropTypes = {
+ disabled: false
+};
+
+
+export class Action extends React.Component {
+ // title
+ // isActive
+ // onClick
+ // See Button parameters.
+
+ render() {
+ return (
+ <div className="action nav-item" onClick={this.props.onClick}>
+ <div
+ className={'nav-link' + (this.props.isActive ? ' active' : '') + (this.props.highlight !== null ? ' updated' : '')}>
+ {this.props.title}
+ {this.props.highlight !== null
+ && this.props.highlight !== undefined
+ && <span className={'update'}>{this.props.highlight}</span>}
+ </div>
+ </div>
+ );
+ }
+}
+
+Action.propTypes = {
+ title: PropTypes.string.isRequired,
+ onClick: PropTypes.func.isRequired,
+ highlight: PropTypes.any,
+ isActive: PropTypes.bool
+};
+
+Action.defaultProps = {
+ highlight: null,
+ isActive: false
+};