diff --git a/package-lock.json b/package-lock.json index 4e2b8815..96440e9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "@vitejs/plugin-react": "^4.3.1", "classnames": "^2.5.1", "cross-env": "7.0.3", - "decentraland-ui2": "^0.6.0", + "decentraland-ui2": "^0.6.1", "electron": "31.1.0", "electron-builder": "24.13.3", "eslint": "8.57.0", @@ -9454,9 +9454,9 @@ } }, "node_modules/decentraland-ui2": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/decentraland-ui2/-/decentraland-ui2-0.6.0.tgz", - "integrity": "sha512-Wgsge+Yt4pJPR7nAD2ATEvNeS0IOD/kbLeVhozCFnTToMr1qZNklPCvEc+pX5JAXqIQur5kb86b4lR7sV7SqhQ==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/decentraland-ui2/-/decentraland-ui2-0.6.1.tgz", + "integrity": "sha512-DmQeKsAdgcCw60BWSxGa8R/e3mwyugXkBifd1rNZ24J+AdgKz36wG9nU/MYG2KNpZgftQQw7OcgMAzq6zBfctw==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index d3877df7..c03ff496 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@vitejs/plugin-react": "^4.3.1", "classnames": "^2.5.1", "cross-env": "7.0.3", - "decentraland-ui2": "^0.6.0", + "decentraland-ui2": "^0.6.1", "electron": "31.1.0", "electron-builder": "24.13.3", "eslint": "8.57.0", diff --git a/packages/main/src/modules/bin.ts b/packages/main/src/modules/bin.ts index 89d6ec06..eb154cce 100644 --- a/packages/main/src/modules/bin.ts +++ b/packages/main/src/modules/bin.ts @@ -186,17 +186,23 @@ export class StreamError extends ErrorBase { } } +export type StreamType = 'all' | 'stdout' | 'stderr'; + export type Child = { pkg: string; bin: string; args: string[]; cwd: string; process: Electron.UtilityProcess; - on: (pattern: RegExp, handler: (data?: string) => void) => number; - once: (pattern: RegExp, handler: (data?: string) => void) => number; + on: (pattern: RegExp, handler: (data?: string) => void, streamType?: StreamType) => number; + once: (pattern: RegExp, handler: (data?: string) => void, streamType?: StreamType) => number; off: (index: number) => void; wait: () => Promise; - waitFor: (resolvePattern: RegExp, rejectPattern?: RegExp) => Promise; + waitFor: ( + resolvePattern: RegExp, + rejectPattern?: RegExp, + opts?: { resolve?: StreamType; reject?: StreamType }, + ) => Promise; kill: () => Promise; alive: () => boolean; }; @@ -205,6 +211,7 @@ type Matcher = { pattern: RegExp; handler: (data: string) => void; enabled: boolean; + streamType: StreamType; }; type RunOptions = { @@ -245,13 +252,13 @@ export function run(pkg: string, bin: string, options: RunOptions = {}): Child { const stdout: Uint8Array[] = []; forked.stdout!.on('data', (data: Buffer) => { - handleData(data, matchers); + handleData(data, matchers, 'stdout'); stdout.push(Uint8Array.from(data)); }); const stderr: Uint8Array[] = []; forked.stderr!.on('data', (data: Buffer) => { - handleData(data, matchers); + handleData(data, matchers, 'stderr'); stderr.push(Uint8Array.from(data)); }); @@ -287,30 +294,27 @@ export function run(pkg: string, bin: string, options: RunOptions = {}): Child { } }); - function handleStream(stream: NodeJS.ReadableStream) { - stream!.on('data', (data: Buffer) => handleData(data, matchers)); - } - - handleStream(forked.stdout!); - handleStream(forked.stderr!); - const child: Child = { pkg, bin, args, cwd, process: forked, - on: (pattern, handler) => { + on: (pattern, handler, streamType = 'all') => { if (alive) { - return matchers.push({ pattern, handler, enabled: true }) - 1; + return matchers.push({ pattern, handler, enabled: true, streamType }) - 1; } throw new Error('Process has been killed'); }, - once: (pattern, handler) => { - const index = child.on(pattern, data => { - handler(data); - child.off(index); - }); + once: (pattern, handler, streamType) => { + const index = child.on( + pattern, + data => { + handler(data); + child.off(index); + }, + streamType, + ); return index; }, off: index => { @@ -319,11 +323,11 @@ export function run(pkg: string, bin: string, options: RunOptions = {}): Child { } }, wait: () => promise, - waitFor: (resolvePattern, rejectPattern) => + waitFor: (resolvePattern, rejectPattern, opts) => new Promise((resolve, reject) => { - child.once(resolvePattern, data => resolve(data!)); + child.once(resolvePattern, data => resolve(data!), opts?.resolve); if (rejectPattern) { - child.once(rejectPattern, data => reject(new Error(data))); + child.once(rejectPattern, data => reject(new Error(data)), opts?.reject); } }), kill: async () => { @@ -382,14 +386,21 @@ export function run(pkg: string, bin: string, options: RunOptions = {}): Child { return child; } -async function handleData(buffer: Buffer, matchers: Matcher[]) { +async function handleData(buffer: Buffer, matchers: Matcher[], type: StreamType) { const data = buffer.toString('utf8'); log.info(`[UtilityProcess] ${data}`); // pipe data to console - for (const { pattern, handler, enabled } of matchers) { + for (const { pattern, handler, enabled, streamType } of matchers) { if (!enabled) continue; + if (streamType !== 'all' && streamType !== type) continue; pattern.lastIndex = 0; // reset regexp if (pattern.test(data)) { - handler(data); + // remove control characters from data + const text = data.replace( + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + '', + ); + handler(text); } } } diff --git a/packages/main/src/modules/cli.ts b/packages/main/src/modules/cli.ts index 35ceb296..30cd2737 100644 --- a/packages/main/src/modules/cli.ts +++ b/packages/main/src/modules/cli.ts @@ -44,6 +44,7 @@ export async function deploy({ path, target, targetContent }: DeployOptions) { deployServer = run('@dcl/sdk-commands', 'sdk-commands', { args: [ 'deploy', + '--no-browser', '--port', port.toString(), ...(target ? ['--target', target] : []), @@ -54,7 +55,11 @@ export async function deploy({ path, target, targetContent }: DeployOptions) { }); // App ready at - await deployServer.waitFor(/app ready at/i); + await deployServer.waitFor(/listening/i, /error:/i, { reject: 'stderr' }); + + deployServer.waitFor(/close the terminal/gi).then(() => deployServer?.kill()); + + deployServer.wait().catch(); // handle rejection of main promise to avoid warnings in console return port; } diff --git a/packages/main/src/modules/electron.ts b/packages/main/src/modules/electron.ts index 17a767d1..9f299386 100644 --- a/packages/main/src/modules/electron.ts +++ b/packages/main/src/modules/electron.ts @@ -1,6 +1,6 @@ import path from 'path'; import fs from 'fs/promises'; -import { app, BrowserWindow, dialog, type OpenDialogOptions, shell } from 'electron'; +import { app, BrowserWindow, clipboard, dialog, type OpenDialogOptions, shell } from 'electron'; export function getHome() { return app.getPath('home'); @@ -35,3 +35,7 @@ export async function openExternal(url: string) { export async function getAppVersion() { return app.getVersion(); } + +export async function copyToClipboard(text: string) { + clipboard.writeText(text); +} diff --git a/packages/main/src/modules/handle.ts b/packages/main/src/modules/handle.ts index 179fd5df..9f55a631 100644 --- a/packages/main/src/modules/handle.ts +++ b/packages/main/src/modules/handle.ts @@ -1,6 +1,6 @@ import { ipcMain } from 'electron'; import log from 'electron-log'; -import type { Ipc } from '/shared/types/ipc'; +import type { Ipc, IpcError, IpcResult } from '/shared/types/ipc'; // wrapper for ipcMain.handle with types export async function handle( @@ -14,11 +14,19 @@ export async function handle( .map((arg, idx) => `args[${idx}]=${JSON.stringify(arg)}`) .join(' ')}`.trim(), ); - const result = await handler(event, ...(args as Parameters)); + const value = await handler(event, ...(args as Parameters)); + const result: IpcResult = { + success: true, + value, + }; return result; } catch (error: any) { log.error(`[IPC] channel=${channel} error=${error.message}`); - throw error; + const result: IpcError = { + success: false, + error: error.message, + }; + return result; } }); } diff --git a/packages/main/src/modules/ipc.ts b/packages/main/src/modules/ipc.ts index 6aaf6c9d..20fc1c25 100644 --- a/packages/main/src/modules/ipc.ts +++ b/packages/main/src/modules/ipc.ts @@ -15,6 +15,7 @@ export function initIpc() { ); handle('electron.showOpenDialog', (_event, opts) => electron.showOpenDialog(opts)); handle('electron.openExternal', (_event, url) => electron.openExternal(url)); + handle('electron.copyToClipboard', (_event, text) => electron.copyToClipboard(text)); // inspector handle('inspector.start', () => inspector.start()); diff --git a/packages/preload/src/modules/editor.ts b/packages/preload/src/modules/editor.ts index 670ddf6f..2cb503c3 100644 --- a/packages/preload/src/modules/editor.ts +++ b/packages/preload/src/modules/editor.ts @@ -2,10 +2,6 @@ import type { DeployOptions } from '/shared/types/ipc'; import { invoke } from './invoke'; -export async function getWorkspaceConfigPath(_path: string) { - return invoke('electron.getWorkspaceConfigPath', _path); -} - export async function getVersion() { return invoke('electron.getAppVersion'); } diff --git a/packages/preload/src/modules/invoke.ts b/packages/preload/src/modules/invoke.ts index 5bbda222..c25d2ebe 100644 --- a/packages/preload/src/modules/invoke.ts +++ b/packages/preload/src/modules/invoke.ts @@ -1,10 +1,17 @@ import { ipcRenderer } from 'electron'; -import type { Ipc } from '/shared/types/ipc'; +import type { Ipc, IpcError, IpcResult } from '/shared/types/ipc'; // wrapper for ipcRenderer.invoke with types export async function invoke( channel: T, ...args: Parameters ): Promise> { - return ipcRenderer.invoke(channel, ...args); + const result = await (ipcRenderer.invoke(channel, ...args) as Promise< + IpcResult> | IpcError + >); + if (result.success) { + return result.value; + } else { + throw new Error(result.error); + } } diff --git a/packages/preload/src/modules/misc.ts b/packages/preload/src/modules/misc.ts index 0caeac70..84ed37ce 100644 --- a/packages/preload/src/modules/misc.ts +++ b/packages/preload/src/modules/misc.ts @@ -6,3 +6,7 @@ export async function openExternal(url: string) { if (!isUrl(url)) throw new Error('Invalid URL provided'); await invoke('electron.openExternal', url); } + +export async function copyToClipboard(text: string) { + await invoke('electron.copyToClipboard', text); +} diff --git a/packages/preload/src/modules/workspace.ts b/packages/preload/src/modules/workspace.ts index 4d13923e..e733ec3e 100644 --- a/packages/preload/src/modules/workspace.ts +++ b/packages/preload/src/modules/workspace.ts @@ -7,6 +7,7 @@ import { type DependencyState, SortBy, type Project } from '/shared/types/projec import { PACKAGES_LIST } from '/shared/types/pkg'; import { DEFAULT_DEPENDENCY_UPDATE_STRATEGY } from '/shared/types/settings'; import type { Template, Workspace } from '/shared/types/workspace'; +import { FileSystemStorage } from '/shared/types/storage'; import { getConfig, setConfig } from './config'; import { exists, writeFile as deepWriteFile } from './fs'; @@ -18,7 +19,6 @@ import { getDefaultScenesPath, getScenesPath } from './settings'; import { getScene } from './scene'; import { DEFAULT_THUMBNAIL, NEW_SCENE_NAME, EMPTY_SCENE_TEMPLATE_REPO } from './constants'; -import { getWorkspaceConfigPath } from './editor'; import { getProjectId } from './analytics'; /** @@ -54,7 +54,7 @@ export async function hasNodeModules(_path: string) { } export async function getOldProjectThumbnailPath(_path: string) { - const workspaceConfigPath = await getWorkspaceConfigPath(_path); + const workspaceConfigPath = await getConfigPath(_path); return path.join(workspaceConfigPath, 'images', 'project-thumbnail.png'); } @@ -374,3 +374,14 @@ export async function openFolder(_path: string) { const error = await shell.openPath(_path); if (error) throw new Error(error); } + +export async function getConfigPath(_path: string) { + return invoke('electron.getWorkspaceConfigPath', _path); +} + +export async function getProjectInfo(_path: string) { + const configPath = await getConfigPath(_path); + const projectInfoPath = path.join(configPath, 'project.json'); + const projectInfo = await FileSystemStorage.getOrCreate(projectInfoPath); + return projectInfo; +} diff --git a/packages/renderer/src/components/Button/styles.css b/packages/renderer/src/components/Button/styles.css index 531edc3d..5862e56b 100644 --- a/packages/renderer/src/components/Button/styles.css +++ b/packages/renderer/src/components/Button/styles.css @@ -45,3 +45,15 @@ .Button.MuiButton-colorInfo:hover { background-color: var(--light-gray); } + +.Button.MuiButton-outlined { + color: var(--dcl) !important; + background-color: transparent; + border-color: var(--dcl); +} + +.Button.MuiButton-outlined:hover { + color: var(--dcl) !important; + background-color: transparent; + border-color: var(--dcl-dark); +} diff --git a/packages/renderer/src/components/EditorPage/component.tsx b/packages/renderer/src/components/EditorPage/component.tsx index c3830302..73ed9c9d 100644 --- a/packages/renderer/src/components/EditorPage/component.tsx +++ b/packages/renderer/src/components/EditorPage/component.tsx @@ -8,7 +8,6 @@ import PublicIcon from '@mui/icons-material/Public'; import { useSelector } from '#store'; -import { DEPLOY_URLS } from '/shared/types/deploy'; import { isWorkspaceError } from '/shared/types/workspace'; import { t } from '/@/modules/store/translation/utils'; @@ -17,7 +16,7 @@ import { useEditor } from '/@/hooks/useEditor'; import EditorPng from '/assets/images/editor.png'; -import { PublishProject, type StepValue } from '../Modals/PublishProject'; +import { PublishProject } from '../Modals/PublishProject'; import { Button } from '../Button'; import { Header } from '../Header'; import { Row } from '../Row'; @@ -35,7 +34,6 @@ export function EditorPage() { saveAndGetThumbnail, inspectorPort, openPreview, - publishScene, openCode, updateScene, loadingPreview, @@ -89,24 +87,6 @@ export function EditorPage() { setOpen(undefined); }, []); - const handleSubmitModal = useCallback( - ({ target, value }: StepValue) => { - switch (target) { - case 'worlds': - return publishScene({ - targetContent: import.meta.env.VITE_WORLDS_SERVER || DEPLOY_URLS.WORLDS, - }); - case 'test': - return publishScene({ target: import.meta.env.VITE_TEST_SERVER || DEPLOY_URLS.TEST }); - case 'custom': - return publishScene({ target: value }); - default: - return publishScene(); - } - }, - [isReady], - ); - // inspector url const htmlUrl = `http://localhost:${import.meta.env.VITE_INSPECTOR_PORT || inspectorPort}`; let binIndexJsUrl = `${htmlUrl}/bin/index.js`; @@ -213,7 +193,6 @@ export function EditorPage() { open={open === 'publish'} project={project} onClose={handleCloseModal} - onSubmit={handleSubmitModal} /> )} diff --git a/packages/renderer/src/components/Loader/styles.css b/packages/renderer/src/components/Loader/styles.css index 876f4e63..aade5f89 100644 --- a/packages/renderer/src/components/Loader/styles.css +++ b/packages/renderer/src/components/Loader/styles.css @@ -1,6 +1,6 @@ .Loader { width: 100%; - height: 100vh; + height: 100%; display: flex; justify-content: center; align-items: center; diff --git a/packages/renderer/src/components/Modals/PublishProject/PublishModal/component.tsx b/packages/renderer/src/components/Modals/PublishProject/PublishModal/component.tsx new file mode 100644 index 00000000..49ffb743 --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/PublishModal/component.tsx @@ -0,0 +1,45 @@ +import { styled } from 'decentraland-ui2'; +import { Modal as BaseModal } from 'decentraland-ui2/dist/components/Modal/Modal'; +import { type ModalProps } from 'decentraland-ui2/dist/components/Modal/Modal.types'; + +function noop() {} + +const Modal = styled(BaseModal)(props => ({ + '& > .MuiPaper-root .MuiBox-root:first-child': { + paddingBottom: 8, + }, + '& > .MuiPaper-root .MuiBox-root:first-child h5': { + lineHeight: '2em', + }, + '& > .MuiPaper-root > h6': { + textAlign: 'center', + color: 'var(--dcl-silver)', + }, + '& > .MuiBackdrop-root': { + transition: 'none!important', + }, + '& > .MuiPaper-root': { + backgroundColor: 'var(--darker-gray)', + backgroundImage: 'none', + }, + '& [aria-label="back"]': + props.onBack !== noop + ? {} + : { + opacity: 0, + cursor: 'default', + }, +})); + +export function PublishModal(props: React.PropsWithChildren) { + const { onBack, ...rest } = props; + return ( + + {props.children} + + ); +} diff --git a/packages/renderer/src/components/Modals/PublishProject/PublishModal/index.ts b/packages/renderer/src/components/Modals/PublishProject/PublishModal/index.ts new file mode 100644 index 00000000..d236bc7f --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/PublishModal/index.ts @@ -0,0 +1 @@ +export { PublishModal } from './component'; diff --git a/packages/renderer/src/components/Modals/PublishProject/PublishToLand/component.tsx b/packages/renderer/src/components/Modals/PublishProject/PublishToLand/component.tsx deleted file mode 100644 index 3d1840f8..00000000 --- a/packages/renderer/src/components/Modals/PublishProject/PublishToLand/component.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { useCallback, useMemo, useState } from 'react'; -import { Box, Button, Typography } from 'decentraland-ui2'; -import { Atlas } from 'decentraland-ui2/dist/components/Atlas/Atlas'; -import type { SceneParcels } from '@dcl/schemas'; - -import { useSelector } from '#store'; - -import { DEPLOY_URLS } from '/shared/types/deploy'; -import type { Project } from '/shared/types/projects'; - -import { t } from '/@/modules/store/translation/utils'; -import { selectors as landSelectors } from '/@/modules/store/land'; -import { useEditor } from '/@/hooks/useEditor'; -import { useWorkspace } from '/@/hooks/useWorkspace'; - -import { COLORS, type Coordinate } from './types'; - -function calculateParcels(project: Project, point: Coordinate): Coordinate[] { - const [baseX, baseY] = project.scene.base.split(',').map(coord => parseInt(coord, 10)); - return project.scene.parcels.map(parcel => { - const [x, y] = parcel.split(',').map(coord => parseInt(coord, 10)); - return { x: x - baseX + point.x, y: y - baseY + point.y }; - }); -} - -export function PublishToLand({ onClose }: { onClose: () => void }) { - const { project, publishScene } = useEditor(); - const { updateProject, updateSceneJson } = useWorkspace(); - const tiles = useSelector(state => state.land.tiles); - const landTiles = useSelector(state => landSelectors.getLandTiles(state.land)); - const [hover, setHover] = useState({ x: 0, y: 0 }); - const [placement, setPlacement] = useState(null); - - if (!project) return null; - - // Memoize the project parcels centered around the hover position - const projectParcels = useMemo(() => calculateParcels(project, hover), [project, hover]); - - const handleClickPublish = useCallback(() => { - publishScene({ target: import.meta.env.VITE_CATALYST_SERVER || DEPLOY_URLS.CATALYST_SERVER }); - onClose(); - }, [publishScene, onClose]); - - const handleHover = useCallback((x: number, y: number) => { - setHover({ x, y }); - }, []); - - const isHighlighted = useCallback( - (x: number, y: number) => - !placement && projectParcels.some(parcel => parcel.x === x && parcel.y === y), - [placement, projectParcels], - ); - - const isPlaced = useCallback( - (x: number, y: number) => { - if (!placement) return false; - const placedParcels = calculateParcels(project, placement); - return placedParcels.some(parcel => parcel.x === x && parcel.y === y); - }, - [project, placement], - ); - - const isValid = useMemo(() => { - return hover && projectParcels.every(({ x, y }) => !!landTiles[`${x},${y}`]); - }, [landTiles, hover, projectParcels]); - - const strokeLayer = useCallback( - (x: number, y: number) => { - const placed = isPlaced(x, y); - if (isHighlighted(x, y) || placed) { - return { - color: isValid || placed ? COLORS.selectedStroke : COLORS.indicatorStroke, - scale: 1.5, - }; - } - return null; - }, - [isHighlighted, isValid, isPlaced], - ); - - const highlightLayer = useCallback( - (x: number, y: number) => { - const placed = isPlaced(x, y); - if (isHighlighted(x, y) || placed) { - return { color: isValid || placed ? COLORS.selected : COLORS.indicator, scale: 1.2 }; - } - return null; - }, - [isHighlighted, isValid, isPlaced], - ); - - const ownedLayer = useCallback( - (x: number, y: number) => { - const key = `${x},${y}`; - return landTiles[key] && landTiles[key].land.owner === tiles[key].owner - ? { color: COLORS.freeParcel } - : null; - }, - [tiles, landTiles], - ); - - const handlePlacement = useCallback( - (x: number, y: number) => { - if (!isValid) return; - - const newPlacement = { x, y }; - setPlacement(newPlacement); - - const sceneUpdates: SceneParcels = { - base: `${x},${y}`, - parcels: calculateParcels(project, newPlacement).map(({ x, y }) => `${x},${y}`), - }; - - updateSceneJson(project.path, { scene: sceneUpdates }); - updateProject({ - ...project, - scene: sceneUpdates, - worldConfiguration: undefined, // Cannot deploy to a LAND with a world configuration - updatedAt: Date.now(), - }); - }, - [project, isValid, updateProject, updateSceneJson], - ); - - const handleClearPlacement = useCallback(() => { - setPlacement(null); - }, []); - - return ( - - - {/* @ts-expect-error TODO: Update properties in UI2, making the not required `optional` */} - - - - - - {placement - ? t('modal.publish_project.land.select_parcel.place_scene', { - coords: `${placement.x},${placement.y}`, - }) - : t('modal.publish_project.land.select_parcel.select_parcel')} - - {placement && ( - - )} - - - - - ); -} diff --git a/packages/renderer/src/components/Modals/PublishProject/component.tsx b/packages/renderer/src/components/Modals/PublishProject/component.tsx index 943b863c..7eed760e 100644 --- a/packages/renderer/src/components/Modals/PublishProject/component.tsx +++ b/packages/renderer/src/components/Modals/PublishProject/component.tsx @@ -1,183 +1,50 @@ -import { type ChangeEvent, useCallback, useState } from 'react'; -import { MenuItem, Select, type SelectChangeEvent } from 'decentraland-ui2'; -import { Modal } from 'decentraland-ui2/dist/components/Modal/Modal'; - -import { misc } from '#preload'; -import { isUrl } from '/shared/utils'; -import { t } from '/@/modules/store/translation/utils'; - -import GenesisPlazaPng from '/assets/images/genesis_plaza.png'; -import LandPng from '/assets/images/land.png'; -import WorldsPng from '/assets/images/worlds.png'; - -import { Button } from '../../Button'; -import { OptionBox } from '../../EditorPage/OptionBox'; -import { PublishToWorld } from './PublishToWorld'; -import { PublishToLand } from './PublishToLand'; - -import type { AlternativeTarget, Step, StepProps, StepValue, Props } from './types'; - -import './styles.css'; - -export function PublishProject({ open, project, onSubmit, onClose }: Props) { - const [step, setStep] = useState('initial'); - - const close = useCallback(() => { - setStep('initial'); - onClose(); - }, []); - - const handleClose = useCallback(() => close(), []); - - const handleChangeStep = useCallback( - (step: Step) => () => { - setStep(step); - }, - [], +import { useCallback, useMemo, useState } from 'react'; +import { Initial } from './steps/Initial'; +import { AlternativeServers } from './steps/AlternativeServers'; +import { PublishToWorld } from './steps/PublishToWorld'; +import { PublishToLand } from './steps/PublishToLand'; +import { Deploy } from './steps/Deploy'; + +import type { Props, Step } from './types'; + +export function PublishProject({ open, project, onClose }: Omit) { + const [history, setHistory] = useState([]); + const step = useMemo( + () => (history.length > 0 ? history[history.length - 1] : 'initial'), + [history], ); - const handleClickPublish = useCallback((value: StepValue) => { - onSubmit(value); - close(); - }, []); - - return ( - - {step === 'initial' && } - {step === 'alternative-servers' && } - {step === 'publish-to-land' && } - {step === 'publish-to-world' && } - - ); -} - -function Initial({ onStepChange }: { onStepChange: (step: Step) => () => void }) { - return ( -
- {t('modal.publish_project.select')} -
- - -
- - {t('modal.publish_project.alternative_servers.title')} - -
- ); -} - -function AlternativeServers({ onClick }: StepProps) { - const [option, setOption] = useState('test'); - const [customUrl, setCustomUrl] = useState(''); - const [error, setError] = useState(''); - - const handleClick = useCallback(() => { - if (option === 'custom' && !isUrl(customUrl)) { - return setError(t('modal.publish_project.alternative_servers.errors.url')); - } - const value: StepValue = { target: option, value: customUrl }; - onClick(value); - }, [option, customUrl]); + const handleClose = useCallback(() => { + setHistory([]); + onClose(); + }, [setHistory, onClose]); - const handleChangeSelect = useCallback((e: SelectChangeEvent) => { - setOption(e.target.value as AlternativeTarget); - }, []); + const handleBack = useCallback(() => { + setHistory(history => (history.length > 0 ? history.slice(0, -1) : [])); + }, [history, setHistory]); - const handleChangeCustom = useCallback( - (e: ChangeEvent) => { - if (error) setError(''); - setCustomUrl(e.target.value); + const handleStep = useCallback( + (newStep: Step) => { + setHistory(history => [...history, newStep]); }, - [error], + [setHistory], ); - const handleClickLearnMore = useCallback(() => { - if (option === 'custom') { - return misc.openExternal( - 'https://docs.decentraland.org/creator/development-guide/sdk7/publishing/#custom-servers', - ); - } - misc.openExternal( - 'https://docs.decentraland.org/creator/development-guide/sdk7/publishing/#the-test-server', - ); - }, [option]); + const props: Props = { + open, + project, + onClose: handleClose, + onBack: handleBack, + onStep: handleStep, + }; return ( -
- {t('modal.publish_project.select')} -
-
-
-

{t('modal.publish_project.alternative_servers.list')}

- - {option === 'custom' && ( -
- - {t('modal.publish_project.alternative_servers.custom_server_url')} - - - {error} -
- )} -
- -
-
- - {t('option_box.learn_more')} - - -
-
-
+ <> + {step === 'initial' && } + {step === 'alternative-servers' && } + {step === 'publish-to-land' && } + {step === 'publish-to-world' && } + {step === 'deploy' && } + ); } diff --git a/packages/renderer/src/components/Modals/PublishProject/index.ts b/packages/renderer/src/components/Modals/PublishProject/index.ts index 0d3eda45..8e2b6e6f 100644 --- a/packages/renderer/src/components/Modals/PublishProject/index.ts +++ b/packages/renderer/src/components/Modals/PublishProject/index.ts @@ -1,3 +1,2 @@ import { PublishProject } from './component'; -import type { StepValue } from './types'; -export { PublishProject, StepValue }; +export { PublishProject }; diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/AlternativeServers/component.tsx b/packages/renderer/src/components/Modals/PublishProject/steps/AlternativeServers/component.tsx new file mode 100644 index 00000000..54d45cd0 --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/AlternativeServers/component.tsx @@ -0,0 +1,115 @@ +import { type ChangeEvent, useCallback, useState } from 'react'; +import { Button, MenuItem, Select, type SelectChangeEvent } from 'decentraland-ui2'; +import { misc } from '#preload'; +import { t } from '/@/modules/store/translation/utils'; +import { isUrl } from '/shared/utils'; +import GenesisPlazaPng from '/assets/images/genesis_plaza.png'; +import type { AlternativeTarget, Props } from '../../types'; + +import './styles.css'; +import { useEditor } from '/@/hooks/useEditor'; +import { PublishModal } from '../../PublishModal'; + +export function AlternativeServers(props: Props) { + const { publishScene } = useEditor(); + const [option, setOption] = useState('test'); + const [customUrl, setCustomUrl] = useState(''); + const [error, setError] = useState(''); + + const handleClick = useCallback(() => { + if (option === 'custom' && !isUrl(customUrl)) { + return setError(t('modal.publish_project.alternative_servers.errors.url')); + } + if (option === 'test') { + void publishScene(); + } else if (option === 'custom') { + if (!isUrl(customUrl)) { + return setError(t('modal.publish_project.alternative_servers.errors.url')); + } + void publishScene({ target: customUrl }); + } else { + throw new Error('Invalid option'); + } + props.onStep('deploy'); + }, [option, customUrl, props.onStep, publishScene]); + + const handleChangeSelect = useCallback((e: SelectChangeEvent) => { + setOption(e.target.value as AlternativeTarget); + }, []); + + const handleChangeCustom = useCallback( + (e: ChangeEvent) => { + if (error) setError(''); + setCustomUrl(e.target.value); + }, + [error], + ); + + const handleClickLearnMore = useCallback(() => { + if (option === 'custom') { + return misc.openExternal( + 'https://docs.decentraland.org/creator/development-guide/sdk7/publishing/#custom-servers', + ); + } + misc.openExternal( + 'https://docs.decentraland.org/creator/development-guide/sdk7/publishing/#the-test-server', + ); + }, [option]); + + return ( + +
+
+
+
+

{t('modal.publish_project.alternative_servers.list')}

+ + {option === 'custom' && ( +
+ + {t('modal.publish_project.alternative_servers.custom_server_url')} + + + {error} +
+ )} +
+ +
+
+ + {t('option_box.learn_more')} + + +
+
+
+
+ ); +} diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/AlternativeServers/index.ts b/packages/renderer/src/components/Modals/PublishProject/steps/AlternativeServers/index.ts new file mode 100644 index 00000000..33ff9f08 --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/AlternativeServers/index.ts @@ -0,0 +1 @@ +export { AlternativeServers } from './component'; diff --git a/packages/renderer/src/components/Modals/PublishProject/styles.css b/packages/renderer/src/components/Modals/PublishProject/steps/AlternativeServers/styles.css similarity index 81% rename from packages/renderer/src/components/Modals/PublishProject/styles.css rename to packages/renderer/src/components/Modals/PublishProject/steps/AlternativeServers/styles.css index e14bdcae..f2d3990e 100644 --- a/packages/renderer/src/components/Modals/PublishProject/styles.css +++ b/packages/renderer/src/components/Modals/PublishProject/steps/AlternativeServers/styles.css @@ -1,31 +1,5 @@ /* TODO: update the Modal component in ui2 to allow providing a className to the Material Modal...*/ -/* Initial Modal */ -.MuiModal-root .Initial { - display: flex; - flex-direction: column; -} - -.MuiModal-root .Initial .select, -.MuiModal-root .Initial .alternative_servers { - text-align: center; -} - -.MuiModal-root .Initial .options { - display: flex; - margin: 20px 0; -} - -.MuiModal-root .Initial .options .OptionBox + .OptionBox { - margin-left: 20px; -} - -.MuiModal-root .Initial .alternative_servers { - text-decoration: underline; - text-transform: uppercase; - cursor: pointer; -} - /* AlternativeServers Modal */ .MuiModal-root .AlternativeServers { display: flex; diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/component.tsx b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/component.tsx new file mode 100644 index 00000000..984e5d49 --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/component.tsx @@ -0,0 +1,375 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { type AuthChain, Authenticator } from '@dcl/crypto'; +import { localStorageGetIdentity } from '@dcl/single-sign-on-client'; +import { ChainId } from '@dcl/schemas'; +import { Typography, Checkbox } from 'decentraland-ui2'; +import { misc, workspace } from '#preload'; +import type { IFileSystemStorage } from '/shared/types/storage'; +import { t } from '/@/modules/store/translation/utils'; +import { Loader } from '/@/components/Loader'; +import { useEditor } from '/@/hooks/useEditor'; +import { useIsMounted } from '/@/hooks/useIsMounted'; +import { useAuth } from '/@/hooks/useAuth'; +import { addBase64ImagePrefix } from '/@/modules/image'; +import { PublishModal } from '../../PublishModal'; +import { Button } from '../../../../Button'; +import { type Props } from '../../types'; +import type { File, Info } from './types'; +import './styles.css'; + +const MAX_FILE_PATH_LENGTH = 50; + +function getPath(filename: string) { + return filename.length > MAX_FILE_PATH_LENGTH + ? `${filename.slice(0, MAX_FILE_PATH_LENGTH / 2)}...${filename.slice( + -(MAX_FILE_PATH_LENGTH / 2), + )}` + : filename; +} + +const KB = 1024; +const MB = KB * 1024; +const GB = MB * 1024; + +function getSize(size: number) { + if (size < KB) { + return `${size.toFixed(2)} B`; + } + if (size < MB) { + return `${(size / KB).toFixed(2)} KB`; + } + if (size < GB) { + return `${(size / MB).toFixed(2)} MB`; + } + return `${(size / GB).toFixed(2)} GB`; +} + +export function Deploy(props: Props) { + const infoRef = useRef(); + const { chainId, wallet, avatar } = useAuth(); + const [files, setFiles] = useState([]); + const [info, setInfo] = useState(null); + const { loadingPublish, publishPort, project, publishError } = useEditor(); + const isMounted = useIsMounted(); + const [isDeploying, setIsDeploying] = useState(false); + const [error, setError] = useState(null); + const [isSuccessful, setIsSuccessful] = useState(false); + const [showWarning, setShowWarning] = useState(false); + const [skipWarning, setSkipWarning] = useState(false); + + // read skip warning flag + useEffect(() => { + if (project) { + workspace.getProjectInfo(project.path).then(info => { + info.get('skipPublishWarning').then(value => { + setSkipWarning(!!value); + }); + infoRef.current = info; + }); + } + }, [project]); + + const url = useMemo(() => { + const port = import.meta.env.VITE_CLI_DEPLOY_PORT || publishPort; + return port ? `http://localhost:${port}/api` : null; + }, [publishPort]); + + const handlePublish = useCallback(() => { + if (!url) return; + setShowWarning(false); + async function deploy(payload: { address: string; authChain: AuthChain; chainId: ChainId }) { + setIsDeploying(true); + setError(null); + const resp = await fetch(`${url}/deploy`, { + method: 'post', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + if (resp.ok) { + return; + } + let error = (await resp.json()).message as string; + if (/Response was/.test(error)) { + try { + error = error.split('["')[1].split('"]')[0]; + } catch (e) { + /* */ + } + } + throw new Error(error); + } + if (wallet && info && info.rootCID) { + const identity = localStorageGetIdentity(wallet); + if (identity && chainId) { + const authChain = Authenticator.signPayload(identity, info.rootCID); + void deploy({ address: wallet, authChain, chainId }) + .then(() => { + if (!isMounted()) return; + setIsDeploying(false); + setIsSuccessful(true); + }) + .catch(error => { + setIsDeploying(false); + setError(error.message); + }); + } else { + setError('Invalid identity or chainId'); + } + } + // write skip warning flag + infoRef.current?.set('skipPublishWarning', skipWarning); + }, [wallet, info, url, chainId, skipWarning]); + + const handleBack = useCallback(() => { + setShowWarning(false); + setSkipWarning(false); + }, []); + + useEffect(() => { + if (!url || isSuccessful) return; + async function fetchFiles() { + const resp = await fetch(`${url}/files`); + const files = await resp.json(); + return files as File[]; + } + async function fetchInfo() { + const resp = await fetch(`${url}/info`); + const info = await resp.json(); + return info as Info; + } + void Promise.all([fetchFiles(), fetchInfo()]) + .then(([files, info]) => { + if (!isMounted()) return; + setFiles(files); + setInfo(info); + }) + .catch(); + }, [url, isSuccessful]); + + // set publish error + useEffect(() => { + if (publishError) { + setError(publishError); + } + }, [publishError, setError]); + + // jump in + const jumpInUrl = useMemo(() => { + if (info && project) { + if (info.isWorld) { + if (project.worldConfiguration) { + return `http://decentraland.org/play/world/${project.worldConfiguration.name}`; + } + } else { + return `http://decentraland.org/play?position=${project.scene.base}`; + } + } + return null; + }, [info, project]); + + const handleJumpIn = useCallback(() => { + if (info && project) { + if (info.isWorld) { + if (project.worldConfiguration) { + void misc.openExternal(`decentraland://?realm=${project.worldConfiguration.name}`); + } + } else { + void misc.openExternal(`decentraland://?position=${project.scene.base}`); + } + } + }, [info, project]); + + return ( + +
+ {showWarning ? ( +
+
+
+
+ {t('modal.publish_project.deploy.warning.message', { + ul: (child: string) =>
    {child}
, + li: (child: string) =>
  • {child}
  • , + })} +
    +
    +
    + + + + + +
    +
    + ) : null} + {loadingPublish ? ( + + ) : error ? ( + <> +
    +
    +

    {error}

    +
    + + ) : !info ? null : ( + <> +
    +
    + {chainId === ChainId.ETHEREUM_MAINNET + ? t('modal.publish_project.deploy.ethereum.mainnet') + : t('modal.publish_project.deploy.ethereum.testnet')} +
    + {wallet ? ( +
    + {wallet.slice(0, 6)}...{wallet.slice(-4)} +
    + ) : null} + {info.isWorld ? ( + avatar ? ( +
    + {avatar.name} + {avatar.hasClaimedName ? : null} +
    + ) : null + ) : ( +
    + + {info.baseParcel} +
    + )} +
    +
    +
    + {project ? ( +
    + ) : null} +
    + {info.title} + + {info.description} + +
    +
    + {!isSuccessful ? ( +
    +
    +
    + {t('modal.publish_project.deploy.files.count', { count: files.length })} +
    +
    + {t('modal.publish_project.deploy.files.size', { + size: getSize(files.reduce((total, file) => total + file.size, 0)), + b: (child: string) => {child}, + })} +
    +
    +
    + {files.map(file => ( +
    +
    + {getPath(file.name)} +
    +
    {getSize(file.size)}
    +
    + ))} +
    +
    +

    {error}

    + +
    +
    + ) : ( +
    +
    + +
    + {t('modal.publish_project.deploy.success.message')} +
    +
    + +
    + {jumpInUrl} + jumpInUrl && misc.copyToClipboard(jumpInUrl)} + /> +
    +
    +
    +
    + +
    +
    + )} +
    + + )} +
    + + ); +} diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/copy.svg b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/copy.svg new file mode 100644 index 00000000..79c78bb4 --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/copy.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/deploy.svg b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/deploy.svg new file mode 100644 index 00000000..93cf28cd --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/deploy.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/jump-in.svg b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/jump-in.svg new file mode 100644 index 00000000..f80b5781 --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/jump-in.svg @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/pin.svg b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/pin.svg new file mode 100644 index 00000000..d8f3ea56 --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/pin.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/success.svg b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/success.svg new file mode 100644 index 00000000..9f1a2ffe --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/success.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/verified.svg b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/verified.svg new file mode 100644 index 00000000..046adbcd --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/verified.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/warning.svg b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/warning.svg new file mode 100644 index 00000000..1e1dec42 --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/images/warning.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/index.ts b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/index.ts new file mode 100644 index 00000000..7d6651e1 --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/index.ts @@ -0,0 +1 @@ +export { Deploy } from './component'; diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/styles.css b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/styles.css new file mode 100644 index 00000000..84fcb8cd --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/styles.css @@ -0,0 +1,283 @@ +.Deploy { + position: relative; + min-height: 300px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: stretch; + gap: 24px; +} + +.Deploy > .Loader { + top: -40px; + position: relative; +} + +.Deploy .ethereum { + display: flex; + gap: 8px; +} + +.Deploy .chip { + min-height: 24px; + border-radius: 16px; + padding: 4px 10px; + font-size: 13px; + line-height: 18px; + background-color: rgba(255, 255, 255, 0.08); + font-weight: 600; + display: flex; + align-items: center; + gap: 4px; +} + +.Deploy .chip.network { + background-color: #1f87e5; + text-transform: uppercase; +} + +.Deploy .chip.username .verified { + display: inline-flex; + width: 16px; + height: 16px; + background: center no-repeat url(images/verified.svg); +} + +.Deploy .chip.parcel .pin { + display: inline-flex; + width: 16px; + height: 16px; + background: center no-repeat url(images/pin.svg); +} + +.Deploy .scene { + display: flex; + gap: 32px; +} + +.Deploy .scene .info { + flex: 0 0 auto; + width: 256px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.Deploy .scene .info .thumbnail { + width: 100%; + height: 160px; + background-position: center; + background-size: cover; + background-repeat: no-repeat; + border-radius: 8px; +} + +.Deploy .scene .info .text { + display: flex; + flex-direction: column; + gap: 8px; +} + +.Deploy .scene .files { + display: flex; + flex-direction: column; + gap: 16px; + justify-content: stretch; + flex: 1 1 auto; +} + +.Deploy .scene .files .filters { + display: flex; + flex: 0 0 auto; + justify-content: space-between; + color: #a09ba8; + background-color: rgba(255, 255, 255, 0.05); + height: 48px; + align-items: center; + padding: 0px 16px; + border-radius: 48px; +} + +.Deploy .scene .files .filters .count { + flex: none; + text-transform: uppercase; +} + +.Deploy .scene .files .filters .size { + flex: none; +} + +.Deploy .scene .files .list { + flex: 1 1 auto; + display: flex; + flex-direction: column; + overflow: auto; + max-height: 400px; +} + +.Deploy .scene .files .list .file { + display: flex; + justify-content: space-between; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); + padding: 6px 16px; + color: #a09ba8; +} + +.Deploy .scene .files .list .file:last-child { + border-bottom: none; +} + +.Deploy .scene .files .actions { + flex: 0 0 auto; + display: flex; + justify-content: space-between; + align-items: center; +} + +.Deploy .scene .files .Button .deploy-icon { + width: 24px; + height: 24px; + background: center no-repeat url(images/deploy.svg); + margin-left: 8px; +} + +.Deploy .scene .files .Button .Loader { + margin-left: 12px; +} + +.Deploy .scene .Button { + font-size: 15px; + padding: 8px 22px; + max-height: 40px; + flex: none; +} + +.Deploy .scene .files .error { + color: var(--error); +} + +.Deploy .cli-publish-error { + color: var(--dcl-silver); + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 32px; +} + +.Deploy .Warning { + width: 81px; + height: 70px; + background: center no-repeat url(images/warning.svg); +} + +.Deploy .scene .success { + flex: 1 1 auto; + display: flex; + flex-direction: column; + padding-left: 32px; + border-left: 1px solid var(--dark-gray); +} + +.Deploy .scene .success .content { + flex: 1 1 auto; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 16px; + min-height: 380px; + padding-bottom: 80px; +} + +.Deploy .scene .success .content .success-icon { + width: 80px; + height: 80px; + background: center no-repeat url(images/success.svg); +} + +.Deploy .scene .success .content .message { + color: var(--dcl-silver); + font-size: 20px; + font-weight: 500; + text-transform: uppercase; +} + +.Deploy .scene .success .content .url { + color: var(--light-gray); + border: 1px solid var(--dark-gray); + font-size: 13px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.46px; + padding: 4px 10px; + border-radius: 10px; + display: flex; + align-items: center; +} + +.Deploy .scene .success .content .jump-in-url label { + color: var(--dcl-silver); + line-height: 24px; + font-size: 14px; + letter-spacing: 0.17px; + font-weight: 500; + margin-bottom: 2px; + display: block; +} + +.Deploy .scene .success .content .jump-in-url .copy-icon { + width: 18px; + height: 18px; + background: center no-repeat url(images/copy.svg); + margin-left: 8px; + display: inline-block; + cursor: pointer; +} + +.Deploy .scene .success .actions { + flex: none; + display: flex; + justify-content: flex-end; +} + +.Deploy .scene .success .Button .jump-in-icon { + width: 24px; + height: 24px; + background: center no-repeat url(images/jump-in.svg); + margin-left: 8px; +} + +.Deploy .publish-warning { + position: absolute; + width: 600px; + background-color: var(--black); + padding: 32px 32px; + border-radius: 8px; + display: flex; + align-items: stretch; + justify-content: center; + flex-direction: column; + gap: 16px; + top: 40px; + left: calc(50% - 300px); +} + +.Deploy .publish-warning .content { + display: flex; + align-items: flex-start; + justify-content: center; + flex-direction: column; + gap: 16px; + color: var(--dcl-silver); +} + +.Deploy .publish-warning .actions { + display: flex; + justify-content: space-between; +} + +.Deploy .publish-warning .dont-show-again { + display: flex; + align-items: center; +} diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/types.ts b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/types.ts new file mode 100644 index 00000000..ec6eb7b7 --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/Deploy/types.ts @@ -0,0 +1,16 @@ +export type File = { + name: string; + size: number; +}; + +export type Info = { + baseParcel: string; + debug: boolean; + description: string; + isPortableExperience: boolean; + isWorld: boolean; + parcels: string[]; + rootCID: string; + skipValidations: boolean; + title: string; +}; diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/Initial/component.tsx b/packages/renderer/src/components/Modals/PublishProject/steps/Initial/component.tsx new file mode 100644 index 00000000..791fe37b --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/Initial/component.tsx @@ -0,0 +1,70 @@ +import { OptionBox } from '/@/components/EditorPage/OptionBox'; +import { t } from '/@/modules/store/translation/utils'; +import LandPng from '/assets/images/land.png'; +import WorldsPng from '/assets/images/worlds.png'; +import type { Props } from '../../types'; + +import './styles.css'; +import { useAuth } from '/@/hooks/useAuth'; +import { Button } from 'decentraland-ui2'; +import { PublishModal } from '../../PublishModal'; + +export function Initial(props: Props) { + const { isSignedIn, signIn } = useAuth(); + const { onBack: _, ...rest } = props; + if (!isSignedIn) { + return ( + +
    + +
    +
    + ); + } else { + return ( + +
    +
    + props.onStep('publish-to-world')} + learnMoreUrl="https://docs.decentraland.org/creator/worlds/about/#publish-a-world" + /> + props.onStep('publish-to-land')} + learnMoreUrl="https://docs.decentraland.org/creator/development-guide/sdk7/publishing-permissions/#land-permission-options" + /> +
    + props.onStep('alternative-servers')} + > + {t('modal.publish_project.alternative_servers.title')} + +
    +
    + ); + } +} diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/Initial/index.ts b/packages/renderer/src/components/Modals/PublishProject/steps/Initial/index.ts new file mode 100644 index 00000000..58143fdf --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/Initial/index.ts @@ -0,0 +1 @@ +export { Initial } from './component'; diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/Initial/styles.css b/packages/renderer/src/components/Modals/PublishProject/steps/Initial/styles.css new file mode 100644 index 00000000..534925a4 --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/Initial/styles.css @@ -0,0 +1,27 @@ +/* TODO: update the Modal component in ui2 to allow providing a className to the Material Modal...*/ + +/* Initial Modal */ +.MuiModal-root .Initial { + display: flex; + flex-direction: column; +} + +.MuiModal-root .Initial .select, +.MuiModal-root .Initial .alternative_servers { + text-align: center; +} + +.MuiModal-root .Initial .options { + display: flex; + margin: 20px 0; +} + +.MuiModal-root .Initial .options .OptionBox + .OptionBox { + margin-left: 20px; +} + +.MuiModal-root .Initial .alternative_servers { + text-decoration: underline; + text-transform: uppercase; + cursor: pointer; +} diff --git a/packages/renderer/src/components/Modals/PublishProject/steps/PublishToLand/component.tsx b/packages/renderer/src/components/Modals/PublishProject/steps/PublishToLand/component.tsx new file mode 100644 index 00000000..65501ba3 --- /dev/null +++ b/packages/renderer/src/components/Modals/PublishProject/steps/PublishToLand/component.tsx @@ -0,0 +1,243 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Box, Button, styled, Typography } from 'decentraland-ui2'; +import { Atlas } from 'decentraland-ui2/dist/components/Atlas/Atlas'; +import type { SceneParcels } from '@dcl/schemas'; + +import { useSelector } from '#store'; + +import { DEPLOY_URLS } from '/shared/types/deploy'; +import type { Project } from '/shared/types/projects'; + +import { t } from '/@/modules/store/translation/utils'; +import { selectors as landSelectors } from '/@/modules/store/land'; +import { useEditor } from '/@/hooks/useEditor'; +import { useWorkspace } from '/@/hooks/useWorkspace'; + +import { COLORS, type Coordinate } from './types'; +import { type Props } from '../../types'; +import { PublishModal } from '../../PublishModal'; + +function parseCoords(coords: string) { + return coords.split(',').map(coord => parseInt(coord, 10)) as [number, number]; +} +function calculateParcels(project: Project, point: Coordinate): Coordinate[] { + const [baseX, baseY] = parseCoords(project.scene.base); + return project.scene.parcels.map(parcel => { + const [x, y] = parseCoords(parcel); + return { x: x - baseX + point.x, y: y - baseY + point.y }; + }); +} + +const PublishToLandModal = styled(PublishModal)({ + '& > .MuiPaper-root > .MuiBox-root:last-child': { + padding: 0, + }, +}); + +export function PublishToLand(props: Props) { + const { project, publishScene } = useEditor(); + const { updateProject, updateSceneJson } = useWorkspace(); + const tiles = useSelector(state => state.land.tiles); + const landTiles = useSelector(state => landSelectors.getLandTiles(state.land)); + const [hover, setHover] = useState({ x: 0, y: 0 }); + const [placement, setPlacement] = useState(null); + const [initialPlacement, setInitialPlacement] = useState(null); + const [didAutoPlace, setDidAutoPlace] = useState(false); + + if (!project) return null; + + // Memoize the project parcels centered around the hover position + const projectParcels = useMemo(() => calculateParcels(project, hover), [project, hover]); + + const handleNext = useCallback(async () => { + if (!placement) return; + const sceneUpdates: SceneParcels = { + base: `${placement.x},${placement.y}`, + parcels: calculateParcels(project, placement).map(({ x, y }) => `${x},${y}`), + }; + await updateSceneJson(project.path, { scene: sceneUpdates, worldConfiguration: undefined }); + updateProject({ + ...project, + scene: sceneUpdates, + worldConfiguration: undefined, // Cannot deploy to a LAND with a world configuration + updatedAt: Date.now(), + }); + void publishScene({ + target: import.meta.env.VITE_CATALYST_SERVER || DEPLOY_URLS.CATALYST_SERVER, + }); + props.onStep('deploy'); + }, [placement, props.onStep]); + + const handleHover = useCallback((x: number, y: number) => { + setHover({ x, y }); + }, []); + + const isHighlighted = useCallback( + (x: number, y: number) => + !placement && projectParcels.some(parcel => parcel.x === x && parcel.y === y), + [placement, projectParcels], + ); + + const isPlaced = useCallback( + (x: number, y: number) => { + if (!placement) return false; + const placedParcels = calculateParcels(project, placement); + return placedParcels.some(parcel => parcel.x === x && parcel.y === y); + }, + [project, placement], + ); + + const isValid = useMemo(() => { + return hover && projectParcels.every(({ x, y }) => !!landTiles[`${x},${y}`]); + }, [landTiles, hover, projectParcels]); + + const strokeLayer = useCallback( + (x: number, y: number) => { + const placed = isPlaced(x, y); + if (isHighlighted(x, y) || placed) { + return { + color: isValid || placed ? COLORS.selectedStroke : COLORS.indicatorStroke, + scale: 1.5, + }; + } + return null; + }, + [isHighlighted, isValid, isPlaced], + ); + + const highlightLayer = useCallback( + (x: number, y: number) => { + const placed = isPlaced(x, y); + if (isHighlighted(x, y) || placed) { + return { color: isValid || placed ? COLORS.selected : COLORS.indicator, scale: 1.2 }; + } + return null; + }, + [isHighlighted, isValid, isPlaced], + ); + + const ownedLayer = useCallback( + (x: number, y: number) => { + const key = `${x},${y}`; + return landTiles[key] && landTiles[key].land.owner === tiles[key].owner + ? { color: COLORS.freeParcel } + : null; + }, + [tiles, landTiles], + ); + + const handlePlacement = useCallback( + (x: number, y: number) => { + if (!isValid) return; + setPlacement({ x, y }); + }, + [project, isValid], + ); + + const handleClearPlacement = useCallback(() => { + setPlacement(null); + }, []); + + // set initial placement + useEffect(() => { + if (!initialPlacement) { + const { base, parcels } = project.scene; + // use the base parcel if it's a valid coord + if (base in landTiles && parcels.every(parcel => parcel in landTiles)) { + const [x, y] = parseCoords(base); + setInitialPlacement({ x, y }); + } else { + // if the base parcel in the scene.json is not valid (ie. it is 0,0) then select the first coord of the available tiles as the initial placement + const available = Object.keys(landTiles); + if (available.length > 0) { + const [x, y] = parseCoords(available[0]); + setInitialPlacement({ x, y }); + } + } + } + }, [initialPlacement, setInitialPlacement, project, landTiles]); + + // use initial placement if possible + useEffect(() => { + if (didAutoPlace) return; + if (!placement && initialPlacement) { + const initialPlacementParcels = calculateParcels(project, initialPlacement); + if (initialPlacementParcels.every(({ x, y }) => !!landTiles[`${x},${y}`])) { + setPlacement(initialPlacement); + setDidAutoPlace(true); + } + } + }, [placement, setPlacement, initialPlacement, didAutoPlace, setDidAutoPlace, landTiles]); + + return ( + + + + {/* @ts-expect-error TODO: Update properties in UI2, making the not required `optional` */} + + + + + + {placement + ? t('modal.publish_project.land.select_parcel.place_scene', { + coords: `${placement.x},${placement.y}`, + }) + : t('modal.publish_project.land.select_parcel.select_parcel')} + + {placement && ( + + )} + + + + + + ); +} diff --git a/packages/renderer/src/components/Modals/PublishProject/PublishToLand/index.ts b/packages/renderer/src/components/Modals/PublishProject/steps/PublishToLand/index.ts similarity index 100% rename from packages/renderer/src/components/Modals/PublishProject/PublishToLand/index.ts rename to packages/renderer/src/components/Modals/PublishProject/steps/PublishToLand/index.ts diff --git a/packages/renderer/src/components/Modals/PublishProject/PublishToLand/types.ts b/packages/renderer/src/components/Modals/PublishProject/steps/PublishToLand/types.ts similarity index 100% rename from packages/renderer/src/components/Modals/PublishProject/PublishToLand/types.ts rename to packages/renderer/src/components/Modals/PublishProject/steps/PublishToLand/types.ts diff --git a/packages/renderer/src/components/Modals/PublishProject/PublishToWorld/component.tsx b/packages/renderer/src/components/Modals/PublishProject/steps/PublishToWorld/component.tsx similarity index 83% rename from packages/renderer/src/components/Modals/PublishProject/PublishToWorld/component.tsx rename to packages/renderer/src/components/Modals/PublishProject/steps/PublishToWorld/component.tsx index 03cd343c..29ad2678 100644 --- a/packages/renderer/src/components/Modals/PublishProject/PublishToWorld/component.tsx +++ b/packages/renderer/src/components/Modals/PublishProject/steps/PublishToWorld/component.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { ChainId } from '@dcl/schemas/dist/dapps/chain-id'; import { Checkbox, CircularProgress as Loader, @@ -23,7 +22,6 @@ import { t } from '/@/modules/store/translation/utils'; import { addBase64ImagePrefix } from '/@/modules/image'; import { ENSProvider } from '/@/modules/store/ens/types'; import { getEnsProvider } from '/@/modules/store/ens/utils'; -import { useAuth } from '/@/hooks/useAuth'; import { useEditor } from '/@/hooks/useEditor'; import { useWorkspace } from '/@/hooks/useWorkspace'; @@ -31,32 +29,41 @@ import EmptyWorldSVG from '/assets/images/empty-deploy-to-world.svg'; import LogoDCLSVG from '/assets/images/logo-dcl.svg'; import LogoENSSVG from '/assets/images/logo-ens.svg'; -import { Button } from '../../../Button'; +import { PublishModal } from '../../PublishModal'; +import { Button } from '../../../../Button'; +import { type Props } from '../../types'; import './styles.css'; -export function PublishToWorld({ onClose }: { onClose: () => void }) { +export function PublishToWorld(props: Props) { const { project, publishScene } = useEditor(); const names = useSelector(state => state.ens.data); const emptyNames = Object.keys(names).length === 0; - const handleClickPublish = useCallback(() => { + const handleNext = useCallback(() => { publishScene({ targetContent: import.meta.env.VITE_WORLDS_SERVER || DEPLOY_URLS.WORLDS }); - onClose(); - }, []); + props.onStep('deploy'); + }, [props.onStep, publishScene]); - return emptyNames ? ( - - ) : ( - + return ( + + {!emptyNames ? ( + + ) : ( + + )} + ); } function SelectWorld({ project, onPublish }: { project: Project; onPublish: () => void }) { - const { chainId } = useAuth(); const { updateSceneJson, updateProject } = useWorkspace(); const names = useSelector(state => state.ens.data); const [name, setName] = useState(project.worldConfiguration?.name || ''); @@ -79,13 +86,6 @@ function SelectWorld({ project, onPublish }: { project: Project; onPublish: () = return _names; }, [names, ensProvider]); - const getExplorerUrl = useMemo(() => { - if (chainId === ChainId.ETHEREUM_SEPOLIA) { - return `decentraland://?realm=${DEPLOY_URLS.DEV_WORLDS}/world/${name}&NETWORK=sepolia`; - } - return `decentraland://?realm=${name}`; - }, [name]); - const handleClick = useCallback(() => { onPublish(); }, [project, name]); @@ -137,21 +137,6 @@ function SelectWorld({ project, onPublish }: { project: Project; onPublish: () = return (
    -
    - - {t('modal.publish_project.worlds.select_world.title')} - - - {t('modal.publish_project.worlds.select_world.description')} - -
    {!projectIsReady ? : } @@ -221,22 +206,6 @@ function SelectWorld({ project, onPublish }: { project: Project; onPublish: () = - {!!name && ( - - {t('modal.publish_project.worlds.select_world.world_url_description', { - b: (child: JSX.Element) => {child}, - br: () =>
    , - world_url: ( - misc.openExternal(getExplorerUrl)} - > - {getExplorerUrl} - - ), - })} -
    - )} {hasWorldContent && (
    diff --git a/packages/renderer/src/components/Modals/PublishProject/PublishToWorld/index.ts b/packages/renderer/src/components/Modals/PublishProject/steps/PublishToWorld/index.ts similarity index 100% rename from packages/renderer/src/components/Modals/PublishProject/PublishToWorld/index.ts rename to packages/renderer/src/components/Modals/PublishProject/steps/PublishToWorld/index.ts diff --git a/packages/renderer/src/components/Modals/PublishProject/PublishToWorld/styles.css b/packages/renderer/src/components/Modals/PublishProject/steps/PublishToWorld/styles.css similarity index 98% rename from packages/renderer/src/components/Modals/PublishProject/PublishToWorld/styles.css rename to packages/renderer/src/components/Modals/PublishProject/steps/PublishToWorld/styles.css index df36afe0..50869598 100644 --- a/packages/renderer/src/components/Modals/PublishProject/PublishToWorld/styles.css +++ b/packages/renderer/src/components/Modals/PublishProject/steps/PublishToWorld/styles.css @@ -24,6 +24,7 @@ .MuiModal-root .SelectWorld .thumbnail img { width: 100%; object-fit: cover; + border-radius: 8px; } .MuiModal-root .SelectWorld .box .selection { diff --git a/packages/renderer/src/components/Modals/PublishProject/types.ts b/packages/renderer/src/components/Modals/PublishProject/types.ts index f007062a..b78a0659 100644 --- a/packages/renderer/src/components/Modals/PublishProject/types.ts +++ b/packages/renderer/src/components/Modals/PublishProject/types.ts @@ -4,13 +4,18 @@ export type Props = { open: boolean; project: Project; onClose: () => void; - onSubmit: (value: StepValue) => void; + onBack?: () => void; + onStep: (step: Step) => void; }; -export type Step = 'initial' | 'alternative-servers' | 'publish-to-world' | 'publish-to-land'; +export type Step = + | 'initial' + | 'alternative-servers' + | 'publish-to-world' + | 'publish-to-land' + | 'deploy'; + export type InitialTarget = 'worlds' | 'land'; export type AlternativeTarget = 'test' | 'custom'; -export type Target = InitialTarget | AlternativeTarget; -export type StepValue = { target: Target; value?: string }; -export type StepProps = { onClick: (value: StepValue) => void }; +export type TargetType = InitialTarget | AlternativeTarget; diff --git a/packages/renderer/src/hooks/useIsMounted.ts b/packages/renderer/src/hooks/useIsMounted.ts new file mode 100644 index 00000000..739300df --- /dev/null +++ b/packages/renderer/src/hooks/useIsMounted.ts @@ -0,0 +1,14 @@ +import { useCallback, useEffect, useRef } from 'react'; + +export function useIsMounted() { + const isMounted = useRef(false); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + return useCallback(() => isMounted.current, []); +} diff --git a/packages/renderer/src/hooks/useWorkspace.ts b/packages/renderer/src/hooks/useWorkspace.ts index 485b8aa7..69734376 100644 --- a/packages/renderer/src/hooks/useWorkspace.ts +++ b/packages/renderer/src/hooks/useWorkspace.ts @@ -64,7 +64,7 @@ export const useWorkspace = () => { }, []); const updateSceneJson = useCallback((path: string, updates: Partial) => { - dispatch(workspaceActions.updateSceneJson({ path, updates })); + return dispatch(workspaceActions.updateSceneJson({ path, updates })); }, []); const isLoading = workspace.status === 'loading'; diff --git a/packages/renderer/src/modules/store/editor/slice.ts b/packages/renderer/src/modules/store/editor/slice.ts index 71a98e87..44264123 100644 --- a/packages/renderer/src/modules/store/editor/slice.ts +++ b/packages/renderer/src/modules/store/editor/slice.ts @@ -22,8 +22,9 @@ export type EditorState = { project?: Project; inspectorPort: number; publishPort: number; - loadingInspector: boolean; loadingPublish: boolean; + publishError: string | null; + loadingInspector: boolean; loadingPreview: boolean; isInstalling: boolean; isInstalled: boolean; @@ -35,8 +36,9 @@ const initialState: EditorState = { version: null, inspectorPort: 0, publishPort: 0, - loadingInspector: false, loadingPublish: false, + publishError: null, + loadingInspector: false, loadingPreview: false, isInstalling: false, isInstalled: false, @@ -84,13 +86,14 @@ export const slice = createSlice({ builder.addCase(publishScene.pending, state => { state.publishPort = 0; state.loadingPublish = true; + state.publishError = null; }); builder.addCase(publishScene.fulfilled, (state, action) => { state.publishPort = action.payload; state.loadingPublish = false; }); builder.addCase(publishScene.rejected, (state, action) => { - state.error = action.error.message ? new Error(action.error.message) : null; + state.publishError = action.error.message || null; state.loadingPublish = false; }); builder.addCase(workspaceActions.createProject.pending, state => { diff --git a/packages/renderer/src/modules/store/translation/locales/en.json b/packages/renderer/src/modules/store/translation/locales/en.json index 3d867ff0..268fd6fb 100644 --- a/packages/renderer/src/modules/store/translation/locales/en.json +++ b/packages/renderer/src/modules/store/translation/locales/en.json @@ -112,7 +112,6 @@ "dcl": "NAME", "ens": "ENS Domain" }, - "world_url_description": "The URL to jump in your World will be:

    {world_url}", "world_has_content": "The existing content in {world} World will be replaced with the new content you're about to publish.", "confirm_world_replace_content": "I understand that this action is irreversible" }, @@ -150,6 +149,30 @@ "errors": { "url": "Invalid URL" } + }, + "deploy": { + "warning": { + "message": "PLEASE READ CAREFULLY:
    • After deployment, your scene will undergo processing before becoming available.
    • This process may take 15 minutes on average.
    • During this time, your scene will appear empty until it has been updated on the client.
    ", + "checkbox": "Don't show again", + "continue": "Continue", + "back": "Go back" + }, + "ethereum": { + "mainnet": "Mainnet", + "testnet": "Testnet" + }, + "files": { + "count": "{count} {count, plural, one {file} other {files}}", + "size": "Total Size {size}", + "publish": "Publish" + }, + "success": { + "message": "You scene is successfully published", + "url": "The URL to jump in your {target} is:", + "world": "World", + "land": "Land", + "jump_in": "Jump In" + } } }, "app_settings": { diff --git a/packages/renderer/src/modules/store/translation/locales/es.json b/packages/renderer/src/modules/store/translation/locales/es.json index 737beeec..d97b50d0 100644 --- a/packages/renderer/src/modules/store/translation/locales/es.json +++ b/packages/renderer/src/modules/store/translation/locales/es.json @@ -111,7 +111,6 @@ "dcl": "NOMBRE", "ens": "DOMINIO ENS" }, - "world_url_description": "La URL para acceder a tu mundo será:

    {world_url}", "world_has_content": "El contenido existente en el mundo {world} será reemplazado por el nuevo contenido que estás a punto de publicar.", "confirm_world_replace_content": "Entiendo que esta acción es irreversible." }, @@ -148,6 +147,30 @@ "errors": { "url": "URL inválida" } + }, + "deploy": { + "warning": { + "message": "POR FAVOR LEA CUIDADOSAMENTE:
    • Después de la implementación, tu escena pasará por un proceso antes de estar disponible.
    • Este proceso puede tardar en promedio 15 minutos.
    • Durante este tiempo, tu escena aparecerá vacía hasta que se haya actualizado en el cliente.
    ", + "checkbox": "No mostrar de nuevo", + "continue": "Continuar", + "back": "Regresar" + }, + "ethereum": { + "mainnet": "Mainnet", + "testnet": "Testnet" + }, + "files": { + "count": "{count} {count, plural, one {archivo} other {archivos}}", + "size": "Tamaño Total {size}", + "publish": "Publicar" + }, + "success": { + "message": "Tu escena se ha publicado con éxito", + "url": "La URL para acceder a tu {target} es:", + "world": "Mundo", + "land": "Tierra", + "jump_in": "Entrar" + } } }, "app_settings": { diff --git a/packages/renderer/src/modules/store/translation/locales/zh.json b/packages/renderer/src/modules/store/translation/locales/zh.json index 5245cc41..fc3bcb80 100644 --- a/packages/renderer/src/modules/store/translation/locales/zh.json +++ b/packages/renderer/src/modules/store/translation/locales/zh.json @@ -111,7 +111,6 @@ "dcl": "姓名", "ens": "ENS 域" }, - "world_url_description": "在您的世界中跳躍的 URL 為:

    {world_url}", "world_has_content": "{world} World 中的現有內容將替換為您要發布的新內容。", "confirm_world_replace_content": "我了解此操作不可逆轉" }, @@ -148,6 +147,30 @@ "errors": { "url": "无效的网址" } + }, + "deploy": { + "warning": { + "message": "请仔细阅读:
    • 部署后,您的场景将进行处理,然后才能使用。
    • 此过程平均可能需要15分钟。
    • 在此期间,您的场景将显示为空,直到客户端更新。
    ", + "checkbox": "不再显示", + "continue": "继续", + "back": "返回" + }, + "ethereum": { + "mainnet": "主网", + "testnet": "测试网" + }, + "files": { + "count": "{count} {count, plural, one {文件} other {文件}}", + "size": "总大小 {size}", + "publish": "发布" + }, + "success": { + "message": "您的场景已成功发布", + "url": "跳转到您的 {target} 的URL是:", + "world": "世界", + "land": "土地", + "jump_in": "跳转" + } } }, "app_settings": { diff --git a/packages/renderer/src/themes/theme.css b/packages/renderer/src/themes/theme.css index 94302ea4..96674bc3 100644 --- a/packages/renderer/src/themes/theme.css +++ b/packages/renderer/src/themes/theme.css @@ -96,3 +96,8 @@ a { .MuiDialog-paper { position: inherit !important; } + +/* Temp: make disabled buttons look disabled */ +.Button.MuiButton-root.Mui-disabled { + opacity: 0.5; +} diff --git a/packages/shared/types/ipc.ts b/packages/shared/types/ipc.ts index ce56b8b7..9adde080 100644 --- a/packages/shared/types/ipc.ts +++ b/packages/shared/types/ipc.ts @@ -3,6 +3,14 @@ import { type OpenDialogOptions } from 'electron'; import type { Outdated } from '/shared/types/npm'; export type DeployOptions = { path: string; target?: string; targetContent?: string }; +export type IpcResult = { + success: true; + value: T; +}; +export type IpcError = { + success: false; + error: string; +}; export interface Ipc { 'electron.getAppHome': () => string; @@ -10,6 +18,7 @@ export interface Ipc { 'electron.getWorkspaceConfigPath': (path: string) => Promise; 'electron.showOpenDialog': (opts: Partial) => Promise; 'electron.openExternal': (url: string) => Promise; + 'electron.copyToClipboard': (text: string) => Promise; 'inspector.start': () => Promise; 'bin.install': () => Promise; 'bin.code': (path: string) => Promise; diff --git a/packages/shared/types/storage.ts b/packages/shared/types/storage.ts index a8e866fe..b21bb775 100644 --- a/packages/shared/types/storage.ts +++ b/packages/shared/types/storage.ts @@ -5,7 +5,7 @@ type StorageData = { [key: string]: unknown; }; -type FileSystemStorage = Awaited>; +export type IFileSystemStorage = Awaited>; async function _createFileSystemStorage(storagePath: string) { const dir = path.dirname(storagePath); @@ -57,18 +57,18 @@ async function _createFileSystemStorage(storagePath: string) { } // In-memory Map of storages -const storageMap = new Map(); +const storageMap = new Map(); export const FileSystemStorage = { - async create(path: string): Promise { + async create(path: string): Promise { const storage = await _createFileSystemStorage(path); storageMap.set(path, storage); return storage; }, - get(path: string): FileSystemStorage | undefined { + get(path: string): IFileSystemStorage | undefined { return storageMap.get(path); }, - async getOrCreate(path: string): Promise { + async getOrCreate(path: string): Promise { return storageMap.get(path) ?? (await this.create(path)); }, }; diff --git a/types/env.d.ts b/types/env.d.ts index a9bd0fc9..48413b14 100644 --- a/types/env.d.ts +++ b/types/env.d.ts @@ -29,6 +29,7 @@ interface ImportMetaEnv { VITE_ASSET_PACKS_CONTENT_URL: string | undefined; VITE_ASSET_PACKS_JS_PORT: string | undefined; VITE_ASSET_PACKS_JS_PATH: string | undefined; + VITE_CLI_DEPLOY_PORT: string | undefined; // Publish VITE_WORLDS_SERVER: string | undefined;