Compare commits

...

2 Commits

Author SHA1 Message Date
efd7127575 react: GlobalState: data, appCtx + polling feature
- Polling
 - time beased URI polling
 - can be diabled

- GlobaState
 - utilizes Flux pattern
 - data:
    for storing data for UI to work with
 - appData:
    for string global UI state

- major refactoring
2023-10-18 17:20:37 +02:00
fa29d2a631 ammend 2023-10-14 20:24:26 +02:00
19 changed files with 275 additions and 103 deletions

View File

@ -16,3 +16,7 @@
.App-link { .App-link {
color: #61dafb; color: #61dafb;
} }
.Container {
margin-top: 25px;
}

View File

@ -1,47 +1,31 @@
import './App.css'; import './App.css';
import React, { useState, useEffect, useCallback } from 'react'; import React from 'react'
import { import {
BrowserRouter, BrowserRouter,
Routes, Routes,
Route, Route,
} from "react-router-dom"; } from "react-router-dom"
import Header from "./Header" import Header from "./components/Header"
import Leaderboard from "./Leaderboard"; import Leaderboard from "./components/Leaderboard"
import GameProposal from "./GameProposal"; import GameProposal from "./components/GameProposal"
import DataPolling from './components/DataPolling';
//import { UserProvider } from "../contexts/UserProvider"
//import { GameProposalProvider } from './context/GameProposal';
function App() { function App() {
const [games, setGames] = useState(null);
const [polling, setPolling] = useState(false);
const pollGames = useCallback(() => {
if (polling)
return;
setPolling(true);
fetch('/api/gamestate')
.then(response => response.json())
.then(data => {
setGames(data);
setPolling(false);
})
.catch(err => console.log(err.message));
}, [polling]);
useEffect(() => {
const timer = setInterval(pollGames(), 35 * 1000); // <<-- poll new gamestates every 35 sec
return clearInterval(timer);
}, [pollGames])
return <div className="App"> return <div className="App">
<BrowserRouter> <BrowserRouter>
<Header/> <Header/>
<div className="Container"> <DataPolling/>
<Routes> <div className="Container">
<Route path="/leaderboard" element={<Leaderboard/>} /> <Routes>
<Route path="/gameproposal" element={<GameProposal games={games}/>} /> <Route path="/leaderboard" element={<Leaderboard/>} />
</Routes> <Route path="/gameproposal" element={<GameProposal/>} />
</div> </Routes>
</div>
</BrowserRouter> </BrowserRouter>
</div> </div>
} }

View File

@ -1,17 +0,0 @@
import { Link } from "react-router-dom";
import './Header.css';
export default function Header() {
return (
<p>
<h1>CordaCheckers</h1>
<nav>
<Link to="/leaderboard">Leaderboard</Link> {"| "}
<Link to="/gameproposal">Game Proposal</Link> {"| "}
<Link to="/game">Active Games</Link> {"| "}
<Link to="/archive">Archive</Link> {"| "}
<Link to="about">About</Link>
</nav>
</p>
);
}

View File

@ -0,0 +1,16 @@
import React from "react"
import { AppData } from "../../context/data"
import { AppContext } from "../../context/app"
export default function DataPolling() {
const [appData] = React.useContext(AppData)
const [appCtx, dispatchAppData] = React.useContext(AppContext)
return <div className={DataPolling.name}>
polling
<button onClick={() => dispatchAppData({type: "togglePolling"})}>
{ appCtx.disablePolling === true ? "off" : "on" }
</button>
{ appData.fetching }
</div>
}

View File

@ -6,11 +6,11 @@ export function Accept({uuid}) {
</button> </button>
} }
export function Reject({uuid}) { // export function Reject({uuid}) {
return <button className="action" id={uuid} type="submit"> // return <button className="action" id={uuid} type="submit" >
Reject // Reject
</button> // </button>
} // }
export function Cancel({uuid}) { export function Cancel({uuid}) {
return <button className="action" id={uuid} type="submit"> return <button className="action" id={uuid} type="submit">

View File

@ -0,0 +1,24 @@
import React, {useState} from 'react';
export default function Cancel({uuid}) {
const [pending, setPending] = useState(new Map())
for (const [key, value] of pending)
console.log("cancel", key, value);
function sendRequest(uuid2reject) {
const nextPending = new Map(pending)
nextPending.set(uuid2reject, null)
setPending(nextPending)
}
const status = pending.get(uuid)
const isPending = status !== undefined
return <button
className={isPending ? "visible" : "action"}
onClick={() => sendRequest(uuid) }>
Cancel
</button>
}

View File

@ -0,0 +1,21 @@
import React, {useState} from 'react';
export default function Reject({uuid}) {
const [pending, setPending] = useState([])
for (const [key, value] of Object.entries(pending))
console.log("pending ", key, value);
function sendRequest(reject_uuid) {
setPending( // Replace the old array
[ // with a new array consisting of:
...pending, // - all the old items
{ uuid: reject_uuid } // - and a new item at the end
]
)
}
return <button className="action" onClick={() => sendRequest(uuid) }>
Reject
</button>
}

View File

@ -42,6 +42,6 @@
} }
.stone { .stone {
font-size: 160%; font-size: 140%;
vertical-align: -3px; vertical-align: -3px;
} }

View File

@ -1,24 +1,25 @@
import './index.css';
import React from 'react'; import React from 'react';
import {Accept, Reject, Cancel} from './GameProposalAction'; import {Accept} from './GameProposalAction';
import './GameProposal.css'; import Reject from './Reject'
import Cancel from './GameProposalCancel'
const State = { import { AppData } from "../../context/data"
WaitForOpponent: "GAME_PROPOSAL_WAIT_FOR_OPPONENT",
WaitForYou: "GAME_PROPOSAL_WAIT_FOR_YOU",
}
const GameProposal = ({games}) => {
if (games == null) export default function GameProposal() {
return <p>Loading..</p> const [data] = React.useContext(AppData)
// for (const [key, value] of Object.entries(games)) if (data.games == null)
return <div>Loading..</div>
// for (const [key, value] of Object.entries(data.games))
// console.log(key, value); // console.log(key, value);
const waitForYou = games const waitForYou = data.games
.filter(game => game.status === State.WaitForYou) .filter(game => game.status === Status.WaitForYou)
.map(game => { .map(game => {
return <div class="li" key={game.uuid}> return <div className="li" key={game.uuid}>
<p> <p>
You {Stone(game.myColor)} <i>vs</i> {game.opponentName} {Stone(oppositeColor(game.myColor))} You {Stone(game.myColor)} <i>vs</i> {game.opponentName} {Stone(oppositeColor(game.myColor))}
<br/> <br/>
@ -30,10 +31,10 @@ const GameProposal = ({games}) => {
</div> </div>
}); });
const WaitForOpponent = games const WaitForOpponent = data.games
.filter(game => game.status === State.WaitForOpponent) .filter(game => game.status === Status.WaitForOpponent)
.map(game => { .map(game => {
return <div class="li" key={game.uuid}> return <div className="li" key={game.uuid}>
<p> <p>
You {Stone(game.myColor)} <i>vs</i> {game.opponentName} {Stone(oppositeColor(game.myColor))} You {Stone(game.myColor)} <i>vs</i> {game.opponentName} {Stone(oppositeColor(game.myColor))}
<br/> <br/>
@ -47,7 +48,7 @@ const GameProposal = ({games}) => {
return <div className="GameProposal"> return <div className="GameProposal">
{waitForYou} {waitForYou}
{WaitForOpponent.length > 0 && {WaitForOpponent.length > 0 &&
<div class="separator"> <div className="separator">
waiting for opponent ({WaitForOpponent.length}) waiting for opponent ({WaitForOpponent.length})
</div> </div>
} }
@ -55,14 +56,21 @@ const GameProposal = ({games}) => {
</div> </div>
}; };
const Status = {
WaitForOpponent: "GAME_PROPOSAL_WAIT_FOR_OPPONENT",
WaitForYou: "GAME_PROPOSAL_WAIT_FOR_YOU",
}
function Stone(color) { function Stone(color) {
if (color === "WHITE") if (color === "WHITE")
return <span class="stone"></span> return <span className="stone"></span>
if (color === "BLACK") if (color === "BLACK")
return <span class="stone"></span> return <span className="stone"></span>
return <span class="stone">{color}</span> return <span className="stone">{color}</span>
} }
function oppositeColor(color) { function oppositeColor(color) {
@ -74,5 +82,3 @@ function oppositeColor(color) {
return color return color
} }
export default GameProposal;

View File

@ -0,0 +1,17 @@
import './index.css';
import React from "react"
import { Link } from "react-router-dom";
export default function Header() {
return <div>
<h1>CordaCheckers</h1>
<nav>
<Link to="/leaderboard">Leaderboard</Link> {"| "}
<Link to="/gameproposal">Game Proposal</Link> {"| "}
<Link to="/game">Active Games</Link> {"| "}
<Link to="/archive">Archive</Link> {"| "}
<Link to="about">About</Link>
</nav>
</div>
}

View File

@ -1,22 +1,11 @@
import React, { useState, useEffect } from 'react'; import React from "react"
import './Leaderboard.css'; import './index.css';
import { AppData } from "../../context/data"
const Leaderboard = () => { export default function Leaderboard() {
const [data] = React.useContext(AppData)
const [data, setData] = useState(null); if (data.leaderboard == null)
useEffect(() => {
fetch('/api/leaderboard')
.then((response) => response.json())
.then((data) => {
setData(data);
})
.catch((err) => {
console.log(err.message);
});
}, []);
if (data == null)
return <p>Loading...</p> return <p>Loading...</p>
// var listItems = Object.keys(data).map(playerName => { // var listItems = Object.keys(data).map(playerName => {
@ -28,8 +17,8 @@ const Leaderboard = () => {
// }); // });
// return <ul>{listItems}</ul>; // return <ul>{listItems}</ul>;
const tableRows = Object.keys(data).map(playerName => { const tableRows = Object.keys(data.leaderboard).map(playerName => {
var rank = data[playerName]; var rank = data.leaderboard[playerName];
return <tr key={playerName}> return <tr key={playerName}>
<td>{playerName}</td> <td>{playerName}</td>
@ -55,5 +44,3 @@ const Leaderboard = () => {
</table> </table>
</div> </div>
}; };
export default Leaderboard;

View File

@ -0,0 +1,17 @@
import React from "react"
import { reducer, initialState } from "./reducer"
export const AppContext = React.createContext({
state: initialState,
dispatch: () => null
})
export const AppContextProvider = ({ children }) => {
const [state, dispatch] = React.useReducer(reducer, initialState)
return (
<AppContext.Provider value={[ state, dispatch ]}>
{ children }
</AppContext.Provider>
)
}

View File

@ -0,0 +1,16 @@
export const reducer = (state, action) => {
switch (action.type) {
case "togglePolling":
return { ...state,
disablePolling: !state.disablePolling // on/off
}
default:
return state
}
}
export const initialState = {
disablePolling: false,
}

View File

@ -0,0 +1,40 @@
import { useState, useCallback, useEffect, } from "react"
export default function Poll(url, interval_sec, disabled) {
const [cache, setCache] = useState(null)
const [fetching, setFetching] = useState(false)
const [timeoutID, setTimeoutID] = useState(null)
const fecthData = useCallback(() => {
setTimeoutID(null)
setFetching(true)
fetch(url)
.then((response) => {
setFetching(false)
return response.json()
})
.then((freshData) => setCache(freshData))
.catch((err) => console.log(err.message))
}, [url])
useEffect(() => {
if (cache == null) {
fecthData() // <<-- run immediatly on startup
}
else if (disabled === true) {
clearTimeout(timeoutID) // cancel already scheduled fetch
setTimeoutID(null)
}
else if (timeoutID === null) {
const timeoutID = setTimeout(fecthData, interval_sec * 1000)
setTimeoutID(timeoutID)
console.log("Fetch '" +url +"' scheduled in " +interval_sec +" sec")
}
}, [url, cache, fecthData, timeoutID, disabled, interval_sec]);
return {
data: cache,
fetching
}
}

View File

@ -0,0 +1,36 @@
import React from "react"
import { reducer, initialState } from "./reducer"
import { AppContext } from "../app"
import Poll from "./Poll"
export const AppData = React.createContext({
state: initialState,
dispatch: () => null
})
export const AppDataProvider = ({ children }) => {
const [data, dispatchData] = React.useReducer(reducer, initialState)
const [appContext] = React.useContext(AppContext)
const games = Poll('api/gamestate', 30, appContext.disablePolling)
const leaderboard = Poll('api/leaderboard', 60, appContext.disablePolling)
data.games = games.data
data.leaderboard = leaderboard.data
var fetching = []
if (games.fetching === true)
fetching = [...fetching, "games"]
if (leaderboard.fetching === true)
fetching = [...fetching, "leaderboard"]
data.fetching = fetching
return (
<AppData.Provider value={[data, dispatchData]}>
{children}
</AppData.Provider>
)
}

View File

@ -0,0 +1,14 @@
export const reducer = (state, action) => {
switch (action.type) {
default:
console.warn("Unknown action.type", action)
return state
}
}
export const initialState = {
games: null,
leaderboard: null,
fetching: []
}

View File

@ -1,14 +1,21 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
import App from './App';
import { AppDataProvider } from "./context/data"
import { AppContextProvider } from "./context/app"
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( root.render(
<React.StrictMode>
<App /> //<React.StrictMode>
</React.StrictMode> <AppContextProvider>
<AppDataProvider>
<App />
</AppDataProvider>
</AppContextProvider>
//</React.StrictMode>
); );
// If you want to start measuring performance in your app, pass a function // If you want to start measuring performance in your app, pass a function