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 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();
|
||||
if (name == string.Empty)
|
||||
return Results.BadRequest("name cannot be blank");
|
||||
if (name.Length > 12)
|
||||
return Results.BadRequest("name too long");
|
||||
|
||||
var player = playerService.GetOrAdd(name, id);
|
||||
return player != null
|
||||
|
@ -53,7 +55,7 @@ public static class Program
|
|||
|
||||
using var ws = await context.WebSockets.AcceptWebSocketAsync();
|
||||
await clientScreenServer.HandleClient(ws);
|
||||
return null;
|
||||
return Results.Empty;
|
||||
});
|
||||
|
||||
app.Map("/controls", async (HttpContext context, [FromQuery] Guid playerId) =>
|
||||
|
@ -66,7 +68,7 @@ public static class Program
|
|||
|
||||
using var ws = await context.WebSockets.AcceptWebSocketAsync();
|
||||
await controlsServer.HandleClient(ws, player);
|
||||
return null;
|
||||
return Results.Empty;
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import './Controls.css';
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
import useWebSocket, {ReadyState} from 'react-use-websocket';
|
||||
import {useEffect} from 'react';
|
||||
import Column from "./components/Column.tsx";
|
||||
|
||||
|
@ -12,6 +12,7 @@ export default function Controls({playerId, logout}: {
|
|||
const {
|
||||
sendMessage,
|
||||
getWebSocket,
|
||||
readyState
|
||||
} = useWebSocket(url.toString(), {
|
||||
onError: logout
|
||||
});
|
||||
|
@ -25,7 +26,6 @@ export default function Controls({playerId, logout}: {
|
|||
return;
|
||||
|
||||
const typeCode = type === 'input-on' ? 0x01 : 0x02;
|
||||
|
||||
const controls = {
|
||||
'ArrowLeft': 0x03,
|
||||
'ArrowUp': 0x01,
|
||||
|
@ -49,6 +49,9 @@ export default function Controls({playerId, logout}: {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (readyState !== ReadyState.OPEN)
|
||||
return;
|
||||
|
||||
const up = keyEventListener('input-off');
|
||||
const down = keyEventListener('input-on');
|
||||
window.onkeyup = up;
|
||||
|
@ -57,7 +60,7 @@ export default function Controls({playerId, logout}: {
|
|||
window.onkeydown = null;
|
||||
window.onkeyup = null;
|
||||
};
|
||||
}, [sendMessage]);
|
||||
}, [readyState]);
|
||||
|
||||
return <Column className="Controls">
|
||||
<div className='flex-column Controls-Container'>
|
||||
|
|
|
@ -1,16 +1,9 @@
|
|||
|
||||
@keyframes BlinkJoinForm {
|
||||
@keyframes BlinkJoinFormBorder {
|
||||
from {
|
||||
font-weight: normal;
|
||||
border-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
50% {
|
||||
border-color: var(--color-background);
|
||||
}
|
||||
|
||||
to {
|
||||
font-weight: bold;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
@ -29,7 +22,7 @@
|
|||
padding: 16px;
|
||||
|
||||
animation-duration: 1s;
|
||||
animation-name: BlinkJoinForm;
|
||||
animation-name: BlinkJoinFormBorder;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: alternate;
|
||||
animation-timing-function: ease-in-out;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import {useEffect, useState} from 'react';
|
||||
import './JoinForm.css';
|
||||
import {NameId, PlayerResponse, postPlayer} from './serverCalls';
|
||||
import {NameId, Player, postPlayer} from './serverCalls';
|
||||
import {Guid} from './Guid.ts';
|
||||
import Column from "./components/Column.tsx";
|
||||
import Button from "./components/Button.tsx";
|
||||
|
@ -10,28 +10,32 @@ export default function JoinForm({setNameId, clientId}: {
|
|||
setNameId: (mutator: (oldState: NameId) => NameId) => void,
|
||||
clientId: Guid
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
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(() => {
|
||||
if (!clicked || data)
|
||||
return;
|
||||
|
||||
try {
|
||||
postPlayer({name, id: clientId})
|
||||
.then(value => {
|
||||
if (value)
|
||||
setNameId(prev => ({...prev, ...value}));
|
||||
else
|
||||
setClicked(false);
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
alert(e);
|
||||
}
|
||||
}, [clicked, setData, data, clientId, setClicked, setNameId]);
|
||||
postPlayer({name, id: clientId})
|
||||
.then(response => {
|
||||
if (response.ok && response.successResult) {
|
||||
setNameId(_ => response.successResult!);
|
||||
setErrorText(null);
|
||||
return;
|
||||
}
|
||||
|
||||
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 setClickedTrue = () => setClicked(true);
|
||||
|
||||
|
@ -47,5 +51,6 @@ export default function JoinForm({setNameId, clientId}: {
|
|||
onClick={setClickedTrue}
|
||||
disabled={disableButtons}
|
||||
text='INSERT COIN'/>
|
||||
{errorText && <p>{errorText}</p>}
|
||||
</Column>;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {useEffect, useState} from 'react';
|
||||
import {PlayerResponse, getPlayer} from './serverCalls';
|
||||
import {Player, getPlayer} from './serverCalls';
|
||||
import {Guid} from "./Guid.ts";
|
||||
import Column from "./components/Column.tsx";
|
||||
|
||||
|
@ -7,13 +7,13 @@ export default function PlayerInfo({playerId, logout}: {
|
|||
playerId: Guid,
|
||||
logout: () => void
|
||||
}) {
|
||||
const [player, setPlayer] = useState<PlayerResponse | null>();
|
||||
const [player, setPlayer] = useState<Player | null>();
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
getPlayer(playerId).then(value => {
|
||||
if (value)
|
||||
setPlayer(value);
|
||||
getPlayer(playerId).then(response => {
|
||||
if (response.successResult)
|
||||
setPlayer(response.successResult);
|
||||
else
|
||||
logout();
|
||||
});
|
||||
|
|
|
@ -1,15 +1,19 @@
|
|||
import {getScores, PlayerResponse} from "./serverCalls.tsx";
|
||||
import {getScores, Player} from "./serverCalls.tsx";
|
||||
import {useEffect, useState} from "react";
|
||||
import DataTable from "./components/DataTable.tsx";
|
||||
|
||||
function numberSorter(a: number, b: number) {
|
||||
return b - a;
|
||||
}
|
||||
|
||||
export default function Scoreboard({}: {}) {
|
||||
const [players, setPlayers] = useState<PlayerResponse[]>([]);
|
||||
const [players, setPlayers] = useState<Player[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
getScores().then(value => {
|
||||
if (value)
|
||||
setPlayers(value);
|
||||
if (value.successResult)
|
||||
setPlayers(value.successResult);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -22,8 +26,20 @@ export default function Scoreboard({}: {}) {
|
|||
className='flex-grow'
|
||||
columns={[
|
||||
{field: 'name'},
|
||||
{field: 'kills', visualize: p => p.scores.kills.toString()},
|
||||
{field: 'deaths', visualize: p => p.scores.deaths.toString()},
|
||||
{field: 'k/d', visualize: p => (p.scores.kills / p.scores.deaths).toString()}
|
||||
{
|
||||
field: 'kills',
|
||||
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 {
|
||||
border: solid var(--border-size-thin) var(--color-primary);
|
||||
border: solid var(--border-size-thin);
|
||||
padding: var(--padding-normal);
|
||||
|
||||
color: var(--color-primary);
|
||||
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);
|
||||
color: var(--color-background);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.Button:disabled {
|
||||
border-color: var(--color-text);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
.DataTable > table {
|
||||
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';
|
||||
|
||||
export type DataTableColumnDefinition<T> = {
|
||||
field: 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}: {
|
||||
rowData: any,
|
||||
|
@ -44,11 +33,46 @@ export default function DataTable<T>({data, columns, className}: {
|
|||
columns: DataTableColumnDefinition<any>[],
|
||||
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 ?? '')}>
|
||||
<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>
|
||||
{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>
|
||||
</table>
|
||||
</div>;
|
||||
|
|
|
@ -1,6 +1,14 @@
|
|||
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 id: Guid;
|
||||
readonly scores: {
|
||||
|
@ -14,11 +22,19 @@ export type NameId = {
|
|||
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});
|
||||
if (!response.ok)
|
||||
return null;
|
||||
return await response.json() as T;
|
||||
const result: ServerResponse<T> = {
|
||||
ok: response.ok,
|
||||
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) {
|
||||
|
@ -33,10 +49,10 @@ export function getPlayer(id: Guid) {
|
|||
const url = new URL(import.meta.env.VITE_TANK_PLAYER_URL);
|
||||
url.searchParams.set('id', id);
|
||||
|
||||
return fetchTyped<PlayerResponse>({url, method: 'GET'});
|
||||
return fetchTyped<Player>({url, method: 'GET'});
|
||||
}
|
||||
|
||||
export function getScores() {
|
||||
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