map chooser dialog with previews
This commit is contained in:
parent
a952d4227d
commit
170a7faf0f
|
@ -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()}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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>;
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,4 +10,9 @@
|
||||||
|
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|
||||||
|
max-height: 75vh;
|
||||||
|
max-width: 75vw;
|
||||||
|
|
||||||
|
overflow: scroll;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in a new issue