Improve frontend
This commit is contained in:
parent
570ffd4dff
commit
3d51368825
@ -3,7 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import App from './App';
|
import App from './Client';
|
||||||
|
|
||||||
test('renders learn react link', () => {
|
test('renders learn react link', () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
@ -1,52 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import './App.css';
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
|
|
||||||
const [state, setState] = useState<State>({});
|
|
||||||
const [socket, setSocket] = useState<WebSocket>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const url = new URL(`api/client`, window.location.href);
|
|
||||||
url.protocol = url.protocol.replace("http", "ws");
|
|
||||||
const sock = new WebSocket(url.href);
|
|
||||||
|
|
||||||
sock.onmessage = (e) => {
|
|
||||||
const newState = JSON.parse(e.data) as State;
|
|
||||||
setState(newState);
|
|
||||||
}
|
|
||||||
setSocket(sock);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
sock.close();
|
|
||||||
setSocket(undefined);
|
|
||||||
};
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const clickButton = (button: string) => (e: React.MouseEvent) => {
|
|
||||||
if (!socket) return;
|
|
||||||
socket.send(JSON.stringify({ "button": button }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div style={{ position: "absolute", top: 10, left: "50%" }}>
|
|
||||||
State: <pre>{JSON.stringify(state)}</pre>
|
|
||||||
</div>
|
|
||||||
<div className="buttonwrap">
|
|
||||||
<div className="buttons">
|
|
||||||
{"up down left right a b l r start select".split(" ").map((b) =>
|
|
||||||
<div className={`button button-${b}`} onClick={clickButton(b)}>{b}</div>
|
|
||||||
)}
|
|
||||||
<div className="button button-dummy" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
@ -1,12 +1,25 @@
|
|||||||
@media (orientation: landscape) {
|
@media (orientation: landscape) {
|
||||||
body {
|
.button-start {
|
||||||
flex-direction: row;
|
margin-bottom: 10px;
|
||||||
|
grid-column: 9;
|
||||||
|
grid-row: 7;
|
||||||
|
}
|
||||||
|
.button-select {
|
||||||
|
grid-column: 7;
|
||||||
|
grid-row: 7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (orientation: portrait) {
|
@media (orientation: portrait) {
|
||||||
body {
|
.button-start {
|
||||||
flex-direction: column;
|
margin-bottom: 10px;
|
||||||
|
justify-self: end;
|
||||||
|
grid-column: 13;
|
||||||
|
grid-row: 7;
|
||||||
|
}
|
||||||
|
.button-select {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,6 +38,9 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button.pressed {
|
||||||
|
filter: drop-shadow(2px 2px 6px black);
|
||||||
|
}
|
||||||
.button-a, .button-b {
|
.button-a, .button-b {
|
||||||
--button-height: 50px;
|
--button-height: 50px;
|
||||||
--button-width: 50px;
|
--button-width: 50px;
|
||||||
@ -81,9 +97,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
display: grid;
|
display: grid;
|
||||||
/*grid-template-columns: auto auto auto 1fr auto auto;*/
|
grid-template-columns: 10px 10px auto auto auto 1fr auto 10px auto 1fr auto auto 10px 10px;
|
||||||
grid-template-columns: auto 10px auto auto auto auto 1fr auto auto 10px auto;
|
grid-template-rows: auto 1fr auto auto auto 1fr auto auto;
|
||||||
grid-template-rows: auto 10px auto auto auto 1fr auto auto;
|
|
||||||
}
|
}
|
||||||
.button-up {
|
.button-up {
|
||||||
grid-column: 4;
|
grid-column: 4;
|
||||||
@ -102,16 +117,17 @@
|
|||||||
grid-row: 5;
|
grid-row: 5;
|
||||||
}
|
}
|
||||||
.button-dummy {
|
.button-dummy {
|
||||||
|
pointer-events: none;
|
||||||
grid-column: 4;
|
grid-column: 4;
|
||||||
grid-row: 4;
|
grid-row: 4;
|
||||||
}
|
}
|
||||||
.button-a {
|
.button-a {
|
||||||
grid-column: 9;
|
grid-column: 12;
|
||||||
grid-row: 4;
|
grid-row: 4;
|
||||||
transform: scale(1.5);
|
transform: scale(1.5);
|
||||||
}
|
}
|
||||||
.button-b {
|
.button-b {
|
||||||
grid-column: 8;
|
grid-column: 11;
|
||||||
grid-row: 4;
|
grid-row: 4;
|
||||||
transform: scale(1.5) translate(-45%, 45%);
|
transform: scale(1.5) translate(-45%, 45%);
|
||||||
}
|
}
|
||||||
@ -120,15 +136,19 @@
|
|||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
.button-r {
|
.button-r {
|
||||||
|
justify-self: end;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
grid-column: 11;
|
grid-column: 14;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
.button-start {
|
.button-start {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
grid-column: 6;
|
grid-column: 9;
|
||||||
grid-row: 7;
|
grid-row: 7;
|
||||||
}
|
}
|
||||||
.button-select {
|
.button-select {
|
||||||
grid-column: 6;
|
grid-column: 7;
|
||||||
grid-row: 8;
|
grid-row: 7;
|
||||||
}
|
}
|
||||||
|
*/
|
66
frontend/src/Client.tsx
Normal file
66
frontend/src/Client.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import './Client.css';
|
||||||
|
import { ButtonMap, ButtonType, buttonTypeList, defaultButtonMap, mapToBitvector, WGPPState } from './types';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const Client = () => {
|
||||||
|
|
||||||
|
const [state, setState] = useState<Partial<WGPPState>>({});
|
||||||
|
const [socket, setSocket] = useState<WebSocket>();
|
||||||
|
const [buttonMap, setButtonMap] = useState<ButtonMap>(defaultButtonMap);
|
||||||
|
|
||||||
|
const updateButtonEvent = (button: ButtonType, state: "down" | "up") =>
|
||||||
|
() => setButtonMap((m) => ({ ...m, [button]: (state === "down") }));
|
||||||
|
|
||||||
|
|
||||||
|
// Send data to server
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
//socket.send(JSON.stringify(buttonMap));
|
||||||
|
}, [buttonMap, socket]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const url = new URL(`api/client`, window.location.href);
|
||||||
|
url.protocol = url.protocol.replace("http", "ws");
|
||||||
|
const sock = new WebSocket(url.href);
|
||||||
|
|
||||||
|
sock.onmessage = (e) => {
|
||||||
|
const newState = JSON.parse(e.data) as Partial<WGPPState>;
|
||||||
|
// Merge old and new state
|
||||||
|
setState(oldState => ({ ...oldState, ...newState }));
|
||||||
|
}
|
||||||
|
setSocket(sock);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sock.close();
|
||||||
|
setSocket(undefined);
|
||||||
|
};
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ transform: "translate(-50%, 0)",position: "absolute", top: 10, left: "50%" }}>
|
||||||
|
State: <pre>{JSON.stringify(state)}</pre>
|
||||||
|
ButtonMap: <pre>{mapToBitvector(buttonMap).map(s => s.toString()).join(" ")}</pre>
|
||||||
|
</div>
|
||||||
|
<div className="buttonwrap">
|
||||||
|
<div className="buttons">
|
||||||
|
{buttonTypeList.map((b, index) =>
|
||||||
|
<div key={index} className={`button button-${b}${buttonMap[b] ? " pressed" : ""}`}
|
||||||
|
onTouchStart={updateButtonEvent(b, "down")}
|
||||||
|
onTouchEnd={updateButtonEvent(b, "up")}
|
||||||
|
onMouseDown={updateButtonEvent(b, "down")}
|
||||||
|
onMouseUp={updateButtonEvent(b, "up")}
|
||||||
|
>
|
||||||
|
{b}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="button button-dummy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div >
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Client;
|
16
frontend/src/TVMode.css
Normal file
16
frontend/src/TVMode.css
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
body > div {
|
||||||
|
width: 250px;
|
||||||
|
height: 50vh;
|
||||||
|
background-color: #00ff00;
|
||||||
|
padding: 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.heading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
span {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
58
frontend/src/TVMode.tsx
Normal file
58
frontend/src/TVMode.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import './TVMode.css';
|
||||||
|
import { WGPPState } from './types';
|
||||||
|
|
||||||
|
const TVMode = () => {
|
||||||
|
|
||||||
|
const [state, setState] = useState<Partial<WGPPState>>({});
|
||||||
|
const [socket, setSocket] = useState<WebSocket>();
|
||||||
|
const [endTime, setEndTime] = useState(0);
|
||||||
|
const [secondsRemaining, setSecondsRemaining] =useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEndTime(Date.now() + (state?.timeUntilNextMode??15) * 1000);
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const remaining = Math.round((endTime - Date.now())/1000);
|
||||||
|
setSecondsRemaining(remaining < 0 ? 0 : remaining);
|
||||||
|
}, 500);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [endTime, state?.timeUntilNextMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const url = new URL(`api/client`, window.location.href);
|
||||||
|
url.protocol = url.protocol.replace("http", "ws");
|
||||||
|
const sock = new WebSocket(url.href);
|
||||||
|
|
||||||
|
sock.onmessage = (e) => {
|
||||||
|
const newState = JSON.parse(e.data) as Partial<WGPPState>;
|
||||||
|
// Merge old and new state
|
||||||
|
setState(oldState => ({ ...oldState , ...newState }));
|
||||||
|
}
|
||||||
|
setSocket(sock);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sock.close();
|
||||||
|
setSocket(undefined);
|
||||||
|
};
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (state === undefined ?
|
||||||
|
<span>Loading...</span> :
|
||||||
|
<div>
|
||||||
|
<div className="heading">
|
||||||
|
<span>Mode: {state.mode}</span>
|
||||||
|
<div>
|
||||||
|
<span>{secondsRemaining}s ⏱️</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Current state: <pre>
|
||||||
|
{JSON.stringify(state)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TVMode;
|
@ -1,15 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import Client from './Client';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import App from './App';
|
|
||||||
import reportWebVitals from './reportWebVitals';
|
import reportWebVitals from './reportWebVitals';
|
||||||
|
import TVMode from './TVMode';
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
const root = ReactDOM.createRoot(
|
||||||
document.getElementById('root') as HTMLElement
|
document.getElementById('root') as HTMLElement
|
||||||
);
|
);
|
||||||
|
const tvUi = document.location.href.endsWith("stream");
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
{tvUi ?
|
||||||
|
<TVMode /> :
|
||||||
|
<Client />}
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
25
frontend/src/types.ts
Normal file
25
frontend/src/types.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export const defaultButtonMap : ButtonMap = {up: false, down: false, left: false, right: false, a: false, b: false, l: false, r: false, start: false, select: false};
|
||||||
|
|
||||||
|
export type GameMode = "democracy" | "anarchy" | "random";
|
||||||
|
|
||||||
|
export type WGPPState = {
|
||||||
|
// current game mode
|
||||||
|
mode: GameMode;
|
||||||
|
allowMultitouch?: boolean; // derived from mode
|
||||||
|
|
||||||
|
// stuff for TV
|
||||||
|
nextMode: GameMode;
|
||||||
|
timeUntilNextMode: number;
|
||||||
|
votes: {[button in ButtonType]?: number};
|
||||||
|
|
||||||
|
playerIdle: boolean; // default true, removed on first message
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buttonTypeList: ButtonType[] = "up down left right a b l r start select".split(" ") as ButtonType[];
|
||||||
|
|
||||||
|
export type ButtonType = "up" | "down" | "left" | "right" | "a" | "b" | "l" | "r" | "start" | "select";
|
||||||
|
export type ButtonMap = { [key in ButtonType]: boolean };
|
||||||
|
|
||||||
|
export const mapToBitvector = (map: ButtonMap): number[] => buttonTypeList.map(b => map[b] ? 1: 0);
|
Loading…
Reference in New Issue
Block a user