feat: implement Google Drive storage provider (#87)
This commit is contained in:
parent
f16dbdd61f
commit
c04c9cfdad
16 changed files with 573 additions and 72 deletions
|
|
@ -31,6 +31,8 @@
|
|||
"@floating-ui/react": "^0.26.1",
|
||||
"@mui/base": "5.0.0-beta.34",
|
||||
"@nxg-org/mineflayer-tracker": "^1.2.1",
|
||||
"@react-oauth/google": "^0.12.1",
|
||||
"@types/gapi": "^0.0.47",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/wicg-file-system-access": "^2023.10.2",
|
||||
|
|
@ -47,7 +49,7 @@
|
|||
"esbuild": "^0.19.3",
|
||||
"esbuild-plugin-polyfill-node": "^0.3.0",
|
||||
"express": "^4.18.2",
|
||||
"flying-squid": "npm:@zardoy/flying-squid@^0.0.9",
|
||||
"flying-squid": "npm:@zardoy/flying-squid@^0.0.10",
|
||||
"fs-extra": "^11.1.1",
|
||||
"iconify-icon": "^1.0.8",
|
||||
"jszip": "^3.10.1",
|
||||
|
|
@ -72,7 +74,8 @@
|
|||
"title-case": "3.x",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
"valtio": "^1.11.1",
|
||||
"workbox-build": "^7.0.0"
|
||||
"workbox-build": "^7.0.0",
|
||||
"google-drive-browserfs": "github:zardoy/browserfs#google-drive"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-essentials": "^7.4.6",
|
||||
|
|
|
|||
73
pnpm-lock.yaml
generated
73
pnpm-lock.yaml
generated
|
|
@ -30,6 +30,12 @@ importers:
|
|||
'@nxg-org/mineflayer-tracker':
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
'@react-oauth/google':
|
||||
specifier: ^0.12.1
|
||||
version: 0.12.1(react-dom@18.2.0)(react@18.2.0)
|
||||
'@types/gapi':
|
||||
specifier: ^0.0.47
|
||||
version: 0.0.47
|
||||
'@types/react':
|
||||
specifier: ^18.2.20
|
||||
version: 18.2.20
|
||||
|
|
@ -50,7 +56,7 @@ importers:
|
|||
version: 0.0.11
|
||||
browserfs:
|
||||
specifier: github:zardoy/browserfs#build
|
||||
version: github.com/zardoy/browserfs/0ff5df5c4e67f54b5f032b87dc650e8b78626bc7
|
||||
version: github.com/zardoy/browserfs/e60ca69e74888e057a96a468afe1d62347d3f56f
|
||||
change-case:
|
||||
specifier: ^5.1.2
|
||||
version: 5.1.2
|
||||
|
|
@ -79,11 +85,14 @@ importers:
|
|||
specifier: ^4.18.2
|
||||
version: 4.18.2
|
||||
flying-squid:
|
||||
specifier: npm:@zardoy/flying-squid@^0.0.9
|
||||
version: /@zardoy/flying-squid@0.0.9
|
||||
specifier: npm:@zardoy/flying-squid@^0.0.10
|
||||
version: /@zardoy/flying-squid@0.0.10
|
||||
fs-extra:
|
||||
specifier: ^11.1.1
|
||||
version: 11.1.1
|
||||
google-drive-browserfs:
|
||||
specifier: github:zardoy/browserfs#google-drive
|
||||
version: github.com/zardoy/browserfs/6d7a80cbba53d06eee81b8dd3aae6f256699310f
|
||||
iconify-icon:
|
||||
specifier: ^1.0.8
|
||||
version: 1.0.8
|
||||
|
|
@ -3295,7 +3304,7 @@ packages:
|
|||
requiresBuild: true
|
||||
dependencies:
|
||||
'@gar/promisify': 1.1.3
|
||||
semver: 7.5.4
|
||||
semver: 7.6.0
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
|
|
@ -3898,6 +3907,16 @@ packages:
|
|||
'@babel/runtime': 7.22.11
|
||||
dev: true
|
||||
|
||||
/@react-oauth/google@0.12.1(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-qagsy22t+7UdkYAiT5ZhfM4StXi9PPNvw0zuwNmabrWyMKddczMtBIOARflbaIj+wHiQjnMAsZmzsUYuXeyoSg==}
|
||||
peerDependencies:
|
||||
react: ^18.2.0
|
||||
react-dom: '>=16.8.0'
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@rollup/plugin-babel@5.3.1(@babel/core@7.22.11)(rollup@2.79.1):
|
||||
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
|
@ -5028,6 +5047,10 @@ packages:
|
|||
resolution: {integrity: sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw==}
|
||||
dev: true
|
||||
|
||||
/@types/gapi@0.0.47:
|
||||
resolution: {integrity: sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==}
|
||||
dev: false
|
||||
|
||||
/@types/glob@7.2.0:
|
||||
resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==}
|
||||
dependencies:
|
||||
|
|
@ -5411,7 +5434,7 @@ packages:
|
|||
debug: 4.3.4(supports-color@8.1.1)
|
||||
globby: 11.1.0
|
||||
is-glob: 4.0.3
|
||||
semver: 7.5.4
|
||||
semver: 7.6.0
|
||||
ts-api-utils: 1.0.3(typescript@5.2.2)
|
||||
typescript: 5.2.2
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -5432,7 +5455,7 @@ packages:
|
|||
debug: 4.3.4(supports-color@8.1.1)
|
||||
globby: 11.1.0
|
||||
is-glob: 4.0.3
|
||||
semver: 7.5.4
|
||||
semver: 7.6.0
|
||||
ts-api-utils: 1.0.3(typescript@5.2.2)
|
||||
typescript: 5.2.2
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -5452,7 +5475,7 @@ packages:
|
|||
'@typescript-eslint/types': 6.1.0
|
||||
'@typescript-eslint/typescript-estree': 6.1.0(typescript@5.2.2)
|
||||
eslint: 8.50.0
|
||||
semver: 7.5.4
|
||||
semver: 7.6.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
|
@ -5632,8 +5655,8 @@ packages:
|
|||
tslib: 1.14.1
|
||||
dev: true
|
||||
|
||||
/@zardoy/flying-squid@0.0.9:
|
||||
resolution: {integrity: sha512-Q7xqm+Uu/Y/8jziVM+sazddDw50t3IoQ7y0BTAgLpYaqEsJDvTAoXmHspd9NRFVEs0CM4M/qZxvztCcdJryGIg==}
|
||||
/@zardoy/flying-squid@0.0.10:
|
||||
resolution: {integrity: sha512-uMpNRjYWbBAgWUld4pIPOIM3vBtcR4LWuBBBwDALhZDIRU+Iu7kHjbUD0OfBzayYn78qB3T1d6dJj4oRa0M7Jg==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
|
|
@ -5657,7 +5680,7 @@ packages:
|
|||
prismarine-item: 1.14.0
|
||||
prismarine-nbt: 2.5.0
|
||||
prismarine-provider-anvil: github.com/zardoy/prismarine-provider-anvil/0ddcd9d48574113308e1fbebef60816aced0846f(minecraft-data@3.62.0)
|
||||
prismarine-windows: 2.8.0
|
||||
prismarine-windows: 2.9.0
|
||||
prismarine-world: github.com/zardoy/prismarine-world/c358222204d21fe7d45379fbfcefb047f926c786
|
||||
random-seed: 0.3.0
|
||||
range: 0.0.3
|
||||
|
|
@ -10723,7 +10746,7 @@ packages:
|
|||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
semver: 7.5.4
|
||||
semver: 7.6.0
|
||||
dev: true
|
||||
|
||||
/make-fetch-happen@10.2.1:
|
||||
|
|
@ -11249,7 +11272,7 @@ packages:
|
|||
engines: {node: '>=10'}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
semver: 7.5.4
|
||||
semver: 7.6.0
|
||||
dev: false
|
||||
|
||||
/node-addon-api@5.1.0:
|
||||
|
|
@ -11314,7 +11337,7 @@ packages:
|
|||
nopt: 6.0.0
|
||||
npmlog: 6.0.2
|
||||
rimraf: 3.0.2
|
||||
semver: 7.5.4
|
||||
semver: 7.6.0
|
||||
tar: 6.2.0
|
||||
which: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -11392,7 +11415,7 @@ packages:
|
|||
dependencies:
|
||||
hosted-git-info: 4.1.0
|
||||
is-core-module: 2.13.0
|
||||
semver: 7.5.4
|
||||
semver: 7.6.0
|
||||
validate-npm-package-license: 3.0.4
|
||||
dev: true
|
||||
|
||||
|
|
@ -12159,21 +12182,12 @@ packages:
|
|||
vec3: 0.1.8
|
||||
dev: false
|
||||
|
||||
/prismarine-windows@2.8.0:
|
||||
resolution: {integrity: sha512-9HVhJ8tfCeRubYwQzgz8oiHNAebMJ5hDdjm45PZwrOgewaislnR2HDsbPMWiCcyWkYL7J8bVLVoSzEzv5pH98g==}
|
||||
dependencies:
|
||||
prismarine-item: 1.14.0
|
||||
prismarine-registry: 1.7.0
|
||||
typed-emitter: 2.1.0
|
||||
dev: false
|
||||
|
||||
/prismarine-windows@2.9.0:
|
||||
resolution: {integrity: sha512-fm4kOLjGFPov7TEJRmXHoiPabxIQrG36r2mDjlNxfkcLfMHFb3/1ML6mp4iRQa7wL0GK4DIAyiBqCWoeWDxARg==}
|
||||
dependencies:
|
||||
prismarine-item: 1.14.0
|
||||
prismarine-registry: 1.7.0
|
||||
typed-emitter: 2.1.0
|
||||
dev: true
|
||||
|
||||
/process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
|
@ -13360,7 +13374,7 @@ packages:
|
|||
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
|
||||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
semver: 7.5.4
|
||||
semver: 7.6.0
|
||||
dev: true
|
||||
|
||||
/sisteransi@1.0.5:
|
||||
|
|
@ -15400,8 +15414,15 @@ packages:
|
|||
engines: {node: '>= 0.6.0'}
|
||||
dev: true
|
||||
|
||||
github.com/zardoy/browserfs/0ff5df5c4e67f54b5f032b87dc650e8b78626bc7:
|
||||
resolution: {tarball: https://codeload.github.com/zardoy/browserfs/tar.gz/0ff5df5c4e67f54b5f032b87dc650e8b78626bc7}
|
||||
github.com/zardoy/browserfs/6d7a80cbba53d06eee81b8dd3aae6f256699310f:
|
||||
resolution: {tarball: https://codeload.github.com/zardoy/browserfs/tar.gz/6d7a80cbba53d06eee81b8dd3aae6f256699310f}
|
||||
name: browserfs
|
||||
version: 2.0.0
|
||||
engines: {node: '>= 18'}
|
||||
dev: false
|
||||
|
||||
github.com/zardoy/browserfs/e60ca69e74888e057a96a468afe1d62347d3f56f:
|
||||
resolution: {tarball: https://codeload.github.com/zardoy/browserfs/tar.gz/e60ca69e74888e057a96a468afe1d62347d3f56f}
|
||||
name: browserfs
|
||||
version: 2.0.0-zardoy
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { fsState, loadSave } from './loadSave'
|
|||
import { installTexturePack, installTexturePackFromHandle, updateTexturePackInstalledState } from './texturePack'
|
||||
import { miscUiState } from './globalState'
|
||||
import { setLoadingScreenStatus } from './utils'
|
||||
const { GoogleDriveFileSystem } = require('google-drive-browserfs/src/backends/GoogleDrive') // disable type checking
|
||||
|
||||
browserfs.install(window)
|
||||
const defaultMountablePoints = {
|
||||
|
|
@ -176,6 +177,34 @@ export const mountExportFolder = async () => {
|
|||
return true
|
||||
}
|
||||
|
||||
let googleDriveFileSystem
|
||||
|
||||
/** Only cached! */
|
||||
export const googleDriveGetFileIdFromPath = (path: string) => {
|
||||
return googleDriveFileSystem._getExistingFileId(path)
|
||||
}
|
||||
|
||||
export const mountGoogleDriveFolder = async (readonly: boolean) => {
|
||||
googleDriveFileSystem = new GoogleDriveFileSystem()
|
||||
googleDriveFileSystem.isReadonly = readonly
|
||||
await new Promise<void>(resolve => {
|
||||
browserfs.configure({
|
||||
fs: 'MountableFileSystem',
|
||||
options: {
|
||||
...defaultMountablePoints,
|
||||
'/google': googleDriveFileSystem
|
||||
},
|
||||
}, (e) => {
|
||||
if (e) throw e
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
fsState.isReadonly = readonly
|
||||
fsState.syncFs = false
|
||||
fsState.inMemorySave = false
|
||||
return true
|
||||
}
|
||||
|
||||
export async function removeFileRecursiveAsync (path) {
|
||||
const errors = [] as Array<[string, Error]>
|
||||
try {
|
||||
|
|
|
|||
64
src/googledrive.ts
Normal file
64
src/googledrive.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { GoogleOAuthProvider, useGoogleLogin } from '@react-oauth/google'
|
||||
import { proxy, ref, subscribe } from 'valtio'
|
||||
import React from 'react'
|
||||
|
||||
const CLIENT_ID = '137156026346-igv2gkjsj2hlid92rs3q7cjjnc77s132.apps.googleusercontent.com'
|
||||
// const CLIENT_ID = process.env.GOOGLE_CLIENT_ID
|
||||
const SCOPES = 'https://www.googleapis.com/auth/drive'
|
||||
|
||||
export const GoogleDriveProvider = ({ children }) => {
|
||||
return React.createElement(GoogleOAuthProvider, { clientId: CLIENT_ID } as any, children)
|
||||
// return <GoogleOAuthProvider clientId={CLIENT_ID}><Root /></GoogleOAuthProvider>
|
||||
}
|
||||
|
||||
export const isGoogleDriveAvailable = () => {
|
||||
return !!CLIENT_ID
|
||||
}
|
||||
|
||||
export const useGoogleLogIn = () => {
|
||||
const login = useGoogleLogin({
|
||||
onSuccess (tokenResponse) {
|
||||
localStorage.hasEverLoggedIn = true
|
||||
googleProviderData.accessToken = tokenResponse.access_token
|
||||
googleProviderData.expiresIn = ref(new Date(Date.now() + tokenResponse.expires_in * 1000))
|
||||
googleProviderData.hasEverLoggedIn = true
|
||||
},
|
||||
// interested in initial value only
|
||||
prompt: googleProviderData.hasEverLoggedIn ? 'none' : 'consent',
|
||||
scope: SCOPES,
|
||||
flow: 'implicit',
|
||||
onError (error) {
|
||||
const accessDenied = error.error === 'access_denied' || error.error === 'invalid_scope' || (error as any).error_subtype === 'access_denied'
|
||||
if (accessDenied) {
|
||||
googleProviderData.hasEverLoggedIn = false
|
||||
}
|
||||
}
|
||||
})
|
||||
return login
|
||||
}
|
||||
|
||||
export const googleProviderData = proxy({
|
||||
accessToken: (localStorage.saveAccessToken ? localStorage.accessToken : null) as string | null,
|
||||
hasEverLoggedIn: !!(localStorage.hasEverLoggedIn),
|
||||
isReady: false,
|
||||
expiresIn: localStorage.saveAccessToken ? ref(new Date(Date.now() + 1000 * 60 * 60)) : null,
|
||||
readonlyMode: localStorage.googleReadonlyMode ? localStorage.googleReadonlyMode === 'true' : true,
|
||||
worldsPath: localStorage.googleWorldsPath || '/worlds/'
|
||||
})
|
||||
|
||||
subscribe(googleProviderData, () => {
|
||||
localStorage.googleReadonlyMode = googleProviderData.readonlyMode
|
||||
localStorage.googleWorldsPath = googleProviderData.worldsPath
|
||||
if (googleProviderData.hasEverLoggedIn) {
|
||||
localStorage.hasEverLoggedIn = true
|
||||
} else {
|
||||
delete localStorage.hasEverLoggedIn
|
||||
}
|
||||
|
||||
if (localStorage.saveAccessToken && googleProviderData) {
|
||||
// For testing only
|
||||
localStorage.accessToken = googleProviderData.accessToken || null
|
||||
} else {
|
||||
delete localStorage.accessToken
|
||||
}
|
||||
})
|
||||
|
|
@ -4,6 +4,7 @@ import defaultLocalServerOptions from '../defaultLocalServerOptions'
|
|||
import { mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs'
|
||||
import CreateWorld, { WorldCustomize, creatingWorldState } from './CreateWorld'
|
||||
import { useIsModalActive } from './utils'
|
||||
import { getWorldsPath } from './SingleplayerProvider'
|
||||
|
||||
export default () => {
|
||||
const activeCreate = useIsModalActive('create-world')
|
||||
|
|
@ -24,7 +25,7 @@ export default () => {
|
|||
// create new world
|
||||
const { title, type, version } = creatingWorldState
|
||||
// todo display path in ui + disable if exist
|
||||
const savePath = await uniqueFileNameFromWorldName(title, `/data/worlds`)
|
||||
const savePath = await uniqueFileNameFromWorldName(title, getWorldsPath())
|
||||
await mkdirRecursive(savePath)
|
||||
let generation
|
||||
if (type === 'flat') {
|
||||
|
|
|
|||
106
src/react/GoogleButton.css
Normal file
106
src/react/GoogleButton.css
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
.gsi-material-button {
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: #f2f2f2;
|
||||
background-image: none;
|
||||
border: none;
|
||||
-webkit-border-radius: 20px;
|
||||
border-radius: 20px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
color: #1f1f1f;
|
||||
cursor: pointer;
|
||||
font-family: 'Roboto', arial, sans-serif;
|
||||
font-size: 14px;
|
||||
height: 40px;
|
||||
letter-spacing: 0.25px;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
padding: 0 12px;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
-webkit-transition: background-color .218s, border-color .218s, box-shadow .218s;
|
||||
transition: background-color .218s, border-color .218s, box-shadow .218s;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
width: auto;
|
||||
max-width: 400px;
|
||||
min-width: min-content;
|
||||
}
|
||||
|
||||
.gsi-material-button .gsi-material-button-icon {
|
||||
height: 20px;
|
||||
margin-right: 12px;
|
||||
min-width: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.gsi-material-button .gsi-material-button-content-wrapper {
|
||||
-webkit-align-items: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
-webkit-flex-direction: row;
|
||||
flex-direction: row;
|
||||
-webkit-flex-wrap: nowrap;
|
||||
flex-wrap: nowrap;
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gsi-material-button .gsi-material-button-contents {
|
||||
-webkit-flex-grow: 1;
|
||||
flex-grow: 1;
|
||||
font-family: 'Roboto', arial, sans-serif;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.gsi-material-button .gsi-material-button-state {
|
||||
-webkit-transition: opacity .218s;
|
||||
transition: opacity .218s;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.gsi-material-button:disabled {
|
||||
cursor: default;
|
||||
background-color: #ffffff61;
|
||||
}
|
||||
|
||||
.gsi-material-button:disabled .gsi-material-button-state {
|
||||
background-color: #1f1f1f1f;
|
||||
}
|
||||
|
||||
.gsi-material-button:disabled .gsi-material-button-contents {
|
||||
opacity: 38%;
|
||||
}
|
||||
|
||||
.gsi-material-button:disabled .gsi-material-button-icon {
|
||||
opacity: 38%;
|
||||
}
|
||||
|
||||
.gsi-material-button:not(:disabled):active .gsi-material-button-state,
|
||||
.gsi-material-button:not(:disabled):focus .gsi-material-button-state {
|
||||
background-color: #001d35;
|
||||
opacity: 12%;
|
||||
}
|
||||
|
||||
.gsi-material-button:not(:disabled):hover {
|
||||
-webkit-box-shadow: 0 1px 2px 0 rgba(60, 64, 67, .30), 0 1px 3px 1px rgba(60, 64, 67, .15);
|
||||
box-shadow: 0 1px 2px 0 rgba(60, 64, 67, .30), 0 1px 3px 1px rgba(60, 64, 67, .15);
|
||||
}
|
||||
|
||||
.gsi-material-button:not(:disabled):hover .gsi-material-button-state {
|
||||
background-color: #001d35;
|
||||
opacity: 8%;
|
||||
}
|
||||
22
src/react/GoogleButton.tsx
Normal file
22
src/react/GoogleButton.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import './GoogleButton.css'
|
||||
|
||||
export default ({ onClick }) => {
|
||||
return <button className="gsi-material-button" onClick={onClick} style={{
|
||||
transform: 'scale(0.55) translate(-20%)',
|
||||
}}>
|
||||
<div className="gsi-material-button-state" />
|
||||
<div className="gsi-material-button-content-wrapper">
|
||||
<div className="gsi-material-button-icon">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"></path>
|
||||
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"></path>
|
||||
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"></path>
|
||||
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"></path>
|
||||
<path fill="none" d="M0 0h48v48H0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="gsi-material-button-contents">Continue with Google</span>
|
||||
<span style={{ display: 'none' }}>Continue with Google</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
|
|
@ -3,10 +3,11 @@ import styles from './input.module.css'
|
|||
import { useUsingTouch } from './utils'
|
||||
|
||||
interface Props extends React.ComponentProps<'input'> {
|
||||
rootStyles?: React.CSSProperties
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
export default ({ autoFocus, ...inputProps }: Props) => {
|
||||
export default ({ autoFocus, rootStyles, ...inputProps }: Props) => {
|
||||
const ref = useRef<HTMLInputElement>(null!)
|
||||
const isTouch = useUsingTouch()
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ export default ({ autoFocus, ...inputProps }: Props) => {
|
|||
ref.current.focus()
|
||||
}, [])
|
||||
|
||||
return <div className={styles.container}>
|
||||
return <div className={styles.container} style={rootStyles}>
|
||||
<input ref={ref} className={styles.input} autoComplete='off' autoCapitalize='off' autoCorrect='off' autoSave='off' spellCheck='false' {...inputProps} />
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ const meta: Meta<{ open }> = {
|
|||
lastPlayed: Date.now() - 600_000,
|
||||
size: 100_000,
|
||||
}))}
|
||||
providerActions={{
|
||||
local () { },
|
||||
}}
|
||||
providers={{
|
||||
local: 'Local',
|
||||
test: 'Test',
|
||||
}}
|
||||
onWorldAction={() => { }}
|
||||
onGeneralAction={() => { }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import classNames from 'classnames'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
// todo optimize size
|
||||
import missingWorldPreview from 'minecraft-assets/minecraft-assets/data/1.10/gui/presets/isles.png'
|
||||
|
|
@ -9,6 +9,7 @@ import { focusable } from 'tabbable'
|
|||
import styles from './singleplayer.module.css'
|
||||
import Input from './Input'
|
||||
import Button from './Button'
|
||||
import Tabs from './Tabs'
|
||||
|
||||
export interface WorldProps {
|
||||
name: string
|
||||
|
|
@ -20,6 +21,7 @@ export interface WorldProps {
|
|||
detail?: string
|
||||
onInteraction?(interaction: 'enter' | 'space')
|
||||
}
|
||||
|
||||
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction }: WorldProps) => {
|
||||
const timeRelativeFormatted = useMemo(() => {
|
||||
if (!lastPlayed) return
|
||||
|
|
@ -56,11 +58,19 @@ const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus,
|
|||
|
||||
interface Props {
|
||||
worldData: WorldProps[] | null // null means loading
|
||||
providers: Record<string, string>
|
||||
activeProvider?: string
|
||||
setActiveProvider?: (provider: string) => void
|
||||
providerActions?: Record<string, (() => void) | undefined | JSX.Element>
|
||||
disabledProviders?: string[]
|
||||
isReadonly?: boolean
|
||||
error?: string
|
||||
|
||||
onWorldAction (action: 'load' | 'export' | 'delete' | 'edit', worldName: string): void
|
||||
onGeneralAction (action: 'cancel' | 'create'): void
|
||||
}
|
||||
|
||||
export default ({ worldData, onGeneralAction, onWorldAction }: Props) => {
|
||||
export default ({ worldData, onGeneralAction, onWorldAction, activeProvider, setActiveProvider, providerActions, providers, disabledProviders, error, isReadonly }: Props) => {
|
||||
const containerRef = useRef<any>()
|
||||
const firstButton = useRef<HTMLButtonElement>(null!)
|
||||
|
||||
|
|
@ -79,6 +89,10 @@ export default ({ worldData, onGeneralAction, onWorldAction }: Props) => {
|
|||
const [search, setSearch] = useState('')
|
||||
const [focusedWorld, setFocusedWorld] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setFocusedWorld('')
|
||||
}, [activeProvider])
|
||||
|
||||
return <div ref={containerRef}>
|
||||
<div className="dirt-bg" />
|
||||
<div className={classNames('fullscreen', styles.root)}>
|
||||
|
|
@ -87,24 +101,42 @@ export default ({ worldData, onGeneralAction, onWorldAction }: Props) => {
|
|||
<Input autoFocus value={search} onChange={({ target: { value } }) => setSearch(value)} />
|
||||
</div>
|
||||
<div className={classNames(styles.content, !worldData && styles.content_loading)}>
|
||||
{
|
||||
worldData
|
||||
? worldData.filter(data => data.title.toLowerCase().includes(search.toLowerCase())).map(({ name, title, size, lastPlayed, detail }) => (
|
||||
<World title={title} lastPlayed={lastPlayed} size={size} name={name} onFocus={setFocusedWorld} isFocused={focusedWorld === name} key={name} onInteraction={(interaction) => {
|
||||
if (interaction === 'enter') onWorldAction('load', name)
|
||||
else if (interaction === 'space') firstButton.current?.focus()
|
||||
}} detail={detail} />
|
||||
))
|
||||
: <div style={{
|
||||
fontSize: 10,
|
||||
color: 'lightgray',
|
||||
}}>Loading...</div>
|
||||
}
|
||||
<Tabs tabs={Object.keys(providers)} disabledTabs={disabledProviders} activeTab={activeProvider ?? ''} labels={providers} onTabChange={(tab) => {
|
||||
setActiveProvider?.(tab as any)
|
||||
}} fullSize />
|
||||
<div style={{
|
||||
marginTop: 3,
|
||||
}}>
|
||||
{
|
||||
providerActions && <div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
// overflow: 'auto',
|
||||
}}>
|
||||
<span style={{ fontSize: 9, marginRight: 3 }}>Actions: </span> {Object.entries(providerActions).map(([label, action]) => (
|
||||
typeof action === 'function' ? <Button key={label} onClick={action} style={{ width: 100 }}>{label}</Button> : <Fragment key={label}>{action}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
{
|
||||
worldData
|
||||
? worldData.filter(data => data.title.toLowerCase().includes(search.toLowerCase())).map(({ name, title, size, lastPlayed, detail }) => (
|
||||
<World title={title} lastPlayed={lastPlayed} size={size} name={name} onFocus={setFocusedWorld} isFocused={focusedWorld === name} key={name} onInteraction={(interaction) => {
|
||||
if (interaction === 'enter') onWorldAction('load', name)
|
||||
else if (interaction === 'space') firstButton.current?.focus()
|
||||
}} detail={detail} />
|
||||
))
|
||||
: <div style={{
|
||||
fontSize: 10,
|
||||
color: error ? 'red' : 'lightgray',
|
||||
}}>{error || 'Loading (#dev check console if loading too long)...'}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 400 }}>
|
||||
<div>
|
||||
<Button rootRef={firstButton} disabled={!focusedWorld} onClick={() => onWorldAction('load', focusedWorld)}>LOAD WORLD</Button>
|
||||
<Button onClick={() => onGeneralAction('create')}>Create New World</Button>
|
||||
<Button onClick={() => onGeneralAction('create')} disabled={isReadonly}>Create New World</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button style={{ width: 100 }} disabled={!focusedWorld} onClick={() => onWorldAction('export', focusedWorld)}>Export</Button>
|
||||
|
|
|
|||
|
|
@ -1,29 +1,68 @@
|
|||
import fs from 'fs'
|
||||
import { proxy, useSnapshot } from 'valtio'
|
||||
import { useEffect } from 'react'
|
||||
import { proxy, subscribe, useSnapshot } from 'valtio'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { loadScript } from 'prismarine-viewer/viewer/lib/utils'
|
||||
import { fsState, loadSave, longArrayToNumber, readLevelDat } from '../loadSave'
|
||||
import { mountExportFolder, removeFileRecursiveAsync } from '../browserfs'
|
||||
import { googleDriveGetFileIdFromPath, mountExportFolder, mountGoogleDriveFolder, removeFileRecursiveAsync } from '../browserfs'
|
||||
import { hideCurrentModal, showModal } from '../globalState'
|
||||
import { haveDirectoryPicker, setLoadingScreenStatus } from '../utils'
|
||||
import { exportWorld } from '../builtinCommands'
|
||||
import { googleProviderData, useGoogleLogIn, GoogleDriveProvider, isGoogleDriveAvailable } from '../googledrive'
|
||||
import Singleplayer, { WorldProps } from './Singleplayer'
|
||||
import { useIsModalActive } from './utils'
|
||||
import { showOptionsModal } from './SelectOption'
|
||||
import Input from './Input'
|
||||
import GoogleButton from './GoogleButton'
|
||||
|
||||
const worldsProxy = proxy({ value: null as null | WorldProps[] })
|
||||
const worldsProxy = proxy({
|
||||
value: null as null | WorldProps[],
|
||||
selectedProvider: 'local' as 'local' | 'google',
|
||||
error: '',
|
||||
})
|
||||
|
||||
export const readWorlds = () => {
|
||||
export const getWorldsPath = () => {
|
||||
return worldsProxy.selectedProvider === 'local' ? `/data/worlds` : worldsProxy.selectedProvider === 'google' ? `/google/${googleProviderData.worldsPath.replace(/\/$/, '')}` : ''
|
||||
}
|
||||
|
||||
const providersEnableFeatures = {
|
||||
local: {
|
||||
calculateSize: true,
|
||||
delete: true,
|
||||
export: true,
|
||||
},
|
||||
google: {
|
||||
calculateSize: false,
|
||||
// TODO
|
||||
delete: false,
|
||||
export: false,
|
||||
}
|
||||
}
|
||||
|
||||
export const readWorlds = (abortController: AbortController) => {
|
||||
if (abortController.signal.aborted) return
|
||||
worldsProxy.error = '';
|
||||
(async () => {
|
||||
try {
|
||||
const loggedIn = !!googleProviderData.accessToken
|
||||
worldsProxy.value = null
|
||||
const worlds = await fs.promises.readdir(`/data/worlds`)
|
||||
worldsProxy.value = (await Promise.allSettled(worlds.map(async (folder) => {
|
||||
const { levelDat } = (await readLevelDat(`/data/worlds/${folder}`))!
|
||||
if (worldsProxy.selectedProvider === 'google' && !loggedIn) {
|
||||
worldsProxy.value = []
|
||||
return
|
||||
}
|
||||
const worldsPath = getWorldsPath()
|
||||
const provider = worldsProxy.selectedProvider
|
||||
|
||||
const worlds = await fs.promises.readdir(worldsPath)
|
||||
|
||||
const newMappedWorlds = (await Promise.allSettled(worlds.map(async (folder) => {
|
||||
const { levelDat } = (await readLevelDat(`${worldsPath}/${folder}`))!
|
||||
let size = 0
|
||||
// todo use whole dir size
|
||||
for (const region of await fs.promises.readdir(`/data/worlds/${folder}/region`)) {
|
||||
const stat = await fs.promises.stat(`/data/worlds/${folder}/region/${region}`)
|
||||
size += stat.size
|
||||
if (providersEnableFeatures[provider].calculateSize) {
|
||||
// todo use whole dir size
|
||||
for (const region of await fs.promises.readdir(`${worldsPath}/${folder}/region`)) {
|
||||
const stat = await fs.promises.stat(`${worldsPath}/${folder}/region/${region}`)
|
||||
size += stat.size
|
||||
}
|
||||
}
|
||||
const levelName = levelDat.LevelName as string | undefined
|
||||
return {
|
||||
|
|
@ -40,11 +79,18 @@ export const readWorlds = () => {
|
|||
}
|
||||
return true
|
||||
}).map(x => (x as Extract<typeof x, { value }>).value)
|
||||
if (abortController.signal.aborted) return
|
||||
worldsProxy.value = newMappedWorlds
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return
|
||||
console.warn(err)
|
||||
worldsProxy.value = []
|
||||
worldsProxy.value = null
|
||||
worldsProxy.error = err.message
|
||||
}
|
||||
})()
|
||||
})().catch((err) => {
|
||||
// todo it still doesn't work for some reason!
|
||||
worldsProxy.error = err.message
|
||||
})
|
||||
}
|
||||
|
||||
export const loadInMemorySave = async (worldPath: string) => {
|
||||
|
|
@ -56,32 +102,125 @@ export const loadInMemorySave = async (worldPath: string) => {
|
|||
}
|
||||
|
||||
export default () => {
|
||||
const worlds = useSnapshot(worldsProxy).value as WorldProps[] | null
|
||||
const active = useIsModalActive('singleplayer')
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
readWorlds()
|
||||
}, [active])
|
||||
|
||||
if (!active) return null
|
||||
|
||||
return <GoogleDriveProvider>
|
||||
<Inner />
|
||||
</GoogleDriveProvider>
|
||||
}
|
||||
|
||||
const Inner = () => {
|
||||
const worlds = useSnapshot(worldsProxy).value as WorldProps[] | null
|
||||
const { selectedProvider, error } = useSnapshot(worldsProxy)
|
||||
const readWorldsAbortController = useRef(new AbortController())
|
||||
|
||||
// 3rd party providers
|
||||
useEffect(() => {
|
||||
if (selectedProvider !== 'google') return
|
||||
void loadScript('https://apis.google.com/js/api.js').then(async (scriptEl) => {
|
||||
if (!scriptEl) return // already loaded
|
||||
gapi.load('client', () => {
|
||||
void gapi.client.load('https://www.googleapis.com/discovery/v1/apis/drive/v3/rest').then(() => {
|
||||
googleProviderData.isReady = true
|
||||
})
|
||||
})
|
||||
})
|
||||
}, [selectedProvider])
|
||||
|
||||
const loggedIn = !!useSnapshot(googleProviderData).accessToken
|
||||
const googleDriveReadonly = useSnapshot(googleProviderData).readonlyMode
|
||||
const { worldsPath } = useSnapshot(googleProviderData)
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (selectedProvider === 'google') {
|
||||
await mountGoogleDriveFolder(googleProviderData.readonlyMode)
|
||||
}
|
||||
if (selectedProvider === 'local' && !(await fs.promises.stat('/data/worlds').catch(() => false))) {
|
||||
await fs.promises.mkdir('/data/worlds')
|
||||
}
|
||||
readWorlds(readWorldsAbortController.current)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
readWorldsAbortController.current.abort()
|
||||
readWorldsAbortController.current = new AbortController()
|
||||
}
|
||||
}, [selectedProvider, loggedIn, worldsPath, googleDriveReadonly])
|
||||
|
||||
const googleLogIn = useGoogleLogIn()
|
||||
|
||||
const isGoogleProviderReady = useSnapshot(googleProviderData).isReady
|
||||
const providerActions = selectedProvider === 'google' ? isGoogleProviderReady ? loggedIn ? {
|
||||
'Log Out' () {
|
||||
googleProviderData.hasEverLoggedIn = false
|
||||
googleProviderData.accessToken = null
|
||||
// TODO revoke token
|
||||
},
|
||||
async [`Read Only: ${googleDriveReadonly ? 'ON' : 'OFF'}`] () {
|
||||
if (googleProviderData.readonlyMode) {
|
||||
const choice = await showOptionsModal('[Unstable Feature] Enabling world save might corrupt your worlds, eg remove entities (note: you can always restore previous version of files in Drive)', ['Continue'])
|
||||
if (choice !== 'Continue') return
|
||||
}
|
||||
googleProviderData.readonlyMode = !googleProviderData.readonlyMode
|
||||
},
|
||||
'Worlds Path': <Input rootStyles={{ width: 100 }} placeholder='Worlds path' defaultValue={worldsPath} onBlur={(e) => {
|
||||
googleProviderData.worldsPath = e.target.value
|
||||
}} />
|
||||
} : {
|
||||
'Log In': <GoogleButton onClick={googleLogIn} />
|
||||
} : {
|
||||
'Loading...' () { }
|
||||
} : undefined
|
||||
// end
|
||||
|
||||
return <Singleplayer
|
||||
error={error}
|
||||
isReadonly={selectedProvider === 'google' && (googleDriveReadonly || !isGoogleProviderReady)}
|
||||
providers={{
|
||||
local: 'Local',
|
||||
google: 'Google Drive',
|
||||
}}
|
||||
disabledProviders={[...isGoogleDriveAvailable() ? [] : ['google']]}
|
||||
worldData={worlds}
|
||||
providerActions={providerActions}
|
||||
activeProvider={selectedProvider}
|
||||
setActiveProvider={(provider) => {
|
||||
worldsProxy.selectedProvider = provider as any
|
||||
}}
|
||||
onWorldAction={async (action, worldName) => {
|
||||
const worldPath = `/data/worlds/${worldName}`
|
||||
const worldPath = `${getWorldsPath()}/${worldName}`
|
||||
const openInGoogleDrive = () => {
|
||||
const fileId = googleDriveGetFileIdFromPath(worldPath.replace('/google/', ''))
|
||||
if (!fileId) return alert('File not found')
|
||||
window.open(`https://drive.google.com/drive/folders/${fileId}`)
|
||||
}
|
||||
|
||||
if (action === 'load') {
|
||||
setLoadingScreenStatus(`Starting loading world ${worldName}`)
|
||||
await loadInMemorySave(worldPath)
|
||||
return
|
||||
}
|
||||
if (action === 'delete') {
|
||||
if (selectedProvider === 'google') {
|
||||
openInGoogleDrive()
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm('Are you sure you want to delete current world')) return
|
||||
setLoadingScreenStatus(`Removing world ${worldName}`)
|
||||
await removeFileRecursiveAsync(worldPath)
|
||||
setLoadingScreenStatus(undefined)
|
||||
readWorlds()
|
||||
readWorlds(readWorldsAbortController.current)
|
||||
}
|
||||
if (action === 'export') {
|
||||
if (selectedProvider === 'google') {
|
||||
openInGoogleDrive()
|
||||
return
|
||||
}
|
||||
|
||||
const selectedVariant =
|
||||
haveDirectoryPicker()
|
||||
? await showOptionsModal('Select export type', ['Select folder (recommended)', 'Download ZIP file'])
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import Slider from './Slider'
|
|||
const meta: Meta<typeof Slider> = {
|
||||
component: Slider,
|
||||
args: {
|
||||
label: 'hapiness',
|
||||
label: 'happiness',
|
||||
value: 0,
|
||||
updateValue (value) {
|
||||
console.log('updateValue', value)
|
||||
|
|
|
|||
23
src/react/Tabs.stories.tsx
Normal file
23
src/react/Tabs.stories.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import Tabs from './Tabs'
|
||||
|
||||
const meta: Meta<typeof Tabs> = {
|
||||
component: Tabs,
|
||||
args: {
|
||||
tabs: [
|
||||
'Tab 1',
|
||||
'Tab 2',
|
||||
],
|
||||
activeTab: 'Tab 1',
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Tabs>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
fullSize: true,
|
||||
},
|
||||
}
|
||||
50
src/react/Tabs.tsx
Normal file
50
src/react/Tabs.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import Button from './Button'
|
||||
|
||||
interface Props {
|
||||
tabs: string[]
|
||||
activeTab: string
|
||||
onTabChange: (tab: string) => void
|
||||
|
||||
disabledTabs?: string[]
|
||||
labels?: Record<string, string>
|
||||
fullSize?: boolean
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export default ({ tabs, activeTab, labels, onTabChange, fullSize, style, disabledTabs }: Props) => {
|
||||
return <div style={{
|
||||
width: fullSize ? '100%' : undefined,
|
||||
display: fullSize ? 'flex' : undefined,
|
||||
...style,
|
||||
}}>
|
||||
{tabs.map(tab => {
|
||||
const active = tab === activeTab
|
||||
return <div key={tab} style={{
|
||||
position: 'relative',
|
||||
width: fullSize ? '100%' : 150,
|
||||
}}>
|
||||
<Button disabled={active || disabledTabs?.includes(tab)} style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// background: active ? 'rgb(77, 77, 77)' : 'rgb(114, 114, 114)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: 9,
|
||||
padding: '2px 0px',
|
||||
}} onClick={() => {
|
||||
onTabChange(tab)
|
||||
}}>{labels?.[tab] ?? tab}</Button>
|
||||
{active && <div style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 1,
|
||||
background: 'white',
|
||||
width: '50%',
|
||||
margin: 'auto',
|
||||
}} />}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -46,9 +46,9 @@ if (hideStats) {
|
|||
export const initWithRenderer = (canvas) => {
|
||||
if (hideStats) return
|
||||
statsGl.init(canvas)
|
||||
if (statsGl.gpuPanel && process.env.NODE_ENV !== 'production') {
|
||||
addStatsGlStat(statsGl.gpuPanel.canvas)
|
||||
}
|
||||
// if (statsGl.gpuPanel && process.env.NODE_ENV !== 'production') {
|
||||
// addStatsGlStat(statsGl.gpuPanel.canvas)
|
||||
// }
|
||||
// addStatsGlStat(statsGl.msPanel.canvas)
|
||||
statsGl.container.style.display = 'flex'
|
||||
statsGl.container.style.justifyContent = 'flex-end'
|
||||
|
|
|
|||
|
|
@ -24,5 +24,8 @@
|
|||
"src",
|
||||
"cypress",
|
||||
"prismarine-viewer/viewer"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue