improved theming, error handling, table sort
This commit is contained in:
parent
35256ba88d
commit
7ce0e543ec
|
@ -5,4 +5,16 @@ internal sealed record class Scores(int Kills = 0, int Deaths = 0)
|
||||||
public int Kills { get; set; } = Kills;
|
public int Kills { get; set; } = Kills;
|
||||||
|
|
||||||
public int Deaths { get; set; } = Deaths;
|
public int Deaths { get; set; } = Deaths;
|
||||||
|
|
||||||
|
public double Ratio
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (Kills == 0)
|
||||||
|
return 0;
|
||||||
|
if (Deaths == 0)
|
||||||
|
return Kills;
|
||||||
|
return Kills / (double)Deaths;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,6 +32,8 @@ public static class Program
|
||||||
name = name.Trim().ToUpperInvariant();
|
name = name.Trim().ToUpperInvariant();
|
||||||
if (name == string.Empty)
|
if (name == string.Empty)
|
||||||
return Results.BadRequest("name cannot be blank");
|
return Results.BadRequest("name cannot be blank");
|
||||||
|
if (name.Length > 12)
|
||||||
|
return Results.BadRequest("name too long");
|
||||||
|
|
||||||
var player = playerService.GetOrAdd(name, id);
|
var player = playerService.GetOrAdd(name, id);
|
||||||
return player != null
|
return player != null
|
||||||
|
@ -53,7 +55,7 @@ public static class Program
|
||||||
|
|
||||||
using var ws = await context.WebSockets.AcceptWebSocketAsync();
|
using var ws = await context.WebSockets.AcceptWebSocketAsync();
|
||||||
await clientScreenServer.HandleClient(ws);
|
await clientScreenServer.HandleClient(ws);
|
||||||
return null;
|
return Results.Empty;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.Map("/controls", async (HttpContext context, [FromQuery] Guid playerId) =>
|
app.Map("/controls", async (HttpContext context, [FromQuery] Guid playerId) =>
|
||||||
|
@ -66,7 +68,7 @@ public static class Program
|
||||||
|
|
||||||
using var ws = await context.WebSockets.AcceptWebSocketAsync();
|
using var ws = await context.WebSockets.AcceptWebSocketAsync();
|
||||||
await controlsServer.HandleClient(ws, player);
|
await controlsServer.HandleClient(ws, player);
|
||||||
return null;
|
return Results.Empty;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import './Controls.css';
|
import './Controls.css';
|
||||||
import useWebSocket from 'react-use-websocket';
|
import useWebSocket, {ReadyState} from 'react-use-websocket';
|
||||||
import {useEffect} from 'react';
|
import {useEffect} from 'react';
|
||||||
import Column from "./components/Column.tsx";
|
import Column from "./components/Column.tsx";
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ export default function Controls({playerId, logout}: {
|
||||||
const {
|
const {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
getWebSocket,
|
getWebSocket,
|
||||||
|
readyState
|
||||||
} = useWebSocket(url.toString(), {
|
} = useWebSocket(url.toString(), {
|
||||||
onError: logout
|
onError: logout
|
||||||
});
|
});
|
||||||
|
@ -25,7 +26,6 @@ export default function Controls({playerId, logout}: {
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const typeCode = type === 'input-on' ? 0x01 : 0x02;
|
const typeCode = type === 'input-on' ? 0x01 : 0x02;
|
||||||
|
|
||||||
const controls = {
|
const controls = {
|
||||||
'ArrowLeft': 0x03,
|
'ArrowLeft': 0x03,
|
||||||
'ArrowUp': 0x01,
|
'ArrowUp': 0x01,
|
||||||
|
@ -49,6 +49,9 @@ export default function Controls({playerId, logout}: {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (readyState !== ReadyState.OPEN)
|
||||||
|
return;
|
||||||
|
|
||||||
const up = keyEventListener('input-off');
|
const up = keyEventListener('input-off');
|
||||||
const down = keyEventListener('input-on');
|
const down = keyEventListener('input-on');
|
||||||
window.onkeyup = up;
|
window.onkeyup = up;
|
||||||
|
@ -57,7 +60,7 @@ export default function Controls({playerId, logout}: {
|
||||||
window.onkeydown = null;
|
window.onkeydown = null;
|
||||||
window.onkeyup = null;
|
window.onkeyup = null;
|
||||||
};
|
};
|
||||||
}, [sendMessage]);
|
}, [readyState]);
|
||||||
|
|
||||||
return <Column className="Controls">
|
return <Column className="Controls">
|
||||||
<div className='flex-column Controls-Container'>
|
<div className='flex-column Controls-Container'>
|
||||||
|
|
|
@ -1,16 +1,9 @@
|
||||||
|
@keyframes BlinkJoinFormBorder {
|
||||||
@keyframes BlinkJoinForm {
|
|
||||||
from {
|
from {
|
||||||
font-weight: normal;
|
|
||||||
border-color: var(--color-secondary);
|
border-color: var(--color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
|
||||||
border-color: var(--color-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
to {
|
||||||
font-weight: bold;
|
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,7 +22,7 @@
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|
||||||
animation-duration: 1s;
|
animation-duration: 1s;
|
||||||
animation-name: BlinkJoinForm;
|
animation-name: BlinkJoinFormBorder;
|
||||||
animation-iteration-count: infinite;
|
animation-iteration-count: infinite;
|
||||||
animation-direction: alternate;
|
animation-direction: alternate;
|
||||||
animation-timing-function: ease-in-out;
|
animation-timing-function: ease-in-out;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import './JoinForm.css';
|
import './JoinForm.css';
|
||||||
import {NameId, PlayerResponse, postPlayer} from './serverCalls';
|
import {NameId, Player, postPlayer} from './serverCalls';
|
||||||
import {Guid} from './Guid.ts';
|
import {Guid} from './Guid.ts';
|
||||||
import Column from "./components/Column.tsx";
|
import Column from "./components/Column.tsx";
|
||||||
import Button from "./components/Button.tsx";
|
import Button from "./components/Button.tsx";
|
||||||
|
@ -10,28 +10,32 @@ export default function JoinForm({setNameId, clientId}: {
|
||||||
setNameId: (mutator: (oldState: NameId) => NameId) => void,
|
setNameId: (mutator: (oldState: NameId) => NameId) => void,
|
||||||
clientId: Guid
|
clientId: Guid
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [clicked, setClicked] = useState(false);
|
const [clicked, setClicked] = useState(false);
|
||||||
const [data, setData] = useState<PlayerResponse | null>(null);
|
const [data, setData] = useState<Player | null>(null);
|
||||||
|
const [errorText, setErrorText] = useState<string | null>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!clicked || data)
|
if (!clicked || data)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try {
|
postPlayer({name, id: clientId})
|
||||||
postPlayer({name, id: clientId})
|
.then(response => {
|
||||||
.then(value => {
|
if (response.ok && response.successResult) {
|
||||||
if (value)
|
setNameId(_ => response.successResult!);
|
||||||
setNameId(prev => ({...prev, ...value}));
|
setErrorText(null);
|
||||||
else
|
return;
|
||||||
setClicked(false);
|
}
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
alert(e);
|
|
||||||
}
|
|
||||||
}, [clicked, setData, data, clientId, setClicked, setNameId]);
|
|
||||||
|
|
||||||
|
if (response.additionalErrorText)
|
||||||
|
setErrorText(`${response.statusCode} (${response.statusText}): ${response.additionalErrorText}`);
|
||||||
|
else
|
||||||
|
setErrorText(`${response.statusCode} (${response.statusText})`);
|
||||||
|
|
||||||
|
setClicked(false);
|
||||||
|
});
|
||||||
|
}, [clicked, setData, data, clientId, setClicked, setNameId, errorText]);
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
const disableButtons = clicked || name.trim() === '';
|
const disableButtons = clicked || name.trim() === '';
|
||||||
const setClickedTrue = () => setClicked(true);
|
const setClickedTrue = () => setClicked(true);
|
||||||
|
|
||||||
|
@ -47,5 +51,6 @@ export default function JoinForm({setNameId, clientId}: {
|
||||||
onClick={setClickedTrue}
|
onClick={setClickedTrue}
|
||||||
disabled={disableButtons}
|
disabled={disableButtons}
|
||||||
text='INSERT COIN'/>
|
text='INSERT COIN'/>
|
||||||
|
{errorText && <p>{errorText}</p>}
|
||||||
</Column>;
|
</Column>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {PlayerResponse, getPlayer} from './serverCalls';
|
import {Player, getPlayer} from './serverCalls';
|
||||||
import {Guid} from "./Guid.ts";
|
import {Guid} from "./Guid.ts";
|
||||||
import Column from "./components/Column.tsx";
|
import Column from "./components/Column.tsx";
|
||||||
|
|
||||||
|
@ -7,13 +7,13 @@ export default function PlayerInfo({playerId, logout}: {
|
||||||
playerId: Guid,
|
playerId: Guid,
|
||||||
logout: () => void
|
logout: () => void
|
||||||
}) {
|
}) {
|
||||||
const [player, setPlayer] = useState<PlayerResponse | null>();
|
const [player, setPlayer] = useState<Player | null>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
getPlayer(playerId).then(value => {
|
getPlayer(playerId).then(response => {
|
||||||
if (value)
|
if (response.successResult)
|
||||||
setPlayer(value);
|
setPlayer(response.successResult);
|
||||||
else
|
else
|
||||||
logout();
|
logout();
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,15 +1,19 @@
|
||||||
import {getScores, PlayerResponse} from "./serverCalls.tsx";
|
import {getScores, Player} from "./serverCalls.tsx";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import DataTable from "./components/DataTable.tsx";
|
import DataTable from "./components/DataTable.tsx";
|
||||||
|
|
||||||
|
function numberSorter(a: number, b: number) {
|
||||||
|
return b - a;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Scoreboard({}: {}) {
|
export default function Scoreboard({}: {}) {
|
||||||
const [players, setPlayers] = useState<PlayerResponse[]>([]);
|
const [players, setPlayers] = useState<Player[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
getScores().then(value => {
|
getScores().then(value => {
|
||||||
if (value)
|
if (value.successResult)
|
||||||
setPlayers(value);
|
setPlayers(value.successResult);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -22,8 +26,20 @@ export default function Scoreboard({}: {}) {
|
||||||
className='flex-grow'
|
className='flex-grow'
|
||||||
columns={[
|
columns={[
|
||||||
{field: 'name'},
|
{field: 'name'},
|
||||||
{field: 'kills', visualize: p => p.scores.kills.toString()},
|
{
|
||||||
{field: 'deaths', visualize: p => p.scores.deaths.toString()},
|
field: 'kills',
|
||||||
{field: 'k/d', visualize: p => (p.scores.kills / p.scores.deaths).toString()}
|
visualize: p => p.scores.kills.toString(),
|
||||||
|
sorter: (a, b) => numberSorter(a.scores.kills, b.scores.kills)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'deaths',
|
||||||
|
visualize: p => p.scores.deaths.toString(),
|
||||||
|
sorter: (a, b) => numberSorter(a.scores.deaths, b.scores.deaths)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'ratio',
|
||||||
|
visualize: p => p.scores.ratio.toString(),
|
||||||
|
sorter: (a, b) => numberSorter(a.scores.ratio, b.scores.ratio)
|
||||||
|
}
|
||||||
]}/>
|
]}/>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,22 @@
|
||||||
.Button {
|
.Button {
|
||||||
border: solid var(--border-size-thin) var(--color-primary);
|
border: solid var(--border-size-thin);
|
||||||
padding: var(--padding-normal);
|
padding: var(--padding-normal);
|
||||||
|
|
||||||
color: var(--color-primary);
|
|
||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.Button:hover, .Button:active {
|
.Button:enabled {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Button:hover:enabled {
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
color: var(--color-background);
|
color: var(--color-background);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.Button:disabled {
|
||||||
|
border-color: var(--color-text);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
.DataTable > table {
|
.DataTable > table {
|
||||||
table-layout: auto;
|
table-layout: auto;
|
||||||
|
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: calc(2*var(--padding-normal));
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,24 +1,13 @@
|
||||||
import {ReactNode} from "react";
|
import {ReactNode, useState} from "react";
|
||||||
import './DataTable.css';
|
import './DataTable.css';
|
||||||
|
|
||||||
export type DataTableColumnDefinition<T> = {
|
export type DataTableColumnDefinition<T> = {
|
||||||
field: string,
|
field: string,
|
||||||
label?: string,
|
label?: string,
|
||||||
visualize?: (value: T) => ReactNode
|
visualize?: (value: T) => ReactNode,
|
||||||
|
sorter?: (a: T, b: T) => number
|
||||||
};
|
};
|
||||||
|
|
||||||
function TableHead({columns}: { columns: DataTableColumnDefinition<any>[] }) {
|
|
||||||
return <thead className='DataTableHead'>
|
|
||||||
<tr>
|
|
||||||
{
|
|
||||||
columns.map(column =>
|
|
||||||
<th key={column.field}>
|
|
||||||
{column.label ?? column.field}
|
|
||||||
</th>)
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
</thead>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataTableRow({rowData, columns}: {
|
function DataTableRow({rowData, columns}: {
|
||||||
rowData: any,
|
rowData: any,
|
||||||
|
@ -44,11 +33,46 @@ export default function DataTable<T>({data, columns, className}: {
|
||||||
columns: DataTableColumnDefinition<any>[],
|
columns: DataTableColumnDefinition<any>[],
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
|
const [sortBy, setSortBy] = useState<DataTableColumnDefinition<any> | null>(null);
|
||||||
|
const [sortReversed, setSortReversed] = useState(false);
|
||||||
|
|
||||||
|
const headerClick = (column: DataTableColumnDefinition<any>) => {
|
||||||
|
console.log('column clicked', column);
|
||||||
|
|
||||||
|
if (column.field === sortBy?.field)
|
||||||
|
setSortReversed(prevState => !prevState);
|
||||||
|
else if (column.sorter)
|
||||||
|
setSortBy(column);
|
||||||
|
else
|
||||||
|
console.log('cannot sort by', column)
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataToDisplay = [...data];
|
||||||
|
const actualSorter = sortReversed && sortBy?.sorter
|
||||||
|
? (a: T, b: T) => -sortBy.sorter!(a, b)
|
||||||
|
: sortBy?.sorter;
|
||||||
|
|
||||||
|
dataToDisplay.sort(actualSorter)
|
||||||
|
|
||||||
|
console.log('sorted', {dataToDisplay});
|
||||||
|
|
||||||
return <div className={'DataTable ' + (className ?? '')}>
|
return <div className={'DataTable ' + (className ?? '')}>
|
||||||
<table>
|
<table>
|
||||||
<TableHead columns={columns}/>
|
<thead className='DataTableHead'>
|
||||||
|
<tr>
|
||||||
|
{
|
||||||
|
columns.map(column =>
|
||||||
|
<th key={column.field} onClick={() => headerClick(column)}>
|
||||||
|
{column.label ?? column.field}
|
||||||
|
</th>)
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.map((element, index) => <DataTableRow key={index} rowData={element} columns={columns}/>)}
|
{
|
||||||
|
dataToDisplay.map((element, index) =>
|
||||||
|
<DataTableRow key={`${sortBy?.field}-${index}`} rowData={element} columns={columns}/>)
|
||||||
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>;
|
</div>;
|
||||||
|
|
|
@ -1,6 +1,14 @@
|
||||||
import {Guid} from './Guid.ts';
|
import {Guid} from './Guid.ts';
|
||||||
|
|
||||||
export type PlayerResponse = {
|
export type ServerResponse<T> = {
|
||||||
|
ok: boolean;
|
||||||
|
statusCode: number;
|
||||||
|
statusText: string;
|
||||||
|
additionalErrorText?: string;
|
||||||
|
successResult?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Player = {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly id: Guid;
|
readonly id: Guid;
|
||||||
readonly scores: {
|
readonly scores: {
|
||||||
|
@ -14,11 +22,19 @@ export type NameId = {
|
||||||
id: Guid
|
id: Guid
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchTyped<T>({url, method}: { url: URL; method: string; }) {
|
export async function fetchTyped<T>({url, method}: { url: URL; method: string; }): Promise<ServerResponse<T>> {
|
||||||
const response = await fetch(url, {method});
|
const response = await fetch(url, {method});
|
||||||
if (!response.ok)
|
const result: ServerResponse<T> = {
|
||||||
return null;
|
ok: response.ok,
|
||||||
return await response.json() as T;
|
statusCode: response.status,
|
||||||
|
statusText: response.statusText
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok)
|
||||||
|
result.successResult = await response.json();
|
||||||
|
else
|
||||||
|
result.additionalErrorText = await response.text();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function postPlayer({name, id}: NameId) {
|
export function postPlayer({name, id}: NameId) {
|
||||||
|
@ -33,10 +49,10 @@ export function getPlayer(id: Guid) {
|
||||||
const url = new URL(import.meta.env.VITE_TANK_PLAYER_URL);
|
const url = new URL(import.meta.env.VITE_TANK_PLAYER_URL);
|
||||||
url.searchParams.set('id', id);
|
url.searchParams.set('id', id);
|
||||||
|
|
||||||
return fetchTyped<PlayerResponse>({url, method: 'GET'});
|
return fetchTyped<Player>({url, method: 'GET'});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScores() {
|
export function getScores() {
|
||||||
const url = new URL('/scores', import.meta.env.VITE_TANK_API);
|
const url = new URL('/scores', import.meta.env.VITE_TANK_API);
|
||||||
return fetchTyped<PlayerResponse[]>({url, method: 'GET'});
|
return fetchTyped<Player[]>({url, method: 'GET'});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue