pages235/src/react/AddServerOrConnect.tsx
Vitaly 5bd33a546a
More build configs & optimise reconnect and immediate game enter (#398)
feat(custom-builds): Add a way to bundle only specific minecraft version data, this does not affect assets though
env:
MIN_MC_VERSION
MAX_MC_VERSION
new SKIP_MC_DATA_RECIPES - if recipes are not used in game
fix: refactor QS params handling to ensure panorama & main menu never loaded when immedieate game enter action is expected (eg ?autoConnect=1)
2025-07-18 04:39:05 +03:00

256 lines
9.7 KiB
TypeScript

import React, { useEffect } from 'react'
import { appQueryParams } from '../appParams'
import { fetchServerStatus, isServerValid } from '../api/mcStatusApi'
import { parseServerAddress } from '../parseServerAddress'
import Screen from './Screen'
import Input, { INPUT_LABEL_WIDTH, InputWithLabel } from './Input'
import Button from './Button'
import SelectGameVersion from './SelectGameVersion'
import { usePassesScaledDimensions } from './UIProvider'
export interface BaseServerInfo {
ip: string
name?: string
versionOverride?: string
proxyOverride?: string
usernameOverride?: string
/** Username or always use new if true */
authenticatedAccountOverride?: string | true
}
interface Props {
onBack: () => void
onConfirm: (info: BaseServerInfo) => void
title?: string
initialData?: BaseServerInfo
parseQs?: boolean
onQsConnect?: (server: BaseServerInfo) => void
placeholders?: Pick<BaseServerInfo, 'proxyOverride' | 'usernameOverride'>
accounts?: string[]
authenticatedAccounts?: number
versions?: string[]
}
export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions }: Props) => {
const isSmallHeight = !usePassesScaledDimensions(null, 350)
const qsParamName = parseQs ? appQueryParams.name : undefined
const qsParamIp = parseQs ? appQueryParams.ip : undefined
const qsParamVersion = parseQs ? appQueryParams.version : undefined
const qsParamProxy = parseQs ? appQueryParams.proxy : undefined
const qsParamUsername = parseQs ? appQueryParams.username : undefined
const qsParamLockConnect = parseQs ? appQueryParams.lockConnect : undefined
const parsedQsIp = parseServerAddress(qsParamIp)
const parsedInitialIp = parseServerAddress(initialData?.ip)
const [serverName, setServerName] = React.useState(initialData?.name ?? qsParamName ?? '')
const [serverIp, setServerIp] = React.useState(parsedQsIp.serverIpFull || parsedInitialIp.serverIpFull || '')
const [versionOverride, setVersionOverride] = React.useState(initialData?.versionOverride ?? /* legacy */ initialData?.['version'] ?? qsParamVersion ?? '')
const [proxyOverride, setProxyOverride] = React.useState(initialData?.proxyOverride ?? qsParamProxy ?? '')
const [usernameOverride, setUsernameOverride] = React.useState(initialData?.usernameOverride ?? qsParamUsername ?? '')
const lockConnect = qsParamLockConnect === 'true'
const smallWidth = !usePassesScaledDimensions(400)
const initialAccount = initialData?.authenticatedAccountOverride
const [accountIndex, setAccountIndex] = React.useState(initialAccount === true ? -2 : initialAccount ? (accounts?.includes(initialAccount) ? accounts.indexOf(initialAccount) : -2) : -1)
const freshAccount = accountIndex === -2
const noAccountSelected = accountIndex === -1
const authenticatedAccountOverride = noAccountSelected ? undefined : freshAccount ? true : accounts?.[accountIndex]
let ipFinal = serverIp
ipFinal = ipFinal.replace(/:$/, '')
const commonUseOptions: BaseServerInfo = {
name: serverName,
ip: ipFinal,
versionOverride: versionOverride || undefined,
proxyOverride: proxyOverride || undefined,
usernameOverride: usernameOverride || undefined,
authenticatedAccountOverride,
}
const [fetchedServerInfoIp, setFetchedServerInfoIp] = React.useState<string | undefined>(undefined)
const [serverOnline, setServerOnline] = React.useState(null as boolean | null)
const [onlinePlayersList, setOnlinePlayersList] = React.useState<string[]>([])
useEffect(() => {
const controller = new AbortController()
const checkServer = async () => {
if (!qsParamIp || !isServerValid(qsParamIp)) return
try {
const status = await fetchServerStatus(qsParamIp)
if (!status) return
setServerOnline(status.raw.online)
setOnlinePlayersList(status.raw.players?.list.map(p => p.name_raw) ?? [])
setFetchedServerInfoIp(qsParamIp)
} catch (err) {
console.error('Failed to fetch server status:', err)
}
}
void checkServer()
return () => controller.abort()
}, [qsParamIp])
const validateUsername = (username: string) => {
if (!username) return undefined
if (onlinePlayersList.includes(username)) {
return { border: 'red solid 1px' }
}
const MINECRAFT_USERNAME_REGEX = /^\w{3,16}$/
if (!MINECRAFT_USERNAME_REGEX.test(username)) {
return { border: 'red solid 1px' }
}
return undefined
}
const validateServerIp = () => {
if (!serverIp) return undefined
if (serverOnline) {
return { border: 'lightgreen solid 1px' }
} else {
return { border: 'red solid 1px' }
}
}
const displayConnectButton = qsParamIp
const serverExamples = ['example.com:25565', 'play.hypixel.net', 'ws://play.pcm.gg']
// pick random example
const example = serverExamples[Math.floor(Math.random() * serverExamples.length)]
return <Screen title={qsParamIp ? 'Connect to Server' : title} backdrop>
<form
style={{
display: 'flex',
flexDirection: 'column',
height: '100%'
}}
onSubmit={(e) => {
e.preventDefault()
onConfirm(commonUseOptions)
}}
>
<div style={{
display: smallWidth ? 'flex' : 'grid',
gap: 3,
...(smallWidth ? {
flexDirection: 'column',
} : {
gridTemplateColumns: '1fr 1fr'
})
}}
>
<InputWithLabel
required
label="Server IP"
autoFocus={!lockConnect}
value={serverIp}
disabled={lockConnect && parsedQsIp.host !== null}
onChange={({ target: { value } }) => {
setServerIp(value)
setServerOnline(false)
}}
validateInput={serverOnline === null || fetchedServerInfoIp !== serverIp ? undefined : validateServerIp}
placeholder={example}
/>
{!lockConnect && <>
<div style={{ display: 'flex' }}>
<InputWithLabel label="Server Name" value={serverName} onChange={({ target: { value } }) => setServerName(value)} placeholder='Defaults to IP' />
</div>
</>}
{isSmallHeight ? <div style={{ gridColumn: 'span 2', marginTop: 10, }} /> : <div style={{ gridColumn: smallWidth ? '' : 'span 2' }}>Overrides:</div>}
<div style={{
display: 'flex',
flexDirection: 'column',
}}>
<label style={{ fontSize: 12, marginBottom: 1, color: 'lightgray' }}>Version Override</label>
<SelectGameVersion
selected={{ value: versionOverride, label: versionOverride }}
versions={versions?.map(v => { return { value: v, label: v } }) ?? []}
onChange={(value) => {
setVersionOverride(value)
}}
placeholder="Optional, but recommended to specify"
disabled={lockConnect}
/>
</div>
<InputWithLabel
label="Proxy Override"
value={proxyOverride}
disabled={lockConnect && (qsParamProxy !== null || !!placeholders?.proxyOverride) || serverIp.startsWith('ws://') || serverIp.startsWith('wss://')}
onChange={({ target: { value } }) => setProxyOverride(value)}
placeholder={serverIp.startsWith('ws://') || serverIp.startsWith('wss://') ? 'Not needed for websocket servers' : placeholders?.proxyOverride}
/>
<InputWithLabel
label="Username Override"
value={usernameOverride}
disabled={!noAccountSelected || (lockConnect && qsParamUsername !== null)}
onChange={({ target: { value } }) => setUsernameOverride(value)}
placeholder={placeholders?.usernameOverride}
validateInput={!serverOnline || fetchedServerInfoIp !== serverIp ? undefined : validateUsername}
/>
<label style={{
display: 'flex',
flexDirection: 'column',
}}
>
<span style={{ fontSize: 12, marginBottom: 1, color: 'lightgray' }}>Account Override</span>
<select
onChange={({ target: { value } }) => setAccountIndex(Number(value))}
style={{
background: 'gray',
color: 'white',
height: 20,
fontSize: 13,
}}
defaultValue={initialAccount === true ? -2 : initialAccount === undefined ? -1 : (fallbackIfNotFound((accounts ?? []).indexOf(initialAccount)) ?? -2)}
disabled={lockConnect && qsParamUsername !== null}
>
<option value={-1}>Offline Account (Username)</option>
{accounts?.map((account, i) => <option key={i} value={i}>{account} (Logged In)</option>)}
<option value={-2}>Any other MS account</option>
</select>
</label>
{!lockConnect && <>
<ButtonWrapper onClick={() => {
onBack()
}}>
Cancel
</ButtonWrapper>
<ButtonWrapper type='submit'>
{displayConnectButton ? translate('Save') : <strong>{translate('Save')}</strong>}
</ButtonWrapper>
</>}
{displayConnectButton && (
<div style={{
gridColumn: smallWidth ? '' : 'span 2',
display: 'flex',
justifyContent: 'center'
}}>
<ButtonWrapper
data-test-id='connect-qs'
onClick={() => {
onQsConnect?.(commonUseOptions)
}}
>
<strong>{translate('Connect')}</strong>
</ButtonWrapper>
</div>
)}
</div>
</form>
</Screen>
}
const ButtonWrapper = ({ ...props }: React.ComponentProps<typeof Button>) => {
props.style ??= {}
props.style.width = INPUT_LABEL_WIDTH
return <Button {...props} />
}
const fallbackIfNotFound = (index: number) => (index === -1 ? undefined : index)