feat: brand new Servers List UI /w auto login feature! (#110)

This commit is contained in:
Vitaly 2024-05-04 16:07:18 +03:00 committed by GitHub
commit 826c66b9ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 976 additions and 719 deletions

View file

@ -2,6 +2,20 @@
"version": 1,
"defaultHost": "<from-proxy>",
"defaultProxy": "proxy.mcraft.fun",
"defaultVersion": "1.18.2",
"mapsProvider": "https://maps.mcraft.fun/"
"mapsProvider": "https://maps.mcraft.fun/",
"promoteServers": [
{
"ip": "kaboom.pw",
"description": "Chaos and destruction server. Free for everyone."
},
{
"ip": "go.mineberry.org",
"version": "1.18.2",
"description": "One of the best servers here. Join now!"
},
{
"ip": "play.minemalia.com",
"description": "Only login with existing accounts."
}
]
}

View file

@ -53,20 +53,19 @@ it('Loads & renders singleplayer', () => {
})
it.only('Joins to server', () => {
// visit('/?version=1.16.1')
visit('/?ip=localhost&version=1.16.1')
window.localStorage.version = ''
visit()
// todo replace with data-test
cy.get('[data-test-id="connect-screen-button"]', { includeShadowDom: true }).click()
cy.get('input#serverip', { includeShadowDom: true }).clear().focus().type('localhost')
cy.get('input#botversion', { includeShadowDom: true }).clear().focus().type('1.16.1') // todo needs to fix autoversion
cy.get('[data-test-id="connect-to-server"]', { includeShadowDom: true }).click()
// cy.get('[data-test-id="servers-screen-button"]').click()
// cy.get('[data-test-id="server-ip"]').clear().focus().type('localhost')
// cy.get('[data-test-id="version"]').clear().focus().type('1.16.1') // todo needs to fix autoversion
cy.get('[data-test-id="connect-qs"]').click()
testWorldLoad()
})
it('Loads & renders zip world', () => {
cleanVisit()
cy.get('[data-test-id="select-file-folder"]', { includeShadowDom: true }).click({ shiftKey: true })
cy.get('[data-test-id="select-file-folder"]').click({ shiftKey: true })
cy.get('input[type="file"]').selectFile('cypress/superflat.zip', { force: true })
testWorldLoad()
})

View file

@ -34,6 +34,7 @@
"dependencies": {
"@dimaka/interface": "0.0.3-alpha.0",
"@floating-ui/react": "^0.26.1",
"@mui/base": "5.0.0-beta.40",
"@nxg-org/mineflayer-auto-jump": "^0.7.7",
"@nxg-org/mineflayer-tracker": "^1.2.1",
"@react-oauth/google": "^0.12.1",
@ -60,7 +61,6 @@
"google-drive-browserfs": "github:zardoy/browserfs#google-drive",
"iconify-icon": "^1.0.8",
"jszip": "^3.10.1",
"lit": "^2.8.0",
"lodash-es": "^4.17.21",
"minecraft-assets": "^1.12.2",
"minecraft-data": "3.62.0",

94
pnpm-lock.yaml generated
View file

@ -26,6 +26,9 @@ importers:
'@floating-ui/react':
specifier: ^0.26.1
version: 0.26.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@mui/base':
specifier: 5.0.0-beta.40
version: 5.0.0-beta.40(@types/react@18.2.20)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@nxg-org/mineflayer-auto-jump':
specifier: ^0.7.7
version: 0.7.7
@ -104,9 +107,6 @@ importers:
jszip:
specifier: ^3.10.1
version: 3.10.1
lit:
specifier: ^2.8.0
version: 2.8.0
lodash-es:
specifier: ^4.17.21
version: 4.17.21
@ -1110,6 +1110,10 @@ packages:
resolution: {integrity: sha512-ee7jVNlWN09+KftVOu9n7S8gQzD/Z6hN/I8VBRXW4P1+Xe7kJGXMwu8vds4aGIMHZnNbdpSWCfZZtinytpcAvA==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.24.5':
resolution: {integrity: sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==}
engines: {node: '>=6.9.0'}
'@babel/template@7.22.5':
resolution: {integrity: sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==}
engines: {node: '>=6.9.0'}
@ -1661,6 +1665,12 @@ packages:
react: ^18.2.0
react-dom: '>=16.8.0'
'@floating-ui/react-dom@2.0.9':
resolution: {integrity: sha512-q0umO0+LQK4+p6aGyvzASqKbKOJcAHJ7ycE9CuUvfx3s9zTHWmGJTPOIlM/hmSBfUfg/XfY5YhLBLR/LHwShQQ==}
peerDependencies:
react: ^18.2.0
react-dom: '>=16.8.0'
'@floating-ui/react@0.26.1':
resolution: {integrity: sha512-5gyJIJ2tZOPMgmZ/vEcVhdmQiy75b7LPO71sYIiDsxGcZ4hxLuygQWCuT0YXHqppt//Eese+L6t5KnX/gZ3tVA==}
peerDependencies:
@ -1931,6 +1941,35 @@ packages:
resolution: {integrity: sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==}
engines: {node: '>= 10'}
'@mui/base@5.0.0-beta.40':
resolution: {integrity: sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==}
engines: {node: '>=12.0.0'}
peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0
react: ^18.2.0
react-dom: ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@mui/types@7.2.14':
resolution: {integrity: sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ==}
peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@mui/utils@5.15.14':
resolution: {integrity: sha512-0lF/7Hh/ezDv5X7Pry6enMsbYyGKjADzvHyo3Qrc/SSlTsQ1VkbDMbH0m2t3OR5iIVLwMoxwM7yGd+6FCMtTFA==}
engines: {node: '>=12.0.0'}
peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0
react: ^18.2.0
peerDependenciesMeta:
'@types/react':
optional: true
'@ndelangen/get-tarball@3.0.9':
resolution: {integrity: sha512-9JKTEik4vq+yGosHYhZ1tiH/3WpUS0Nh0kej4Agndhox8pAdWhEx5knFVRcb/ya9knCRCs1rPxNrSXTDdfVqpA==}
@ -2774,6 +2813,9 @@ packages:
'@types/pretty-hrtime@1.0.1':
resolution: {integrity: sha512-VjID5MJb1eGKthz2qUerWT8+R4b9N+CHvGCzg9fn4kWZgaF9AhdYikQio3R7wV8YY1NsQKPaCwKz1Yff+aHNUQ==}
'@types/prop-types@15.7.12':
resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==}
'@types/prop-types@15.7.5':
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
@ -3664,6 +3706,10 @@ packages:
resolution: {integrity: sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==}
engines: {node: '>=6'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@0.5.3:
resolution: {integrity: sha512-RwBeO/B/vZR3dfKL1ye/vx8MHZ40ugzpyfeVG5GsiuGnrlMWe2o8wxBbLCpw9CsxV+wHuzYlCiWnybrIA0ling==}
@ -9458,6 +9504,10 @@ snapshots:
dependencies:
regenerator-runtime: 0.14.0
'@babel/runtime@7.24.5':
dependencies:
regenerator-runtime: 0.14.0
'@babel/template@7.22.5':
dependencies:
'@babel/code-frame': 7.22.13
@ -9864,6 +9914,12 @@ snapshots:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
'@floating-ui/react-dom@2.0.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@floating-ui/dom': 1.5.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
'@floating-ui/react@0.26.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@floating-ui/react-dom': 2.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@ -10279,6 +10335,34 @@ snapshots:
'@msgpack/msgpack@2.8.0': {}
'@mui/base@5.0.0-beta.40(@types/react@18.2.20)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@babel/runtime': 7.24.5
'@floating-ui/react-dom': 2.0.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@mui/types': 7.2.14(@types/react@18.2.20)
'@mui/utils': 5.15.14(@types/react@18.2.20)(react@18.2.0)
'@popperjs/core': 2.11.8
clsx: 2.1.1
prop-types: 15.8.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.20
'@mui/types@7.2.14(@types/react@18.2.20)':
optionalDependencies:
'@types/react': 18.2.20
'@mui/utils@5.15.14(@types/react@18.2.20)(react@18.2.0)':
dependencies:
'@babel/runtime': 7.24.5
'@types/prop-types': 15.7.12
prop-types: 15.8.1
react: 18.2.0
react-is: 18.2.0
optionalDependencies:
'@types/react': 18.2.20
'@ndelangen/get-tarball@3.0.9':
dependencies:
gunzip-maybe: 1.4.2
@ -11577,6 +11661,8 @@ snapshots:
'@types/pretty-hrtime@1.0.1': {}
'@types/prop-types@15.7.12': {}
'@types/prop-types@15.7.5': {}
'@types/qs@6.9.8': {}
@ -12705,6 +12791,8 @@ snapshots:
clsx@1.1.1: {}
clsx@2.1.1: {}
color-convert@0.5.3: {}
color-convert@1.9.3:

View file

@ -19,6 +19,10 @@ export function openURL (url, newTab = true) {
}
}
export const isMobile = () => {
return window.matchMedia('(pointer: coarse)').matches || navigator.userAgent.includes('Mobile')
}
export function chunkPos (pos: { x: number, z: number }) {
const x = Math.floor(pos.x / 16)
const z = Math.floor(pos.z / 16)

15
src/connect.ts Normal file
View file

@ -0,0 +1,15 @@
export type ConnectOptions = {
server?: string;
singleplayer?: any;
username: string;
password?: any;
proxy?: any;
botVersion?: any;
serverOverrides?;
serverOverridesFlat?;
peerId?: string;
ignoreQs?: boolean;
onSuccessfulPlay?: () => void
autoLoginPassword?: string
serverIndex?: string
}

View file

@ -1,5 +1,5 @@
import { isMobile } from 'prismarine-viewer/viewer/lib/simpleUtils'
import { WorldRendererThree } from 'prismarine-viewer/viewer/lib/worldrendererThree'
import { isMobile } from './menus/components/common'
if (process.env.NODE_ENV === 'development') {
if (sessionStorage.lastReload) {

View file

@ -20,9 +20,6 @@ const inner = async () => {
if (resourcePackState.resourcePackInstalled) {
if (!confirm(`You are going to install a new resource pack, which will REPLACE the current one: ${await getResourcePackName()} Continue?`)) return
}
} else {
const menu = document.getElementById('play-screen')
menu.style = 'display: none;'
}
const name = mapUrl.slice(mapUrl.lastIndexOf('/') + 1).slice(-25)
const downloadThing = texturepack ? 'texturepack' : 'world'
@ -78,7 +75,7 @@ export default async () => {
try {
return await inner()
} catch (err) {
setLoadingScreenStatus(`Failed to download. Either refresh page or remove mapUrl param from URL. Reason: ${err.message}`)
setLoadingScreenStatus(`Failed to download. Either refresh page or remove map param from URL. Reason: ${err.message}`)
return true
}
}

View file

@ -56,10 +56,10 @@ const showModalInner = (modal: Modal) => {
return true
}
export const showModal = (elem: (HTMLElement & Record<string, any>) | { reactType: string }) => {
export const showModal = (elem: /* (HTMLElement & Record<string, any>) | */{ reactType: string }) => {
const resolved = elem instanceof HTMLElement ? { elem: ref(elem) } : elem
const curModal = activeModalStack.at(-1)
if (elem === curModal?.elem || (elem.reactType && elem.reactType === curModal?.reactType) || !showModalInner(resolved)) return
if (/* elem === curModal?.elem || */(elem.reactType && elem.reactType === curModal?.reactType) || !showModalInner(resolved)) return
if (curModal) defaultModalActions.hide(curModal)
activeModalStack.push(resolved)
}
@ -118,11 +118,12 @@ export const showContextmenu = (items: ContextMenuItem[], { clientX, clientY })
// ---
export type AppConfig = {
defaultHost?: string
defaultHostSave?: string
// defaultHost?: string
// defaultHostSave?: string
defaultProxy?: string
defaultProxySave?: string
defaultVersion?: string
// defaultProxySave?: string
// defaultVersion?: string
promoteServers?: Array<{ip, description, version?}>
mapsProvider?: string
}
@ -130,12 +131,14 @@ export const miscUiState = proxy({
currentDisplayQr: null as string | null,
currentTouch: null as boolean | null,
serverIp: null as string | null,
username: '',
hasErrors: false,
singleplayer: false,
flyingSquid: false,
wanOpened: false,
/** wether game hud is shown (in playing state) */
gameLoaded: false,
loadedServerIndex: '',
/** currently trying to load or loaded mc version, after all data is loaded */
loadedDataVersion: null as string | null,
appLoaded: false,

10
src/globals.d.ts vendored
View file

@ -27,7 +27,6 @@ declare const customEvents: import('typed-emitter').default<{
declare const beforeRenderFrame: Array<() => void>
declare interface Document {
getElementById (id): any
exitPointerLock?(): void
}
@ -37,14 +36,7 @@ declare namespace JSX {
}
}
declare interface DocumentFragment {
getElementById (id): HTMLElement & Record<string, any>
querySelector (id): HTMLElement & Record<string, any>
}
declare interface Window extends Record<string, any> {
}
declare interface Window extends Record<string, any> {}
type StringKeys<T extends object> = Extract<keyof T, string>

View file

@ -10,9 +10,6 @@ import initCollisionShapes from './getCollisionShapes'
import { itemsAtlases, onGameLoad } from './inventoryWindows'
import { supportedVersions } from 'minecraft-protocol'
import './menus/components/button'
import './menus/components/edit_box'
import './menus/play_screen'
import 'core-js/features/array/at'
import 'core-js/features/promise/with-resolvers'
@ -90,6 +87,7 @@ import { saveToBrowserMemory } from './react/PauseScreen'
import { ViewerWrapper } from 'prismarine-viewer/viewer/lib/viewerWrapper'
import './devReload'
import './water'
import { ConnectOptions } from './connect'
window.debug = debug
window.THREE = THREE
@ -240,13 +238,10 @@ const cleanConnectIp = (host: string | undefined, defaultPort: string | undefine
}
}
async function connect (connectOptions: {
server?: string; singleplayer?: any; username: string; password?: any; proxy?: any; botVersion?: any; serverOverrides?; serverOverridesFlat?; peerId?: string; ignoreQs?: boolean
}) {
async function connect (connectOptions: ConnectOptions) {
if (miscUiState.gameLoaded) return
miscUiState.hasErrors = false
lastConnectOptions.value = connectOptions
document.getElementById('play-screen').style = 'display: none;'
removePanorama()
const { singleplayer } = connectOptions
@ -331,7 +326,7 @@ async function connect (connectOptions: {
})
if (proxy) {
console.log(`using proxy ${proxy.host}${proxy.port && `:${proxy.port}`}`)
console.log(`using proxy ${proxy.host}:${proxy.port || location.port}`)
net['setProxy']({ hostname: proxy.host, port: proxy.port })
}
@ -528,12 +523,6 @@ async function connect (connectOptions: {
bot.once('login', () => {
worldInteractions.initBot()
// server is ok, add it to the history
if (!connectOptions.server) return
const serverHistory: string[] = JSON.parse(localStorage.getItem('serverHistory') || '[]')
serverHistory.unshift(connectOptions.server)
localStorage.setItem('serverHistory', JSON.stringify([...new Set(serverHistory)]))
setLoadingScreenStatus('Loading world')
})
@ -548,10 +537,16 @@ async function connect (connectOptions: {
window.pathfinder = pathfinder
miscUiState.gameLoaded = true
miscUiState.loadedServerIndex = connectOptions.serverIndex ?? ''
customEvents.emit('gameLoaded')
if (p2pConnectTimeout) clearTimeout(p2pConnectTimeout)
setLoadingScreenStatus('Placing blocks (starting viewer)')
localStorage.lastConnectOptions = JSON.stringify(connectOptions)
connectOptions.onSuccessfulPlay?.()
if (connectOptions.autoLoginPassword) {
bot.chat(`/login ${connectOptions.autoLoginPassword}`)
}
console.log('bot spawned - starting viewer')
@ -726,6 +721,7 @@ async function connect (connectOptions: {
console.log('Done!')
// todo
onGameLoad(async () => {
if (!viewer.world.downloadedBlockStatesData && !viewer.world.customBlockStatesData) {
await new Promise<void>(resolve => {
@ -733,6 +729,7 @@ async function connect (connectOptions: {
})
}
miscUiState.serverIp = server.host as string | null
miscUiState.username = username
})
if (appStatusState.isError) return
@ -839,12 +836,26 @@ void window.fetch('config.json').then(async res => res.json()).then(c => c, (err
miscUiState.appConfig = config
})
// qs open actions
downloadAndOpenFile().then((downloadAction) => {
if (downloadAction) return
const qs = new URLSearchParams(window.location.search)
if (qs.get('reconnect') && process.env.NODE_ENV === 'development') {
const ip = qs.get('ip')
const lastConnect = JSON.parse(localStorage.lastConnectOptions ?? {})
void connect({
...lastConnect, // todo mixing is not good idea
ip: ip || undefined
})
return
}
if (qs.get('ip') || qs.get('proxy')) {
// show server editor for connect or save
showModal({ reactType: 'editServer' })
}
void Promise.resolve().then(() => {
// try to connect to peer
const qs = new URLSearchParams(window.location.search)
const peerId = qs.get('connectPeer')
const version = qs.get('peerVersion')
if (peerId) {

View file

@ -1,136 +0,0 @@
//@ts-check
import { LitElement, html, css, unsafeCSS } from 'lit'
import widgetsGui from 'minecraft-assets/minecraft-assets/data/1.17.1/gui/widgets.png'
import { playSound, loadSound } from '../../basicSounds'
class Button extends LitElement {
static get styles () {
return css`
.button {
--txrV: 66px;
position: relative;
width: 200px;
height: 20px;
font-family: minecraft, mojangles, monospace;
font-size: 10px;
color: white;
text-shadow: 1px 1px #222;
border: none;
z-index: 1;
outline: none;
display: inline-flex;
justify-content: center;
align-items: center;
}
.button:hover,
.button:focus-visible {
--txrV: 86px;
}
.button:disabled {
--txrV: 46px;
color: #A0A0A0;
text-shadow: 1px 1px #111;
}
.button::after {
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
width: calc(50% + 1px);
height: 20px;
background: url('${unsafeCSS(widgetsGui)}');
background-size: 256px;
background-position-y: calc(var(--txrV) * -1);
z-index: -1;
}
.button::before {
content: '';
display: block;
position: absolute;
top: 0;
left: 50%;
width: 50%;
height: 20px;
background: url('${unsafeCSS(widgetsGui)}');
background-size: 256px;
background-position-x: calc(-200px + 100%);
background-position-y: calc(var(--txrV) * -1);
z-index: -1;
}
.icon {
position: absolute;
top: 3px;
left: 3px;
font-size: 14px;
}
`
}
static get properties () {
return {
label: {
type: String,
attribute: 'pmui-label'
},
width: {
type: String,
attribute: 'pmui-width'
},
disabled: {
type: Boolean,
},
onPress: {
type: Function,
attribute: 'pmui-click'
},
icon: {
type: Function,
attribute: 'pmui-icon'
},
testId: {
type: String,
attribute: 'pmui-test-id'
}
}
}
constructor () {
super()
this.label = ''
this.icon = undefined
this.testId = undefined
this.disabled = false
this.width = '200px'
this.onPress = () => { }
}
render () {
return html`
<button
class="button"
?disabled=${this.disabled}
@click=${this.onBtnClick}
style="width: ${this.width};"
data-test-id=${this.testId}
>
<!-- todo self host icons -->
${this.icon ? html`<iconify-icon class="icon" icon="${this.icon}"></iconify-icon>` : ''}
${this.label}
</button>
`
}
onBtnClick (e) {
playSound('button_click.mp3')
this.dispatchEvent(new window.CustomEvent('pmui-click', { detail: e }))
}
}
loadSound('button_click.mp3')
window.customElements.define('pmui-button', Button)

View file

@ -1,60 +0,0 @@
import { css } from 'lit'
const commonCss = css`
.bg {
position: absolute;
top: 0;
left: 0;
background: rgba(0, 0, 0, 0.75);
width: 100%;
height: 100%;
}
.title {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%);
font-size: 10px;
color: white;
text-align: center;
text-shadow: 1px 1px #222;
}
.text {
color: white;
font-size: 10px;
text-shadow: 1px 1px #222;
}
`
/** @returns {boolean} */
function isMobile () {
return window.matchMedia('(pointer: coarse)').matches || navigator.userAgent.includes('Mobile')
}
// todo there are better workarounds and proper way to detect notch
/** @returns {boolean} */
function isProbablyIphone () {
if (!isMobile()) return false
const smallest = window.innerWidth < window.innerHeight ? window.innerWidth : window.innerHeight
return smallest < 600
}
/**
* @param {string} url
*/
function openURL (url, newTab = true) {
if (newTab) {
window.open(url, '_blank', 'noopener,noreferrer')
} else {
window.open(url, '_self')
}
}
export {
isProbablyIphone,
commonCss,
isMobile,
openURL,
}

View file

@ -1,161 +0,0 @@
const { LitElement, html, css } = require('lit')
const { ifDefined } = require('lit/directives/if-defined.js')
class EditBox extends LitElement {
static get styles () {
return css`
.edit-container {
position: relative;
width: 200px;
height: 20px;
background: black;
border: 1px solid grey;
}
.edit-container.invalid {
border: 1px solid #c70000;
}
.edit-container.warning {
border: 1px solid rgb(159, 151, 0);
}
.edit-container.invalid:hover,
.edit-container.invalid:focus-within {
border-color: red;
}
.edit-container.warning:hover,
.edit-container.warning:focus-within {
border-color: yellow;
}
.edit-container:hover,
.edit-container:focus-within {
border-color: white;
}
.edit-container label {
position: absolute;
z-index: 2;
pointer-events: none;
bottom: 21px;
left: 0;
font-size: 10px;
color: rgb(206, 206, 206);
text-shadow: 1px 1px black;
}
.edit-box {
position: relative;
outline: none;
border: none;
background: none;
left: 1px;
width: calc(100% - 2px);
height: 100%;
font-family: minecraft, mojangles, monospace;
font-size: 10px;
color: white;
text-shadow: 1px 1px #222;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type=number] {
appearance: textfield;
-moz-appearance: textfield;
}
`
}
constructor () {
super()
this.width = '200px'
this.id = ''
this.value = ''
this.label = ''
this.required = false
}
static get properties () {
return {
width: {
type: String,
attribute: 'pmui-width'
},
id: {
type: String,
attribute: 'pmui-id'
},
label: {
type: String,
attribute: 'pmui-label'
},
value: {
type: String,
attribute: 'pmui-value'
},
autocompleteValues: {
type: Array,
},
type: {
type: String,
attribute: 'pmui-type'
},
inputMode: {
type: String,
attribute: 'pmui-inputmode'
},
required: {
type: Boolean,
attribute: 'pmui-required'
},
placeholder: {
type: String,
attribute: 'pmui-placeholder'
},
state: {
type: String,
attribute: true
}
}
}
render () {
return html`
<div
class="edit-container ${this.state ?? ''}"
style="width: ${this.width};"
>
<label for="${this.id}">${this.label}</label>
${this.autocompleteValues ? html`
<datalist id="${this.id}-list">
${this.autocompleteValues.map(value => html`
<option value="${value}"></option>
`)}
</datalist>
` : ''}
<input
id="${this.id}"
type="${this.type ?? 'text'}"
name=""
spellcheck="false"
?required=${this.required}
autocomplete="off"
autocapitalize="off"
value="${this.value}"
placeholder=${ifDefined(this.placeholder || undefined)}
list=${ifDefined(this.autocompleteValues ? `${this.id}-list` : undefined)}
inputmode=${ifDefined(this.inputMode || undefined)}
@input=${({ target: { value } }) => { this.value = this.inputMode === 'decimal' ? value.replaceAll(',', '.') : value }}
class="edit-box">
</div>
`
}
}
window.customElements.define('pmui-editbox', EditBox)

View file

@ -1,250 +0,0 @@
//@ts-check
const { LitElement, html, css } = require('lit')
const viewerSupportedVersions = require('prismarine-viewer/viewer/supportedVersions.json')
const { supportedVersions } = require('minecraft-protocol')
const { hideCurrentModal, miscUiState } = require('../globalState')
const { commonCss } = require('./components/common')
const fullySupporedVersions = viewerSupportedVersions
class PlayScreen extends LitElement {
static get styles () {
return css`
${commonCss}
.title {
top: 12px;
}
.edit-boxes {
position: fixed;
top: 59px;
left: 50%;
display: flex;
flex-direction: column;
gap: 14px 0;
transform: translate(-50%);
width: 310px;
}
.wrapper {
width: 100%;
display: flex;
flex-direction: row;
gap: 0 4px;
}
.button-wrapper {
display: flex;
flex-direction: row;
gap: 0 4px;
position: absolute;
bottom: 9px;
left: 50%;
transform: translate(-50%);
width: 310px;
}
.extra-info-version {
font-size: 10px;
color: rgb(206, 206, 206);
text-shadow: 1px 1px black;
position: absolute;
left: calc(50% + 2px);
bottom: -34px;
}
.extra-info-proxy {
font-size: 8px;
color: rgb(206, 206, 206);
text-shadow: 1px 1px black;
margin:0;
margin-top:-12px;
}
a {
color: white;
}
`
}
static get properties () {
return {
server: { type: String },
serverImplicit: { type: String },
serverport: { type: Number },
proxy: { type: String },
proxyImplicit: { type: String },
proxyport: { type: Number },
username: { type: String },
password: { type: String },
version: { type: String }
}
}
constructor () {
super()
this.version = ''
this.serverport = ''
this.proxyport = ''
this.server = ''
this.proxy = ''
this.username = ''
this.password = ''
this.serverImplicit = ''
this.proxyImplicit = ''
// todo set them sooner add indicator
void window.fetch('config.json').then(async res => res.json()).then(c => c, (error) => {
console.warn('Failed to load optional config.json', error)
return {}
}).then(async (/** @type {import('../globalState').AppConfig} */config) => {
miscUiState.appConfig = config
const params = new URLSearchParams(window.location.search)
const getParam = (localStorageKey, qs = localStorageKey) => {
const qsValue = qs ? params.get(qs) : undefined
if (qsValue) {
this.style.display = 'block'
}
return qsValue || window.localStorage.getItem(localStorageKey)
}
if (config.defaultHost === '<from-proxy>' || config.defaultHostSave === '<from-proxy>') {
let proxy = config.defaultProxy || config.defaultProxySave || params.get('proxy')
const cleanUrl = url => url.replaceAll(/(https?:\/\/|\/$)/g, '')
if (proxy && cleanUrl(proxy) !== cleanUrl(location.origin + location.pathname)) {
if (!proxy.startsWith('http')) proxy = 'https://' + proxy
const proxyConfig = await fetch(proxy + '/config.json').then(async res => res.json()).then(c => c, (error) => {
console.warn(`Failed to load config.json from proxy ${proxy}`, error)
return {}
})
if (config.defaultHost === '<from-proxy>' && proxyConfig.defaultHost) {
config.defaultHost = proxyConfig.defaultHost
} else {
config.defaultHost = ''
}
if (config.defaultHostSave === '<from-proxy>' && proxyConfig.defaultHostSave) {
config.defaultHostSave = proxyConfig.defaultHostSave
} else {
config.defaultHostSave = ''
}
}
this.server = this.serverImplicit
}
this.serverImplicit = config.defaultHost ?? ''
this.proxyImplicit = config.defaultProxy ?? ''
this.server = getParam('server', 'ip') ?? config.defaultHostSave ?? ''
this.proxy = getParam('proxy') ?? config.defaultProxySave ?? ''
this.version = getParam('version') || (window.localStorage.getItem('version') ?? config.defaultVersion ?? '')
this.username = getParam('username') || 'pviewer' + (Math.floor(Math.random() * 1000))
this.password = getParam('password') || ''
if (process.env.NODE_ENV === 'development' && params.get('reconnect') && this.server && this.username) {
this.onConnectPress()
}
})
}
render () {
return html`
<div class="backdrop"></div>
<p class="title">Join a Server</p>
<main class="edit-boxes">
<div class="wrapper">
<pmui-editbox
pmui-width="150px"
pmui-label="Server IP"
pmui-id="serverip"
pmui-value="${this.server}"
pmui-type="url"
pmui-required="${this.serverImplicit === ''}}"
pmui-placeholder="${this.serverImplicit}"
.autocompleteValues=${JSON.parse(localStorage.getItem('serverHistory') || '[]')}
@input=${e => { this.server = e.target.value }}
></pmui-editbox>
<pmui-editbox
pmui-width="150px"
pmui-label="Server Port"
pmui-id="port"
pmui-value="${this.serverport}"
pmui-type="number"
pmui-placeholder="25565"
@input=${e => { this.serverport = e.target.value }}
></pmui-editbox>
</div>
<div class="wrapper">
<pmui-editbox
pmui-width="150px"
pmui-label="Proxy IP"
pmui-id="proxy"
pmui-value="${this.proxy}"
pmui-required="${this.proxyImplicit === ''}}"
pmui-placeholder="${this.proxyImplicit}"
pmui-type="url"
@input=${e => { this.proxy = e.target.value }}
></pmui-editbox>
<pmui-editbox
pmui-width="150px"
pmui-label="Proxy Port"
pmui-id="port"
pmui-value="${this.proxyport}"
pmui-type="number"
@input=${e => { this.proxyport = e.target.value }}
></pmui-editbox>
</div>
<div class="wrapper">
<p class="extra-info-proxy">Enter proxy url you want to use. <a href="https://github.com/zardoy/prismarine-web-client/issues/3">Learn more</a>.</p>
</div>
<div class="wrapper">
<pmui-editbox
pmui-width="150px"
pmui-label="Username"
pmui-id="username"
pmui-value="${this.username}"
@input=${e => { this.username = e.target.value }}
></pmui-editbox>
<pmui-editbox
pmui-width="150px"
pmui-label="Bot Version"
pmui-id="botversion"
pmui-value="${this.version}"
pmui-inputmode="decimal"
state="${this.version && (fullySupporedVersions.includes(/** @type {any} */(this.version)) ? '' : supportedVersions.includes(this.version) ? 'warning' : 'invalid')}"
.autocompleteValues=${supportedVersions}
@input=${e => { this.version = e.target.value = e.target.value.replaceAll(',', '.') }}
></pmui-editbox>
</div>
<p class="extra-info-version">Leave blank and it will be chosen automatically</p>
</main>
<div class="button-wrapper">
<pmui-button pmui-test-id="connect-to-server" pmui-width="150px" pmui-label="Connect" @pmui-click=${this.onConnectPress}></pmui-button>
<pmui-button pmui-width="150px" pmui-label="Cancel" @pmui-click=${() => hideCurrentModal()}></pmui-button>
</div>
`
}
onConnectPress () {
const server = this.server ? `${this.server}${this.serverport && `:${this.serverport}`}` : this.serverImplicit
const proxy = this.proxy ? `${this.proxy}${this.proxyport && `:${this.proxyport}`}` : this.proxyImplicit
window.localStorage.setItem('username', this.username)
window.localStorage.setItem('password', this.password)
window.localStorage.setItem('server', server)
window.localStorage.setItem('proxy', proxy)
window.localStorage.setItem('version', this.version)
window.dispatchEvent(new window.CustomEvent('connect', {
detail: {
server,
proxy,
username: this.username,
password: this.password,
botVersion: this.version
}
}))
}
}
window.customElements.define('pmui-playscreen', PlayScreen)

View file

@ -1,7 +1,7 @@
import { useState } from 'react'
import { useSnapshot } from 'valtio'
import { openURL } from 'prismarine-viewer/viewer/lib/simpleUtils'
import { miscUiState, openOptionsMenu, showModal } from './globalState'
import { openURL } from './menus/components/common'
import { AppOptions, options } from './optionsStorage'
import Button from './react/Button'
import { OptionMeta, OptionSlider } from './react/OptionsItems'

110
src/react/AddServer.tsx Normal file
View file

@ -0,0 +1,110 @@
import React from 'react'
import Screen from './Screen'
import Input from './Input'
import Button from './Button'
import { useIsSmallWidth } from './simpleHooks'
export interface NewServerInfo {
ip: string
name?: string
versionOverride?: string
proxyOverride?: string
usernameOverride?: string
passwordOverride?: string
}
interface Props {
onBack: () => void
onConfirm: (info: NewServerInfo) => void
title?: string
initialData?: NewServerInfo
parseQs?: boolean
onQsConnect?: (server: NewServerInfo) => void
defaults?: Pick<NewServerInfo, 'proxyOverride' | 'usernameOverride'>
}
export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, defaults }: Props) => {
const qsParams = parseQs ? new URLSearchParams(window.location.search) : undefined
const [serverName, setServerName] = React.useState(initialData?.name ?? qsParams?.get('name') ?? '')
const ipWithoutPort = initialData?.ip.split(':')[0]
const port = initialData?.ip.split(':')[1]
const [serverIp, setServerIp] = React.useState(ipWithoutPort ?? qsParams?.get('ip') ?? '')
const [serverPort, setServerPort] = React.useState(port ?? '')
const [versionOverride, setVersionOverride] = React.useState(initialData?.versionOverride ?? qsParams?.get('version') ?? '')
const [proxyOverride, setProxyOverride] = React.useState(initialData?.proxyOverride ?? qsParams?.get('proxy') ?? '')
const [usernameOverride, setUsernameOverride] = React.useState(initialData?.usernameOverride ?? qsParams?.get('username') ?? '')
const [passwordOverride, setPasswordOverride] = React.useState(initialData?.passwordOverride ?? qsParams?.get('password') ?? '')
const smallWidth = useIsSmallWidth()
return <Screen title={qsParams?.get('ip') ? 'Connect to Server' : title} backdrop>
<form style={{
display: 'flex',
flexDirection: 'column',
height: '100%'
}}
onSubmit={(e) => {
e.preventDefault()
let ip = serverIp.includes(':') ? serverIp : `${serverIp}:${serverPort}`
ip = ip.replace(/:$/, '')
onConfirm({
name: serverName,
ip,
versionOverride,
proxyOverride,
usernameOverride,
passwordOverride
})
}}
>
<div style={{
display: 'grid',
gap: 3,
gridTemplateColumns: smallWidth ? '1fr' : '1fr 1fr'
}}>
<div style={{ gridColumn: smallWidth ? '' : 'span 2', display: 'flex', justifyContent: 'center' }}>
<InputWithLabel label="Server Name" value={serverName} onChange={({ target: { value } }) => setServerName(value)} placeholder='Defaults to IP' />
</div>
<InputWithLabel required label="Server IP" value={serverIp} onChange={({ target: { value } }) => setServerIp(value)} />
<InputWithLabel label="Server Port" value={serverPort} onChange={({ target: { value } }) => setServerPort(value)} placeholder='25565' />
<div style={{ gridColumn: smallWidth ? '' : 'span 2' }}>Overrides:</div>
<InputWithLabel label="Version Override" value={versionOverride} onChange={({ target: { value } }) => setVersionOverride(value)} placeholder='Optional, but recommended to specify' />
<InputWithLabel label="Proxy Override" value={proxyOverride} onChange={({ target: { value } }) => setProxyOverride(value)} placeholder={defaults?.proxyOverride} />
<InputWithLabel label="Username Override" value={usernameOverride} onChange={({ target: { value } }) => setUsernameOverride(value)} placeholder={defaults?.usernameOverride} />
<InputWithLabel label="Password Override" value={passwordOverride} onChange={({ target: { value } }) => setPasswordOverride(value)} /* placeholder='For advanced usage only' */ />
<Button onClick={() => {
onBack()
}}>Cancel</Button>
<Button type='submit'>Save</Button>
{qsParams?.get('ip') && <div style={{ gridColumn: smallWidth ? '' : 'span 2', display: 'flex', justifyContent: 'center' }}>
<Button
data-test-id='connect-qs'
onClick={() => {
onQsConnect?.({
name: serverName,
ip: serverIp,
versionOverride,
proxyOverride,
usernameOverride,
passwordOverride
})
}}
>Connect</Button>
</div>}
</div>
</form>
</Screen>
}
const InputWithLabel = ({ label, span, ...props }: React.ComponentProps<typeof Input> & { label, span?}) => {
return <div style={{
display: 'flex',
flexDirection: 'column',
gridRow: span ? 'span 2 / span 2' : undefined,
}}>
<label style={{ fontSize: 12, marginBottom: 1, color: 'lightgray' }}>{label}</label>
<Input {...props} />
</div>
}

View file

@ -15,7 +15,7 @@ interface Props extends React.ComponentProps<'button'> {
void loadSound('button_click.mp3')
export default (({ label, icon, children, inScreen, rootRef, ...args }) => {
export default (({ label, icon, children, inScreen, rootRef, type = 'button', ...args }) => {
const onClick = (e) => {
void playSound('button_click.mp3')
args.onClick?.(e)
@ -29,7 +29,7 @@ export default (({ label, icon, children, inScreen, rootRef, ...args }) => {
args.style.width = 20
}
return <button ref={rootRef} {...args} className={classNames(buttonCss.button, args.className)} onClick={onClick}>
return <button ref={rootRef} {...args} className={classNames(buttonCss.button, args.className)} onClick={onClick} type={type}>
{icon && <iconify-icon class={buttonCss.icon} icon={icon}></iconify-icon>}
{label}
{children}

View file

@ -6,6 +6,8 @@ import { hideCurrentModal, miscUiState } from '../globalState'
import { options } from '../optionsStorage'
import ChatContainer, { Message, fadeMessage } from './ChatContainer'
import { useIsModalActive } from './utils'
import { hideNotification, showNotification } from './NotificationProvider'
import { updateLoadedServerData } from './ServersListProvider'
export default () => {
const [messages, setMessages] = useState([] as Message[])
@ -43,6 +45,17 @@ export default () => {
opened={isChatActive}
sendMessage={(message) => {
const builtinHandled = tryHandleBuiltinCommand(message)
if (miscUiState.loadedServerIndex && (message.startsWith('/login') || message.startsWith('/register'))) {
showNotification('Click here to save your password in browser for auto-login', undefined, false, undefined, () => {
updateLoadedServerData((server) => {
server.autoLogin ??= {}
const password = message.split(' ')[1]
server.autoLogin[miscUiState.username] = password
return server
})
hideNotification()
})
}
if (!builtinHandled) {
bot.chat(message)
}
@ -52,7 +65,10 @@ export default () => {
}}
fetchCompletionItems={async (triggerKind, completeValue) => {
if ((triggerKind === 'explicit' || options.autoRequestCompletions)) {
let items = await bot.tabComplete(completeValue, true, true)
let items = [] as string[]
try {
items = await bot.tabComplete(completeValue, true, true)
} catch (err) {}
if (typeof items[0] === 'object') {
// @ts-expect-error
if (items[0].match) items = items.map(i => i.match)

View file

@ -5,13 +5,15 @@ import { useUsingTouch } from './utils'
interface Props extends React.ComponentProps<'input'> {
rootStyles?: React.CSSProperties
autoFocus?: boolean
inputRef?: React.RefObject<HTMLInputElement>
}
export default ({ autoFocus, rootStyles, ...inputProps }: Props) => {
export default ({ autoFocus, rootStyles, inputRef, ...inputProps }: Props) => {
const ref = useRef<HTMLInputElement>(null!)
const isTouch = useUsingTouch()
useEffect(() => {
if (inputRef) (inputRef as any).current = ref.current
if (!autoFocus || isTouch) return // Don't make screen keyboard popup on mobile
ref.current.focus()
}, [])

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'
import { openURL } from '../menus/components/common'
import { openURL } from 'prismarine-viewer/viewer/lib/simpleUtils'
import { haveDirectoryPicker } from '../utils'
import styles from './mainMenu.module.css'
import Button from './Button'
@ -67,7 +67,7 @@ export default ({ connectToServerAction, mapsProvider, singleplayerAction, optio
placement: 'top',
}}
onClick={connectToServerAction}
data-test-id='connect-screen-button'
data-test-id='servers-screen-button'
>
Connect to server
</ButtonWithTooltip>

View file

@ -2,8 +2,8 @@ import fs from 'fs'
import { Transition } from 'react-transition-group'
import { useSnapshot } from 'valtio'
import { useEffect } from 'react'
import { openURL } from 'prismarine-viewer/viewer/lib/simpleUtils'
import { activeModalStack, miscUiState, openOptionsMenu, showModal } from '../globalState'
import { openURL } from '../menus/components/common'
import { openGithub, setLoadingScreenStatus } from '../utils'
import { openFilePicker, copyFilesAsync, mkdirRecursive, openWorldDirectory, removeFileRecursiveAsync } from '../browserfs'
@ -25,7 +25,7 @@ export default () => {
return <Transition in={!noDisplay} timeout={disableAnimation ? 0 : 100} mountOnEnter unmountOnExit>
{(state) => <div style={{ transition: state === 'exiting' || disableAnimation ? '' : '100ms opacity ease-in', ...state === 'entered' ? { opacity: 1 } : { opacity: 0 } }}>
<MainMenu
connectToServerAction={() => showModal(document.getElementById('play-screen'))}
connectToServerAction={() => showModal({ reactType: 'serversList' })}
singleplayerAction={async () => {
const oldFormatSave = fs.existsSync('./world/level.dat')
if (oldFormatSave) {

View file

@ -137,5 +137,5 @@ export const messageFormatStylesMap = {
strikethrough: 'text-decoration:line-through',
underlined: 'text-decoration:underline',
italic: 'font-style:italic',
obfuscated: 'color: #222326;background-color: #222326;'
obfuscated: 'filter:blur(2px)',
}

View file

@ -2,6 +2,7 @@ import { join } from 'path'
import { useEffect } from 'react'
import { useSnapshot } from 'valtio'
import { usedServerPathsV1 } from 'flying-squid/dist/lib/modules/world'
import { openURL } from 'prismarine-viewer/viewer/lib/simpleUtils'
import {
activeModalStack,
showModal,
@ -13,7 +14,6 @@ import { fsState } from '../loadSave'
import { disconnect } from '../flyingSquidUtils'
import { pointerLock, setLoadingScreenStatus } from '../utils'
import { closeWan, openToWanAndCopyJoinLink, getJoinLink } from '../localServerMultiplayer'
import { openURL } from '../menus/components/common'
import { copyFilesAsyncWithProgress, mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs'
import { useIsModalActive } from './utils'
import { showOptionsModal } from './SelectOption'

View file

@ -0,0 +1,55 @@
import type { Meta, StoryObj } from '@storybook/react'
import 'iconify-icon'
import { useState } from 'react'
import AddServer from './AddServer'
import ServersList from './ServersList'
const meta: Meta<typeof ServersList> = {
component: ServersList,
render (args) {
const [addOpen, setAddOpen] = useState(false)
const [username, setUsername] = useState('')
return addOpen ?
<AddServer onBack={() => {
setAddOpen(false)
}}
onConfirm={(info) => {
console.log('add server', info)
}} /> :
<ServersList
worldData={[{
name: 'test',
title: 'Server',
formattedTextOverride: 'play yes',
}]}
joinServer={(ip) => {
console.log('joinServer', ip)
}}
initialProxies={{
proxies: ['localhost', 'mc.hypixel.net'],
selected: 'localhost',
}}
updateProxies={newData => {
console.log('setProxies', newData)
}}
onWorldAction={() => {}}
onGeneralAction={(action) => {
if (action === 'create') {
setAddOpen(true)
}
}}
username={username}
setUsername={setUsername}
/>
},
}
export default meta
type Story = StoryObj<typeof ServersList>;
export const Primary: Story = {
args: {
},
}

165
src/react/ServersList.tsx Normal file
View file

@ -0,0 +1,165 @@
import React from 'react'
import { useAutocomplete } from '@mui/base'
import { omitObj } from '@zardoy/utils'
import Singleplayer from './Singleplayer'
import Input from './Input'
import Button from './Button'
import PixelartIcon from './PixelartIcon'
interface Props extends React.ComponentProps<typeof Singleplayer> {
joinServer: (ip: string, overrides: {
username?: string
password?: string
proxy?: string
version?: string
shouldSave?: boolean
}) => void
initialProxies: SavedProxiesLocalStorage
updateProxies: (proxies: SavedProxiesLocalStorage) => void
username: string
setUsername: (username: string) => void
}
export interface SavedProxiesLocalStorage {
proxies: readonly string[]
selected: string
}
type ProxyStatusResult = {
time: number
ping: number
status: 'success' | 'error' | 'unknown'
}
export default ({ initialProxies, updateProxies: updateProxiesProp, joinServer, username, setUsername, ...props }: Props) => {
const [proxies, setProxies] = React.useState(initialProxies)
const updateProxies = (newData: SavedProxiesLocalStorage) => {
setProxies(newData)
updateProxiesProp(newData)
}
const autocomplete = useAutocomplete({
value: proxies.selected,
options: proxies.proxies.filter(proxy => proxy !== proxies.selected),
onInputChange (event, value, reason) {
// console.log('onChange', { event, value, reason, details })
if (value) {
updateProxies({
...proxies,
selected: value
})
}
},
freeSolo: true
})
const [serverIp, setServerIp] = React.useState('')
const [save, setSave] = React.useState(true)
return <Singleplayer {...props}
firstRowChildrenOverride={<form style={{ width: '100%', display: 'flex', justifyContent: 'center' }} onSubmit={(e) => {
e.preventDefault()
joinServer(serverIp, {
shouldSave: save,
})
}}
>
<div style={{ display: 'flex', gap: 5, alignItems: 'center' }}>
{/* todo history */}
<Input required placeholder='Quick Connect IP' value={serverIp} onChange={({ target: { value } }) => setServerIp(value)} />
<label style={{ fontSize: 10, display: 'flex', alignItems: 'center', gap: 5, height: '100%', marginTop: '-1px' }}>
<input type='checkbox' checked={save}
style={{ borderRadius: 0 }}
onChange={({ target: { checked } }) => setSave(checked)}
/> Save</label>
<Button style={{ width: 90 }} type='submit'>Join Server</Button>
</div>
</form>}
searchRowChildrenOverride={
<div style={{
// marginTop: 12,
}}>
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
<span style={{ color: 'lightgray', fontSize: 14 }}>Proxy:</span>
<div {...autocomplete.getRootProps()} style={{ position: 'relative', width: 130 }}>
<ProxyRender
{...omitObj(autocomplete.getInputProps(), 'ref')}
inputRef={autocomplete.getInputProps().ref as any}
status='unknown'
ip=''
/>
{autocomplete.groupedOptions && <ul {...autocomplete.getListboxProps()} style={{
position: 'absolute',
zIndex: 1,
// marginTop: 10,
}}>
{autocomplete.groupedOptions.map((proxy, index) => {
const { itemRef, ...optionProps } = autocomplete.getOptionProps({ option: proxy, index })
return <ProxyRender {...optionProps as any} ip={proxy} disabled />
})}
</ul>}
</div>
<PixelartIcon iconName='user' styles={{ fontSize: 14, color: 'lightgray', marginLeft: 2 }} />
<Input rootStyles={{ width: 80 }} value={username} onChange={({ target: { value } }) => setUsername(value)} />
</div>
</div>
}
serversLayout
onWorldAction={(action, serverName) => {
if (action === 'load') {
joinServer(serverName, {})
}
props.onWorldAction?.(action, serverName)
}}
/>
}
type Status = 'unknown' | 'error' | 'success'
const ProxyRender = ({ status, ip, inputRef, value, setValue, ...props }: {
status: Status
ip: string
} & Record<string, any>) => {
const iconPerStatus = {
unknown: 'cellular-signal-0',
error: 'cellular-signal-off',
success: 'cellular-signal-3',
}
return <div style={{
position: 'relative',
}} {...props}>
<Input
inputRef={inputRef}
style={{
paddingLeft: 16,
}}
rootStyles={{
width: '100%',
}}
value={value}
// onChange={({ target: { value } }) => setValue?.(value)}
onChange={props.onChange}
/>
<div style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
gap: 2
}}>
<PixelartIcon iconName={iconPerStatus.unknown} />
<div style={{
fontSize: 10,
// color: 'lightgray',
// ellipsis
width: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{ip}
</div>
</div>
</div>
}

View file

@ -0,0 +1,339 @@
import { useEffect, useMemo, useState } from 'react'
import { proxy } from 'valtio'
import { qsOptions } from '../optionsStorage'
import { ConnectOptions } from '../connect'
import { hideCurrentModal, miscUiState, showModal } from '../globalState'
import ServersList from './ServersList'
import AddServer from './AddServer'
import { useDidUpdateEffect, useIsModalActive } from './utils'
interface StoreServerItem {
ip: string,
name?: string
version?: string
lastJoined?: number
description?: string
proxyOverride?: string
usernameOverride?: string
passwordOverride?: string
optionsOverride?: Record<string, any>
autoLogin?: Record<string, string>
}
type ServerResponse = {
version: {
name_raw: string
}
// display tooltip
players?: {
online: number
max: number
list: Array<{
name_raw: string
name_clean: string
}>
}
icon: string
motd: {
raw: string
}
// todo circle error icon
mods?: Array<{ name, version }>
// todo display via hammer icon
software?: string
plugins?: Array<{ name, version }>
}
type AdditionalDisplayData = {
formattedText: string
textNameRight: string
icon?: string
}
const getInitialServersList = () => {
if (localStorage['serversList']) return JSON.parse(localStorage['serversList']) as StoreServerItem[]
const servers = [] as StoreServerItem[]
const legacyServersList = localStorage['serverHistory'] ? JSON.parse(localStorage['serverHistory']) as string[] : null
if (legacyServersList) {
for (const server of legacyServersList) {
if (!server || localStorage['server'] === server) continue
servers.push({ ip: server, lastJoined: Date.now() })
}
}
if (localStorage['server']) {
const legacyLastJoinedServer: StoreServerItem = {
ip: localStorage['server'],
passwordOverride: localStorage['password'],
version: localStorage['version'],
lastJoined: Date.now()
}
servers.push(legacyLastJoinedServer)
}
if (servers.length === 0) { // server list is empty, let's suggest some
for (const server of miscUiState.appConfig?.promoteServers ?? []) {
servers.push({
ip: server.ip,
description: server.description,
version: server.version,
})
}
}
return servers
}
const setNewServersList = (serversList: StoreServerItem[]) => {
localStorage['serversList'] = JSON.stringify(serversList)
// cleanup legacy
localStorage.removeItem('serverHistory')
localStorage.removeItem('server')
localStorage.removeItem('password')
localStorage.removeItem('version')
}
const getInitialProxies = () => {
const proxies = [] as string[]
if (miscUiState.appConfig?.defaultProxy) {
proxies.push(miscUiState.appConfig.defaultProxy)
}
if (localStorage['proxy']) {
proxies.push(localStorage['proxy'])
localStorage.removeItem('proxy')
}
return proxies
}
export const updateLoadedServerData = (callback: (data: StoreServerItem) => StoreServerItem) => {
// function assumes component is not mounted to avoid sync issues after save
const { loadedServerIndex } = miscUiState
if (!loadedServerIndex) return
const servers = getInitialServersList()
const server = servers[loadedServerIndex]
servers[loadedServerIndex] = callback(server)
setNewServersList(servers)
}
const Inner = () => {
const [proxies, setProxies] = useState<readonly string[]>(localStorage['proxies'] ? JSON.parse(localStorage['proxies']) : getInitialProxies())
const [selectedProxy, setSelectedProxy] = useState(localStorage.getItem('selectedProxy') ?? proxies?.[0] ?? '')
const [serverEditScreen, setServerEditScreen] = useState<StoreServerItem | true | null>(null) // true for add
const [defaultUsername, setDefaultUsername] = useState(localStorage['username'] ?? (`mcrafter${Math.floor(Math.random() * 1000)}`))
useEffect(() => {
localStorage.setItem('username', defaultUsername)
}, [defaultUsername])
useEffect(() => {
// TODO! do not unmount on connecting screen
// if (proxies.length) {
// localStorage.setItem('proxies', JSON.stringify(proxies))
// }
// if (selectedProxy) {
// localStorage.setItem('selectedProxy', selectedProxy)
// }
}, [proxies])
const [serversList, setServersList] = useState<StoreServerItem[]>(() => getInitialServersList())
const [additionalData, setAdditionalData] = useState<Record<string, AdditionalDisplayData>>({})
useDidUpdateEffect(() => {
// save data only on user changes
setNewServersList(serversList)
}, [serversList])
// by lastJoined
const serversListSorted = useMemo(() => {
return serversList.map((server, index) => ({ ...server, index })).sort((a, b) => (b.lastJoined ?? 0) - (a.lastJoined ?? 0))
}, [serversList])
useEffect(() => {
const update = async () => {
for (const server of serversListSorted) {
const isInLocalNetwork = server.ip.startsWith('192.168.') || server.ip.startsWith('10.') || server.ip.startsWith('172.') || server.ip.startsWith('127.') || server.ip.startsWith('localhost')
if (isInLocalNetwork) continue
// eslint-disable-next-line no-await-in-loop
await fetch(`https://api.mcstatus.io/v2/status/java/${server.ip}`).then(async r => r.json()).then((data: ServerResponse) => {
const versionClean = data.version.name_raw.replace(/^[^\d.]+/, '')
setAdditionalData(old => {
return ({
...old,
[server.ip]: {
formattedText: data.motd.raw,
textNameRight: `${versionClean} ${data.players?.online ?? '??'}/${data.players?.max ?? '??'}`,
icon: data.icon,
}
})
})
})
}
}
void update()
}, [serversListSorted])
const isEditScreenModal = useIsModalActive('editServer')
useEffect(() => {
if (!isEditScreenModal) {
setServerEditScreen(null)
}
}, [isEditScreenModal])
useEffect(() => {
if (serverEditScreen && !isEditScreenModal) {
showModal({ reactType: 'editServer' })
}
}, [serverEditScreen])
if (isEditScreenModal) {
return <AddServer
defaults={{
proxyOverride: selectedProxy,
usernameOverride: defaultUsername,
}}
parseQs={!serverEditScreen}
onBack={() => {
hideCurrentModal()
}}
onConfirm={(info) => {
if (!serverEditScreen) return
if (serverEditScreen === true) {
const server: StoreServerItem = { ...info, lastJoined: Date.now() } // so it appears first
setServersList(old => [...old, server])
} else {
const index = serversList.indexOf(serverEditScreen)
serversList[index] = info
setServersList([...serversList])
}
setServerEditScreen(null)
}}
initialData={!serverEditScreen || serverEditScreen === true ? undefined : serverEditScreen}
onQsConnect={(info) => {
const connectOptions: ConnectOptions = {
username: info.usernameOverride || defaultUsername,
server: info.ip,
proxy: info.proxyOverride || selectedProxy,
botVersion: info.versionOverride,
password: info.passwordOverride,
ignoreQs: true,
}
dispatchEvent(new CustomEvent('connect', { detail: connectOptions }))
}}
/>
}
return <ServersList
joinServer={(indexOrIp, overrides) => {
let ip = indexOrIp
let server: StoreServerItem | undefined
if (overrides.shouldSave === undefined) {
// hack: inner component doesn't know of overrides for existing servers
server = serversListSorted.find(s => s.index.toString() === indexOrIp)!
ip = server.ip
overrides = server
}
const lastJoinedUsername = serversListSorted.find(s => s.usernameOverride)?.usernameOverride
let username = overrides.username || defaultUsername
if (!username) {
username = prompt('Username', lastJoinedUsername || '')
if (!username) return
setDefaultUsername(username)
}
const options = {
username,
server: ip,
proxy: overrides.proxy || selectedProxy,
botVersion: overrides.version,
password: overrides.password,
ignoreQs: true,
autoLoginPassword: server?.autoLogin?.[username],
onSuccessfulPlay () {
if (overrides.shouldSave && !serversList.some(s => s.ip === ip)) {
const newServersList = [...serversList, {
ip,
lastJoined: Date.now(),
}]
// setServersList(newServersList)
setNewServersList(newServersList) // component is not mounted
}
if (overrides.shouldSave === undefined) { // loading saved
// find and update
const server = serversList.find(s => s.ip === ip)
if (server) {
server.lastJoined = Date.now()
// setServersList([...serversList])
setNewServersList(serversList) // component is not mounted
}
}
// save new selected proxy (if new)
if (!proxies.includes(selectedProxy)) {
// setProxies([...proxies, selectedProxy])
localStorage.setItem('proxies', JSON.stringify([...proxies, selectedProxy]))
}
if (selectedProxy) {
localStorage.setItem('selectedProxy', selectedProxy)
}
},
serverIndex: overrides.shouldSave ? serversList.length.toString() : indexOrIp // assume last
} satisfies ConnectOptions
dispatchEvent(new CustomEvent('connect', { detail: options }))
// qsOptions
}}
username={defaultUsername}
setUsername={setDefaultUsername}
onWorldAction={(action, index) => {
const server = serversList[index]
if (!server) return
if (action === 'edit') {
setServerEditScreen(server)
}
if (action === 'delete') {
setServersList(old => old.filter(s => s !== server))
}
}}
onGeneralAction={(action) => {
if (action === 'create') {
setServerEditScreen(true)
}
if (action === 'cancel') {
hideCurrentModal()
}
}}
worldData={serversListSorted.map(server => {
const additional = additionalData[server.ip]
return {
name: server.index.toString(),
title: server.name || server.ip,
detail: (server.version ?? '') + ' ' + (server.usernameOverride ?? ''),
// lastPlayed: server.lastJoined,
formattedTextOverride: additional?.formattedText,
worldNameRight: additional?.textNameRight ?? '',
iconSrc: additional?.icon,
}
})}
initialProxies={{
proxies,
selected: selectedProxy,
}}
updateProxies={({ proxies, selected }) => {
// new proxy is saved in joinServer
setProxies(proxies)
setSelectedProxy(selected)
}}
/>
}
export default () => {
const editServerModalActive = useIsModalActive('editServer')
const isServersListModalActive = useIsModalActive('serversList')
const eitherModal = isServersListModalActive || editServerModalActive
return eitherModal ? <Inner /> : null
}

View file

@ -10,22 +10,25 @@ import styles from './singleplayer.module.css'
import Input from './Input'
import Button from './Button'
import Tabs from './Tabs'
import MessageFormattedString from './MessageFormattedString'
export interface WorldProps {
name: string
title: string
iconBase64?: string
size?: number
lastPlayed?: number
isFocused?: boolean
onFocus?: (name: string) => void
iconSrc?: string
detail?: string
formattedTextOverride?: string
worldNameRight?: string
onFocus?: (name: string) => void
onInteraction?(interaction: 'enter' | 'space')
}
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconBase64 }: WorldProps) => {
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight }: WorldProps) => {
const timeRelativeFormatted = useMemo(() => {
if (!lastPlayed) return
if (!lastPlayed) return ''
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
const diff = Date.now() - lastPlayed
const minutes = Math.floor(diff / 1000 / 60)
@ -38,7 +41,7 @@ const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus,
return formatter.format(-minutes, 'minute')
}, [lastPlayed])
const sizeFormatted = useMemo(() => {
if (!size) return
if (!size) return ''
return filesize(size)
}, [size])
@ -48,18 +51,29 @@ const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus,
onInteraction?.(e.code === 'Enter' ? 'enter' : 'space')
}
}} onDoubleClick={() => onInteraction?.('enter')}>
<img className={`${styles.world_image} ${iconBase64 ? '' : styles.image_missing}`} src={iconBase64 ? `data:image/png;base64,${iconBase64}` : missingWorldPreview} alt='world preview' />
<img className={`${styles.world_image} ${iconSrc ? '' : styles.image_missing}`} src={iconSrc ?? missingWorldPreview} alt='world preview' />
<div className={styles.world_info}>
<div className={styles.world_title} title='level.dat world name'>{title}</div>
<div className={styles.world_info_description_line}>{timeRelativeFormatted} {detail.slice(-30)}</div>
<div className={styles.world_info_description_line}>{sizeFormatted}</div>
<div className={styles.world_title}>
<div>{title}</div>
<div className={styles.world_title_right}>{worldNameRight}</div>
</div>
{formattedTextOverride ? <div className={styles.world_info_formatted}>
<MessageFormattedString message={formattedTextOverride} />
</div> :
<>
<div className={styles.world_info_description_line}>{timeRelativeFormatted} {detail.slice(-30)}</div>
<div className={styles.world_info_description_line}>{sizeFormatted}</div>
</>}
</div>
</div>
}
interface Props {
worldData: WorldProps[] | null // null means loading
providers: Record<string, string>
serversLayout?: boolean
firstRowChildrenOverride?: React.ReactNode
searchRowChildrenOverride?: React.ReactNode
providers?: Record<string, string>
activeProvider?: string
setActiveProvider?: (provider: string) => void
providerActions?: Record<string, (() => void) | undefined | JSX.Element>
@ -74,9 +88,24 @@ interface Props {
onGeneralAction (action: 'cancel' | 'create'): void
}
export default ({ worldData, onGeneralAction, onWorldAction, activeProvider, setActiveProvider, providerActions, providers, disabledProviders, error, isReadonly, warning, warningAction, warningActionLabel }: Props) => {
export default ({
worldData,
onGeneralAction,
onWorldAction,
firstRowChildrenOverride,
serversLayout,
searchRowChildrenOverride,
activeProvider,
setActiveProvider,
providerActions,
providers = {},
disabledProviders,
error,
isReadonly,
warning, warningAction, warningActionLabel
}: Props) => {
const containerRef = useRef<any>()
const firstButton = useRef<HTMLButtonElement>(null!)
const firstButton = useRef<HTMLButtonElement>(null)
useTypedEventListener(window, 'keydown', (e) => {
if (e.code === 'ArrowDown' || e.code === 'ArrowUp') {
@ -100,10 +129,10 @@ export default ({ worldData, onGeneralAction, onWorldAction, activeProvider, set
return <div ref={containerRef}>
<div className="dirt-bg" />
<div className={classNames('fullscreen', styles.root)}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span className={classNames('screen-title', styles.title)}>Select Saved World</span>
<span className={classNames('screen-title', styles.title)}>{serversLayout ? 'Join Java Servers' : 'Select Saved World'}</span>
{searchRowChildrenOverride || <div style={{ display: 'flex', flexDirection: 'column' }}>
<Input autoFocus value={search} onChange={({ target: { value } }) => setSearch(value)} />
</div>
</div>}
<div className={classNames(styles.content, !worldData && styles.content_loading)}>
<Tabs tabs={Object.keys(providers)} disabledTabs={disabledProviders} activeTab={activeProvider ?? ''} labels={providers} onTabChange={(tab) => {
setActiveProvider?.(tab as any)
@ -147,15 +176,17 @@ export default ({ worldData, onGeneralAction, onWorldAction, activeProvider, set
}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 400 }}>
<div>
<Button rootRef={firstButton} disabled={!focusedWorld} onClick={() => onWorldAction('load', focusedWorld)}>LOAD WORLD</Button>
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 400, paddingBottom: 3 }}>
{firstRowChildrenOverride || <div>
<Button rootRef={firstButton} disabled={!focusedWorld} onClick={() => onWorldAction('load', focusedWorld)}>Load World</Button>
<Button onClick={() => onGeneralAction('create')} disabled={isReadonly}>Create New World</Button>
</div>
</div>}
<div>
<Button style={{ width: 100 }} disabled={!focusedWorld} onClick={() => onWorldAction('export', focusedWorld)}>Export</Button>
{serversLayout ? <Button style={{ width: 100 }} disabled={!focusedWorld} onClick={() => onWorldAction('edit', focusedWorld)}>Edit</Button> : <Button style={{ width: 100 }} disabled={!focusedWorld} onClick={() => onWorldAction('export', focusedWorld)}>Export</Button>}
<Button style={{ width: 100 }} disabled={!focusedWorld} onClick={() => onWorldAction('delete', focusedWorld)}>Delete</Button>
<Button style={{ width: 100 }} /* disabled={!focusedWorld} */ onClick={() => onWorldAction('edit', focusedWorld)} disabled>Edit</Button>
{serversLayout ?
<Button style={{ width: 100 }} onClick={() => onGeneralAction('create')}>Add</Button> :
<Button style={{ width: 100 }} /* disabled={!focusedWorld} */ onClick={() => onWorldAction('edit', focusedWorld)} disabled>Edit</Button>}
<Button style={{ width: 100 }} onClick={() => onGeneralAction('cancel')}>Cancel</Button>
</div>
</div>

View file

@ -85,7 +85,7 @@ export const readWorlds = (abortController: AbortController) => {
title: levelName ?? folder,
lastPlayed: levelDat.LastPlayed && longArrayToNumber(levelDat.LastPlayed),
detail: `${levelDat.Version?.Name ?? 'unknown version'}, ${folder}`,
iconBase64,
iconSrc: iconBase64 ? `data:image/png;base64,${iconBase64}` : undefined,
size,
} satisfies WorldProps
}))).filter((x, i) => {

View file

@ -1,39 +1,40 @@
.container {
position: relative;
width: 200px;
height: 20px;
background: black;
border: 1px solid grey;
box-sizing: content-box;
position: relative;
width: 200px;
height: 20px;
background: black;
border: 1px solid grey;
box-sizing: content-box;
}
.input {
position: relative;
outline: none;
border: none;
background: none;
left: 1px;
width: calc(100% - 2px);
height: 100%;
font-family: minecraft, mojangles, monospace;
font-size: 10px;
color: white;
text-shadow: 1px 1px #222;
position: relative;
outline: none;
border: none;
background: none;
left: 1px;
width: calc(100% - 2px);
height: 100%;
font-family: minecraft, mojangles, monospace;
font-size: 10px;
color: white;
text-shadow: 1px 1px #222;
padding-left: 2px;
}
.container:hover,
.container:focus-within {
border-color: white;
}
.container:focus-within {
border-color: white;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type=number] {
appearance: textfield;
-moz-appearance: textfield;
}
/* Firefox */
input[type=number] {
appearance: textfield;
-moz-appearance: textfield;
}

6
src/react/simpleHooks.ts Normal file
View file

@ -0,0 +1,6 @@
import { useMedia } from 'react-use'
const SMALL_SCREEN_MEDIA = '@media (max-width: 440px)'
export const useIsSmallWidth = () => {
return useMedia(SMALL_SCREEN_MEDIA.replace('@media ', ''))
}

View file

@ -28,11 +28,25 @@
display: flex;
outline: none;
}
.world_title {
display: flex;
justify-content: space-between;
align-items: center;
}
.world_title_right {
color: #999;
font-size: 9px;
}
.world_info {
margin-left: 3px;
display: flex;
flex-direction: column;
font-size: 11px;
white-space: nowrap;
}
.world_info_formatted {
font-size: 10px;
white-space: pre;
}
.world_info_description_line {
color: #999;

View file

@ -33,6 +33,7 @@ import TouchAreasControlsProvider from './react/TouchAreasControlsProvider'
import NotificationProvider, { showNotification } from './react/NotificationProvider'
import HotbarRenderApp from './react/HotbarRenderApp'
import Crosshair from './react/Crosshair'
import ServersListProvider from './react/ServersListProvider'
const RobustPortal = ({ children, to }) => {
return createPortal(<PerComponentErrorBoundary>{children}</PerComponentErrorBoundary>, to)
@ -139,6 +140,7 @@ const App = () => {
<CreateWorldProvider />
<AppStatusProvider />
<SelectOption />
<ServersListProvider />
<OptionsRenderApp />
<MainMenuRenderApp />
<NotificationProvider />

View file

@ -2,10 +2,10 @@
import { subscribeKey } from 'valtio/utils'
import { WorldRendererThree } from 'prismarine-viewer/viewer/lib/worldrendererThree'
import { isMobile } from 'prismarine-viewer/viewer/lib/simpleUtils'
import { options, watchValue } from './optionsStorage'
import { reloadChunks } from './utils'
import { miscUiState } from './globalState'
import { isMobile } from './menus/components/common'
subscribeKey(options, 'renderDistance', reloadChunks)
subscribeKey(options, 'multiplayerRenderDistance', reloadChunks)