diff --git a/config.json b/config.json index b7fa1d7e..c1db50c3 100644 --- a/config.json +++ b/config.json @@ -2,6 +2,20 @@ "version": 1, "defaultHost": "", "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." + } + ] } diff --git a/cypress/e2e/index.spec.ts b/cypress/e2e/index.spec.ts index 8b168bf1..35399393 100644 --- a/cypress/e2e/index.spec.ts +++ b/cypress/e2e/index.spec.ts @@ -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() }) diff --git a/package.json b/package.json index dee909a8..4d99a6d5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc504d17..1ef5b1d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/prismarine-viewer/viewer/lib/simpleUtils.ts b/prismarine-viewer/viewer/lib/simpleUtils.ts index 5c602f1c..3f17e5ad 100644 --- a/prismarine-viewer/viewer/lib/simpleUtils.ts +++ b/prismarine-viewer/viewer/lib/simpleUtils.ts @@ -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) diff --git a/src/connect.ts b/src/connect.ts new file mode 100644 index 00000000..5e4df859 --- /dev/null +++ b/src/connect.ts @@ -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 +} diff --git a/src/devReload.ts b/src/devReload.ts index a89551d3..19e50263 100644 --- a/src/devReload.ts +++ b/src/devReload.ts @@ -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) { diff --git a/src/downloadAndOpenFile.ts b/src/downloadAndOpenFile.ts index b3d3a059..7ac154fc 100644 --- a/src/downloadAndOpenFile.ts +++ b/src/downloadAndOpenFile.ts @@ -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 } } diff --git a/src/globalState.ts b/src/globalState.ts index fa1c4cda..1b0526f9 100644 --- a/src/globalState.ts +++ b/src/globalState.ts @@ -56,10 +56,10 @@ const showModalInner = (modal: Modal) => { return true } -export const showModal = (elem: (HTMLElement & Record) | { reactType: string }) => { +export const showModal = (elem: /* (HTMLElement & Record) | */{ 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, diff --git a/src/globals.d.ts b/src/globals.d.ts index b0a8d6db..7d2a478c 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -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 - querySelector (id): HTMLElement & Record -} - -declare interface Window extends Record { - -} +declare interface Window extends Record {} type StringKeys = Extract diff --git a/src/index.ts b/src/index.ts index c4cff16a..0cd178d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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(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) { diff --git a/src/menus/components/button.js b/src/menus/components/button.js deleted file mode 100644 index 1f726821..00000000 --- a/src/menus/components/button.js +++ /dev/null @@ -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` - - ` - } - - 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) diff --git a/src/menus/components/common.js b/src/menus/components/common.js deleted file mode 100644 index 83c74abd..00000000 --- a/src/menus/components/common.js +++ /dev/null @@ -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, -} diff --git a/src/menus/components/edit_box.js b/src/menus/components/edit_box.js deleted file mode 100644 index c7210d43..00000000 --- a/src/menus/components/edit_box.js +++ /dev/null @@ -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` -
- - ${this.autocompleteValues ? html` - - ${this.autocompleteValues.map(value => html` - - `)} - - ` : ''} - { this.value = this.inputMode === 'decimal' ? value.replaceAll(',', '.') : value }} - class="edit-box"> -
- ` - } -} - -window.customElements.define('pmui-editbox', EditBox) diff --git a/src/menus/play_screen.js b/src/menus/play_screen.js deleted file mode 100644 index e9517d9e..00000000 --- a/src/menus/play_screen.js +++ /dev/null @@ -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 === '' || config.defaultHostSave === '') { - 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 === '' && proxyConfig.defaultHost) { - config.defaultHost = proxyConfig.defaultHost - } else { - config.defaultHost = '' - } - if (config.defaultHostSave === '' && 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` -
- -

Join a Server

- -
-
- { this.server = e.target.value }} - > - { this.serverport = e.target.value }} - > -
-
- { this.proxy = e.target.value }} - > - { this.proxyport = e.target.value }} - > -
-
-

Enter proxy url you want to use. Learn more.

-
-
- { this.username = e.target.value }} - > - { this.version = e.target.value = e.target.value.replaceAll(',', '.') }} - > -
-

Leave blank and it will be chosen automatically

-
- -
- - hideCurrentModal()}> -
- ` - } - - 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) diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index 2457c34f..1c92a39c 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -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' diff --git a/src/react/AddServer.tsx b/src/react/AddServer.tsx new file mode 100644 index 00000000..bae39ac9 --- /dev/null +++ b/src/react/AddServer.tsx @@ -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 +} + +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 +
{ + e.preventDefault() + let ip = serverIp.includes(':') ? serverIp : `${serverIp}:${serverPort}` + ip = ip.replace(/:$/, '') + onConfirm({ + name: serverName, + ip, + versionOverride, + proxyOverride, + usernameOverride, + passwordOverride + }) + }} + > +
+
+ setServerName(value)} placeholder='Defaults to IP' /> +
+ setServerIp(value)} /> + setServerPort(value)} placeholder='25565' /> +
Overrides:
+ setVersionOverride(value)} placeholder='Optional, but recommended to specify' /> + setProxyOverride(value)} placeholder={defaults?.proxyOverride} /> + setUsernameOverride(value)} placeholder={defaults?.usernameOverride} /> + setPasswordOverride(value)} /* placeholder='For advanced usage only' */ /> + + + {qsParams?.get('ip') &&
+ +
} +
+
+
+} + +const InputWithLabel = ({ label, span, ...props }: React.ComponentProps & { label, span?}) => { + return
+ + +
+} diff --git a/src/react/Button.tsx b/src/react/Button.tsx index 4d346b14..db7e575a 100644 --- a/src/react/Button.tsx +++ b/src/react/Button.tsx @@ -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 + + } + searchRowChildrenOverride={ +
+
+ Proxy: +
+ + {autocomplete.groupedOptions &&
    + {autocomplete.groupedOptions.map((proxy, index) => { + const { itemRef, ...optionProps } = autocomplete.getOptionProps({ option: proxy, index }) + return + })} +
} +
+ + setUsername(value)} /> +
+
+ } + 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) => { + const iconPerStatus = { + unknown: 'cellular-signal-0', + error: 'cellular-signal-off', + success: 'cellular-signal-3', + } + + return
+ setValue?.(value)} + onChange={props.onChange} + /> +
+ +
+ {ip} +
+
+
+} diff --git a/src/react/ServersListProvider.tsx b/src/react/ServersListProvider.tsx new file mode 100644 index 00000000..ecd74916 --- /dev/null +++ b/src/react/ServersListProvider.tsx @@ -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 + autoLogin?: Record +} + +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(localStorage['proxies'] ? JSON.parse(localStorage['proxies']) : getInitialProxies()) + const [selectedProxy, setSelectedProxy] = useState(localStorage.getItem('selectedProxy') ?? proxies?.[0] ?? '') + const [serverEditScreen, setServerEditScreen] = useState(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(() => getInitialServersList()) + const [additionalData, setAdditionalData] = useState>({}) + + 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 { + 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 { + 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 ? : null +} diff --git a/src/react/Singleplayer.tsx b/src/react/Singleplayer.tsx index a4d8c559..76a75e82 100644 --- a/src/react/Singleplayer.tsx +++ b/src/react/Singleplayer.tsx @@ -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')}> - world preview + world preview
-
{title}
-
{timeRelativeFormatted} {detail.slice(-30)}
-
{sizeFormatted}
+
+
{title}
+
{worldNameRight}
+
+ {formattedTextOverride ?
+ +
: + <> +
{timeRelativeFormatted} {detail.slice(-30)}
+
{sizeFormatted}
+ }
} interface Props { worldData: WorldProps[] | null // null means loading - providers: Record + serversLayout?: boolean + firstRowChildrenOverride?: React.ReactNode + searchRowChildrenOverride?: React.ReactNode + providers?: Record activeProvider?: string setActiveProvider?: (provider: string) => void providerActions?: Record 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() - const firstButton = useRef(null!) + const firstButton = useRef(null) useTypedEventListener(window, 'keydown', (e) => { if (e.code === 'ArrowDown' || e.code === 'ArrowUp') { @@ -100,10 +129,10 @@ export default ({ worldData, onGeneralAction, onWorldAction, activeProvider, set return
-
- Select Saved World + {serversLayout ? 'Join Java Servers' : 'Select Saved World'} + {searchRowChildrenOverride ||
setSearch(value)} /> -
+
}
{ setActiveProvider?.(tab as any) @@ -147,15 +176,17 @@ export default ({ worldData, onGeneralAction, onWorldAction, activeProvider, set }
-
-
- +
+ {firstRowChildrenOverride ||
+ -
+
}
- + {serversLayout ? : } - + {serversLayout ? + : + }
diff --git a/src/react/SingleplayerProvider.tsx b/src/react/SingleplayerProvider.tsx index 8eb3af0f..35704e8e 100644 --- a/src/react/SingleplayerProvider.tsx +++ b/src/react/SingleplayerProvider.tsx @@ -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) => { diff --git a/src/react/input.module.css b/src/react/input.module.css index 9ad01c90..ce889645 100644 --- a/src/react/input.module.css +++ b/src/react/input.module.css @@ -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; +} diff --git a/src/react/simpleHooks.ts b/src/react/simpleHooks.ts new file mode 100644 index 00000000..9202bfa2 --- /dev/null +++ b/src/react/simpleHooks.ts @@ -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 ', '')) +} diff --git a/src/react/singleplayer.module.css b/src/react/singleplayer.module.css index 1e092b39..fef43b76 100644 --- a/src/react/singleplayer.module.css +++ b/src/react/singleplayer.module.css @@ -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; diff --git a/src/reactUi.tsx b/src/reactUi.tsx index 4c5f1516..419d8dff 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -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({children}, to) @@ -139,6 +140,7 @@ const App = () => { + diff --git a/src/watchOptions.ts b/src/watchOptions.ts index 009463be..56fc9c2c 100644 --- a/src/watchOptions.ts +++ b/src/watchOptions.ts @@ -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)