feat: implement Google Drive storage provider (#87)

This commit is contained in:
Vitaly 2024-03-17 05:25:21 +03:00 committed by GitHub
commit c04c9cfdad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 573 additions and 72 deletions

View file

@ -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
View file

@ -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:

View file

@ -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
View 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
}
})

View file

@ -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
View 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%;
}

View 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>
}

View file

@ -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>
}

View file

@ -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={() => { }}
/>

View file

@ -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>

View file

@ -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'])

View 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)

View 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
View 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>
}

View file

@ -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'

View file

@ -24,5 +24,8 @@
"src",
"cypress",
"prismarine-viewer/viewer"
],
"exclude": [
"node_modules",
]
}