From 7ce0e543ec74fe0f5cda33e6c4a812b0960dc694 Mon Sep 17 00:00:00 2001 From: Vinzenz Schroeter Date: Sun, 14 Apr 2024 18:26:13 +0200 Subject: [PATCH] improved theming, error handling, table sort --- TanksServer/Models/Scores.cs | 12 +++++ TanksServer/Program.cs | 6 ++- tank-frontend/src/Controls.tsx | 9 ++-- tank-frontend/src/JoinForm.css | 11 +---- tank-frontend/src/JoinForm.tsx | 37 +++++++------- tank-frontend/src/PlayerInfo.tsx | 10 ++-- tank-frontend/src/Scoreboard.tsx | 30 +++++++++--- tank-frontend/src/components/Button.css | 15 ++++-- tank-frontend/src/components/DataTable.css | 3 ++ tank-frontend/src/components/DataTable.tsx | 56 +++++++++++++++------- tank-frontend/src/serverCalls.tsx | 30 +++++++++--- 11 files changed, 151 insertions(+), 68 deletions(-) diff --git a/TanksServer/Models/Scores.cs b/TanksServer/Models/Scores.cs index 65c29c1..dc2c98c 100644 --- a/TanksServer/Models/Scores.cs +++ b/TanksServer/Models/Scores.cs @@ -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; + } + } } diff --git a/TanksServer/Program.cs b/TanksServer/Program.cs index 43c72da..118de93 100644 --- a/TanksServer/Program.cs +++ b/TanksServer/Program.cs @@ -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(); diff --git a/tank-frontend/src/Controls.tsx b/tank-frontend/src/Controls.tsx index 87ce033..5e15fc2 100644 --- a/tank-frontend/src/Controls.tsx +++ b/tank-frontend/src/Controls.tsx @@ -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
diff --git a/tank-frontend/src/JoinForm.css b/tank-frontend/src/JoinForm.css index f46d954..a1baee0 100644 --- a/tank-frontend/src/JoinForm.css +++ b/tank-frontend/src/JoinForm.css @@ -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; diff --git a/tank-frontend/src/JoinForm.tsx b/tank-frontend/src/JoinForm.tsx index e10cddd..58a729c 100644 --- a/tank-frontend/src/JoinForm.tsx +++ b/tank-frontend/src/JoinForm.tsx @@ -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(null); + const [data, setData] = useState(null); + const [errorText, setErrorText] = useState(); 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 &&

{errorText}

} ; } diff --git a/tank-frontend/src/PlayerInfo.tsx b/tank-frontend/src/PlayerInfo.tsx index 4680a5d..55698d4 100644 --- a/tank-frontend/src/PlayerInfo.tsx +++ b/tank-frontend/src/PlayerInfo.tsx @@ -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(); + const [player, setPlayer] = useState(); useEffect(() => { const refresh = () => { - getPlayer(playerId).then(value => { - if (value) - setPlayer(value); + getPlayer(playerId).then(response => { + if (response.successResult) + setPlayer(response.successResult); else logout(); }); diff --git a/tank-frontend/src/Scoreboard.tsx b/tank-frontend/src/Scoreboard.tsx index f69a4a5..47dcb04 100644 --- a/tank-frontend/src/Scoreboard.tsx +++ b/tank-frontend/src/Scoreboard.tsx @@ -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([]); + const [players, setPlayers] = useState([]); 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) + } ]}/> } diff --git a/tank-frontend/src/components/Button.css b/tank-frontend/src/components/Button.css index 3099e7d..94c2085 100644 --- a/tank-frontend/src/components/Button.css +++ b/tank-frontend/src/components/Button.css @@ -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); +} diff --git a/tank-frontend/src/components/DataTable.css b/tank-frontend/src/components/DataTable.css index 0818972..a15db29 100644 --- a/tank-frontend/src/components/DataTable.css +++ b/tank-frontend/src/components/DataTable.css @@ -1,3 +1,6 @@ .DataTable > table { table-layout: auto; + + border-collapse: separate; + border-spacing: calc(2*var(--padding-normal)); } diff --git a/tank-frontend/src/components/DataTable.tsx b/tank-frontend/src/components/DataTable.tsx index 8867eb0..21aafef 100644 --- a/tank-frontend/src/components/DataTable.tsx +++ b/tank-frontend/src/components/DataTable.tsx @@ -1,24 +1,13 @@ -import {ReactNode} from "react"; +import {ReactNode, useState} from "react"; import './DataTable.css'; export type DataTableColumnDefinition = { field: string, label?: string, - visualize?: (value: T) => ReactNode + visualize?: (value: T) => ReactNode, + sorter?: (a: T, b: T) => number }; -function TableHead({columns}: { columns: DataTableColumnDefinition[] }) { - return - - { - columns.map(column => - - {column.label ?? column.field} - ) - } - - ; -} function DataTableRow({rowData, columns}: { rowData: any, @@ -44,11 +33,46 @@ export default function DataTable({data, columns, className}: { columns: DataTableColumnDefinition[], className?: string }) { + const [sortBy, setSortBy] = useState | null>(null); + const [sortReversed, setSortReversed] = useState(false); + + const headerClick = (column: DataTableColumnDefinition) => { + 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
- + + + { + columns.map(column => + ) + } + + - {data.map((element, index) => )} + { + dataToDisplay.map((element, index) => + ) + }
headerClick(column)}> + {column.label ?? column.field} +
; diff --git a/tank-frontend/src/serverCalls.tsx b/tank-frontend/src/serverCalls.tsx index d1b30bc..3c0cfd9 100644 --- a/tank-frontend/src/serverCalls.tsx +++ b/tank-frontend/src/serverCalls.tsx @@ -1,6 +1,14 @@ import {Guid} from './Guid.ts'; -export type PlayerResponse = { +export type ServerResponse = { + 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({url, method}: { url: URL; method: string; }) { +export async function fetchTyped({url, method}: { url: URL; method: string; }): Promise> { const response = await fetch(url, {method}); - if (!response.ok) - return null; - return await response.json() as T; + const result: ServerResponse = { + 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({url, method: 'GET'}); + return fetchTyped({url, method: 'GET'}); } export function getScores() { const url = new URL('/scores', import.meta.env.VITE_TANK_API); - return fetchTyped({url, method: 'GET'}); + return fetchTyped({url, method: 'GET'}); }