improved theming, error handling, table sort

This commit is contained in:
Vinzenz Schroeter 2024-04-14 18:26:13 +02:00
parent 35256ba88d
commit 7ce0e543ec
11 changed files with 151 additions and 68 deletions

View file

@ -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;
}
}
}

View file

@ -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();

View file

@ -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'>

View file

@ -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;

View file

@ -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>;
}

View file

@ -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();
});

View file

@ -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)
}
]}/>
}

View file

@ -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);
}

View file

@ -1,3 +1,6 @@
.DataTable > table {
table-layout: auto;
border-collapse: separate;
border-spacing: calc(2*var(--padding-normal));
}

View file

@ -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>;

View file

@ -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'});
}