map chooser dialog with previews

This commit is contained in:
Vinzenz Schroeter 2024-05-05 23:33:08 +02:00
parent a952d4227d
commit 170a7faf0f
7 changed files with 131 additions and 30 deletions

View file

@ -8,7 +8,7 @@ import Scoreboard from './Scoreboard.tsx';
import Button from './components/Button.tsx'; import Button from './components/Button.tsx';
import MapChooser from './MapChooser.tsx'; import MapChooser from './MapChooser.tsx';
import './App.css'; import './App.css';
import {getRandomTheme, useStoredTheme} from "./theme.ts"; import {getRandomTheme, useStoredTheme} from './theme.ts';
import {useState} from 'react'; import {useState} from 'react';
export default function App() { export default function App() {
@ -21,7 +21,7 @@ export default function App() {
<Row> <Row>
<h1 className="flex-grow">CCCB-Tanks!</h1> <h1 className="flex-grow">CCCB-Tanks!</h1>
<MapChooser /> <MapChooser theme={theme}/>
<Button text="change colors" onClick={() => setTheme(_ => getRandomTheme())}/> <Button text="change colors" onClick={() => setTheme(_ => getRandomTheme())}/>
<Button <Button
onClick={() => window.open('https://github.com/kaesaecracker/cccb-tanks-cs', '_blank')?.focus()} onClick={() => window.open('https://github.com/kaesaecracker/cccb-tanks-cs', '_blank')?.focus()}

View file

@ -7,3 +7,17 @@
border-color: var(--color-primary); border-color: var(--color-primary);
color: var(--color-primary); color: var(--color-primary);
} }
.MapChooser-Row {
flex-wrap: wrap;
}
.MapChooser-Preview {
flex-grow: 1;
padding: var(--padding-normal);
border: solid var(--border-size-thin) var(--color-primary);
}
.MapChooser-Preview-Highlight {
border-color: var(--color-secondary);
}

View file

@ -1,9 +1,84 @@
import {ChangeEvent} from 'react'; import {useState} from 'react';
import {makeApiUrl} from './serverCalls';
import './MapChooser.css';
import {useMutation, useQuery} from '@tanstack/react-query'; import {useMutation, useQuery} from '@tanstack/react-query';
import {makeApiUrl, MapInfo} from './serverCalls';
import Dialog from './components/Dialog.tsx';
import PixelGridCanvas from './components/PixelGridCanvas.tsx';
import Column from './components/Column.tsx';
import {Theme} from './theme.ts';
import Button from './components/Button.tsx';
import Row from './components/Row.tsx';
import './MapChooser.css';
export default function MapChooser() { function base64ToArrayBuffer(base64: string) {
const binaryString = atob(base64);
const bytes = new Uint8ClampedArray(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
function MapPreview({mapName, theme, highlight, onClick}: {
readonly mapName: string,
readonly theme: Theme,
readonly highlight: boolean,
readonly onClick: () => void
}) {
const query = useQuery({
queryKey: ['get-map', mapName],
queryFn: async () => {
const url = makeApiUrl(`/map/${mapName}`);
const response = await fetch(url, {method: 'GET'});
if (!response.ok)
throw new Error(`response failed with code ${response.status} (${response.status})${await response.text()}`);
return await response.json() as MapInfo;
}
});
if (query.isError)
return <p>{query.error.message}</p>;
else if (query.isPending)
return <p>loading...</p>;
const {name, preview} = query.data;
return <Column
key={mapName}
className={'MapChooser-Preview' + (highlight ? ' MapChooser-Preview-Highlight' : '')}
onClick={onClick}
>
<PixelGridCanvas pixels={base64ToArrayBuffer(preview)} theme={theme}/>
<p>{name}</p>
</Column>;
}
function MapChooserDialog({mapNames, theme, onClose, onConfirm}: {
readonly mapNames: string[];
readonly theme: Theme;
readonly onConfirm: (mapName: string) => void;
readonly onClose: () => void;
}) {
const [chosenMap, setChosenMap] = useState<string>();
return <Dialog>
<h3>Choose a map</h3>
<Row className="MapChooser-Row">
{mapNames.map(name => <MapPreview
key={name}
mapName={name}
theme={theme}
highlight={chosenMap == name}
onClick={() => setChosenMap(name)}
/>)}
</Row>
<Button text="confirm" disabled={!chosenMap} onClick={() => chosenMap && onConfirm(chosenMap)}/>
<Button text="cancel" onClick={onClose}/>
</Dialog>;
}
export default function MapChooser({theme}: {
readonly theme: Theme;
}) {
const query = useQuery({ const query = useQuery({
queryKey: ['get-maps'], queryKey: ['get-maps'],
queryFn: async () => { queryFn: async () => {
@ -27,22 +102,22 @@ export default function MapChooser() {
} }
}); });
const onChange = (event: ChangeEvent<HTMLSelectElement>) => { const [open, setOpen] = useState(false);
if (event.target.selectedIndex < 1)
return;
event.preventDefault();
const chosenMap = event.target.options[event.target.selectedIndex].value; return <>
postMap.mutate(chosenMap); <Button text="Change map"
}; disabled={!query.isSuccess || postMap.isPending}
onClick={() => setOpen(true)}/>
if (query.isError) {query.isSuccess && open &&
return <></>; <MapChooserDialog
mapNames={query.data!}
const disabled = !query.isSuccess || postMap.isPending; theme={theme}
onClose={() => setOpen(false)}
return <select className="MapChooser-DropDown" onChange={onChange} disabled={disabled}> onConfirm={name => {
<option value="" defaultValue={''}>Choose map</option> setOpen(false);
{query.isSuccess && query.data.map(m => <option key={m} value={m}>{m}</option>)} postMap.mutate(name);
</select>; }}
/>
}
</>;
} }

View file

@ -1,12 +1,13 @@
import {ReactNode} from "react"; import {MouseEventHandler, ReactNode} from 'react';
import './Column.css' import './Column.css'
export default function Column({children, className}: { export default function Column({children, className, onClick}: {
children: ReactNode, children: ReactNode;
className?: string className?: string;
onClick?: MouseEventHandler<HTMLDivElement>;
}) { }) {
return <div className={'Column flex-column ' + (className ?? '')}> return <div className={'Column flex-column ' + (className ?? '')} onClick={onClick}>
{children} {children}
</div> </div>
} }

View file

@ -10,4 +10,9 @@
gap: 16px; gap: 16px;
padding: 16px; padding: 16px;
max-height: 75vh;
max-width: 75vw;
overflow: scroll;
} }

View file

@ -6,7 +6,7 @@ export default function Dialog({children, className}: {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
}) { }) {
return <Column className={'Dialog ' + className ?? ''}> return <Column className={'Dialog ' + (className ?? '')}>
{children} {children}
</Column> </Column>
} }

View file

@ -34,6 +34,12 @@ export type PlayerInfoMessage = {
readonly openConnections: number; readonly openConnections: number;
} }
export type MapInfo = {
readonly name: string;
readonly typeName: string;
readonly preview: string;
}
export function useMyWebSocket<T = unknown>(url: string, options: Options = {}) { export function useMyWebSocket<T = unknown>(url: string, options: Options = {}) {
return useWebSocket<T>(url, { return useWebSocket<T>(url, {
shouldReconnect: () => true, shouldReconnect: () => true,