diff --git a/webapp/src/App.js b/webapp/src/App.js index 379665f..9730e57 100644 --- a/webapp/src/App.js +++ b/webapp/src/App.js @@ -9,7 +9,9 @@ import About from "./components/About" import Games from './container/Games'; import Leaderboard from './container/Leaderboard'; -import usePollingReducer from './reducer/polling'; +import useConfigReducer from './reducer/config'; +import useUserReducer from './reducer/user'; +import useLeaderboardReducer from './reducer/leaderboard'; import useGamesReducer from './reducer/games'; import useUserApi from './api/user'; @@ -17,35 +19,40 @@ import useLeaderboardApi from './api/leaderboard'; import useGamesApi from './api/games'; export default function App() { - const pollingReducer = usePollingReducer(); + const [config, dispatcConfig] = useConfigReducer(); - const leaderboard = useLeaderboardApi().poll(pollingReducer); - const user = useUserApi().get(); - - const [games, dispatchGames] = useGamesReducer(); - const gamesApi = useGamesApi(dispatchGames); - gamesApi.list(pollingReducer); + const user = useUserApi(useUserReducer()).getUser(); + const leaderboard = useLeaderboardApi(useLeaderboardReducer(), config).pollTable(); const players = { leaderboard, isCurrentUser: (playerName) => user?.isCurrentUser(playerName) === true ? true : null }; + const gamesReducer = useGamesReducer(); + const gamesApi = useGamesApi(gamesReducer, config); + const games = gamesApi.pollGamesList(); + + const isPolling = { + games: games.isPollingGamesList, + leaderboard: leaderboard.isPollingTable + } + return ( -
+
} /> } /> - } /> + } /> } /> ) } -function Header({ pollingReducer }) { - const [polling, dispatchPolling] = pollingReducer; +function Header({ configReducer, isPolling }) { + const [config, dispatcConfig] = configReducer; return (
@@ -54,8 +61,8 @@ function Header({ pollingReducer }) { dispatchPolling({ type: 'toggleOnOff' })} + isOnline={config.online} + onClick={() => dispatcConfig({ type: 'toggleOnline' })} />
diff --git a/webapp/src/api/games.js b/webapp/src/api/games.js index 01d4501..58b800d 100644 --- a/webapp/src/api/games.js +++ b/webapp/src/api/games.js @@ -1,26 +1,28 @@ -import usePolling from '../hook/Polling'; +import { usePolling, doPushing } from '../hook/api'; -export default function useGamesApi(dispatchGames) { +export default function useGamesApi(gamesReducer, config) { + const [games, dispatchGames] = gamesReducer; - const useList = (pollingReducer) => { - const [polling, dispatchPolling] = pollingReducer; - - const onResponce = (json) => { - dispatchGames({ type: 'next', list: json }); + const usePollingGamesList = () => { + const onSuccess = (gamesList) => { + dispatchGames({ type: 'next', gamesList }); } - const mode = (polling.enabled === true) - ? { interval_sec: 30 } // fetch gamesList every half a minue - : { interval_stop: true } // user has fliped OfflineToggel - - const isPolling = usePolling('/api/games', onResponce, mode); - - if (isPolling !== polling.games) { - dispatchPolling({ type: 'next', games: isPolling }); + const isPollingGamesList = usePolling('/api/games', onSuccess, config.intervalMode(30)); + if (games.isPollingGamesList !== isPollingGamesList) { + dispatchGames({ type: 'next', isPollingGamesList }); } + + return games; } + return { - list: useList + pollGamesList: usePollingGamesList, + + pushNewGame: (reqParams) => doPushing('/api/gameproposal', 'POST', reqParams, { + onPushing: (isPushingNewGame) => dispatchGames({ type: 'next', isPushingNewGame }), + onSuccess: (game) => dispatchGames({ type: 'next', gamesList: [...games.gamesList, game] }) + }), } } \ No newline at end of file diff --git a/webapp/src/api/leaderboard.js b/webapp/src/api/leaderboard.js index c4901b8..280afaa 100644 --- a/webapp/src/api/leaderboard.js +++ b/webapp/src/api/leaderboard.js @@ -1,26 +1,22 @@ -import { useState } from "react"; -import usePolling from "../hook/Polling"; +import { usePolling } from "../hook/api"; -export default function useLeaderboardApi() { - const [leaderboard, setLeaderboard] = useState(null); +export default function useLeaderboardApi(leaderboardReducer, config) { + const [leaderboard, dispatchLeaderboard] = leaderboardReducer; - const usePoll = (pollingReducer) => { - const [polling, dispatchPolling] = pollingReducer; + const usePollingTable = () => { + const onSuccess = (table) => { + dispatchLeaderboard({ type: 'next', table }); + } - const mode = (polling.enabled === true) - ? { interval_sec: 300 } // update leaderbord stats every 5 min - : { interval_stop: true } // user has fliped OfflineToggel - - const isPolling = usePolling('/api/leaderboard', setLeaderboard, mode); - - if (isPolling !== polling.leaderboard) { - dispatchPolling({ type: 'next', leaderboard: isPolling }); + const isPollingTable = usePolling('/api/leaderboard', onSuccess, config.intervalMode(300)); + if (leaderboard.isPollingTable !== isPollingTable) { + dispatchLeaderboard({ type: 'next', isPollingTable }); } return leaderboard; } return { - poll: usePoll + pollTable: usePollingTable } } \ No newline at end of file diff --git a/webapp/src/api/user.js b/webapp/src/api/user.js index 18f199c..9d8df5c 100644 --- a/webapp/src/api/user.js +++ b/webapp/src/api/user.js @@ -1,20 +1,19 @@ -import usePolling from "../hook/Polling"; -import useUserReducer from "../reducer/user"; +import { usePolling } from "../hook/api"; -export default function useUserApi() { - const [user, dispatchUser] = useUserReducer(); +export default function useUserApi(userReducer) { + const [user, dispatchUser] = userReducer; - const useGet = () => { - const onResponce = (json) => { - dispatchUser({ type: "parse", json }); + const useGetUser = () => { + const onSuccess = (userJson) => { + dispatchUser({ type: "parse", userJson }); } - usePolling('/api/user', onResponce); // <<-- fetch once + usePolling('/api/user', onSuccess); // <<-- fetch once return user; } return { - get: useGet + getUser: useGetUser } } \ No newline at end of file diff --git a/webapp/src/container/Games.css b/webapp/src/container/Games.css index 3d8680a..2508acd 100644 --- a/webapp/src/container/Games.css +++ b/webapp/src/container/Games.css @@ -97,16 +97,19 @@ margin: 2px; } -.ActionPanel .Create:hover, /* OR */ -.game-action.busy +.ActionPanel .Create.ready /* , */ /* OR .game-action.busy */ { background-color:#00b0ff60; } -.ActionPanel .Create.enabled:active { +.ActionPanel .Create.ready:hover { background-color:#00b0ffa0; } +.ActionPanel .Create.ready:active { + background-color:#00b0fff0; +} + .ActionPanel .Cancel:hover, .ActionPanel .Reject:hover { background-color:#ff000030 diff --git a/webapp/src/container/Games.jsx b/webapp/src/container/Games.jsx index 43da121..e94d41b 100644 --- a/webapp/src/container/Games.jsx +++ b/webapp/src/container/Games.jsx @@ -19,7 +19,8 @@ import GameBoard from './games/GameBoard'; import './Games.css'; -export default function Games({ context: { games, dispatchGames, gamesApi }, players }) { +export default function Games({ context: { gamesReducer, gamesApi }, players }) { + const [games, dispatchGames] = gamesReducer; return ( @@ -29,7 +30,7 @@ export default function Games({ context: { games, dispatchGames, gamesApi }, pla
- + {/* @@ -80,11 +81,15 @@ function ViewProvider({ players, dispatchGames }) { ) } -function ActionPanel() { +function ActionPanel({ players, gamesApi }) { return (
- } /> + gamesApi.pushNewGame(reqParams)} + /> + } /> , , ]} /> , , ]} /> , ]} /> diff --git a/webapp/src/container/Leaderboard.jsx b/webapp/src/container/Leaderboard.jsx index ca92490..9493dd7 100644 --- a/webapp/src/container/Leaderboard.jsx +++ b/webapp/src/container/Leaderboard.jsx @@ -4,13 +4,12 @@ import Loading from '../components/Loading'; export default function Leaderboard({ players }) { - const leaderboard = players.leaderboard; - - if (leaderboard == null) + const table = players.leaderboard.table; + if (table == null) return - const tableRows = Object.keys(leaderboard).map(playerName => { - var rank = leaderboard[playerName]; + const tableRows = Object.keys(table).map(playerName => { + var rank = table[playerName]; return {playerName} diff --git a/webapp/src/container/games/action/Create.jsx b/webapp/src/container/games/action/Create.jsx index 2cee6c6..cddb347 100644 --- a/webapp/src/container/games/action/Create.jsx +++ b/webapp/src/container/games/action/Create.jsx @@ -1,6 +1,66 @@ -import React from 'react'; +import React, { useContext } from 'react'; +import { GamesContext } from '../../../context/games'; +import Wobler from '../../../components/Wobler'; +import { Color } from '../../../components/Checkers'; -export default function Create() { - return +export default function Create({ isCurrentUser, pushNewGame }) { + const games = useContext(GamesContext); + + const hasPlayers = checkPlayers(games.newGame); + const hasCurrentUser = checkCurrentUser(games.newGame, isCurrentUser); + + const prepareNewGameRequest = () => { + if (!hasPlayers) + return alert("Black and White players must be selected for the game"); + + if (!hasCurrentUser) + return alert("You must be one of the selected players"); + + if (games.isPushingNewGame) + return; // current request is still being processed + + /* + * Prepare & send NewGame request + */ + const [opponentName, opponentColor] = getOpponent(games.newGame, isCurrentUser); + + const reqParams = { + opponentName, + opponentColor, + board: null, // default board configuration + message: 'default NewGame req message' + } + + pushNewGame(reqParams); + } + + return ( + + ) } + +function checkPlayers({ whitePlayer, blackPlayer }) { + return whitePlayer && blackPlayer + && whitePlayer !== blackPlayer; +} + +function checkCurrentUser({ whitePlayer, blackPlayer }, isCurrentUser) { + return isCurrentUser(whitePlayer) || isCurrentUser(blackPlayer); +} + +function getOpponent({whitePlayer, blackPlayer}, isCurrentUser) { + if (isCurrentUser(whitePlayer)) { + return [blackPlayer, Color.black]; + } + + if (isCurrentUser(blackPlayer)) { + return [whitePlayer, Color.white]; + } + + return ['', '']; +} \ No newline at end of file diff --git a/webapp/src/container/games/view/GameSelector.jsx b/webapp/src/container/games/view/GameSelector.jsx index 5578e7c..23c6d34 100644 --- a/webapp/src/container/games/view/GameSelector.jsx +++ b/webapp/src/container/games/view/GameSelector.jsx @@ -7,14 +7,14 @@ import Loading from '../../../components/Loading'; export default function GameSelector({ yours, opponents, onClick }) { - const games = useContext(GamesContext); - if (games.list === null) + const gamesList = useContext(GamesContext).gamesList; + if (gamesList === null) return - const yoursList = games.list.filter(game => game.status === yours) + const yoursList = gamesList.filter(game => game.status === yours) .map(game => ) - const opponentsList = games.list.filter(game => game.status === opponents) + const opponentsList = gamesList.filter(game => game.status === opponents) .map(game => ) return ( diff --git a/webapp/src/container/games/view/NewGame.jsx b/webapp/src/container/games/view/NewGame.jsx index 0ce7d3e..a729a70 100644 --- a/webapp/src/container/games/view/NewGame.jsx +++ b/webapp/src/container/games/view/NewGame.jsx @@ -11,9 +11,9 @@ export default function NewGame({ players, onSelectPlayer }) { /* * Name options */ - const nameOptions = !players.leaderboard + const nameOptions = !players.leaderboard.table ? [] - : Object.keys(players.leaderboard).map(playerName => + : Object.keys(players.leaderboard.table).map(playerName => ) diff --git a/webapp/src/hook/Polling.js b/webapp/src/hook/api.js similarity index 60% rename from webapp/src/hook/Polling.js rename to webapp/src/hook/api.js index abfa3b7..28e759c 100644 --- a/webapp/src/hook/Polling.js +++ b/webapp/src/hook/api.js @@ -8,22 +8,21 @@ import { useState, useRef, useCallback, useEffect, } from "react" - interval_stop */ -export default function usePolling(uri, onResponce, mode = null) { +export function usePolling(uri, onSuccess, mode = null) { const [isPolling, setPolling] = useState(false); const initialPollRef = useRef(true); const initialPoll = initialPollRef.current; - + const intervalTimerIdRef = useRef(null); const intervalTimerId = intervalTimerIdRef.current; - const pollData = useCallback(() => { setPolling(true); initialPollRef.current = false; fetch(uri) - .then((responce) => { + .then((response) => { setPolling(false); if (typeof mode?.interval_sec === 'number') { @@ -31,15 +30,15 @@ export default function usePolling(uri, onResponce, mode = null) { intervalTimerIdRef.current = setTimeout(pollData, mode.interval_sec * 1000); } - return responce.json(); + return response.json(); }) .then((json) => { - onResponce(json); + onSuccess(json); }) .catch((err) => { console.warn(err.message); }) - }, [uri, mode, onResponce, initialPollRef, intervalTimerIdRef]); + }, [uri, mode, onSuccess, initialPollRef, intervalTimerIdRef]); const stopPollInterval = useCallback(() => { console.log("Cancel scheduled fetch for", uri); @@ -51,11 +50,36 @@ export default function usePolling(uri, onResponce, mode = null) { useEffect(() => { if ((initialPoll || (typeof mode?.interval_sec === 'number' && intervalTimerId === null)) && !isPolling) { pollData(); - } else - if (mode?.interval_stop && intervalTimerId) { - stopPollInterval(); - } + } else + if (mode?.interval_stop && intervalTimerId) { + stopPollInterval(); + } }, [initialPoll, mode, intervalTimerId, isPolling, pollData, stopPollInterval]); return isPolling; +} + +export async function doPushing(uri, method, data, { onSuccess, onPushing }) { + if (onPushing) + onPushing(true); + + try { + const response = await fetch(uri, { + method, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), // body data type must match "Content-Type" header + }); + + if (!response.ok) + throw new Error(`Error! status: ${response.status}`); + + if (onSuccess) + onSuccess(await response.json()); +// } catch (err) { + } finally { + if (onPushing) + onPushing(false); + } } \ No newline at end of file diff --git a/webapp/src/reducer/config.js b/webapp/src/reducer/config.js new file mode 100644 index 0000000..1d2f3a4 --- /dev/null +++ b/webapp/src/reducer/config.js @@ -0,0 +1,44 @@ +import { useReducer } from 'react'; +import { namedLocalStorage } from '../util/PersistentStorage'; +import { nextState } from '../util/StateHelper'; + +const Persistent = (() => { + const [getOnline, setOnline] = namedLocalStorage('config.online', true); + + return { + getOnline, + setOnline + } +})(); // <<--- Execute + +const initialState = { + online: Persistent.getOnline() === 'true', + + intervalMode +}; + +function dispatch(state, action) { + switch (action.type) { + + case 'toggleOnline': return { + ...state, + online: Persistent.setOnline(!state.online) + }; + + case 'next': + return nextState(state, action); + + default: + throw Error('ConfigReducer: unknown action.type', action.type); + } +} + +export default function useConfigReducer() { + return useReducer(dispatch, initialState); +} + +function intervalMode(interval_sec) { + return (this.online === true) + ? { interval_sec } // fetch from API every interval_sec + : { interval_stop: true } // user has fliped OfflineToggel +} \ No newline at end of file diff --git a/webapp/src/reducer/games.js b/webapp/src/reducer/games.js index 854ef37..073bd6a 100644 --- a/webapp/src/reducer/games.js +++ b/webapp/src/reducer/games.js @@ -1,16 +1,20 @@ import { useReducer } from 'react'; import { nextState } from '../util/StateHelper'; -export const gamesInitialState = { - list: null, +const initialState = { + gamesList: null, newGame: { whitePlayer: '', blackPlayer: '' - } + }, + + // Network + isPollingGamesList: false, + isPushingNewGame: false, }; -export function gamesReducer(state, action) { +function reducer(state, action) { switch (action.type) { case 'next': @@ -22,5 +26,5 @@ export function gamesReducer(state, action) { } export default function useGamesReducer() { - return useReducer(gamesReducer, gamesInitialState); + return useReducer(reducer, initialState); } \ No newline at end of file diff --git a/webapp/src/reducer/leaderboard.js b/webapp/src/reducer/leaderboard.js new file mode 100644 index 0000000..f2b0141 --- /dev/null +++ b/webapp/src/reducer/leaderboard.js @@ -0,0 +1,24 @@ +import { useReducer } from 'react'; +import { nextState } from '../util/StateHelper'; + +const initialState = { + table: null, + + // Network + isPollingTable: false +}; + +function reducer(state, action) { + switch (action.type) { + + case 'next': + return nextState(state, action); + + default: + throw Error('LeaderboardReducer: unknown action.type', action.type); + } +} + +export default function useLeaderboardReducer() { + return useReducer(reducer, initialState); +} \ No newline at end of file diff --git a/webapp/src/reducer/polling.js b/webapp/src/reducer/polling.js deleted file mode 100644 index 750f2ca..0000000 --- a/webapp/src/reducer/polling.js +++ /dev/null @@ -1,39 +0,0 @@ -import { useReducer } from 'react'; -import { namedLocalStorage } from '../util/PersistentStorage'; -import { nextState } from '../util/StateHelper'; - -const Persistent = (() => { - const [getEnabled, setEnabled] = namedLocalStorage('polling.enabled', true); - - return { - getEnabled, - setEnabled - } -})(); // <<--- Execute - -export const pollingInitialState = { - enabled: Persistent.getEnabled() === 'true', - - games: false, - leaderboard: false -}; - -export function pollingReducer(curntState, action) { - switch (action.type) { - - case 'toggleOnOff': return { - ...curntState, - enabled: Persistent.setEnabled(!curntState.enabled) - }; - - case 'next': - return nextState(curntState, action); - - default: - throw Error('Unknown action.type:' + action.type); - } -} - -export default function usePollingReducer() { - return useReducer(pollingReducer, pollingInitialState); -} \ No newline at end of file diff --git a/webapp/src/reducer/user.js b/webapp/src/reducer/user.js index 0351553..1cf1265 100644 --- a/webapp/src/reducer/user.js +++ b/webapp/src/reducer/user.js @@ -1,7 +1,7 @@ import { useReducer } from 'react'; import { localeCompare } from '../util/Locale'; -export const userInitialState = { +const initialState = { username: '', isCurrentUser: function (otherUsername) { @@ -9,13 +9,13 @@ export const userInitialState = { } }; -export function userReducer(state, action) { +function reducer(state, action) { switch (action.type) { case 'parse': return { ...state, - username: action.json.username + username: action.userJson.username }; default: @@ -24,5 +24,5 @@ export function userReducer(state, action) { } export default function useUserReducer() { - return useReducer(userReducer, userInitialState); + return useReducer(reducer, initialState); } \ No newline at end of file