diff --git a/package.json b/package.json index a21c60b3..18077e28 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91a178d0..983ff2b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/browserfs.ts b/src/browserfs.ts index 13c266ac..978f704d 100644 --- a/src/browserfs.ts +++ b/src/browserfs.ts @@ -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(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 { diff --git a/src/googledrive.ts b/src/googledrive.ts new file mode 100644 index 00000000..3c9c89b0 --- /dev/null +++ b/src/googledrive.ts @@ -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 +} + +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 + } +}) diff --git a/src/react/CreateWorldProvider.tsx b/src/react/CreateWorldProvider.tsx index 2bf0b321..58208f95 100644 --- a/src/react/CreateWorldProvider.tsx +++ b/src/react/CreateWorldProvider.tsx @@ -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') { diff --git a/src/react/GoogleButton.css b/src/react/GoogleButton.css new file mode 100644 index 00000000..e90da816 --- /dev/null +++ b/src/react/GoogleButton.css @@ -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%; + } diff --git a/src/react/GoogleButton.tsx b/src/react/GoogleButton.tsx new file mode 100644 index 00000000..4effecb6 --- /dev/null +++ b/src/react/GoogleButton.tsx @@ -0,0 +1,22 @@ +import './GoogleButton.css' + +export default ({ onClick }) => { + return +} diff --git a/src/react/Input.tsx b/src/react/Input.tsx index 0fc8be3a..d2e264cb 100644 --- a/src/react/Input.tsx +++ b/src/react/Input.tsx @@ -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(null!) const isTouch = useUsingTouch() @@ -15,7 +16,7 @@ export default ({ autoFocus, ...inputProps }: Props) => { ref.current.focus() }, []) - return
+ return
} diff --git a/src/react/Singleplayer.stories.tsx b/src/react/Singleplayer.stories.tsx index 27b5a61d..83b93b24 100644 --- a/src/react/Singleplayer.stories.tsx +++ b/src/react/Singleplayer.stories.tsx @@ -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={() => { }} /> diff --git a/src/react/Singleplayer.tsx b/src/react/Singleplayer.tsx index c35be9d9..e806c02b 100644 --- a/src/react/Singleplayer.tsx +++ b/src/react/Singleplayer.tsx @@ -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 + activeProvider?: string + setActiveProvider?: (provider: string) => void + providerActions?: Record 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() const firstButton = useRef(null!) @@ -79,6 +89,10 @@ export default ({ worldData, onGeneralAction, onWorldAction }: Props) => { const [search, setSearch] = useState('') const [focusedWorld, setFocusedWorld] = useState('') + useEffect(() => { + setFocusedWorld('') + }, [activeProvider]) + return
@@ -87,24 +101,42 @@ export default ({ worldData, onGeneralAction, onWorldAction }: Props) => { setSearch(value)} />
- { - worldData - ? worldData.filter(data => data.title.toLowerCase().includes(search.toLowerCase())).map(({ name, title, size, lastPlayed, detail }) => ( - { - if (interaction === 'enter') onWorldAction('load', name) - else if (interaction === 'space') firstButton.current?.focus() - }} detail={detail} /> - )) - :
Loading...
- } + { + setActiveProvider?.(tab as any) + }} fullSize /> +
+ { + providerActions &&
+ Actions: {Object.entries(providerActions).map(([label, action]) => ( + typeof action === 'function' ? : {action} + ))} +
+ } + { + worldData + ? worldData.filter(data => data.title.toLowerCase().includes(search.toLowerCase())).map(({ name, title, size, lastPlayed, detail }) => ( + { + if (interaction === 'enter') onWorldAction('load', name) + else if (interaction === 'space') firstButton.current?.focus() + }} detail={detail} /> + )) + :
{error || 'Loading (#dev check console if loading too long)...'}
+ } +
- +
diff --git a/src/react/SingleplayerProvider.tsx b/src/react/SingleplayerProvider.tsx index 6d9232f6..6b23d60e 100644 --- a/src/react/SingleplayerProvider.tsx +++ b/src/react/SingleplayerProvider.tsx @@ -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).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 + + +} + +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': { + googleProviderData.worldsPath = e.target.value + }} /> + } : { + 'Log In': + } : { + 'Loading...' () { } + } : undefined + // end + return { + 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']) diff --git a/src/react/Slider.stories.tsx b/src/react/Slider.stories.tsx index 5ad55fca..6b52d7be 100644 --- a/src/react/Slider.stories.tsx +++ b/src/react/Slider.stories.tsx @@ -5,7 +5,7 @@ import Slider from './Slider' const meta: Meta = { component: Slider, args: { - label: 'hapiness', + label: 'happiness', value: 0, updateValue (value) { console.log('updateValue', value) diff --git a/src/react/Tabs.stories.tsx b/src/react/Tabs.stories.tsx new file mode 100644 index 00000000..3b3b8565 --- /dev/null +++ b/src/react/Tabs.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import Tabs from './Tabs' + +const meta: Meta = { + component: Tabs, + args: { + tabs: [ + 'Tab 1', + 'Tab 2', + ], + activeTab: 'Tab 1', + }, +} + +export default meta +type Story = StoryObj; + +export const Primary: Story = { + args: { + fullSize: true, + }, +} diff --git a/src/react/Tabs.tsx b/src/react/Tabs.tsx new file mode 100644 index 00000000..4e750a8a --- /dev/null +++ b/src/react/Tabs.tsx @@ -0,0 +1,50 @@ +import Button from './Button' + +interface Props { + tabs: string[] + activeTab: string + onTabChange: (tab: string) => void + + disabledTabs?: string[] + labels?: Record + fullSize?: boolean + style?: React.CSSProperties +} + +export default ({ tabs, activeTab, labels, onTabChange, fullSize, style, disabledTabs }: Props) => { + return
+ {tabs.map(tab => { + const active = tab === activeTab + return
+ + {active &&
} +
+ })} +
+} diff --git a/src/topRightStats.ts b/src/topRightStats.ts index e1d3f62a..a105ac6b 100644 --- a/src/topRightStats.ts +++ b/src/topRightStats.ts @@ -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' diff --git a/tsconfig.json b/tsconfig.json index fce887b3..47a363c3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,8 @@ "src", "cypress", "prismarine-viewer/viewer" + ], + "exclude": [ + "node_modules", ] }