From 5c71a0f73717bffefb5e23a9e100adb62fc54a61 Mon Sep 17 00:00:00 2001 From: Philip Paquette Date: Sun, 7 Jul 2019 09:23:59 -0400 Subject: Updated the web interface to have 3 tabs - Results / Messages / Current - Updated dependencies in package-lock.json - Set default homepage to "." so that built pages are relative to "index.html". - Add module "helmet" to handle page title. - Simplified page loading (replace static function builder with direct call to a method with component as argument). - Move function loadGameFromDisk in a separate file. - Use React context to access page object. - Add a new React component "Navigation" and simplify page rendering. - Add ability to choose power for any kind of loaded game. In phase history: - Show messages from all past and currently displayed phase. - Display messages from past phase with gray background. - Show messages per protagonist in tabs. - Show message phase in message header - Display message wide (header left, body right). - Display short names for powers in message tabs header. - Add warn function to page component. - Messages from previous phase are displayed with gray color text. - Game registration password input is displayed only if required - On games page: - sorted by descending timestamp created. - In table, game ID is displayed with human readable created date. - Prevent messages from displaying twice. - Re-add checkbox "show orders" to display arrow orders on past maps. - Handle HTML break-lines
and remove all other HTML tags when displaying messages. - Use latest phase as current game phase when loading a game from disk. --- diplomacy/web/src/diplomacy/engine/game.js | 62 +-- diplomacy/web/src/diplomacy/utils/sorted_dict.js | 9 + diplomacy/web/src/gui/core/action.jsx | 52 +++ diplomacy/web/src/gui/core/button.jsx | 52 +++ diplomacy/web/src/gui/core/content.jsx | 51 --- diplomacy/web/src/gui/core/fancybox.jsx | 2 +- diplomacy/web/src/gui/core/forms.jsx | 2 +- diplomacy/web/src/gui/core/page.jsx | 277 ++++--------- diplomacy/web/src/gui/core/tab.jsx | 29 ++ diplomacy/web/src/gui/core/tabs.jsx | 29 +- diplomacy/web/src/gui/core/widgets.jsx | 102 ----- .../gui/diplomacy/contents/content_connection.jsx | 41 +- .../src/gui/diplomacy/contents/content_game.jsx | 455 +++++++++++---------- .../src/gui/diplomacy/contents/content_games.jsx | 83 ++-- .../web/src/gui/diplomacy/forms/join_form.jsx | 19 +- .../src/gui/diplomacy/forms/power_actions_form.jsx | 33 +- .../gui/diplomacy/forms/select_location_form.jsx | 2 +- .../src/gui/diplomacy/forms/select_via_form.jsx | 2 +- .../src/gui/diplomacy/utils/inline_game_view.jsx | 20 +- .../src/gui/diplomacy/utils/load_game_from_disk.js | 83 ++++ diplomacy/web/src/gui/diplomacy/widgets/help.jsx | 13 + .../web/src/gui/diplomacy/widgets/message_view.jsx | 24 +- .../web/src/gui/diplomacy/widgets/navigation.jsx | 61 +++ .../web/src/gui/diplomacy/widgets/page_context.jsx | 3 + .../web/src/gui/diplomacy/widgets/power_order.jsx | 2 +- diplomacy/web/src/index.css | 47 ++- 26 files changed, 843 insertions(+), 712 deletions(-) create mode 100644 diplomacy/web/src/gui/core/action.jsx create mode 100644 diplomacy/web/src/gui/core/button.jsx delete mode 100644 diplomacy/web/src/gui/core/content.jsx create mode 100644 diplomacy/web/src/gui/core/tab.jsx delete mode 100644 diplomacy/web/src/gui/core/widgets.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/utils/load_game_from_disk.js create mode 100644 diplomacy/web/src/gui/diplomacy/widgets/help.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/widgets/navigation.jsx create mode 100644 diplomacy/web/src/gui/diplomacy/widgets/page_context.jsx (limited to 'diplomacy/web/src') diff --git a/diplomacy/web/src/diplomacy/engine/game.js b/diplomacy/web/src/diplomacy/engine/game.js index cc9803e..93da77c 100644 --- a/diplomacy/web/src/diplomacy/engine/game.js +++ b/diplomacy/web/src/diplomacy/engine/game.js @@ -72,14 +72,14 @@ export class Game { this.messages = new SortedDict(gameData instanceof Game ? null : gameData.messages, parseInt); // {short phase name => state} - this.state_history = gameData instanceof Game ? gameData.state_history : new SortedDict(gameData.state_history, comparablePhase); + this.state_history = new SortedDict(gameData instanceof Game ? gameData.state_history.toDict() : gameData.state_history, comparablePhase); // {short phase name => {power name => [orders]}} - this.order_history = gameData instanceof Game ? gameData.order_history : new SortedDict(gameData.order_history, comparablePhase); + this.order_history = new SortedDict(gameData instanceof Game ? gameData.order_history.toDict() : gameData.order_history, comparablePhase); // {short phase name => {unit => [results]}} - this.result_history = gameData instanceof Game ? gameData.result_history : new SortedDict(gameData.result_history, comparablePhase); + this.result_history = new SortedDict(gameData instanceof Game ? gameData.result_history.toDict() : gameData.result_history, comparablePhase); // {short phase name => {message.time_sent => message}} if (gameData instanceof Game) { - this.message_history = gameData.message_history; + this.message_history = new SortedDict(gameData.message_history.toDict(), comparablePhase); } else { this.message_history = new SortedDict(null, comparablePhase); for (let entry of Object.entries(gameData.message_history)) { @@ -115,7 +115,7 @@ export class Game { this.powers[power_name].setState(powerState); } } - } else if(this.state_history.size()) { + } else if (this.state_history.size()) { const lastState = this.state_history.lastValue(); if (lastState.units) { for (let powerName of Object.keys(lastState.units)) { @@ -387,15 +387,21 @@ export class Game { cloneAt(pastPhase) { if (pastPhase !== null && this.state_history.contains(pastPhase)) { - const messages = this.message_history.get(pastPhase); - const orders = this.order_history.get(pastPhase); - const state = this.state_history.get(pastPhase); const game = new Game(this); + const pastPhaseIndex = this.state_history.indexOf(pastPhase); + const nbPastPhases = this.state_history.size(); + for (let i = nbPastPhases - 1; i > pastPhaseIndex; --i) { + const keyToRemove = this.state_history.keyFromIndex(i); + game.message_history.remove(keyToRemove); + game.state_history.remove(keyToRemove); + game.order_history.remove(keyToRemove); + game.result_history.remove(keyToRemove); + } game.setPhaseData({ name: pastPhase, - state: state, - orders: orders, - messages: messages + state: this.state_history.get(pastPhase), + orders: this.order_history.get(pastPhase), + messages: this.message_history.get(pastPhase) }); return game; } @@ -409,32 +415,36 @@ export class Game { } getControllablePowers() { - if (!this.isObserverGame()) { - if (this.isOmniscientGame()) - return Object.keys(this.powers); - return [this.role]; - } - return []; + if (this.isObserverGame() || this.isOmniscientGame()) + return Object.keys(this.powers); + return [this.role]; } - getMessageChannels() { + getMessageChannels(role, all) { const messageChannels = {}; - let messages = this.messages; - if (!messages.size() && this.message_history.contains(this.phase)) - messages = this.message_history.get(this.phase); - if (this.isPlayerGame()) { + role = role || this.role; + let messagesToShow = null; + if (all) { + messagesToShow = this.message_history.values(); + if (this.messages.size() && !this.message_history.contains(this.phase)) + messagesToShow.push(this.messages); + } else { + if (this.messages.size()) + messagesToShow = [this.messages]; + else if (this.message_history.contains(this.phase)) + messagesToShow = this.message_history.get(this.phase); + } + for (let messages of messagesToShow) { for (let message of messages.values()) { let protagonist = null; - if (message.sender === this.role || message.recipient === 'GLOBAL') + if (message.sender === role || message.recipient === 'GLOBAL') protagonist = message.recipient; - else if (message.recipient === this.role) + else if (message.recipient === role) protagonist = message.sender; if (!messageChannels.hasOwnProperty(protagonist)) messageChannels[protagonist] = []; messageChannels[protagonist].push(message); } - } else { - messageChannels['messages'] = messages.values(); } return messageChannels; } diff --git a/diplomacy/web/src/diplomacy/utils/sorted_dict.js b/diplomacy/web/src/diplomacy/utils/sorted_dict.js index 6a27f00..8800dba 100644 --- a/diplomacy/web/src/diplomacy/utils/sorted_dict.js +++ b/diplomacy/web/src/diplomacy/utils/sorted_dict.js @@ -106,4 +106,13 @@ export class SortedDict { values() { return this.__values.slice(); } + + toDict() { + const len = this.__real_keys.length; + const dict = {}; + for (let i = 0; i < len; ++i) { + dict[this.__real_keys[i]] = this.__values[i]; + } + return dict; + } } diff --git a/diplomacy/web/src/gui/core/action.jsx b/diplomacy/web/src/gui/core/action.jsx new file mode 100644 index 0000000..73fe8cb --- /dev/null +++ b/diplomacy/web/src/gui/core/action.jsx @@ -0,0 +1,52 @@ +// ============================================================================== +// 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 . +// ============================================================================== +import React from "react"; +import PropTypes from 'prop-types'; + + +export class Action extends React.Component { + // title + // isActive + // onClick + // See Button parameters. + + render() { + return ( +
+
+ {this.props.title} + {this.props.highlight !== null + && this.props.highlight !== undefined + && {this.props.highlight}} +
+
+ ); + } +} + +Action.propTypes = { + title: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + highlight: PropTypes.any, + isActive: PropTypes.bool +}; + +Action.defaultProps = { + highlight: null, + isActive: false +}; diff --git a/diplomacy/web/src/gui/core/button.jsx b/diplomacy/web/src/gui/core/button.jsx new file mode 100644 index 0000000..0d5dadd --- /dev/null +++ b/diplomacy/web/src/gui/core/button.jsx @@ -0,0 +1,52 @@ +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.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 +}; diff --git a/diplomacy/web/src/gui/core/content.jsx b/diplomacy/web/src/gui/core/content.jsx deleted file mode 100644 index 416ba9e..0000000 --- a/diplomacy/web/src/gui/core/content.jsx +++ /dev/null @@ -1,51 +0,0 @@ -// ============================================================================== -// 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 . -// ============================================================================== -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. , or
...
, 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 index 4d1013d..66a1efe 100644 --- a/diplomacy/web/src/gui/core/fancybox.jsx +++ b/diplomacy/web/src/gui/core/fancybox.jsx @@ -15,8 +15,8 @@ // with this program. If not, see . // ============================================================================== import React from 'react'; -import {Button} from "./widgets"; import PropTypes from 'prop-types'; +import {Button} from "./button"; const TIMES = '\u00D7'; diff --git a/diplomacy/web/src/gui/core/forms.jsx b/diplomacy/web/src/gui/core/forms.jsx index 76d188c..da7250d 100644 --- a/diplomacy/web/src/gui/core/forms.jsx +++ b/diplomacy/web/src/gui/core/forms.jsx @@ -15,8 +15,8 @@ // with this program. If not, see . // ============================================================================== import React from "react"; -import {Button} from "./widgets"; import {UTILS} from "../../diplomacy/utils/utils"; +import {Button} from "./button"; export class Forms { static createOnChangeCallback(component, callback) { diff --git a/diplomacy/web/src/gui/core/page.jsx b/diplomacy/web/src/gui/core/page.jsx index 5ca09fd..ad830f1 100644 --- a/diplomacy/web/src/gui/core/page.jsx +++ b/diplomacy/web/src/gui/core/page.jsx @@ -18,22 +18,14 @@ 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 -}; +import {PageContext} from "../diplomacy/widgets/page_context"; +import {ContentGames} from "../diplomacy/contents/content_games"; +import {loadGameFromDisk} from "../diplomacy/utils/load_game_from_disk"; +import {ContentGame} from "../diplomacy/contents/content_game"; export class Page extends React.Component { @@ -50,23 +42,18 @@ export class Page extends React.Component { error: null, info: null, success: null, - title: null, // Page content parameters - contentName: 'connection', - contentData: null, + name: null, + body: 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.logout = this.logout.bind(this); + this.loadGameFromDisk = this.loadGameFromDisk.bind(this); this.unloadFancyBox = this.unloadFancyBox.bind(this); } @@ -80,26 +67,8 @@ export class Page extends React.Component { 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); + static defaultPage() { + return ; } //// Methods to load a global fancybox. @@ -114,91 +83,39 @@ export class Page extends React.Component { //// 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); + load(name, body, messages) { + const newState = {}; + if (messages) { + for (let key of ['error', 'info', 'success']) + newState[key] = Page.wrapMessage(messages[key]); + } + Diplog.printMessages(newState); + newState.name = name; + newState.body = body; + this.setState(newState); } - loadGames(contentData, messages) { - this.loadPage('games', contentData, messages); + loadGames(messages) { + this.load( + 'games', + , + messages + ); } - loadGame(gameInfo, messages) { - this.loadPage('game', gameInfo, messages); + loadGameFromDisk() { + loadGameFromDisk( + (game) => this.load( + `game: ${game.game_id}`, + , + {success: `Game loaded from disk: ${game.game_id}`} + ), + this.error + ); } - 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); - } - }); + getName() { + return this.state.name; } //// Methods to sign out channel and go back to connection page. @@ -211,16 +128,16 @@ export class Page extends React.Component { this.availableMaps = null; const message = Page.wrapMessage(`Disconnected from channel and server.`); Diplog.success(message); - this.setState(this.copyState({ + this.setState({ error: null, info: null, success: message, - contentName: 'connection', - contentData: null, + name: null, + body: null, // When disconnected, remove all games previously loaded. games: {}, myGames: {} - })); + }); } logout() { @@ -236,10 +153,6 @@ export class Page extends React.Component { //// 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); @@ -306,11 +219,14 @@ export class Page extends React.Component { if (game.client) { game.client.leave() .then(() => { - this.disconnectGame(gameID); - this.loadGames(null, {info: `Game ${gameID} left.`}); + this.disconnectGame(gameID).then(() => { + this.loadGames({info: `Game ${gameID} left.`}); + }); }) .catch(error => this.error(`Error when leaving game ${gameID}: ${error.toString()}`)); } + } else { + this.loadGames({info: `No game to left.`}); } } @@ -319,12 +235,13 @@ export class Page extends React.Component { const game = this.state.myGames[gameID]; if (game.client) game.client.clearAllCallbacks(); - this.channel.getGamesInfo({games: [gameID]}) + return this.channel.getGamesInfo({games: [gameID]}) .then(gamesInfo => { this.updateMyGames(gamesInfo); }) .catch(error => this.error(`Error while leaving game ${gameID}: ${error.toString()}`)); } + return null; } addToMyGames(game) { @@ -335,7 +252,7 @@ export class Page extends React.Component { 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}); + this.setState({myGames: myGames, games: gamesFound}, () => this.loadGames()); } removeFromMyGames(gameID) { @@ -343,7 +260,7 @@ export class Page extends React.Component { const games = Object.assign({}, this.state.myGames); delete games[gameID]; DipStorage.removeUserGame(this.channel.username, gameID); - this.setState({myGames: games}); + this.setState({myGames: games}, () => this.loadGames()); } } @@ -354,81 +271,37 @@ export class Page extends React.Component { //// 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 ( -
-
-
this.success()}> - {successMessage} -
-
this.info()}> - {infoMessage} -
-
this.error()}> - {errorMessage} -
-
- {((hasNavigation || this.channel) && ( -
-
{title}
-
- {(!hasNavigation && ( -
- - {this.channel.username} - - -
- )) || ( -
- -
- {content.navigation.map((nav, index) => { - const navTitle = nav[0]; - const navAction = nav[1]; - return {navTitle}; - })} -
-
- )} + +
+
+
this.success()}> + {successMessage} +
+
this.info()}> + {infoMessage} +
+
this.error()}> + {errorMessage}
- )) || ( -
{title}
- )} - {content.component} - {this.state.onFancyBox && ( - - {this.state.onFancyBox()} - - )} -
+ {this.state.body || Page.defaultPage()} + {this.state.onFancyBox && ( + + {this.state.onFancyBox()} + + )} +
+ ); } } diff --git a/diplomacy/web/src/gui/core/tab.jsx b/diplomacy/web/src/gui/core/tab.jsx new file mode 100644 index 0000000..f1ad4aa --- /dev/null +++ b/diplomacy/web/src/gui/core/tab.jsx @@ -0,0 +1,29 @@ +import React from "react"; +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 ( +
+ {this.props.children} +
+ ); + } +} + +Tab.propTypes = { + display: PropTypes.bool, + className: PropTypes.string, + id: PropTypes.string, + children: PropTypes.oneOfType([PropTypes.array, PropTypes.object]) +}; + +Tab.defaultProps = { + display: false, + className: '', + id: '' +}; diff --git a/diplomacy/web/src/gui/core/tabs.jsx b/diplomacy/web/src/gui/core/tabs.jsx index 6123219..a3f6b9b 100644 --- a/diplomacy/web/src/gui/core/tabs.jsx +++ b/diplomacy/web/src/gui/core/tabs.jsx @@ -15,36 +15,9 @@ // with this program. If not, see . // ============================================================================== import React from "react"; -import {Action} from "./widgets"; +import {Action} from "./action"; 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 ( -
- {this.props.children} -
- ); - } -} - -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). diff --git a/diplomacy/web/src/gui/core/widgets.jsx b/diplomacy/web/src/gui/core/widgets.jsx deleted file mode 100644 index 62a5eb4..0000000 --- a/diplomacy/web/src/gui/core/widgets.jsx +++ /dev/null @@ -1,102 +0,0 @@ -// ============================================================================== -// 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 . -// ============================================================================== -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.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 ( -
-
- {this.props.title} - {this.props.highlight !== null - && this.props.highlight !== undefined - && {this.props.highlight}} -
-
- ); - } -} - -Action.propTypes = { - title: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, - highlight: PropTypes.any, - isActive: PropTypes.bool -}; - -Action.defaultProps = { - highlight: null, - isActive: false -}; diff --git a/diplomacy/web/src/gui/diplomacy/contents/content_connection.jsx b/diplomacy/web/src/gui/diplomacy/contents/content_connection.jsx index 8aa7fb1..8c952a4 100644 --- a/diplomacy/web/src/gui/diplomacy/contents/content_connection.jsx +++ b/diplomacy/web/src/gui/diplomacy/contents/content_connection.jsx @@ -15,28 +15,22 @@ // with this program. If not, see . // ============================================================================== import React from 'react'; -import {Content} from "../../core/content"; import {Connection} from "../../../diplomacy/client/connection"; import {ConnectionForm} from "../forms/connection_form"; import {DipStorage} from "../utils/dipStorage"; +import {Helmet} from "react-helmet"; +import {Navigation} from "../widgets/navigation"; +import {PageContext} from "../widgets/page_context"; -export class ContentConnection extends Content { +export class ContentConnection extends React.Component { constructor(props) { super(props); this.connection = null; this.onSubmit = this.onSubmit.bind(this); } - static builder(page, data) { - return { - title: 'Connection', - navigation: [], - component: - }; - } - onSubmit(data) { - const page = this.getPage(); + const page = this.context; for (let fieldName of ['hostname', 'port', 'username', 'password', 'showServerFields']) if (!data.hasOwnProperty(fieldName)) return page.error(`Missing ${fieldName}, got ${JSON.stringify(data)}`); @@ -46,7 +40,7 @@ export class ContentConnection extends Content { } this.connection = new Connection(data.hostname, data.port, window.location.protocol.toLowerCase() === 'https:'); // Page is passed as logger object (with methods info(), error(), success()) when connecting. - this.connection.connect(this.getPage()) + this.connection.connect(page) .then(() => { page.connection = this.connection; this.connection = null; @@ -71,10 +65,10 @@ export class ContentConnection extends Content { }) .then((gamesInfo) => { if (gamesInfo) { - this.getPage().success('Found ' + gamesInfo.length + ' user games.'); - this.getPage().updateMyGames(gamesInfo); + page.success('Found ' + gamesInfo.length + ' user games.'); + page.updateMyGames(gamesInfo); } - page.loadGames(null, {success: `Account ${data.username} connected.`}); + page.loadGames({success: `Account ${data.username} connected.`}); }) .catch((error) => { page.error('Error while authenticating: ' + error + ' Please re-try.'); @@ -86,6 +80,21 @@ export class ContentConnection extends Content { } render() { - return
; + const title = 'Connection'; + return ( +
+ + {title} | Diplomacy + + + +
+ ); + } + + componentDidMount() { + window.scrollTo(0, 0); } } + +ContentConnection.contextType = PageContext; \ No newline at end of file diff --git a/diplomacy/web/src/gui/diplomacy/contents/content_game.jsx b/diplomacy/web/src/gui/diplomacy/contents/content_game.jsx index 81a689d..b3d933b 100644 --- a/diplomacy/web/src/gui/diplomacy/contents/content_game.jsx +++ b/diplomacy/web/src/gui/diplomacy/contents/content_game.jsx @@ -19,10 +19,8 @@ import Scrollchor from 'react-scrollchor'; import {SelectLocationForm} from "../forms/select_location_form"; import {SelectViaForm} from "../forms/select_via_form"; import {Order} from "../utils/order"; -import {Button} from "../../core/widgets"; import {Bar, Row} from "../../core/layouts"; -import {Content} from "../../core/content"; -import {Tab, Tabs} from "../../core/tabs"; +import {Tabs} from "../../core/tabs"; import {Map} from "../map/map"; import {extendOrderBuilding, ORDER_BUILDER, POSSIBLE_ORDERS} from "../utils/order_building"; import {PowerActionsForm} from "../forms/power_actions_form"; @@ -37,6 +35,13 @@ import {Table} from "../../core/table"; import {PowerView} from "../utils/power_view"; import {FancyBox} from "../../core/fancybox"; import {DipStorage} from "../utils/dipStorage"; +import Helmet from 'react-helmet'; +import {Navigation} from "../widgets/navigation"; +import {PageContext} from "../widgets/page_context"; +import PropTypes from 'prop-types'; +import {Help} from "../widgets/help"; +import {Tab} from "../../core/tab"; +import {Button} from "../../core/button"; const HotKey = require('react-shortcut'); @@ -63,19 +68,13 @@ const TABLE_POWER_VIEW = { wait: ['Waiting', 3] }; -function Help() { - return ( -
-

When building an order, press ESC to reset build.

-

Press letter associated to an order type to start building an order of this type. -
Order type letter is indicated in order type name after order type radio button. -

-

In Phase History tab, use keyboard left and right arrows to navigate in past phases.

-
- ); +function gameReloaded(game, updates) { + if (updates) + return Object.assign({}, updates, game); + return Object.assign({}, game); } -export class ContentGame extends Content { +export class ContentGame extends React.Component { constructor(props) { super(props); @@ -109,7 +108,6 @@ export class ContentGame extends Content { messageHighlights: {}, historyPhaseIndex: null, historyShowOrders: true, - historySubView: 0, historyCurrentLoc: null, historyCurrentOrders: null, wait: null, // {power name => bool} @@ -191,21 +189,6 @@ export class ContentGame extends Content { } } - static builder(page, data) { - return { - title: ContentGame.gameTitle(data), - navigation: [ - ['Help', () => page.loadFancyBox('Help', () => )], - ['Load a game from disk', page.loadGameFromDisk], - ['Save game to disk', () => ContentGame.saveGameToDisk(data)], - [`${UTILS.html.UNICODE_SMALL_LEFT_ARROW} Games`, page.loadGames], - [`${UTILS.html.UNICODE_SMALL_LEFT_ARROW} Leave game`, () => page.leaveGame(data.game_id)], - [`${UTILS.html.UNICODE_SMALL_LEFT_ARROW} Logout`, page.logout] - ], - component: - }; - } - static getServerWaitFlags(engine) { const wait = {}; const controllablePowers = engine.getControllablePowers(); @@ -326,7 +309,7 @@ export class ContentGame extends Content { } getMapInfo() { - return this.props.page.availableMaps[this.props.data.map_name]; + return this.getPage().availableMaps[this.props.data.map_name]; } clearScheduleTimeout() { @@ -343,7 +326,7 @@ export class ContentGame extends Content { engine.deadline_timer = 0; this.clearScheduleTimeout(); } - this.getPage().setTitle(ContentGame.gameTitle(engine)); + this.getPage().load(`game: ${engine.game_id}`, ); } reloadDeadlineTimer(networkGame) { @@ -366,13 +349,17 @@ export class ContentGame extends Content { } networkGameIsDisplayed(networkGame) { - return this.getPage().pageIsGame(networkGame.local); + return this.getPage().getName() === `game: ${networkGame.local.game_id}`; } notifiedNetworkGame(networkGame, notification) { if (this.networkGameIsDisplayed(networkGame)) { const msg = `Game (${networkGame.local.game_id}) received notification ${notification.name}.`; - this.props.page.loadGame(networkGame.local, {info: msg}); + this.getPage().load( + `game: ${networkGame.local.game_id}`, + , + {info: msg} + ); this.reloadDeadlineTimer(networkGame); } } @@ -383,10 +370,11 @@ export class ContentGame extends Content { || !networkGame.channel.game_id_to_instances[networkGame.local.game_id].has(networkGame.local.role) )) { // This power game is now invalid. - this.props.page.disconnectGame(networkGame.local.game_id); + this.getPage().disconnectGame(networkGame.local.game_id); if (this.networkGameIsDisplayed(networkGame)) { - this.props.page.loadGames(null, - {error: `Player game ${networkGame.local.game_id}/${networkGame.local.role} was kicked. Deadline over?`}); + const page = this.getPage(); + page.loadGames( + {error: `${networkGame.local.game_id}/${networkGame.local.role} was kicked. Deadline over?`}); } } else { this.notifiedNetworkGame(networkGame, notification); @@ -398,8 +386,10 @@ export class ContentGame extends Content { .then(allPossibleOrders => { networkGame.local.setPossibleOrders(allPossibleOrders); if (this.networkGameIsDisplayed(networkGame)) { - this.getPage().loadGame( - networkGame.local, {info: `Game update (${notification.name}) to ${networkGame.local.phase}.`} + this.getPage().load( + `game: ${networkGame.local.game_id}`, + , + {info: `Game update (${notification.name}) to ${networkGame.local.phase}.`} ); this.__store_orders(null); this.setState({orders: null, wait: null, messageHighlights: {}}); @@ -414,8 +404,10 @@ export class ContentGame extends Content { .then(allPossibleOrders => { networkGame.local.setPossibleOrders(allPossibleOrders); if (this.networkGameIsDisplayed(networkGame)) { - this.getPage().loadGame( - networkGame.local, {info: `Possible orders re-loaded.`} + this.getPage().load( + `game: ${networkGame.local.game_id}`, + , + {info: `Possible orders re-loaded.`} ); this.reloadDeadlineTimer(networkGame); } @@ -459,7 +451,7 @@ export class ContentGame extends Content { } onChangeCurrentPower(event) { - this.setState({power: event.target.value}); + this.setState({power: event.target.value, tabPastMessages: null, tabCurrentMessages: null}); } onChangeMainTab(tab) { @@ -482,10 +474,14 @@ export class ContentGame extends Content { recipient: recipient, message: body }); - const page = this.props.page; + const page = this.getPage(); networkGame.sendGameMessage({message: message}) .then(() => { - page.loadGame(engine, {success: `Message sent: ${JSON.stringify(message)}`}); + page.load( + `game: ${engine.game_id}`, + , + {success: `Message sent: ${JSON.stringify(message)}`} + ); }) .catch(error => page.error(error.toString())); } @@ -560,10 +556,10 @@ export class ContentGame extends Content { Diplog.info('Sending orders for ' + powerName + ': ' + JSON.stringify(localPowerOrders)); this.props.data.client.setOrders({power_name: powerName, orders: localPowerOrders || []}) .then(() => { - this.props.page.success('Orders sent.'); + this.getPage().success('Orders sent.'); }) .catch(err => { - this.props.page.error(err.toString()); + this.getPage().error(err.toString()); }) .then(() => { this.reloadServerOrders(); @@ -572,10 +568,11 @@ export class ContentGame extends Content { } onProcessGame() { + const page = this.getPage(); this.props.data.client.process() - .then(() => this.props.page.success('Game processed.')) + .then(() => page.success('Game processed.')) .catch(err => { - this.props.page.error(err.toString()); + page.error(err.toString()); }); } @@ -604,7 +601,7 @@ export class ContentGame extends Content { onOrderBuilding(powerName, path) { const pathToSave = path.slice(1); - this.props.page.success(`Building order ${pathToSave.join(' ')} ...`); + this.getPage().success(`Building order ${pathToSave.join(' ')} ...`); this.setState({orderBuildingPath: pathToSave}); } @@ -632,7 +629,7 @@ export class ContentGame extends Content { allOrders[powerName] = {}; allOrders[powerName][localOrder.loc] = localOrder; state.orders = allOrders; - this.props.page.success(`Built order: ${orderString}`); + this.getPage().success(`Built order: ${orderString}`); this.__store_orders(allOrders); this.setState(state); } @@ -684,10 +681,9 @@ export class ContentGame extends Content { }); } - __change_past_phase(newPhaseIndex, subView) { + __change_past_phase(newPhaseIndex) { this.setState({ historyPhaseIndex: newPhaseIndex, - historySubView: (subView ? subView : 0), historyCurrentLoc: null, historyCurrentOrders: null }); @@ -700,16 +696,6 @@ export class ContentGame extends Content { onChangePastPhaseIndex(increment) { const selectObject = document.getElementById('select-past-phase'); if (selectObject) { - if (!this.state.historyShowOrders) { - // We must change map sub-view before showed phase index. - const currentSubView = this.state.historySubView; - const newSubView = currentSubView + (increment ? 1 : -1); - if (newSubView === 0 || newSubView === 1) { - // Sub-view correctly updated. We don't yet change showed phase. - return this.setState({historySubView: newSubView}); - } - // Sub-view badly updated (either from 0 to -1, or from 1 to 2). We must change phase. - } // Let's simply increase or decrease index of showed past phase. const index = selectObject.selectedIndex; const newIndex = index + (increment ? 1 : -1); @@ -741,7 +727,7 @@ export class ContentGame extends Content { } onChangeShowPastOrders(event) { - this.setState({historyShowOrders: event.target.checked, historySubView: 0}); + this.setState({historyShowOrders: event.target.checked}); } renderOrders(engine, currentPowerName) { @@ -763,7 +749,7 @@ export class ContentGame extends Content { let protagonist = message.sender; if (message.recipient === 'GLOBAL') protagonist = message.recipient; - this.getPage().loadGame(this.props.data); + this.getPage().load(`game: ${this.props.data.game_id}`, ); if (this.state.messageHighlights.hasOwnProperty(protagonist) && this.state.messageHighlights[protagonist] > 0) { const messageHighlights = Object.assign({}, this.state.messageHighlights); --messageHighlights[protagonist]; @@ -779,31 +765,28 @@ export class ContentGame extends Content { }); } - renderPastMessages(engine) { - const messageChannels = engine.getMessageChannels(); - let tabNames = null; - if (engine.isPlayerGame()) { - tabNames = []; - for (let powerName of Object.keys(engine.powers)) if (powerName !== engine.role) - tabNames.push(powerName); - tabNames.sort(); - tabNames.push('GLOBAL'); - } else { - tabNames = Object.keys(messageChannels); - } + renderPastMessages(engine, role) { + const messageChannels = engine.getMessageChannels(role, true); + const tabNames = []; + for (let powerName of Object.keys(engine.powers)) if (powerName !== role) + tabNames.push(powerName); + tabNames.sort(); + tabNames.push('GLOBAL'); + const titles = tabNames.map(tabName => (tabName === 'GLOBAL' ? tabName : tabName.substr(0, 3))); const currentTabId = this.state.tabPastMessages || tabNames[0]; return (
{/* Messages. */} - + {tabNames.map(protagonist => ( {(!messageChannels.hasOwnProperty(protagonist) || !messageChannels[protagonist].length ? (
No messages{engine.isPlayerGame() ? ` with ${protagonist}` : ''}.
) : messageChannels[protagonist].map((message, index) => ( - + )) )}
@@ -813,51 +796,41 @@ export class ContentGame extends Content { ); } - renderCurrentMessages(engine) { - const messageChannels = engine.getMessageChannels(); - let tabNames = null; - let highlights = null; - if (engine.isPlayerGame()) { - tabNames = []; - for (let powerName of Object.keys(engine.powers)) if (powerName !== engine.role) - tabNames.push(powerName); - tabNames.sort(); - tabNames.push('GLOBAL'); - highlights = this.state.messageHighlights; - } else { - tabNames = Object.keys(messageChannels); - let totalHighlights = 0; - for (let count of Object.values(this.state.messageHighlights)) - totalHighlights += count; - highlights = {messages: totalHighlights}; - } - const unreadMarked = new Set(); + renderCurrentMessages(engine, role) { + const messageChannels = engine.getMessageChannels(role, true); + const tabNames = []; + for (let powerName of Object.keys(engine.powers)) if (powerName !== role) + tabNames.push(powerName); + tabNames.sort(); + tabNames.push('GLOBAL'); + const titles = tabNames.map(tabName => (tabName === 'GLOBAL' ? tabName : tabName.substr(0, 3))); const currentTabId = this.state.tabCurrentMessages || tabNames[0]; + const highlights = this.state.messageHighlights; + const unreadMarked = new Set(); return (
{/* Messages. */} - {tabNames.map(protagonist => ( - + {(!messageChannels.hasOwnProperty(protagonist) || !messageChannels[protagonist].length ? (
No messages{engine.isPlayerGame() ? ` with ${protagonist}` : ''}.
) : (messageChannels[protagonist].map((message, index) => { let id = null; if (!message.read && !unreadMarked.has(protagonist)) { - if (engine.isOmniscientGame() || message.sender !== engine.role) { + if (engine.isOmniscientGame() || message.sender !== role) { unreadMarked.add(protagonist); id = `${protagonist}-unread`; } } - return ; + read={message.phase !== engine.phase} + id={id} onClick={this.onClickMessage}/>; })) )}
@@ -873,13 +846,13 @@ export class ContentGame extends Content { )} {/* Send form. */} {engine.isPlayerGame() && ( - + this.sendMessage(engine.client, currentTabId, form.message)}/>)}
); } - renderPastMap(gameEngine, showOrders) { + renderMapForResults(gameEngine, showOrders) { return ; } - renderCurrentMap(gameEngine, powerName, orderType, orderPath) { + renderMapForMessages(gameEngine, showOrders) { + return ; + } + + renderMapForCurrent(gameEngine, powerName, orderType, orderPath) { const rawOrders = this.__get_orders(gameEngine); const orders = {}; for (let entry of Object.entries(rawOrders)) { @@ -915,27 +900,52 @@ export class ContentGame extends Content { onSelectVia={this.onSelectVia}/>; } - renderTabPhaseHistory(toDisplay, initialEngine) { + __get_engine_to_display(initialEngine) { const pastPhases = initialEngine.state_history.values().map(state => state.name); - if (initialEngine.phase === 'COMPLETED') { - pastPhases.push('COMPLETED'); - } + pastPhases.push(initialEngine.phase); let phaseIndex = 0; if (initialEngine.displayed) { if (this.state.historyPhaseIndex === null || this.state.historyPhaseIndex >= pastPhases.length) { phaseIndex = pastPhases.length - 1; + } else if (this.state.historyPhaseIndex < 0) { + phaseIndex = pastPhases.length + this.state.historyPhaseIndex; } else { - if (this.state.historyPhaseIndex < 0) { - phaseIndex = pastPhases.length + this.state.historyPhaseIndex; - } else { - phaseIndex = this.state.historyPhaseIndex; - } + phaseIndex = this.state.historyPhaseIndex; } } const engine = ( - phaseIndex === initialEngine.state_history.size() ? - initialEngine : initialEngine.cloneAt(initialEngine.state_history.keyFromIndex(phaseIndex)) + pastPhases[phaseIndex] === initialEngine.phase ? + initialEngine : initialEngine.cloneAt(pastPhases[phaseIndex]) + ); + return {engine, pastPhases, phaseIndex}; + } + + __form_phases(pastPhases, phaseIndex) { + return ( +
+
- {/* Messages. */} - {this.renderCurrentMessages(engine)}
); } + getPage() { + return this.context; + } + render() { + this.props.data.displayed = true; + const page = this.context; const engine = this.props.data; + const title = ContentGame.gameTitle(engine); + const navigation = [ + ['Help', () => page.loadFancyBox('Help', () => )], + ['Load a game from disk', page.loadGameFromDisk], + ['Save game to disk', () => ContentGame.saveGameToDisk(engine)], + [`${UTILS.html.UNICODE_SMALL_LEFT_ARROW} Games`, () => page.loadGames()], + [`${UTILS.html.UNICODE_SMALL_LEFT_ARROW} Leave game`, () => page.leaveGame(engine.game_id)], + [`${UTILS.html.UNICODE_SMALL_LEFT_ARROW} Logout`, page.logout] + ]; const phaseType = engine.getPhaseType(); const controllablePowers = engine.getControllablePowers(); if (this.props.data.client) @@ -1099,12 +1119,14 @@ export class ContentGame extends Content { if (engine.state_history.size()) { hasTabPhaseHistory = true; tabNames.push('phase_history'); - tabTitles.push('Phase history'); + tabTitles.push('Results'); } - if (controllablePowers.length && phaseType) { + tabNames.push('messages'); + tabTitles.push('Messages'); + if (controllablePowers.length && phaseType && !engine.isObserverGame()) { hasTabCurrentPhase = true; tabNames.push('current_phase'); - tabTitles.push('Current phase'); + tabTitles.push('Current'); } if (!tabNames.length) { // This should never happen, but let's display this message. @@ -1135,64 +1157,72 @@ export class ContentGame extends Content { buildCount = engine.getBuildsCount(currentPowerName); } - return ( -
- {(hasTabCurrentPhase && ( -
-
- {(controllablePowers.length === 1 && -
{controllablePowers[0]}
) || ( - - )} -
-
- this.onSetNoOrders(currentPowerName)} - onSetWaitFlag={() => this.setWaitFlag(!currentPower.wait)} - onVote={this.vote} - role={engine.role} - power={currentPower}/> -
-
- )) || ''} - {(hasTabCurrentPhase && ( -
- {(allowedPowerOrderTypes.length && ( - + const navAfterTitle = ( + (controllablePowers.length === 1 && + {controllablePowers[0]}) || ( +
+ +
+ ) + ); + + const currentTabOrderCreation = hasTabCurrentPhase && ( +
+ this.onSetNoOrders(currentPowerName)} + onSetWaitFlag={() => this.setWaitFlag(!currentPower.wait)} + onVote={this.vote} + role={engine.role} + power={currentPower}/> + {(allowedPowerOrderTypes.length && ( + Orderable locations: {orderTypeToLocs[orderBuildingType].join(', ')} - )) - || ( No orderable location.)} - {phaseType === 'A' && ( - (buildCount === null && ( -  (unknown build count) - )) - || (buildCount === 0 ? ( -  (nothing to build or disband) - ) : (buildCount > 0 ? ( -  ({buildCount} unit{buildCount > 1 && 's'} may be built) - ) : ( -  ({-buildCount} unit{buildCount < -1 && 's'} to disband) - ))) - )} -
- )) || ''} + )) + || ( No orderable location.)} + {phaseType === 'A' && ( + (buildCount === null && ( +  (unknown build count) + )) + || (buildCount === 0 ? ( +  (nothing to build or disband) + ) : (buildCount > 0 ? ( +  ({buildCount} unit{buildCount > 1 && 's'} may be built) + ) : ( +  ({-buildCount} unit{buildCount < -1 && 's'} to disband) + ))) + )} +
+ ); + + return ( +
+ + {title} | Diplomacy + + {/* Tab Phase history. */} - {(hasTabPhaseHistory && this.renderTabPhaseHistory(mainTab === 'phase_history', engine)) || ''} + {(hasTabPhaseHistory && this.renderTabResults(mainTab === 'phase_history', engine)) || ''} + {this.renderTabMessages(mainTab === 'messages', engine, currentPowerName)} {/* Tab Current phase. */} {(hasTabCurrentPhase && this.renderTabCurrentPhase( mainTab === 'current_phase', engine, currentPowerName, orderBuildingType, - this.state.orderBuildingPath + this.state.orderBuildingPath, + currentPowerName, + currentTabOrderCreation )) || ''} {this.state.fancy_title && ( @@ -1204,7 +1234,7 @@ export class ContentGame extends Content { } componentDidMount() { - super.componentDidMount(); + window.scrollTo(0, 0); if (this.props.data.client) this.reloadDeadlineTimer(this.props.data.client); this.props.data.displayed = true; @@ -1233,3 +1263,8 @@ export class ContentGame extends Content { } } + +ContentGame.contextType = PageContext; +ContentGame.propTypes = { + data: PropTypes.object.isRequired +}; diff --git a/diplomacy/web/src/gui/diplomacy/contents/content_games.jsx b/diplomacy/web/src/gui/diplomacy/contents/content_games.jsx index 6a62d71..51ad998 100644 --- a/diplomacy/web/src/gui/diplomacy/contents/content_games.jsx +++ b/diplomacy/web/src/gui/diplomacy/contents/content_games.jsx @@ -15,13 +15,18 @@ // with this program. If not, see . // ============================================================================== import React from "react"; -import {Content} from "../../core/content"; -import {Tab, Tabs} from "../../core/tabs"; +import {Tabs} from "../../core/tabs"; import {Table} from "../../core/table"; import {FindForm} from "../forms/find_form"; import {CreateForm} from "../forms/create_form"; import {InlineGameView} from "../utils/inline_game_view"; import {STRINGS} from "../../../diplomacy/utils/strings"; +import {Helmet} from "react-helmet"; +import {Navigation} from "../widgets/navigation"; +import {PageContext} from "../widgets/page_context"; +import {ContentGame} from "./content_game"; +import PropTypes from 'prop-types'; +import {Tab} from "../../core/tab"; const TABLE_LOCAL_GAMES = { game_id: ['Game ID', 0], @@ -35,7 +40,7 @@ const TABLE_LOCAL_GAMES = { my_games: ['My Games', 8], }; -export class ContentGames extends Content { +export class ContentGames extends React.Component { constructor(props) { super(props); @@ -46,15 +51,8 @@ export class ContentGames extends Content { this.wrapGameData = this.wrapGameData.bind(this); } - static builder(page, data) { - return { - title: 'Games', - navigation: [ - ['load a game from disk', page.loadGameFromDisk], - ['logout', page.logout] - ], - component: - }; + getPage() { + return this.context; } onFind(form) { @@ -65,6 +63,7 @@ export class ContentGames extends Content { .then((data) => { this.getPage().success('Found ' + data.length + ' data.'); this.getPage().addGamesFound(data); + this.getPage().loadGames(); }) .catch((error) => { this.getPage().error('Error when looking for distant games: ' + error); @@ -98,7 +97,11 @@ export class ContentGames extends Content { }) .then(allPossibleOrders => { networkGame.local.setPossibleOrders(allPossibleOrders); - this.getPage().loadGame(networkGame.local, {success: 'Game created.'}); + this.getPage().load( + `game: ${networkGame.local.game_id}`, + , + {success: 'Game created.'} + ); }) .catch((error) => { this.getPage().error('Error when creating a game: ' + error); @@ -114,27 +117,55 @@ export class ContentGames extends Content { } render() { - const myGames = this.getPage().getMyGames(); + const title = 'Games'; + const page = this.getPage(); + const navigation = [ + ['load a game from disk', page.loadGameFromDisk], + ['logout', page.logout] + ]; + const myGames = this.props.myGames; + const gamesFound = this.props.gamesFound; + myGames.sort((a, b) => b.timestamp_created - a.timestamp_created); + gamesFound.sort((a, b) => b.timestamp_created - a.timestamp_created); const tab = this.state.tab ? this.state.tab : (myGames.length ? 'my-games' : 'find'); return (
+ + {title} | Diplomacy + + - - - - - - - - -
- + {tab === 'create' ? ( + + + + ) : ''} + {tab === 'find' ? ( + + +
+ + ) : ''} + {tab === 'my-games' ? ( + +
+ + ) : ''} ); } + componentDidMount() { + window.scrollTo(0, 0); + } } + +ContentGames.contextType = PageContext; +ContentGames.propTypes = { + gamesFound: PropTypes.array.isRequired, + myGames: PropTypes.array.isRequired +}; diff --git a/diplomacy/web/src/gui/diplomacy/forms/join_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/join_form.jsx index 0447280..5b3ec13 100644 --- a/diplomacy/web/src/gui/diplomacy/forms/join_form.jsx +++ b/diplomacy/web/src/gui/diplomacy/forms/join_form.jsx @@ -49,20 +49,22 @@ export class JoinForm extends React.Component { const onSubmit = Forms.createOnSubmitCallback(this, this.props.onSubmit); return ( -
+
{Forms.createLabel(this.getPowerNameID(), 'Power:')}
-
- {Forms.createLabel(this.getPasswordID(), '', 'sr-only')} - -
+ {this.props.password_required ? ( +
+ {Forms.createLabel(this.getPasswordID(), '', 'sr-only')} + +
+ ) : ''} {Forms.createSubmit('join', false, onSubmit)} ); @@ -71,6 +73,7 @@ export class JoinForm extends React.Component { JoinForm.propTypes = { game_id: PropTypes.string.isRequired, + password_required: PropTypes.bool.isRequired, powers: PropTypes.arrayOf(PropTypes.string), onChange: PropTypes.func, onSubmit: PropTypes.func diff --git a/diplomacy/web/src/gui/diplomacy/forms/power_actions_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/power_actions_form.jsx index 33bd763..2f7c1f5 100644 --- a/diplomacy/web/src/gui/diplomacy/forms/power_actions_form.jsx +++ b/diplomacy/web/src/gui/diplomacy/forms/power_actions_form.jsx @@ -48,7 +48,6 @@ export class PowerActionsForm extends React.Component { const votes = []; if (this.props.orderTypes.length) { title = 'Create order:'; - header.push({title}); header.push(...this.props.orderTypes.map((orderLetter, index) => (
{Forms.createRadio('order_type', orderLetter, ORDER_BUILDER[orderLetter].name, this.props.orderType, onChange)} @@ -58,10 +57,8 @@ export class PowerActionsForm extends React.Component { } else if (this.props.power.order_is_set) { title = 'Unorderable power (already locked on server).'; titleClass += ' neutral'; - header.push({title}); } else { title = 'No orders available for this power.'; - header.push({title}); } if (!this.props.power.order_is_set) { header.push(Forms.createButton('pass', this.props.onNoOrders)); @@ -90,19 +87,23 @@ export class PowerActionsForm extends React.Component { } } return ( -
- {header} - {Forms.createButton( - (this.props.power.wait ? 'no wait' : 'wait'), - this.props.onSetWaitFlag, - (this.props.power.wait ? 'success' : 'danger') - )} - {votes} - - {this.props.orderTypes.map((letter, index) => ( - onSetOrderType(letter)}/> - ))} - +
+
{title}
+
+ {header} + {Forms.createButton( + (this.props.power.wait ? 'no wait' : 'wait'), + this.props.onSetWaitFlag, + (this.props.power.wait ? 'success' : 'danger') + )} + {votes} + + {this.props.orderTypes.map((letter, index) => ( + onSetOrderType(letter)}/> + ))} + +
); } } diff --git a/diplomacy/web/src/gui/diplomacy/forms/select_location_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/select_location_form.jsx index 3c55e49..6b966d0 100644 --- a/diplomacy/web/src/gui/diplomacy/forms/select_location_form.jsx +++ b/diplomacy/web/src/gui/diplomacy/forms/select_location_form.jsx @@ -16,7 +16,7 @@ // ============================================================================== import React from "react"; import PropTypes from "prop-types"; -import {Button} from "../../core/widgets"; +import {Button} from "../../core/button"; export class SelectLocationForm extends React.Component { render() { diff --git a/diplomacy/web/src/gui/diplomacy/forms/select_via_form.jsx b/diplomacy/web/src/gui/diplomacy/forms/select_via_form.jsx index cc62fe2..51f3306 100644 --- a/diplomacy/web/src/gui/diplomacy/forms/select_via_form.jsx +++ b/diplomacy/web/src/gui/diplomacy/forms/select_via_form.jsx @@ -16,7 +16,7 @@ // ============================================================================== import React from "react"; import PropTypes from "prop-types"; -import {Button} from "../../core/widgets"; +import {Button} from "../../core/button"; export class SelectViaForm extends React.Component { render() { diff --git a/diplomacy/web/src/gui/diplomacy/utils/inline_game_view.jsx b/diplomacy/web/src/gui/diplomacy/utils/inline_game_view.jsx index 0ada4c9..3de649c 100644 --- a/diplomacy/web/src/gui/diplomacy/utils/inline_game_view.jsx +++ b/diplomacy/web/src/gui/diplomacy/utils/inline_game_view.jsx @@ -15,9 +15,10 @@ // with this program. If not, see . // ============================================================================== import React from "react"; -import {Button} from "../../core/widgets"; import {JoinForm} from "../forms/join_form"; import {STRINGS} from "../../../diplomacy/utils/strings"; +import {ContentGame} from "../contents/content_game"; +import {Button} from "../../core/button"; export class InlineGameView { constructor(page, gameData) { @@ -46,7 +47,11 @@ export class InlineGameView { }) .then(allPossibleOrders => { this.game.setPossibleOrders(allPossibleOrders); - this.page.loadGame(this.game, {success: 'Game joined.'}); + this.page.load( + `game: ${this.game.game_id}`, + , + {success: 'Game joined.'} + ); }) .catch((error) => { this.page.error('Error when joining game ' + this.game.game_id + ': ' + error); @@ -54,7 +59,7 @@ export class InlineGameView { } showGame() { - this.page.loadGame(this.game); + this.page.load(`game: ${this.game.game_id}`, ); } getJoinUI() { @@ -70,6 +75,7 @@ export class InlineGameView { } else { // Game not yet joined. return ; } } @@ -124,6 +130,14 @@ export class InlineGameView { return this.getJoinUI(); if (name === 'my_games') return this.getMyGamesButton(); + if (name === 'game_id') { + const date = new Date(this.game.timestamp_created / 1000); + const dateString = `${date.toLocaleDateString()} - ${date.toLocaleTimeString()}`; + return
+
{this.game.game_id}
+
({dateString})
+
; + } return this.game[name]; } } diff --git a/diplomacy/web/src/gui/diplomacy/utils/load_game_from_disk.js b/diplomacy/web/src/gui/diplomacy/utils/load_game_from_disk.js new file mode 100644 index 0000000..1e13f4f --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/utils/load_game_from_disk.js @@ -0,0 +1,83 @@ +import $ from "jquery"; +import {STRINGS} from "../../../diplomacy/utils/strings"; +import {Game} from "../../../diplomacy/engine/game"; + +export function loadGameFromDisk(onLoad, onError) { + 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)) { + onError(`Invalid JSON filename ${file.name}`); + return; + } + 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; + gameObject.state_history = {}; + gameObject.message_history = {}; + gameObject.order_history = {}; + gameObject.result_history = {}; + + // Load all saved phases (expect the latest one) to history fields. + for (let i = 0; i < savedData.phases.length - 1; ++i) { + const savedPhase = savedData.phases[i]; + 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; + gameObject.state_history[gameState.name] = gameState; + gameObject.message_history[gameState.name] = phaseMessages; + gameObject.order_history[gameState.name] = phaseOrders; + gameObject.result_history[gameState.name] = phaseResults; + } + + // Load latest phase separately and use it later to define the current game phase. + const latestPhase = savedData.phases[savedData.phases.length - 1]; + const latestGameState = latestPhase.state; + const latestPhaseOrders = latestPhase.orders || {}; + const latestPhaseResults = latestPhase.results || {}; + const latestPhaseMessages = {}; + if (latestPhase.messages) { + for (let message of latestPhase.messages) { + latestPhaseMessages[message.time_sent] = message; + } + } + if (!latestGameState.name) + latestGameState.name = latestPhase.name; + // TODO: NB: What is latest phase in loaded JSON contains order results? Not sure if it is well handled. + gameObject.result_history[latestGameState.name] = latestPhaseResults; + + 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); + + // Set game current phase and state using latest phase found in JSON file. + game.setPhaseData({ + name: latestGameState.name, + state: latestGameState, + orders: latestPhaseOrders, + messages: latestPhaseMessages + }); + onLoad(game); + }; + reader.readAsText(file); + }); +} diff --git a/diplomacy/web/src/gui/diplomacy/widgets/help.jsx b/diplomacy/web/src/gui/diplomacy/widgets/help.jsx new file mode 100644 index 0000000..1ec1a54 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/widgets/help.jsx @@ -0,0 +1,13 @@ +import React from "react"; + +export function Help() { + return ( +
+

When building an order, press ESC to reset build.

+

Press letter associated to an order type to start building an order of this type. +
Order type letter is indicated in order type name after order type radio button. +

+

In Phase History tab, use keyboard left and right arrows to navigate in past phases.

+
+ ); +} diff --git a/diplomacy/web/src/gui/diplomacy/widgets/message_view.jsx b/diplomacy/web/src/gui/diplomacy/widgets/message_view.jsx index 045a108..46153b8 100644 --- a/diplomacy/web/src/gui/diplomacy/widgets/message_view.jsx +++ b/diplomacy/web/src/gui/diplomacy/widgets/message_view.jsx @@ -15,7 +15,6 @@ // with this program. If not, see . // ============================================================================== import React from "react"; -import {UTILS} from "../../../diplomacy/utils/utils"; import PropTypes from 'prop-types'; export class MessageView extends React.Component { @@ -24,9 +23,13 @@ export class MessageView extends React.Component { const message = this.props.message; const owner = this.props.owner; const id = this.props.id ? {id: this.props.id} : {}; - const messagesLines = message.message.replace('\r\n', '\n').replace('\r', '\n').split('\n'); + const messagesLines = message.message.replace('\r\n', '\n') + .replace('\r', '\n') + .replace('
', '\n') + .replace('
', '\n') + .split('\n'); let onClick = null; - const classNames = ['game-message']; + const classNames = ['game-message', 'row']; if (owner === message.sender) classNames.push('message-sender'); else { @@ -36,12 +39,18 @@ export class MessageView extends React.Component { onClick = this.props.onClick ? {onClick: () => this.props.onClick(message)} : {}; } return ( -
+
-
- {message.sender} {UTILS.html.UNICODE_SMALL_RIGHT_ARROW} {message.recipient} +
+ {message.phase} +
+
+ {messagesLines.map((line, lineIndex) =>
{ + line.replace(/(<([^>]+)>)/ig,"") + }
)}
-
{messagesLines.map((line, lineIndex) =>
{line}
)}
); @@ -50,6 +59,7 @@ export class MessageView extends React.Component { MessageView.propTypes = { message: PropTypes.object, + phase: PropTypes.string, owner: PropTypes.string, onClick: PropTypes.func, id: PropTypes.string, diff --git a/diplomacy/web/src/gui/diplomacy/widgets/navigation.jsx b/diplomacy/web/src/gui/diplomacy/widgets/navigation.jsx new file mode 100644 index 0000000..5d961bc --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/widgets/navigation.jsx @@ -0,0 +1,61 @@ +import React from "react"; +import Octicon, {Person} from "@githubprimer/octicons-react"; +import PropTypes from "prop-types"; + +export class Navigation extends React.Component { + render() { + const hasNavigation = this.props.navigation && this.props.navigation.length; + if (hasNavigation) { + return ( +
+
+ {this.props.title} + {this.props.afterTitle ? this.props.afterTitle : ''} +
+
+ {(!hasNavigation && ( +
+ + {this.props.username} + + +
+ )) || ( +
+ +
+ {this.props.navigation.map((nav, index) => { + const navTitle = nav[0]; + const navAction = nav[1]; + return {navTitle}; + })} +
+
+ )} +
+
+ ); + } + return ( +
{this.props.title}
+ ); + } +} + +Navigation.propTypes = { + title: PropTypes.string.isRequired, + afterTitle: PropTypes.object, + navigation: PropTypes.array, + username: PropTypes.string, +}; diff --git a/diplomacy/web/src/gui/diplomacy/widgets/page_context.jsx b/diplomacy/web/src/gui/diplomacy/widgets/page_context.jsx new file mode 100644 index 0000000..cfb8252 --- /dev/null +++ b/diplomacy/web/src/gui/diplomacy/widgets/page_context.jsx @@ -0,0 +1,3 @@ +import React from "react"; + +export const PageContext = React.createContext(null); diff --git a/diplomacy/web/src/gui/diplomacy/widgets/power_order.jsx b/diplomacy/web/src/gui/diplomacy/widgets/power_order.jsx index 28a5421..4ed4d8a 100644 --- a/diplomacy/web/src/gui/diplomacy/widgets/power_order.jsx +++ b/diplomacy/web/src/gui/diplomacy/widgets/power_order.jsx @@ -15,8 +15,8 @@ // with this program. If not, see . // ============================================================================== import React from "react"; -import {Button} from "../../core/widgets"; import PropTypes from 'prop-types'; +import {Button} from "../../core/button"; export class PowerOrder extends React.Component { render() { diff --git a/diplomacy/web/src/index.css b/diplomacy/web/src/index.css index f33b116..f270135 100644 --- a/diplomacy/web/src/index.css +++ b/diplomacy/web/src/index.css @@ -1,7 +1,7 @@ /** Bootstrap. **/ /** Common. **/ -a.dropdown-item { +span.dropdown-item { cursor: pointer; } @@ -46,7 +46,7 @@ a.dropdown-item { color: green; } -#past-map svg, #current-map svg { +#past-map svg, #current-map svg, #messages-map svg { display: block; width: 100%; height: auto; @@ -94,10 +94,25 @@ main { text-align: center; } +span.power-name { + display: inline-block; + margin-left: 1rem; + margin-right: 1rem; + width: 10rem; +} + #current-power { color: red; font-weight: bold; text-align: center; + display: inline-block; + margin-left: 1rem; + margin-right: 1rem; + width: 10rem; +} + +.form-current-power { + display: inline-block; } .page-messages { @@ -173,9 +188,10 @@ main { display: inline-block; } -.page > .title { +.page > main > .title { border-bottom: 1px solid silver; - padding: 10px; + padding-bottom: 10px; + margin-bottom: 10px; } .left { @@ -341,19 +357,18 @@ main { } .game-message { - padding: 10px; - width: 75%; - border-width: 4px; + width: 90%; + border-width: 2px; border-style: solid; - border-radius: 10px; + margin: 0; } .game-message .message-header { font-weight: bold; + border-right: inherit; } .game-message.message-recipient { - float: left; border-color: rgb(240, 200, 200); background-color: rgb(255, 220, 220); cursor: pointer; @@ -366,18 +381,26 @@ main { } .game-message.message-sender { - float: right; border-color: rgb(200, 200, 240); background-color: rgb(220, 220, 255); + float: right; } .game-message-wrapper { - overflow: auto; clear: both; + overflow: auto; +} + +.game-message-wrapper.other-phase { + color: rgb(140, 140, 140); } .game-message-wrapper + .game-message-wrapper { - margin-top: 10px; + padding-top: 5px; +} + +.game-message-wrapper.other-phase + .game-message-wrapper.new-phase { + margin-top: 5px; } .button-server { -- cgit v1.2.3