diff options
author | Philip Paquette <pcpaquette@gmail.com> | 2018-09-26 07:48:55 -0400 |
---|---|---|
committer | Philip Paquette <pcpaquette@gmail.com> | 2019-04-18 11:14:24 -0400 |
commit | 6187faf20384b0c5a4966343b2d4ca47f8b11e45 (patch) | |
tree | 151ccd21aea20180432c13fe4b58240d3d9e98b6 /diplomacy/web/src/gui/core | |
parent | 96b7e2c03ed98705754f13ae8efa808b948ee3a8 (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.jsx | 51 | ||||
-rw-r--r-- | diplomacy/web/src/gui/core/fancybox.jsx | 59 | ||||
-rw-r--r-- | diplomacy/web/src/gui/core/forms.jsx | 116 | ||||
-rw-r--r-- | diplomacy/web/src/gui/core/layouts.jsx | 55 | ||||
-rw-r--r-- | diplomacy/web/src/gui/core/page.jsx | 434 | ||||
-rw-r--r-- | diplomacy/web/src/gui/core/table.jsx | 112 | ||||
-rw-r--r-- | diplomacy/web/src/gui/core/tabs.jsx | 96 | ||||
-rw-r--r-- | diplomacy/web/src/gui/core/widgets.jsx | 102 |
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 +}; |