mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-24 06:39:29 +00:00
242 lines
8.4 KiB
TypeScript
242 lines
8.4 KiB
TypeScript
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
import type { ServerInfo } from "../../relay/types";
|
|
import styles from "./ServerBrowser.module.css";
|
|
import { useLiveSelector } from "../state/liveConnectionStore";
|
|
import { useSettings } from "./SettingsProvider";
|
|
import { LuUsers } from "react-icons/lu";
|
|
|
|
export function ServerBrowser({ onClose }: { onClose: () => void }) {
|
|
const servers = useLiveSelector((s) => s.servers);
|
|
const serversLoading = useLiveSelector((s) => s.serversLoading);
|
|
const browserToRelayPing = useLiveSelector((s) => s.browserToRelayPing);
|
|
const listServers = useLiveSelector((s) => s.listServers);
|
|
const joinServer = useLiveSelector((s) => s.joinServer);
|
|
const { warriorName, setWarriorName } = useSettings();
|
|
const [selectedAddress, setSelectedAddress] = useState<string | null>(null);
|
|
const handleJoinSelected = () => {
|
|
if (selectedAddress) {
|
|
joinServer(selectedAddress, warriorName);
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const handleJoin = (address: string) => {
|
|
joinServer(address, warriorName);
|
|
onClose();
|
|
};
|
|
const [sortKey, setSortKey] = useState<keyof ServerInfo>("ping");
|
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
dialogRef.current?.focus();
|
|
try {
|
|
document.exitPointerLock();
|
|
} catch {
|
|
/* expected */
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
listServers();
|
|
}, [listServers]);
|
|
|
|
// Block keyboard events from reaching Three.js while open
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
e.stopPropagation();
|
|
if (e.key === "Escape") {
|
|
onClose();
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown, true);
|
|
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
}, [onClose]);
|
|
|
|
const handleSort = useCallback(
|
|
(key: keyof ServerInfo) => {
|
|
if (sortKey === key) {
|
|
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
|
} else {
|
|
setSortKey(key);
|
|
setSortDir("desc");
|
|
}
|
|
},
|
|
[sortKey],
|
|
);
|
|
|
|
const sorted = useMemo(() => {
|
|
return [...servers].sort((a, b) => {
|
|
const av = a[sortKey];
|
|
const bv = b[sortKey];
|
|
const cmp =
|
|
typeof av === "number" && typeof bv === "number"
|
|
? av - bv
|
|
: String(av).localeCompare(String(bv));
|
|
return sortDir === "asc" ? cmp : -cmp;
|
|
});
|
|
}, [servers, sortDir, sortKey]);
|
|
|
|
return (
|
|
<div className={styles.Overlay} onClick={onClose}>
|
|
<div
|
|
className={styles.Dialog}
|
|
ref={dialogRef}
|
|
tabIndex={-1}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className={styles.Header}>
|
|
<h2 className={styles.Title}>Server Browser</h2>
|
|
<span className={styles.ServerCount}>
|
|
{servers.length} server{servers.length !== 1 ? "s" : ""}
|
|
</span>
|
|
<button
|
|
className={styles.RefreshButton}
|
|
onClick={listServers}
|
|
disabled={serversLoading}
|
|
>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
<div className={styles.TableWrapper}>
|
|
<form name="serverList" onSubmit={handleJoinSelected}>
|
|
<table className={styles.Table}>
|
|
<thead>
|
|
<tr>
|
|
<th data-column="server" onClick={() => handleSort("name")}>
|
|
Server Name
|
|
</th>
|
|
<th
|
|
data-column="players"
|
|
onClick={() => handleSort("playerCount")}
|
|
>
|
|
<LuUsers
|
|
className={styles.PlayersIcon}
|
|
title="Players"
|
|
aria-label="Players"
|
|
/>
|
|
</th>
|
|
<th data-column="ping" onClick={() => handleSort("ping")}>
|
|
Ping
|
|
</th>
|
|
<th data-column="map" onClick={() => handleSort("mapName")}>
|
|
Map
|
|
</th>
|
|
<th
|
|
data-column="gameType"
|
|
onClick={() => handleSort("gameType")}
|
|
>
|
|
Type
|
|
</th>
|
|
<th data-column="mod" onClick={() => handleSort("mod")}>
|
|
Mod
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sorted.map((server) => (
|
|
<tr
|
|
key={server.address}
|
|
onClick={() => {
|
|
setSelectedAddress(server.address);
|
|
const form = document.forms.namedItem("serverList")!;
|
|
const inputs = form.elements.namedItem(
|
|
"serverAddress",
|
|
) as RadioNodeList;
|
|
const input = Array.from(inputs).find(
|
|
(input) => input.value === server.address,
|
|
);
|
|
input!.focus();
|
|
}}
|
|
onDoubleClick={() => {
|
|
setSelectedAddress(server.address);
|
|
handleJoin(server.address);
|
|
onClose();
|
|
}}
|
|
>
|
|
<td data-column="server">
|
|
<input
|
|
type="radio"
|
|
className={styles.HiddenRadio}
|
|
name="serverAddress"
|
|
value={server.address}
|
|
checked={selectedAddress === server.address}
|
|
onChange={(event) => {
|
|
setSelectedAddress(event.target.value);
|
|
}}
|
|
/>
|
|
{server.passwordRequired && (
|
|
<span className={styles.PasswordIcon}>🔒</span>
|
|
)}
|
|
{server.name}
|
|
</td>
|
|
<td
|
|
className={
|
|
server.playerCount === 0
|
|
? styles.EmptyServer
|
|
: undefined
|
|
}
|
|
data-column="players"
|
|
>
|
|
{server.playerCount}
|
|
<span className={styles.CompactHidden}>
|
|
 / {server.maxPlayers}
|
|
</span>
|
|
</td>
|
|
<td data-column="ping">
|
|
{browserToRelayPing != null
|
|
? (server.ping + browserToRelayPing).toLocaleString()
|
|
: "\u2014"}
|
|
</td>
|
|
<td data-column="map">{server.mapName}</td>
|
|
<td data-column="gameType">{server.gameType}</td>
|
|
<td data-column="mod">{server.mod}</td>
|
|
</tr>
|
|
))}
|
|
{/* {sorted.length === 0 && !serversLoading && (
|
|
<tr className={styles.Empty}>
|
|
<td colSpan={6}>No servers found</td>
|
|
</tr>
|
|
)}
|
|
{serversLoading && sorted.length === 0 && (
|
|
<tr className={styles.Empty}>
|
|
<td colSpan={6}>Querying master server…</td>
|
|
</tr>
|
|
)} */}
|
|
</tbody>
|
|
</table>
|
|
</form>
|
|
</div>
|
|
<div className={styles.Footer}>
|
|
<div className={styles.WarriorField}>
|
|
<label className={styles.WarriorLabel} htmlFor="warriorName">
|
|
Warrior
|
|
</label>
|
|
<input
|
|
id="warriorName"
|
|
className={styles.WarriorInput}
|
|
type="text"
|
|
value={warriorName}
|
|
onChange={(e) => setWarriorName(e.target.value)}
|
|
placeholder="Name thyself…"
|
|
maxLength={24}
|
|
/>
|
|
</div>
|
|
<span className={styles.Hint}>Double-click a server to join</span>
|
|
<div className={styles.Actions}>
|
|
<button onClick={onClose} className={styles.CloseButton}>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleJoinSelected}
|
|
disabled={!selectedAddress}
|
|
className={styles.JoinButton}
|
|
>
|
|
Join
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|