diff --git a/package.json b/package.json index 52fe387749..8f63be9037 100644 --- a/package.json +++ b/package.json @@ -40,20 +40,11 @@ "@babel/runtime-corejs3": "7.20.13", "@back4app/back4app-settings": "1.6.5", "@back4app2/react-components": "1.0.0-beta.440.64", - "@codemirror/lang-css": "6.3.0", - "@codemirror/lang-html": "6.4.9", - "@codemirror/lang-javascript": "6.2.2", - "@codemirror/lang-json": "6.0.1", - "@codemirror/lang-xml": "6.1.0", - "@codemirror/language": "6.10.3", - "@codemirror/lint": "6.8.2", - "@lezer/highlight": "1.2.1", + "@monaco-editor/react": "4.7.0", "@paddle/paddle-js": "1.4.2", "@sentry/react": "8.52.0", "@stripe/react-stripe-js": "^3.9.0", "@stripe/stripe-js": "^7.8.0", - "@uiw/codemirror-themes": "4.23.6", - "@uiw/react-codemirror": "4.23.6", "axios": "1.5.1", "axios-rate-limit": "1.3.0", "bcryptjs": "2.3.0", @@ -68,7 +59,6 @@ "csvtojson": "2.0.10", "express": "4.19.2", "file-loader": "6.2.0", - "globals": "15.12.0", "graphiql": "2.0.8", "graphql": "16.8.1", "html-webpack-externals-plugin": "3.8.0", @@ -83,6 +73,7 @@ "js-cookie": "3.0.5", "jstree": "3.3.16", "lottie-react": "2.4.0", + "monaco-editor": "0.55.1", "otpauth": "8.0.3", "package-json": "7.0.0", "papaparse": "5.5.2", diff --git a/src/components/B4ACloudCodeView/B4ACloudCodeView.react.js b/src/components/B4ACloudCodeView/B4ACloudCodeView.react.js index 9dd36f7e24..fbf01a97d4 100644 --- a/src/components/B4ACloudCodeView/B4ACloudCodeView.react.js +++ b/src/components/B4ACloudCodeView/B4ACloudCodeView.react.js @@ -1,85 +1,24 @@ import React from 'react'; -// import SyntaxHighlighter from 'react-syntax-highlighter'; -// import style from 'react-syntax-highlighter/dist/esm/styles/hljs/tomorrow-night-eighties'; -// import CodeEditor from '../CodeEditor/CodeEditor.react'; -// import * as modelist from 'ace-builds/src-noconflict/ext-modelist.js'; -// import 'ace-builds/src-noconflict/mode-graphqlschema'; import B4aCodeEditor from '../CodeEditor/B4aCodeEditor.react'; import { getExtension } from '../B4ACodeTree/B4ATreeActions'; -// const pageSize = 4000; export default class B4ACloudCodeView extends React.Component { - constructor(props){ - super(props); - const codePenConfig = { - title: 'Back4AppCloudCodePen', - } - - if (this.props.extension) { - switch(this.props.extension) { - case 'js': - codePenConfig['js'] = this.props.source; - break; - case 'ejs': - codePenConfig['html'] = this.props.source; - break; - } - } else { - codePenConfig['js'] = this.props.source; - } - - this.state = { - codePenConfig, - }; - } - - componentWillReceiveProps(props) { - this.editor.value = props.source; - } - - componentDidUpdate() { - let key = 'js'; - - switch (this.props.extension) { - case 'js': - key = 'js'; - break; - case 'ejs': - key = 'html'; - break; - } - - if (this.props.source !== this.state.codePenConfig[key]) { - const newState = this.state.codePenConfig; - newState[key] = this.props.source; - this.setState(newState); - } - } - extensionDecoder() { if (this.props.fileName && typeof this.props.fileName === 'string') { return getExtension(this.props.fileName); } - return 'javascript' + return 'javascript'; } render() { - // if (style.hljs) { - // style.hljs.background = 'rgb(255 255 255)'; - // style.hljs.color = 'rgb(0 0 0)'; - // style.hljs.height = '100%'; - // style.hljs.padding = '1em 0.5em'; - // } return ( -
+
this.props.onCodeChange(value)} + onCodeChange={value => this.props.onCodeChange(value)} mode={this.extensionDecoder()} - ref={editor => (this.editor = editor)} readOnly={this.props.readOnly || false} />
diff --git a/src/components/B4ACodeTree/B4ACodeTree.react.js b/src/components/B4ACodeTree/B4ACodeTree.react.js index 1886b22fae..f51e233fca 100644 --- a/src/components/B4ACodeTree/B4ACodeTree.react.js +++ b/src/components/B4ACodeTree/B4ACodeTree.react.js @@ -2,10 +2,8 @@ import React from 'react'; import jstree from 'jstree'; // 🚫🚫 DO NOT REMOVE ABOVE LINE, as the scripts needs to be loaded that allows to use $('#tree').jstree for proper tree rendering, it took me a whole day to debug 🀯🀯🀯. import $ from 'jquery'; -// import { Resizable } from 're-resizable'; -import ReactFileReader from 'react-file-reader'; import styles from 'components/B4ACodeTree/B4ACodeTree.scss' -import Button from 'components/Button/Button.react'; +import B4aFileTree from 'components/B4aFileTree/B4aFileTree.react'; import B4ACloudCodeView from 'components/B4ACloudCodeView/B4ACloudCodeView.react'; import B4ATreeActions from 'components/B4ACodeTree/B4ATreeActions'; import Swal from 'sweetalert2'; @@ -48,6 +46,53 @@ const swalWithBootstrapButtons = Swal.mixin({ buttonsStyling: false, }); +class UploadMenu extends React.Component { + componentDidMount() { + document.addEventListener('mousedown', this.handleClickOutside, true); + document.addEventListener('keydown', this.handleEscape, true); + } + + componentWillUnmount() { + document.removeEventListener('mousedown', this.handleClickOutside, true); + document.removeEventListener('keydown', this.handleEscape, true); + } + + handleClickOutside = e => { + if (this.props.menuRef?.current && !this.props.menuRef.current.contains(e.target)) { + this.props.onClose(); + } + }; + + handleEscape = e => { + if (e.key === 'Escape') { + this.props.onClose(); + } + }; + + render() { + return ( +
+ + +
+ ); + } +} + export default class B4ACodeTree extends React.Component { constructor(props){ super(props); @@ -67,11 +112,220 @@ export default class B4ACodeTree extends React.Component { isFolderSelected: true, selectedNodeData: null, loadingFileId: null, - errorFileData: null + errorFileData: null, + // Mirror of jstree's current tree, used to render the VSCode-style + // . Kept in sync via syncTreeData() on every jstree mutation. + treeData: this.props.files || [], + // Path of the currently-selected file in . Kept in sync via + // jstree's `changed.jstree` event in selectNode(). + selectedTreePath: '', + showUploadMenu: false, } // Used to track the latest file load request this.loadRequestId = 0; + + this.fileInputRef = React.createRef(); + this.folderInputRef = React.createRef(); + this.uploadMenuRef = React.createRef(); + } + + // Read the current tree state from jstree and mirror it into React state so + // re-renders. Called after every jstree mutation event. + syncTreeData() { + const inst = $('#tree').jstree(true); + if (!inst) { + return; + } + const data = inst.get_json('#', { no_state: false, no_data: false }); + this.setState({ treeData: Array.isArray(data) ? data : [] }); + } + + // Build the VSCode-style "path" (e.g. "cloud/main.js") for a jstree node by + // walking up the parent chain. We need this to highlight the selected node + // in (which keys nodes by path, not by jstree's internal IDs). + getNodePath(nodeId) { + const inst = $('#tree').jstree(true); + if (!inst || !nodeId) { + return ''; + } + const parts = []; + let current = inst.get_node(nodeId); + while (current && current.id !== '#') { + parts.unshift(current.text); + current = inst.get_node(current.parent); + } + return parts.join('/'); + } + + // Walk a B4aFileTree path back to a jstree node id so we can forward the + // click into jstree (which still owns selection state and edit operations). + findNodeIdByPath(path) { + if (!path) { + return null; + } + const inst = $('#tree').jstree(true); + if (!inst) { + return null; + } + const segments = path.split('/'); + const roots = inst.get_json('#', { flat: false }); + const findIn = (nodes, depth) => { + if (!Array.isArray(nodes) || depth >= segments.length) { + return null; + } + const target = nodes.find(n => n.text === segments[depth]); + if (!target) { + return null; + } + if (depth === segments.length - 1) { + return target.id; + } + return findIn(target.children, depth + 1); + }; + return findIn(roots, 0); + } + + handleFileTreeSelect(node, path) { + if (!node) { + return; + } + const nodeId = node.id || this.findNodeIdByPath(path); + if (!nodeId) { + return; + } + B4ATreeActions.selectFileOnTree(nodeId); + } + + handleContextAction(action, node, path, newName) { + if (this.props.hideControls) { + return; + } + const isFolder = node && (node.type === 'folder' || node.type === 'new-folder'); + const nodeId = node && (node.id || this.findNodeIdByPath(path)); + const isProtectedRoot = + path && path.indexOf('/') === -1 && (node.text === 'cloud' || node.text === 'public'); + const parentNodeId = isFolder + ? (node.id || this.findNodeIdByPath(path)) + : this.findNodeIdByPath(path.split('/').slice(0, -1).join('/')); + + if ((action === 'delete' || action === 'rename') && isProtectedRoot) { + return; + } + + if (action === 'delete') { + if (!nodeId) { + return; + } + B4ATreeActions.selectFileOnTree(nodeId); + B4ATreeActions.remove(`#${nodeId}`, true); + return; + } + + if (action === 'rename') { + if (!nodeId) { + return; + } + B4ATreeActions.selectFileOnTree(nodeId); + const value = B4ATreeActions.sanitizeHTML((newName || '').trim()); + if (!value || value === node.text) { + return; + } + const inst = $('#tree').jstree(true); + inst.rename_node(nodeId, value); + this.setState({ files: inst.get_json() }); + return; + } + + if (parentNodeId) { + B4ATreeActions.selectFileOnTree(parentNodeId); + } + + if (action === 'create-file') { + swalWithBootstrapButtons.fire({ + title: 'Create a new empty file', + text: 'Name your file', + padding: '1rem 2rem', + input: 'text', + inputAttributes: { + autocapitalize: 'off', + placeholder: 'File name', + }, + showCancelButton: true, + reverseButtons: true, + confirmButtonText: 'Create file', + buttonsStyling: false, + showCloseButton: true, + allowOutsideClick: () => !Swal.isLoading() + }).then(({ value }) => { + if (value) { + value = B4ATreeActions.sanitizeHTML(value); + const parent = parentNodeId ? [parentNodeId] : B4ATreeActions.getSelectedParent(); + const newNodeId = B4ATreeActions.addFileOnSelectedNode(value, parent[0]); + B4ATreeActions.selectFileOnTree(newNodeId); + this.setState({ files: $('#tree').jstree(true).get_json() }); + } + }); + } else if (action === 'create-folder') { + swalWithBootstrapButtons.fire({ + title: 'Create a new folder', + text: 'Name your folder', + padding: '1rem 2rem', + input: 'text', + inputAttributes: { + autocapitalize: 'off', + placeholder: 'Folder name', + }, + showCancelButton: true, + reverseButtons: true, + confirmButtonText: 'Create folder', + buttonsStyling: false, + showCloseButton: true, + allowOutsideClick: () => !Swal.isLoading() + }).then(({ value }) => { + if (value) { + value = B4ATreeActions.sanitizeHTML(value); + const targetId = parentNodeId || B4ATreeActions.getSelectedParent()[0]; + const inst = $('#tree').jstree(true); + inst.create_node(targetId, { + type: 'new-folder', + text: value, + state: { opened: true }, + }); + this.setState({ files: inst.get_json() }); + } + }); + } + } + + handleFileTreeDrop(sourcePath, targetPath) { + if (this.props.hideControls || !sourcePath || !targetPath) { + return; + } + const inst = $('#tree').jstree(true); + if (!inst) { + return; + } + const sourceId = this.findNodeIdByPath(sourcePath); + const targetId = this.findNodeIdByPath(targetPath); + if (!sourceId || !targetId) { + return; + } + const sourceNode = inst.get_node(sourceId); + const targetNode = inst.get_node(targetId); + if (!sourceNode || !targetNode || (targetNode.type !== 'folder' && targetNode.type !== 'new-folder')) { + return; + } + if (sourceNode.parent === targetNode.id) { + return; + } + const moved = inst.move_node(sourceNode, targetNode, 'last'); + if (moved === false) { + return; + } + B4ATreeActions.selectFileOnTree(sourceNode.id); + this.syncTreeData(); + this.handleTreeChanges(); } selectSpecificFile(fileName) { @@ -123,6 +377,95 @@ export default class B4ACodeTree extends React.Component { } } + handleNativeFileUpload(e) { + const fileList = e.target.files; + if (!fileList || fileList.length === 0) { + return; + } + const readPromises = Array.from(fileList).map(file => + new Promise(resolve => { + const reader = new FileReader(); + reader.onload = () => resolve({ name: file.name, base64: reader.result }); + reader.readAsDataURL(file); + }) + ); + Promise.all(readPromises).then(results => { + const files = { + fileList: results.map(r => ({ name: r.name, size: 1 })), + base64: results.map(r => r.base64), + }; + this.handleFiles(files); + }); + e.target.value = ''; + } + + handleFolderUpload(e) { + const fileList = e.target.files; + if (!fileList || fileList.length === 0) { + return; + } + const inst = $('#tree').jstree(true); + if (!inst) { + return; + } + const parent = B4ATreeActions.getSelectedParent(); + const parentId = parent[0]; + + const readPromises = Array.from(fileList).map(file => + new Promise(resolve => { + const reader = new FileReader(); + reader.onload = () => resolve({ path: file.webkitRelativePath, base64: reader.result }); + reader.readAsDataURL(file); + }) + ); + + Promise.all(readPromises).then(results => { + const createdFolders = {}; + + const ensureFolder = (segments, rootId) => { + let currentParent = rootId; + let key = ''; + for (const seg of segments) { + key = key ? `${key}/${seg}` : seg; + if (!createdFolders[key]) { + const existingChildren = inst.get_node(currentParent).children || []; + const existing = existingChildren.find(childId => inst.get_node(childId).text === seg); + if (existing) { + createdFolders[key] = existing; + } else { + createdFolders[key] = inst.create_node(currentParent, { + type: 'new-folder', + text: seg, + state: { opened: true }, + }); + } + } + currentParent = createdFolders[key]; + } + return currentParent; + }; + + let lastFileId = null; + for (const { path, base64 } of results) { + const parts = path.split('/'); + const fileName = parts.pop(); + const targetParent = parts.length > 0 ? ensureFolder(parts, parentId) : parentId; + lastFileId = inst.create_node(targetParent, { + type: 'new-file', + text: fileName, + data: { code: base64 }, + }); + } + + this.setState({ files: inst.get_json() }); + this.handleTreeChanges(); + if (lastFileId) { + B4ATreeActions.selectFileOnTree(lastFileId); + } + }); + e.target.value = ''; + } + deleteFile() { if (this.props.hideControls) { return; } if (this.state.nodeId) { @@ -222,25 +565,29 @@ export default class B4ACodeTree extends React.Component { } } } - this.setState({ - source, - selectedFile, - nodeId, - extension, - isImage, - selectedFolder, + this.setState({ + source, + selectedFile, + nodeId, + extension, + isImage, + selectedFolder, isFolderSelected: selected.type == 'folder' || selected.type == 'new-folder' , - currentFolder: selected.text + currentFolder: selected.text, + selectedTreePath: nodeId ? this.getNodePath(nodeId) : (selected.id ? this.getNodePath(selected.id) : ''), }) } // method to identify the selected tree node watchSelectedNode() { - $('#tree').on('select_node.jstree', async (e, data) => this.selectNode(data)) + // Detach any previously bound handlers so repeated calls cannot stack + // duplicate listeners (which would cause selectNode to fire N times per + // click and freeze the editor on file switches). + $('#tree').off('changed.jstree'); $('#tree').on('changed.jstree', (e, data) => { this.selectNode(data); this.setState({ selectedNodeData: data }); - }) + }); } handleTreeChanges() { @@ -272,7 +619,7 @@ export default class B4ACodeTree extends React.Component { } } - updateCodeOnNewFile(type, text, id){ + updateCodeOnNewFile(type, text, id, childrenIds = []){ if (type === 'delete-file') { if (!this.props.hasDeployed) { @@ -318,8 +665,21 @@ export default class B4ACodeTree extends React.Component { text && this.props.cloudCodeChanges.addFile(id); } else if (type === 'delete-folder') { const toBeDeletedFolder = $('#tree').jstree(true).get_node(id); - const toBeDeletedIds = [toBeDeletedFolder.id, ...toBeDeletedFolder.children_d]; + const toBeDeletedIds = toBeDeletedFolder + ? [toBeDeletedFolder.id, ...toBeDeletedFolder.children_d] + : [id, ...childrenIds]; this.props.cloudCodeChanges.removeMultiple(toBeDeletedIds); + } else if (type === 'rename-node') { + const renamedNode = $('#tree').jstree(true).get_node(id); + const renamedIds = renamedNode ? [renamedNode.id, ...renamedNode.children_d] : [id]; + renamedIds.forEach(fileId => this.props.cloudCodeChanges.addFile(fileId)); + this.props.setUpdatedFile(this.props.cloudCodeChanges.getFiles()); + B4ATreeActions.refreshEmptyFolderIcons(); + return; + } else if (type === 'move-node') { + const movedNode = $('#tree').jstree(true).get_node(id); + const movedIds = movedNode ? [movedNode.id, ...movedNode.children_d] : [id]; + movedIds.forEach(fileId => this.props.cloudCodeChanges.addFile(fileId)); } else { // set updated files. const selectedFiles = $('#tree').jstree('get_selected', true) @@ -345,19 +705,41 @@ export default class B4ACodeTree extends React.Component { } $('#tree').jstree(config); this.watchSelectedNode(); + + // Mirror jstree's data into React state on every mutation so + // stays in sync with the source of truth. + $('#tree').on( + 'refresh.jstree create_node.jstree delete_node.jstree rename_node.jstree move_node.jstree set_text.jstree', + () => this.syncTreeData() + ); + $('#tree').on('ready.jstree', () => { + this.syncTreeData(); + this.selectCloudFolder(); + }); + if (!this.props.hideControls) { $('#tree').on('create_node.jstree', (node, parent) => { amplitudeLogEvent(`CloudCode create ${parent?.node?.type}`); this.updateCodeOnNewFile(parent?.node?.type, parent?.node?.text, parent?.node?.id); }); $('#tree').on('delete_node.jstree', (parent, node) => { - if (node?.node?.type === 'new-folder') { + if (node?.node?.type === 'folder' || node?.node?.type === 'new-folder') { amplitudeLogEvent(`CloudCode delete ${parent?.node?.type}`); - this.updateCodeOnNewFile('delete-folder', node?.node?.text, node?.node?.id); + this.updateCodeOnNewFile('delete-folder', node?.node?.text, node?.node?.id, node?.node?.children_d || []); } else { this.updateCodeOnNewFile('delete-file', node?.node?.text, node?.node?.id); } }); + $('#tree').on('rename_node.jstree', (event, data) => { + amplitudeLogEvent(`CloudCode rename ${data?.node?.type}`); + this.updateCodeOnNewFile('rename-node', data?.node?.text, data?.node?.id); + this.handleTreeChanges(); + }); + $('#tree').on('move_node.jstree', (event, data) => { + amplitudeLogEvent(`CloudCode move ${data?.node?.type}`); + this.updateCodeOnNewFile('move-node', data?.node?.text, data?.node?.id); + this.handleTreeChanges(); + }); } } @@ -367,6 +749,12 @@ export default class B4ACodeTree extends React.Component { } } + componentWillUnmount() { + $('#tree').off( + 'changed.jstree create_node.jstree delete_node.jstree rename_node.jstree move_node.jstree set_text.jstree refresh.jstree ready.jstree' + ); + } + render(){ let content; if (this.state.loadingFileId) { @@ -432,84 +820,117 @@ export default class B4ACodeTree extends React.Component { ); } + const handleNewFile = () => { + if (this.state.selectedFile === '') { + this.selectCloudFolder(); + } + swalWithBootstrapButtons.fire({ + title: 'Create a new empty file', + text: 'Name your file', + padding: '1rem 2rem', + input: 'text', + inputAttributes: { + autocapitalize: 'off', + placeholder: 'File name', + }, + showCancelButton: true, + reverseButtons: true, + confirmButtonText: 'Create file', + buttonsStyling: false, + showCloseButton: true, + allowOutsideClick: () => !Swal.isLoading() + }).then(({ value }) => { + if (value) { + value = B4ATreeActions.sanitizeHTML(value); + const parent = B4ATreeActions.getSelectedParent(); + const newNodeId = B4ATreeActions.addFileOnSelectedNode(value, parent[0]); + B4ATreeActions.selectFileOnTree(newNodeId); + this.setState({ files: $('#tree').jstree(true).get_json() }); + } + }); + }; + return (
-
-
-

Files

+
+
+ Explorer {!this.props.hideControls && ( -
-
} - width='20' - additionalStyles={{ minWidth: '60px', background: 'transparent', border: 'none', padding: '0' }} +
+ +
+ + {this.state.showUploadMenu && ( + { + this.setState({ showUploadMenu: false }); + this.fileInputRef.current?.click(); + }} + onUploadFolder={() => { + this.setState({ showUploadMenu: false }); + this.folderInputRef.current?.click(); + }} + onClose={() => this.setState({ showUploadMenu: false })} + /> + )} + this.handleNativeFileUpload(e)} + /> + this.handleFolderUpload(e)} /> - +
)}
-
-
+
+ this.handleFileTreeSelect(node, path)} + onContextAction={!this.props.hideControls ? (action, node, path, newName) => this.handleContextAction(action, node, path, newName) : undefined} + onNodeDrop={!this.props.hideControls ? (sourcePath, targetPath) => this.handleFileTreeDrop(sourcePath, targetPath) : undefined} + defaultExpanded={['cloud', 'public']} + emptyMessage="No files yet" + /> + {/* + jstree still owns selection state, mutation operations, and + deploy serialization. Its DOM is hidden + but kept mounted so all those existing flows keep working. + */} +
+
+
diff --git a/src/components/B4ACodeTree/B4ACodeTree.scss b/src/components/B4ACodeTree/B4ACodeTree.scss index 2725035360..2b1000f0a1 100644 --- a/src/components/B4ACodeTree/B4ACodeTree.scss +++ b/src/components/B4ACodeTree/B4ACodeTree.scss @@ -81,18 +81,34 @@ padding-top: 0.5rem; background: $regal-blue; border-radius: 4px; + margin-bottom: 1rem; & .codeBlockHeader { display: flex; align-items: center; justify-content: space-between; - margin: 0rem 1.5rem; + margin: 0rem 1.5rem 0.5rem 1.5rem; } .languageLabel { user-select: none; } + .cloudSampleEditor { + background: #111214; + border-radius: 0 0 4px 4px; + overflow: hidden; + } + + .copyButton { + background: transparent; + border: 0; + border-radius: 4px; + cursor: pointer; + line-height: 0; + padding: 4px; + } + pre { background: #111214 !important; border-radius: 4px; @@ -152,6 +168,144 @@ } +// VSCode-style sidebar (Explorer header + B4aFileTree). The legacy jstree +// DOM is rendered alongside it but visually hidden via .hiddenJstree so the +// existing mutation and deploy flows keep working. +.vscodeSidebar { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background: rgba(0, 0, 0, 0.2); + color: rgba(255, 255, 255, 0.7); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe WPC', 'Segoe UI', 'Ubuntu', 'Droid Sans', sans-serif; + overflow: hidden; +} + +.vscodeHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + user-select: none; + flex-shrink: 0; +} + +.vscodeHeaderTitle { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.4); +} + +.vscodeHeaderActions { + display: flex; + align-items: center; + gap: 2px; +} + +.vscodeIconButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: 0; + border-radius: 3px; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + transition: background-color 150ms ease, color 150ms ease; + + &:hover { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; + } + + &:focus-visible { + outline: 1px solid rgba(32, 138, 236, 0.6); + outline-offset: -1px; + } +} + +.vscodeTreeWrapper { + position: relative; + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + +.uploadDropdownWrapper { + position: relative; + display: inline-flex; +} + +.uploadDropdown { + position: absolute; + top: 100%; + right: 0; + z-index: 100; + min-width: 150px; + margin-top: 4px; + padding: 4px 0; + background: #252526; + border: 1px solid #3c3c3c; + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.uploadDropdownItem { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 12px; + background: transparent; + border: 0; + outline: none; + text-align: left; + color: rgba(255, 255, 255, 0.8); + font-size: 13px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe WPC', 'Segoe UI', 'Ubuntu', 'Droid Sans', sans-serif; + cursor: pointer; + white-space: nowrap; + transition: background-color 100ms ease; + + &:hover { + background: #094771; + color: #ffffff; + } + + &:focus-visible { + background: #094771; + color: #ffffff; + } +} + +.hiddenInput { + position: absolute; + width: 0; + height: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; +} + +// jstree's DOM stays mounted (it's the source of truth + handles edit ops) +// but is collapsed to zero size so the user only sees . +.hiddenJstree { + position: absolute; + width: 0; + height: 0; + overflow: hidden; + visibility: hidden; + pointer-events: none; +} + .files-box{ background-color: $dark-blue; padding-top: 1.88rem; @@ -345,4 +499,4 @@ justify-content: flex-end; gap: 0.5rem; margin-top: 1.5rem !important; -} \ No newline at end of file +} diff --git a/src/components/B4ACodeTree/B4ATreeActions.js b/src/components/B4ACodeTree/B4ATreeActions.js index 81f5589ec8..8eb8e3e4ab 100644 --- a/src/components/B4ACodeTree/B4ATreeActions.js +++ b/src/components/B4ACodeTree/B4ATreeActions.js @@ -91,6 +91,16 @@ const create = (data, file) => { const remove = (data, showAlert = false) => { const inst = $.jstree.reference(data) const obj = inst.get_node(data); + const parent = inst.get_node(obj.parent); + const requiredCloudFile = parent?.text === 'cloud' && obj?.text === 'main.js'; + const requiredPublicFile = parent?.text === 'public' && obj?.text === 'index.html'; + + if (requiredCloudFile || requiredPublicFile) { + preventRemoveFileModal.text = `Can not remove ${obj.text} file as it is required by cloud code.`; + MySwal.fire(preventRemoveFileModal); + return false; + } + if (showAlert) { const RemoveSwal = withReactContent(Swal.mixin({ customClass: { @@ -106,7 +116,8 @@ const remove = (data, showAlert = false) => { }, buttonsStyling: false, })); - confirmRemoveFileModal.text = `Are you sure you want to remove ${obj.text} file?`; + const nodeKind = obj.type === 'folder' || obj.type === 'new-folder' ? 'folder' : 'file'; + confirmRemoveFileModal.text = `Are you sure you want to remove ${obj.text} ${nodeKind}?`; RemoveSwal.fire(confirmRemoveFileModal).then((alertResponse) => { if (alertResponse.value) { if (inst.is_selected(obj)) {return inst.delete_node(inst.get_selected());} @@ -128,6 +139,8 @@ const encodeFile = async (code, extension) => { return extension + ',' + Base64.encode(code); } +export const DEFAULT_EMPTY_FILE_DATA = {code: 'data:plain/text;base64,'}; + const readFile = (file, newTreeNodes) => { newTreeNodes.push({ text: file.name, @@ -203,7 +216,7 @@ const getSelectedParent = () => { return parent; } -const addFileOnSelectedNode = (name, parent, data = {code: 'data:plain/text;base64,IA=='}) => { +const addFileOnSelectedNode = (name, parent, data = DEFAULT_EMPTY_FILE_DATA) => { const newNodeId = $('#tree').jstree('create_node', parent, { data, type: 'new-file', text: name }, 'inside', false, false); return newNodeId; } diff --git a/src/components/B4ACodeTree/CloudCodeSampleModal.react.js b/src/components/B4ACodeTree/CloudCodeSampleModal.react.js index f927441bbe..2d4ae2df4b 100644 --- a/src/components/B4ACodeTree/CloudCodeSampleModal.react.js +++ b/src/components/B4ACodeTree/CloudCodeSampleModal.react.js @@ -1,42 +1,95 @@ -import React, { useEffect, useRef, Suspense, lazy } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import Icon from 'components/Icon/Icon.react'; +import B4aCodeEditor from 'components/CodeEditor/B4aCodeEditor.react'; import styles from 'components/B4ACodeTree/B4ACodeTree.scss'; import Popover from 'components/Popover/Popover.react'; import Position from 'lib/Position'; -const CodeBlock = lazy(() => import('components/CodeBlock/CodeBlock.react')); +const getEditorHeight = code => { + const lineCount = code.split('\n').length; + return Math.min(Math.max(lineCount * 19 + 16, 96), 320); +}; + +const CloudCodeSampleCodeBlock = ({ language, title, content }) => { + const [copied, setCopied] = useState(false); + const code = String(content || '').trim(); + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy text: ', err); + } + }; + + return ( +
+
+
+
+ {copied &&
Copied!
} + +
+
+
+ +
+
+ ); +}; const getCloudCodeSample = (currentApp) => { - return { - 'js-browser': { - icon: 'js-icon', - name: 'JavaScript (Browser)', - iconColor: '#f7df1c', - blocks: [ - { - title: 'Cloud Functions: Are custom functions that allow to execute logic on the backend.', - content: ` + return { + 'js-browser': { + icon: 'js-icon', + name: 'JavaScript (Browser)', + iconColor: '#f7df1c', + blocks: [ + { + title: 'Cloud Functions: Are custom functions that allow to execute logic on the backend.', + content: ` ~~~javascript Parse.Cloud.define("hello", async (request) => { console.log("Hello from Cloud Code!"); return "Hello from Cloud Code!"; }); ~~~` - }, - { - title: 'Here is how you have to call it via REST API.', - content: String.raw` + }, + { + title: 'Here is how you have to call it via REST API.', + content: String.raw` ~~~bash curl -X POST \ -H "X-Parse-Application-Id: ${currentApp.applicationId}" \ -H "X-Parse-REST-API-Key: ${currentApp.restKey}" \ ${currentApp.serverURL}/functions/hello ~~~` - }, - { - title: 'This example creates an object in your class.', - content: ` + }, + { + title: 'This example creates an object in your class.', + content: ` ~~~javascript Parse.Cloud.define("createObject", async (request) => { const b4aClass = new Parse.Object("B4aSampleClass"); @@ -46,10 +99,10 @@ Parse.Cloud.define("createObject", async (request) => { return "Object created successfully!"; }); ~~~` - }, - { - title: 'Cloud Triggers: Is a function that automatically runs when certain events happen in your database classes. β€” such as when an object is saved, updated, deleted, or queried.', - content: ` + }, + { + title: 'Cloud Triggers: Is a function that automatically runs when certain events happen in your database classes. β€” such as when an object is saved, updated, deleted, or queried.', + content: ` ~~~javascript Parse.Cloud.beforeSave("B4aSampleClass", (request) => { if (request.object.get("value") === undefined) { @@ -57,10 +110,10 @@ Parse.Cloud.beforeSave("B4aSampleClass", (request) => { } }); ~~~` - }, - { - title: 'You can use the createObject function created earlier and omit the value property to see the trigger in action.', - content: String.raw` + }, + { + title: 'You can use the createObject function created earlier and omit the value property to see the trigger in action.', + content: String.raw` ~~~bash curl -X POST \ -H "X-Parse-Application-Id: ${currentApp.applicationId}" \ @@ -69,10 +122,10 @@ curl -X POST \ -d '{"name":"b4aObject"}' \ ${currentApp.serverURL}/functions/createObject ~~~` - }, - { - title: 'Now we can retrieve the object created with this function:', - content: ` + }, + { + title: 'Now we can retrieve the object created with this function:', + content: ` ~~~javascript Parse.Cloud.define("getObjects", async (request) => { const query = new Parse.Query("B4aSampleClass"); @@ -85,10 +138,10 @@ Parse.Cloud.define("getObjects", async (request) => { })); }); ~~~` - }, - { - title: 'Here is how you have to call it via REST API.', - content: String.raw` + }, + { + title: 'Here is how you have to call it via REST API.', + content: String.raw` ~~~bash curl -X POST \ -H "X-Parse-Application-Id: ${currentApp.applicationId}" \ @@ -96,10 +149,10 @@ curl -X POST \ -H "Content-Type: application/json" \ ${currentApp.serverURL}/functions/getObjects ~~~` - }, - { - title: `Cloud Jobs: Are background routines that can be scheduled or triggered to run automatically, ideal for long-running or maintenance tasks.`, - content: ` + }, + { + title: 'Cloud Jobs: Are background routines that can be scheduled or triggered to run automatically, ideal for long-running or maintenance tasks.', + content: ` ~~~javascript Parse.Cloud.job("activeAllObjects", async (request) => { const query = new Parse.Query("B4aSampleClass"); @@ -111,111 +164,110 @@ Parse.Cloud.job("activeAllObjects", async (request) => { } }); ~~~` - }, - { - title: 'Here is how you have to call it. Jobs can be only excute with the Master Key.', - content: String.raw` + }, + { + title: 'Here is how you have to call it. Jobs can be only excute with the Master Key.', + content: String.raw` ~~~bash curl -X POST \ -H "X-Parse-Application-Id: ${currentApp.applicationId}" \ -H "X-Parse-Master-Key: ${currentApp.masterKey}" \ ${currentApp.serverURL}/jobs/activeAllObjects ~~~` - }, - ] - } + }, + ] } + } } const origin = new Position(0, 0) - + const CloudCodeSampleModal = ({ closeModal, currentApp }) => { - const sample = getCloudCodeSample(currentApp)['js-browser']; + const sample = getCloudCodeSample(currentApp)['js-browser']; - const startRef = useRef(null); - const overlayRef = useRef(null); + const startRef = useRef(null); + const overlayRef = useRef(null); - const handlePointerDown = (e) => { - startRef.current = { x: e.clientX, y: e.clientY }; - }; + const handlePointerDown = (e) => { + startRef.current = { x: e.clientX, y: e.clientY }; + }; - const handleClick = (e) => { - if (!overlayRef.current) return; - - const dx = e.clientX - startRef.current.x; - const dy = e.clientY - startRef.current.y; - const distance = Math.sqrt(dx * dx + dy * dy); + const handleClick = (e) => { + if (!overlayRef.current) {return;} - if (distance < 5 && e.target === overlayRef.current) { - closeModal(); - } + const dx = e.clientX - startRef.current.x; + const dy = e.clientY - startRef.current.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < 5 && e.target === overlayRef.current) { + closeModal(); + } + }; + + useEffect(() => { + const toolbar = document.querySelector('#toolbar'); + const sidebar = document.querySelector('#sidebar'); + const codeContainer = document.querySelector('#codeContainer'); + const navbar = document.querySelector('nav'); + + if (toolbar) {toolbar.style.userSelect = 'none';} + if (sidebar) {sidebar.style.userSelect = 'none';} + if (codeContainer) {codeContainer.style.userSelect = 'none';} + if (navbar) {navbar.style.userSelect = 'none'} + + return () => { + if (toolbar) {toolbar.style.userSelect = '';} + if (sidebar) {sidebar.style.userSelect = '';} + if (codeContainer) {codeContainer.style.userSelect = '';} + if (navbar) {navbar.style.userSelect = '';} }; + }, []); - useEffect(() => { - const toolbar = document.querySelector('#toolbar'); - const sidebar = document.querySelector('#sidebar'); - const codeContainer = document.querySelector('#codeContainer'); - const navbar = document.querySelector('nav'); - - if (toolbar) toolbar.style.userSelect = 'none'; - if (sidebar) sidebar.style.userSelect = 'none'; - if (codeContainer) codeContainer.style.userSelect = 'none'; - if (navbar) navbar.style.userSelect = 'none' - - return () => { - if (toolbar) toolbar.style.userSelect = ''; - if (sidebar) sidebar.style.userSelect = ''; - if (codeContainer) codeContainer.style.userSelect = ''; - if (navbar) navbar.style.userSelect = ''; - }; - }, []); - - return ( - -
-
-
-

The examples below show you what Cloud Code looks like.

-
- -
-
- Loading...
}> - {sample.blocks.map((block, i) => ( - ( - - ) - }} - /> - ))} - -
+ return ( + +
+
+
+

The examples below show you what Cloud Code looks like.

+
+ +
+
+ {sample.blocks.map((block, i) => ( + ( + + ) + }} + /> + ))} + -
-
-
- ); + . +
+
+
+ + ); }; export default CloudCodeSampleModal; diff --git a/src/components/B4aCloudCodeBrowser/B4aCloudCodeBrowser.react.js b/src/components/B4aCloudCodeBrowser/B4aCloudCodeBrowser.react.js new file mode 100644 index 0000000000..1526d139e1 --- /dev/null +++ b/src/components/B4aCloudCodeBrowser/B4aCloudCodeBrowser.react.js @@ -0,0 +1,345 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import PropTypes from 'lib/PropTypes'; +import B4aCodeEditor from 'components/CodeEditor/B4aCodeEditor.react'; +import B4aFileTree, { getExtension } from 'components/B4aFileTree/B4aFileTree.react'; +import styles from 'components/B4aCloudCodeBrowser/B4aCloudCodeBrowser.scss'; + +const LANGUAGE_BY_EXTENSION = { + js: 'javascript', + mjs: 'javascript', + jsx: 'javascript', + ts: 'typescript', + tsx: 'typescript', + json: 'json', + html: 'html', + htm: 'html', + css: 'css', + scss: 'scss', + less: 'less', + md: 'markdown', + yaml: 'yaml', + yml: 'yaml', + xml: 'xml', + sh: 'shell', + bash: 'shell', + txt: 'plaintext', +}; + +const getLanguageFromFilename = filename => { + const ext = getExtension(filename); + return LANGUAGE_BY_EXTENSION[ext] || 'plaintext'; +}; + +const getEditorMode = filename => { + // B4aCodeEditor expects the editor's "mode" key (js/json/html/css/javascript) + const ext = getExtension(filename); + if (!ext) { + return 'javascript'; + } + if (ext === 'mjs' || ext === 'jsx') { + return 'javascript'; + } + return ext; +}; + +const decodeFileContent = node => { + if (!node || !node.data || typeof node.data.code !== 'string') { + return '// No content available'; + } + const code = node.data.code; + const match = code.match(/base64,(.*)$/); + if (!match) { + return code; + } + try { + const decoded = window.atob(match[1] || ''); + return decodeURIComponent(escape(decoded)); + } catch (err) { + return '// Error decoding file content'; + } +}; + +const findFirstFile = node => { + if (!node) { + return null; + } + if (node.type !== 'folder' && node.type !== 'new-folder') { + return node; + } + if (!Array.isArray(node.children)) { + return null; + } + for (const child of node.children) { + if (child.type !== 'folder' && child.type !== 'new-folder') { + return child; + } + } + for (const child of node.children) { + const inner = findFirstFile(child); + if (inner) { + return inner; + } + } + return null; +}; + +const FileTabIcon = ({ filename }) => { + const ext = getExtension(filename); + const colors = { + js: '#f7df1e', + mjs: '#f7df1e', + jsx: '#f7df1e', + ts: '#3178c6', + tsx: '#3178c6', + json: '#cbcb41', + html: '#e44d26', + htm: '#e44d26', + css: '#264de4', + scss: '#cf649a', + md: '#9aa0a6', + }; + const color = colors[ext] || '#cccccc'; + return ( + + + + + ); +}; + +const ExpandIcon = () => ( + + + +); + +const CollapseIcon = () => ( + + + +); + +const CloseIcon = () => ( + + + +); + +const CopyIcon = () => ( + + + + +); + +const B4aCloudCodeBrowser = ({ + tree, + rootFilter, + defaultExpanded, + isLoading, + emptyTitle, + emptyDescription, + emptyAction, + onFileSelect, + enableFullscreen, + readOnly, +}) => { + const [selectedNode, setSelectedNode] = useState(null); + const [selectedPath, setSelectedPath] = useState(undefined); + const [fileContent, setFileContent] = useState(''); + const [isFullscreen, setIsFullscreen] = useState(false); + const [copyHint, setCopyHint] = useState(false); + + const handleFileSelect = useCallback( + (node, path) => { + setSelectedNode(node); + setSelectedPath(path); + setFileContent(decodeFileContent(node)); + if (typeof onFileSelect === 'function') { + onFileSelect(node, path); + } + }, + [onFileSelect] + ); + + useEffect(() => { + if (selectedNode || !Array.isArray(tree) || tree.length === 0) { + return; + } + const allowed = Array.isArray(rootFilter) && rootFilter.length > 0 + ? new Set(rootFilter) + : null; + const candidates = allowed + ? tree.filter(n => allowed.has(n.text)) + : tree; + for (const root of candidates) { + const file = findFirstFile(root); + if (file) { + handleFileSelect(file, `${root.text}/${file.text}`); + return; + } + } + }, [tree, rootFilter, selectedNode, handleFileSelect]); + + const language = useMemo( + () => (selectedNode ? getLanguageFromFilename(selectedNode.text) : 'plaintext'), + [selectedNode] + ); + + const editorMode = useMemo( + () => (selectedNode ? getEditorMode(selectedNode.text) : 'javascript'), + [selectedNode] + ); + + const handleCopy = useCallback(() => { + if (!fileContent || typeof navigator === 'undefined' || !navigator.clipboard) { + return; + } + navigator.clipboard.writeText(fileContent).then(() => { + setCopyHint(true); + setTimeout(() => setCopyHint(false), 1500); + }); + }, [fileContent]); + + if (isLoading) { + return ( +
+ Loading cloud code… +
+ ); + } + + const hasContent = Array.isArray(tree) && tree.length > 0 + && (!Array.isArray(rootFilter) || rootFilter.length === 0 + || tree.some(n => rootFilter.includes(n.text))); + + if (!hasContent) { + return ( +
+ {emptyTitle ?

{emptyTitle}

: null} + {emptyDescription ?

{emptyDescription}

: null} + {emptyAction || null} +
+ ); + } + + const fullscreenLayer = isFullscreen && selectedNode + ? createPortal( +
+
+ + {selectedNode.text} + {selectedPath} + + + +
+
+ +
+
, + document.body + ) + : null; + + return ( +
+ +
+ {selectedNode ? ( + +
+ + {selectedNode.text} + {selectedPath} + {enableFullscreen !== false ? ( + + ) : null} +
+
+ +
+
+ ) : ( +
+

Select a file to view its contents

+

Use the explorer on the left to navigate

+
+ )} +
+ {fullscreenLayer} +
+ ); +}; + +B4aCloudCodeBrowser.propTypes = { + tree: PropTypes.arrayOf(PropTypes.any).isRequired.describe('Hierarchical tree data (jstree-style nodes).'), + rootFilter: PropTypes.arrayOf(PropTypes.string).describe('Show only root nodes whose text is in this list (e.g. ["public", "cloud"]).'), + defaultExpanded: PropTypes.arrayOf(PropTypes.string).describe('Tree paths to expand by default.'), + isLoading: PropTypes.bool.describe('Whether the tree is still loading.'), + emptyTitle: PropTypes.string.describe('Title for the empty state.'), + emptyDescription: PropTypes.string.describe('Description for the empty state.'), + emptyAction: PropTypes.any.describe('Optional React node rendered in the empty state (e.g. a link/button).'), + onFileSelect: PropTypes.func.describe('Called with (node, path) whenever a file is selected.'), + enableFullscreen: PropTypes.bool.describe('Whether the fullscreen toggle should be shown (defaults to true).'), + readOnly: PropTypes.bool.describe('Whether the editor should be read-only (defaults to true).'), +}; + +export default B4aCloudCodeBrowser; +export { getLanguageFromFilename, decodeFileContent }; diff --git a/src/components/B4aCloudCodeBrowser/B4aCloudCodeBrowser.scss b/src/components/B4aCloudCodeBrowser/B4aCloudCodeBrowser.scss new file mode 100644 index 0000000000..ebfe1c160c --- /dev/null +++ b/src/components/B4aCloudCodeBrowser/B4aCloudCodeBrowser.scss @@ -0,0 +1,186 @@ +.root { + display: flex; + flex: 1 1 auto; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + background: #1e1e1e; + color: #cccccc; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe WPC', 'Segoe UI', 'Ubuntu', 'Droid Sans', sans-serif; +} + +.sidebar { + display: flex; + flex-direction: column; + width: 224px; + flex-shrink: 0; + border-right: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(0, 0, 0, 0.2); + min-height: 0; +} + +.sidebarHeader { + padding: 8px 12px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.4); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + user-select: none; +} + +.main { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.tabBar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: #252526; + border-bottom: 1px solid #3c3c3c; + min-height: 36px; + flex-shrink: 0; +} + +.tabName { + font-size: 13px; + color: rgba(255, 255, 255, 0.8); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; + max-width: 240px; +} + +.tabPath { + margin-left: auto; + font-size: 12px; + font-family: 'Roboto Mono', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + color: rgba(255, 255, 255, 0.4); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.iconButton { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px; + border: 0; + background: transparent; + color: rgba(255, 255, 255, 0.5); + border-radius: 3px; + cursor: pointer; + transition: background-color 150ms ease, color 150ms ease; + flex-shrink: 0; + + &:hover { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; + } + + &:focus-visible { + outline: 1px solid rgba(32, 138, 236, 0.6); + outline-offset: -1px; + } +} + +.iconButtonLabel { + font-size: 12px; +} + +.editor { + flex: 1 1 auto; + min-height: 0; + background: #1e1e1e; + position: relative; +} + +.placeholder { + flex: 1 1 auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: #1e1e1e; + color: rgba(255, 255, 255, 0.4); + text-align: center; + padding: 16px; + + p { + margin: 0; + font-size: 13px; + } +} + +.placeholderHint { + margin-top: 8px !important; + font-size: 12px !important; + color: rgba(255, 255, 255, 0.3) !important; +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background: #1e1e1e; + color: rgba(255, 255, 255, 0.5); + font-size: 13px; +} + +.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: 32px; + background: #1e1e1e; + color: rgba(255, 255, 255, 0.4); + text-align: center; + gap: 12px; +} + +.emptyTitle { + margin: 0; + font-size: 15px; + font-weight: 600; + color: rgba(255, 255, 255, 0.8); +} + +.emptyDescription { + margin: 0; + font-size: 13px; + color: rgba(255, 255, 255, 0.4); + max-width: 480px; + line-height: 1.5; +} + +.fullscreen { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + flex-direction: column; + background: #1e1e1e; +} + +.fullscreenEditor { + flex: 1 1 auto; + min-height: 0; + background: #1e1e1e; +} diff --git a/src/components/B4aCloudEmpty/B4aCloudEmpty.react.js b/src/components/B4aCloudEmpty/B4aCloudEmpty.react.js index 44088ebe65..d57f9aad5b 100644 --- a/src/components/B4aCloudEmpty/B4aCloudEmpty.react.js +++ b/src/components/B4aCloudEmpty/B4aCloudEmpty.react.js @@ -2,129 +2,122 @@ import React, { useState, Suspense, lazy } from 'react'; import ghostImg from './ghost.png'; import styles from 'components/B4aCloudEmpty/B4aCloudEmpty.scss'; -import Icon from 'components/Icon/Icon.react'; import ReactMarkdown from 'react-markdown'; import Button from 'components/Button/Button.react'; +import B4aCloudEmptyCodeBlock from 'components/B4aCloudEmpty/B4aCloudEmptyCodeBlock.react'; const LazyCloudCodeSampleModal = lazy(() => import('../B4ACodeTree/CloudCodeSampleModal.react')); -const CodeBlock = lazy(() => import('components/CodeBlock/CodeBlock.react')); const B4aCloudEmpty = ({ imgSrc = ghostImg, dark = true, selectMainJs, currentApp, hasDeployed }) => { - const [openCloudCodeSample, setOpenCloudCodeSample] = useState(false); + const [openCloudCodeSample, setOpenCloudCodeSample] = useState(false); - const handleCloudCodeSample = () => { - if(!openCloudCodeSample) { - import('../B4ACodeTree/CloudCodeSampleModal.react') - } - setOpenCloudCodeSample(prev => !prev); + const handleCloudCodeSample = () => { + if(!openCloudCodeSample) { + import('../B4ACodeTree/CloudCodeSampleModal.react') } + setOpenCloudCodeSample(prev => !prev); + } - return ( - <> -
- empty state -
-

Cloud Code β€” Extend Your App Backend with JavaScript

-

Cloud Code lets you run JavaScript functions on the server, together with your app's backend. Use it for backend logic, database triggers, and integrations with external services.

-
- { !hasDeployed && ( - <> -
-

How it works

-
    -
  • -
    1
    -
    -
    - Write your function β€” Use the Cloud Code syntax. handleCloudCodeSample()} className={styles.mainJsText}>See examples β†’ -
    -
    - - ( - - ), - }} - > -{`\`\`\`js + return ( + <> +
    + empty state +
    +

    Cloud Code β€” Extend Your App Backend with JavaScript

    +

    Cloud Code lets you run JavaScript functions on the server, together with your app's backend. Use it for backend logic, database triggers, and integrations with external services.

    +
    + { !hasDeployed && ( + <> +
    +

    How it works

    +
      +
    • +
      1
      +
      +
      + Write your function β€” Use the Cloud Code syntax. handleCloudCodeSample()} className={styles.mainJsText}>See examples β†’ +
      +
      + ( + + ), + }} + > + {`\`\`\`js Parse.Cloud.define("hello", () => { return "Hello from Cloud Code!"; }); `} - - + - {/*
      Parse.Cloud.define("hello", () = "Hello from Cloud Code!");
      */} -
      -
      -
    • -
    • -
      2
      -
      -
      Add it to selectMainJs()} className={styles.mainJsText}>main.js and Deploy β€” All Cloud Code must be defined in selectMainJs()} className={styles.mainJsText}>main.js. If you use other files, import them into selectMainJs()} className={styles.mainJsText}>main.js, then click Deploy. -
      -
      -
    • -
    • -
      3
      -
      -
      Call it via API or SDK β€” After deployment, your function is live and callable:
      -
      - - ( - - ), - }} - > -{`\`\`\`bash + {/*
      Parse.Cloud.define("hello", () = "Hello from Cloud Code!");
      */} +
      +
      +
    • +
    • +
      2
      +
      +
      Add it to selectMainJs()} className={styles.mainJsText}>main.js and Deploy β€” All Cloud Code must be defined in selectMainJs()} className={styles.mainJsText}>main.js. If you use other files, import them into selectMainJs()} className={styles.mainJsText}>main.js, then click Deploy. +
      +
      +
    • +
    • +
      3
      +
      +
      Call it via API or SDK β€” After deployment, your function is live and callable:
      +
      + ( + + ), + }} + > + {`\`\`\`bash curl -X POST ${currentApp.serverURL}/functions/hello \\ -H "X-Parse-Application-Id: ${currentApp && currentApp.applicationId ? currentApp.applicationId : 'YOUR_APP_ID'}" \\ -H "X-Parse-REST-API-Key: ${currentApp && currentApp.restKey ? currentApp.restKey : 'YOUR_REST_KEY'}" `} - - + -
      -
      -
    • -
    -
    -
    -
    - - )} -
    - handleCloudCodeSample()}>View Cloud Code examples β†’ +
    +
    +
  • +
+
+
+
+ + )} +
+ handleCloudCodeSample()}>View Cloud Code examples β†’
- { - openCloudCodeSample && ( - - handleCloudCodeSample()} - currentApp={currentApp} - /> - - ) - } - - ) +
+ { + openCloudCodeSample && ( + + handleCloudCodeSample()} + currentApp={currentApp} + /> + + ) + } + + ) } export default B4aCloudEmpty; diff --git a/src/components/B4aCloudEmpty/B4aCloudEmpty.scss b/src/components/B4aCloudEmpty/B4aCloudEmpty.scss index 686d704c09..d4e7521bb5 100644 --- a/src/components/B4aCloudEmpty/B4aCloudEmpty.scss +++ b/src/components/B4aCloudEmpty/B4aCloudEmpty.scss @@ -130,6 +130,67 @@ pre.line-numbers:before{ border-radius: 0.3rem 0 0 0; } +.cloudEmptyCodeBlock { + position: relative; + display: flex; + width: 100%; + min-width: 0; + border-radius: 0.3rem; + overflow: visible; +} + +.cloudEmptyEditor { + flex: 1 1 auto; + min-width: 0; + background: #111214; + border-radius: 0.3rem; + overflow: hidden; +} + +.cloudEmptyCopy { + position: relative; + display: flex; + align-items: center; + padding: 0 10px; + background: #111214; + border-radius: 0 0.3rem 0.3rem 0; + + .copyTooltip { + position: absolute; + bottom: calc(100% + 8px); + left: calc(100% - 100px); + transform: translateX(50%); + background: $dark-grey; + color: $white; + border-radius: 5px; + padding: .625rem 1rem; + font-size: 12px; + white-space: nowrap; + box-shadow: 0px 6px 16px 0px #0000001A; + animation: fadeIn 0.2s ease-in-out; + + &::after { + content: ''; + position: absolute; + left: 55%; + bottom: -4px; + transform: translateX(-50%) rotate(45deg); + width: 8px; + height: 8px; + background: $dark-grey; + } + } + + .copyButton { + background: transparent; + border: 0; + border-radius: 4px; + cursor: pointer; + line-height: 0; + padding: 4px; + } +} + @media screen and (max-width: 1280px){ .content{ .titleSection{ @@ -154,4 +215,4 @@ pre.line-numbers:before{ max-width: 90%; } } -} \ No newline at end of file +} diff --git a/src/components/B4aCloudEmpty/B4aCloudEmptyCodeBlock.react.js b/src/components/B4aCloudEmpty/B4aCloudEmptyCodeBlock.react.js new file mode 100644 index 0000000000..51a6565b4d --- /dev/null +++ b/src/components/B4aCloudEmpty/B4aCloudEmptyCodeBlock.react.js @@ -0,0 +1,56 @@ +import React, { useState } from 'react'; +import Icon from 'components/Icon/Icon.react'; +import B4aCodeEditor from 'components/CodeEditor/B4aCodeEditor.react'; +import styles from 'components/B4aCloudEmpty/B4aCloudEmpty.scss'; + +const getEditorHeight = code => { + const lineCount = code.split('\n').length; + return Math.min(Math.max(lineCount * 19 + 16, 72), 240); +}; + +const B4aCloudEmptyCodeBlock = ({ language, content, hideCopyButton = false }) => { + const [copied, setCopied] = useState(false); + const code = String(content || '').trim(); + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy text: ', err); + } + }; + + return ( +
+
+ +
+ {!hideCopyButton && ( +
+ {copied &&
Copied!
} + +
+ )} +
+ ); +}; + +export default B4aCloudEmptyCodeBlock; diff --git a/src/components/B4aCloudEmpty/B4aCloudPublicEmpty.react.js b/src/components/B4aCloudEmpty/B4aCloudPublicEmpty.react.js index 90f0799fa5..ec929e4030 100644 --- a/src/components/B4aCloudEmpty/B4aCloudPublicEmpty.react.js +++ b/src/components/B4aCloudEmpty/B4aCloudPublicEmpty.react.js @@ -1,121 +1,104 @@ -import React, { useEffect, Suspense, lazy } from 'react'; +import React from 'react'; import { useParams } from 'react-router-dom'; // import Icon from 'components/Icon/Icon.react'; import ghostImg from './ghost.png'; import styles from 'components/B4aCloudEmpty/B4aCloudEmpty.scss'; import Icon from 'components/Icon/Icon.react'; import ReactMarkdown from 'react-markdown'; - -// Import Prism Line Numbers plugin -import 'prismjs/plugins/line-numbers/prism-line-numbers'; -import 'prismjs/plugins/line-numbers/prism-line-numbers.css'; - -import 'prismjs/components/prism-markup-templating.js'; -import 'prismjs/components/prism-javascript'; -import 'prismjs/components/prism-bash'; - -// eslint-disable-next-line no-unused-vars -import customPrisma from 'stylesheets/b4a-prisma.css'; - -const CodeBlock = lazy(() => import('components/CodeBlock/CodeBlock.react')); +import B4aCloudEmptyCodeBlock from 'components/B4aCloudEmpty/B4aCloudEmptyCodeBlock.react'; const B4aCloudPublicEmpty = ({ imgSrc = ghostImg, dark = true, selectIndex, hasDeployed }) => { - const { appId } = useParams(); - return ( -
- empty state -
-

Web Hosting β€” Deploy Static Sites Instantly

-

Deploy your static websites, HTML pages, JavaScript apps, and assets directly to Back4app. Your files are served globally with automatic HTTPS and custom domain support.

-
- { !hasDeployed && ( - <> -
-

How it works

-
    -
  • -
    1
    -
    -
    - Upload your files β€” Drop your HTML, CSS, JavaScript, images, and other static assets into the selectIndex()} className={styles.mainJsText}>public folder. You can organize files in subdirectories as needed. -
    -
    - ( - - ), - }} - > -{`\`\`\`text + const { appId } = useParams(); + return ( +
    + empty state +
    +

    Web Hosting β€” Deploy Static Sites Instantly

    +

    Deploy your static websites, HTML pages, JavaScript apps, and assets directly to Back4app. Your files are served globally with automatic HTTPS and custom domain support.

    +
    + { !hasDeployed && ( + <> +
    +

    How it works

    +
      +
    • +
      1
      +
      +
      + Upload your files β€” Drop your HTML, CSS, JavaScript, images, and other static assets into the selectIndex()} className={styles.mainJsText}>public folder. You can organize files in subdirectories as needed. +
      +
      + ( + + ), + }} + > + {`\`\`\`text public/ β”œβ”€β”€ index.html β”œβ”€β”€ login.html └── styles.css`} - -
      -
      -
    • -
    • -
      2
      -
      -
      Enable your hosting URL β€” After uploading files, click Deploy and enable your web hosting URL. Your site will be available instantly at a unique Back4app subdomain:
      -
      - - ( - - ), - }} - > - {`\`\`\`bash + +
      +
      +
    • +
    • +
      2
      +
      +
      Enable your hosting URL β€” After uploading files, click Deploy and enable your web hosting URL. Your site will be available instantly at a unique Back4app subdomain:
      +
      + ( + + ), + }} + > + {`\`\`\`bash https://your-app.b4a.app `} - - -
      -
      - -
      -
      -
    • -
    -
    -
    + +
    +
    +
    - - )} -
    - ) +
  • +
+
+
+ +
+ + )} +
+ ) } export default B4aCloudPublicEmpty; diff --git a/src/components/B4aFileTree/B4aFileTree.react.js b/src/components/B4aFileTree/B4aFileTree.react.js new file mode 100644 index 0000000000..7da12d0cdb --- /dev/null +++ b/src/components/B4aFileTree/B4aFileTree.react.js @@ -0,0 +1,590 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import PropTypes from 'lib/PropTypes'; +import styles from 'components/B4aFileTree/B4aFileTree.scss'; + +const FOLDER_TYPES = new Set(['folder', 'new-folder']); + +const isFolder = node => FOLDER_TYPES.has(node && node.type); + +const buildPath = (parentPath, node) => + parentPath ? `${parentPath}/${node.text}` : node.text; + +const ChevronIcon = () => ( + + + +); + +const FolderIcon = ({ open }) => { + const color = open ? '#fbbf24' : 'rgba(251, 191, 36, 0.7)'; + return ( + + + + ); +}; + +const FILE_COLORS = { + js: '#fbbf24', + mjs: '#fbbf24', + jsx: '#fbbf24', + ts: '#60a5fa', + tsx: '#60a5fa', + json: '#ca8a04', + html: '#fb923c', + htm: '#fb923c', + css: '#3b82f6', + scss: '#f472b6', + md: 'rgba(255, 255, 255, 0.6)', + txt: 'rgba(255, 255, 255, 0.5)', +}; + +const FILE_LABELS = { + js: 'JS', + mjs: 'JS', + jsx: 'JSX', + ts: 'TS', + tsx: 'TSX', + json: '{}', + html: '<>', + htm: '<>', + css: '#', + scss: '#', + md: 'MD', +}; + +const getExtension = filename => { + if (typeof filename !== 'string') { + return ''; + } + const idx = filename.lastIndexOf('.'); + if (idx <= 0) { + return ''; + } + return filename.slice(idx + 1).toLowerCase(); +}; + +const FileIcon = ({ filename }) => { + const ext = getExtension(filename); + const color = FILE_COLORS[ext] || 'rgba(255, 255, 255, 0.5)'; + const label = FILE_LABELS[ext] || ''; + return ( + + + + {label ? ( + + {label} + + ) : null} + + ); +}; + +const NewFileIcon = () => ( + + + + + + +); + +const NewFolderIcon = () => ( + + + + + +); + +const RenameIcon = () => ( + + + + +); + +const DeleteIcon = () => ( + + + + + + + +); + +const ContextMenu = ({ x, y, items, onClose }) => { + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = e => { + if (menuRef.current && !menuRef.current.contains(e.target)) { + onClose(); + } + }; + const handleEscape = e => { + if (e.key === 'Escape') { + onClose(); + } + }; + document.addEventListener('mousedown', handleClickOutside, true); + document.addEventListener('keydown', handleEscape, true); + return () => { + document.removeEventListener('mousedown', handleClickOutside, true); + document.removeEventListener('keydown', handleEscape, true); + }; + }, [onClose]); + + useEffect(() => { + if (!menuRef.current) { + return; + } + const rect = menuRef.current.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + if (rect.right > vw) { + menuRef.current.style.left = `${x - rect.width}px`; + } + if (rect.bottom > vh) { + menuRef.current.style.top = `${y - rect.height}px`; + } + }, [x, y]); + + return createPortal( +
+ {items.map(item => ( + + ))} +
, + document.body + ); +}; + +const TreeNode = ({ + node, + parentPath, + depth, + expanded, + selectedPath, + editingPath, + editingValue, + draggingPath, + dropTargetPath, + onToggle, + onSelect, + onContextAction, + onRenameChange, + onRenameCommit, + onRenameCancel, + onDragStart, + onDragEnd, + onDropNode, + canDropNode, +}) => { + const folder = isFolder(node); + const path = buildPath(parentPath, node); + const isExpanded = expanded.has(path); + const isSelected = selectedPath === path; + const isEditing = editingPath === path; + const isDragging = draggingPath === path; + const isDropTarget = dropTargetPath === path; + + const handleClick = () => { + if (isEditing) { + return; + } + if (folder) { + onToggle(path); + } + onSelect(node, path); + }; + + const handleContextMenu = e => { + if (typeof onContextAction !== 'function') { + return; + } + e.preventDefault(); + e.stopPropagation(); + onContextAction(node, path, folder, e.clientX, e.clientY); + }; + + const draggable = typeof onDropNode === 'function' && path.indexOf('/') > -1; + + const handleDragStart = e => { + if (isEditing || !draggable) { + e.preventDefault(); + return; + } + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', path); + onDragStart(path); + }; + + const handleDragOver = e => { + if (!folder || typeof canDropNode !== 'function' || !canDropNode(draggingPath, path)) { + return; + } + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }; + + const handleDrop = e => { + if (!folder || typeof onDropNode !== 'function') { + return; + } + e.preventDefault(); + e.stopPropagation(); + onDropNode(draggingPath || e.dataTransfer.getData('text/plain'), path); + }; + + const handleDragEnter = e => { + if (!folder || typeof canDropNode !== 'function' || !canDropNode(draggingPath, path)) { + return; + } + e.preventDefault(); + onToggle(path, true); + }; + + const indentStyle = { paddingLeft: `${depth * 12 + 8}px` }; + const rowClassName = [ + styles.row, + isSelected ? styles.selected : '', + isDragging ? styles.dragging : '', + isDropTarget ? styles.dropTarget : '', + ].filter(Boolean).join(' '); + + return ( +
+ + + {folder && isExpanded && Array.isArray(node.children) && node.children.length > 0 ? ( +
+ {node.children.map(child => ( + + ))} +
+ ) : null} +
+ ); +}; + +const B4aFileTree = ({ + tree, + selectedPath, + onFileSelect, + onContextAction, + onNodeDrop, + rootFilter, + defaultExpanded, + emptyMessage, +}) => { + const [expanded, setExpanded] = useState(() => new Set(defaultExpanded || [])); + const [ctxMenu, setCtxMenu] = useState(null); + const [editingPath, setEditingPath] = useState(''); + const [editingValue, setEditingValue] = useState(''); + const [draggingPath, setDraggingPath] = useState(''); + const [dropTargetPath, setDropTargetPath] = useState(''); + + useEffect(() => { + if (!selectedPath) { + return; + } + const segments = selectedPath.split('/'); + if (segments.length <= 1) { + return; + } + setExpanded(prev => { + const next = new Set(prev); + let accumulated = ''; + for (let i = 0; i < segments.length - 1; i++) { + accumulated = accumulated ? `${accumulated}/${segments[i]}` : segments[i]; + next.add(accumulated); + } + if (next.size === prev.size) { + return prev; + } + return next; + }); + }, [selectedPath]); + + const toggleFolder = useCallback((path, forceOpen) => { + setExpanded(prev => { + const next = new Set(prev); + if (forceOpen) { + next.add(path); + } else if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + const closeCtxMenu = useCallback(() => setCtxMenu(null), []); + + const startRename = useCallback((node, path) => { + setEditingPath(path); + setEditingValue(node.text || ''); + }, []); + + const cancelRename = useCallback(() => { + setEditingPath(''); + setEditingValue(''); + }, []); + + const commitRename = useCallback((node, path) => { + const nextName = editingValue.trim(); + if (!nextName || nextName === node.text) { + cancelRename(); + return; + } + onContextAction('rename', node, path, nextName); + cancelRename(); + }, [cancelRename, editingValue, onContextAction]); + + const canDropNode = useCallback((sourcePath, targetPath) => { + if (typeof onNodeDrop !== 'function' || !sourcePath || !targetPath) { + return false; + } + if (sourcePath === targetPath) { + return false; + } + if (sourcePath.indexOf('/') === -1) { + return false; + } + if (sourcePath.split('/').slice(0, -1).join('/') === targetPath) { + return false; + } + if (targetPath.startsWith(`${sourcePath}/`)) { + return false; + } + return true; + }, [onNodeDrop]); + + const handleDragStart = useCallback(path => { + setDraggingPath(path); + setDropTargetPath(''); + }, []); + + const handleDragEnd = useCallback(() => { + setDraggingPath(''); + setDropTargetPath(''); + }, []); + + const handleDropNode = useCallback((sourcePath, targetPath) => { + if (!canDropNode(sourcePath, targetPath)) { + handleDragEnd(); + return; + } + onNodeDrop(sourcePath, targetPath); + handleDragEnd(); + }, [canDropNode, handleDragEnd, onNodeDrop]); + + const handleNodeContext = useCallback((node, path, folder, x, y) => { + if (typeof onContextAction !== 'function') { + return; + } + setCtxMenu({ node, path, folder, x, y }); + }, [onContextAction]); + + const ctxMenuItems = useMemo(() => { + if (!ctxMenu) { + return []; + } + const isProtectedRoot = + ctxMenu.path.indexOf('/') === -1 && (ctxMenu.node.text === 'cloud' || ctxMenu.node.text === 'public'); + const items = [ + { + key: 'new-file', + label: 'New File', + icon: , + action: () => onContextAction('create-file', ctxMenu.node, ctxMenu.path), + }, + ]; + if (ctxMenu.folder) { + items.push({ + key: 'new-folder', + label: 'New Folder', + icon: , + action: () => onContextAction('create-folder', ctxMenu.node, ctxMenu.path), + }); + } + if (!isProtectedRoot) { + items.push( + { + key: 'rename', + label: 'Rename', + icon: , + action: () => startRename(ctxMenu.node, ctxMenu.path), + }, + { + key: 'delete', + label: ctxMenu.folder ? 'Delete Folder' : 'Delete File', + icon: , + action: () => onContextAction('delete', ctxMenu.node, ctxMenu.path), + } + ); + } + return items; + }, [ctxMenu, onContextAction, startRename]); + + const filteredTree = useMemo(() => { + if (!Array.isArray(tree) || tree.length === 0) { + return []; + } + if (!rootFilter || rootFilter.length === 0) { + return tree; + } + const allowed = new Set(rootFilter); + return tree.filter(node => allowed.has(node.text)); + }, [tree, rootFilter]); + + if (filteredTree.length === 0) { + return ( +
{emptyMessage || 'No files found'}
+ ); + } + + return ( +
+ {filteredTree.map(node => ( + { + const canDrop = canDropNode(sourcePath, targetPath); + setDropTargetPath(canDrop ? targetPath : ''); + return canDrop; + }} + /> + ))} + {ctxMenu ? ( + + ) : null} +
+ ); +}; + +B4aFileTree.propTypes = { + tree: PropTypes.arrayOf(PropTypes.any).isRequired.describe('Hierarchical tree data (jstree-style nodes with text/type/children).'), + selectedPath: PropTypes.string.describe('The currently selected node path.'), + onFileSelect: PropTypes.func.isRequired.describe('Called with (node, path) when a file is clicked.'), + onContextAction: PropTypes.func.describe('Called with (action, node, path) on right-click menu selection. Actions: "create-file", "create-folder", "rename", "delete".'), + onNodeDrop: PropTypes.func.describe('Called with (sourcePath, targetPath) when a node is dropped onto a folder.'), + rootFilter: PropTypes.arrayOf(PropTypes.string).describe('When set, only root nodes whose `text` is in this list are rendered.'), + defaultExpanded: PropTypes.arrayOf(PropTypes.string).describe('Paths that should start expanded.'), + emptyMessage: PropTypes.string.describe('Message to show when the tree is empty.'), +}; + +export default B4aFileTree; +export { getExtension }; diff --git a/src/components/B4aFileTree/B4aFileTree.scss b/src/components/B4aFileTree/B4aFileTree.scss new file mode 100644 index 0000000000..8ff028decc --- /dev/null +++ b/src/components/B4aFileTree/B4aFileTree.scss @@ -0,0 +1,184 @@ +.tree { + flex: 1 1 auto; + overflow: auto; + padding: 8px 0; + background: transparent; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe WPC', 'Segoe UI', 'Ubuntu', 'Droid Sans', sans-serif; + font-size: 14px; + color: rgba(255, 255, 255, 0.7); + + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); + border-radius: 8px; + } + + &::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.16); + } +} + +.empty { + padding: 16px; + font-size: 14px; + color: rgba(255, 255, 255, 0.4); + text-align: center; +} + +.nodeWrapper { + width: 100%; +} + +.row { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 4px 8px; + background: transparent; + border: 0; + outline: none; + text-align: left; + color: rgba(255, 255, 255, 0.7); + font: inherit; + font-size: 14px; + cursor: pointer; + user-select: none; + white-space: nowrap; + transition: background-color 150ms ease, color 150ms ease; + + &:hover { + background: rgba(255, 255, 255, 0.05); + } + + &:focus-visible { + outline: 1px solid rgba(32, 138, 236, 0.6); + outline-offset: -1px; + } +} + +.selected { + background: rgba(32, 138, 236, 0.2) !important; + color: #208AEC !important; + + &:hover { + background: rgba(32, 138, 236, 0.28) !important; + } +} + +.dragging { + opacity: 0.45; +} + +.dropTarget { + background: rgba(32, 138, 236, 0.18) !important; + box-shadow: inset 2px 0 0 #208AEC; +} + +.chevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: rgba(255, 255, 255, 0.55); + transition: transform 200ms ease; + flex-shrink: 0; +} + +.chevronOpen { + transform: rotate(90deg); +} + +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.label { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.renameInput { + flex: 1 1 auto; + min-width: 0; + height: 24px; + padding: 2px 6px; + background: #2d2d30; + border: 1px solid #208AEC; + border-radius: 4px; + box-shadow: 0 0 0 1px rgba(32, 138, 236, 0.35); + color: #ffffff; + font: inherit; + outline: none; +} + +.children { + display: block; +} + +.contextMenu { + position: fixed; + z-index: 10000; + min-width: 160px; + padding: 4px 0; + background: #252526; + border: 1px solid #3c3c3c; + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + font-size: 11px; + color: rgba(255, 255, 255, 0.8); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe WPC', 'Segoe UI', 'Ubuntu', 'Droid Sans', sans-serif; +} + +.contextMenuItem { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 12px; + background: transparent; + border: 0; + outline: none; + text-align: left; + color: rgba(255, 255, 255, 0.8); + font: inherit; + cursor: pointer; + white-space: nowrap; + transition: background-color 100ms ease; + + &:hover { + background: #094771; + color: #ffffff; + } + + &:focus-visible { + background: #094771; + color: #ffffff; + } +} + +.contextMenuIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; + color: rgba(255, 255, 255, 0.6); +} diff --git a/src/components/CodeEditor/B4aCodeEditor.react.js b/src/components/CodeEditor/B4aCodeEditor.react.js index 7b8eb41bb3..9923fdf688 100644 --- a/src/components/CodeEditor/B4aCodeEditor.react.js +++ b/src/components/CodeEditor/B4aCodeEditor.react.js @@ -1,168 +1,40 @@ -import React, { useState, useEffect, forwardRef } from 'react'; -import CodeMirror from '@uiw/react-codemirror'; -import { javascript, esLint } from '@codemirror/lang-javascript'; -import { html } from '@codemirror/lang-html'; -import { css } from '@codemirror/lang-css'; -import { json } from '@codemirror/lang-json'; -import { xml } from '@codemirror/lang-xml'; -import { linter, lintGutter } from '@codemirror/lint'; -import { search } from '@codemirror/search'; -import { EditorView } from '@codemirror/view'; -import globals from 'globals'; -import { createTheme } from '@uiw/codemirror-themes'; -import { tags as t } from '@lezer/highlight'; -// import * as eslint from 'eslint-linter-browserify'; -// import { StreamLanguage } from '@codemirror/language'; - -const myTheme = createTheme({ - theme: 'dark', - settings: { - background: '#111214', - foreground: '#CECFD0', - caret: '#fff', - selection: '#727377', - selectionMatch: '#727377', - lineHighlight: '#ffffff0f', - backgroundImage: '', - - gutterBackground: '#0A0B0C', - gutterForeground: '#f9f9f980', - gutterBorder: '#dddddd', - gutterActiveForeground: '#f9f9f9', - }, - styles: [ - { tag: [t.comment, t.quote], color: '#7F8C98' }, - { tag: [t.keyword], color: '#FF7AB2', fontWeight: 'bold' }, - { tag: [t.string, t.meta], color: '#27AE60' }, - { tag: [t.typeName], color: '#7cacf8' }, - { tag: [t.definition(t.variableName)], color: '#6BDFFF' }, - { tag: [t.name], color: '#6BAA9F' }, - { tag: [t.variableName], color: '#15A9FF' }, - { tag: [t.regexp, t.link], color: '#FF8170' }, - ], -}); - -const config = { - languageOptions: { - globals: { - ...globals.node, - Parse: true, - }, - ecmaVersion: 2022, - sourceType: 'module' - }, - rules: { - 'no-const-assign': 'error', - 'no-restricted-syntax': [ - 'error', - { - selector: 'VariableDeclaration[kind=\'const\'] > VariableDeclarator[init.type=\'Identifier\'][init.name=\'undefined\']', - message: 'Do not initialize `const` variables to `undefined`.' - } - ] - } -}; - -const loadEslint = () => { - return new Promise((resolve, reject) => { - if (window.eslint) { - resolve(window.eslint); - return; - } - - const script = document.createElement('script'); - script.src = 'https://cdn.jsdelivr.net/npm/eslint-linter-browserify/linter.min.js'; - script.async = true; - script.onload = () => resolve(window.eslint); - script.onerror = reject; - document.head.appendChild(script); - }); +import React, { Suspense, lazy, forwardRef, useMemo } from 'react'; + +// Lazy-load the heavy Monaco-based editor implementation into its own webpack +// chunk so it (and `@monaco-editor/react` + `@monaco-editor/loader`) don't +// land in the main dashboard bundle. The chunk is fetched the first time any +// route mounts an editor (Cloud Code, Playground, Custom Parse Options +// modal) and is cached for subsequent mounts. +const B4aCodeEditorImpl = lazy(() => + import(/* webpackChunkName: "b4a-code-editor" */ './B4aCodeEditorImpl.react') +); + +const loadingFallbackStyle = { + color: '#CECFD0', + background: '#111214', + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '12px', + boxSizing: 'border-box', }; -const B4aCodeEditor = forwardRef(({ code: initialCode, onCodeChange, mode, readOnly = false }, ref) => { - const [code, setCode] = useState(initialCode); - const [eslintInstance, setEslintInstance] = useState(null); - - useEffect(() => { - if (mode === 'javascript' || mode === 'js') { - loadEslint() - .then(eslint => { - setEslintInstance(new eslint.Linter()); - }) - .catch(error => { - console.error('Failed to load ESLint:', error); - }); - } - }, [mode]); - - useEffect(() => { - if (window && window.document.querySelector('.cm-theme')) { - const el = window.document.querySelector('.cm-theme'); - el.style.height = '100%'; - el.style.fontSize = '12px'; - } - }, []); - - useEffect(() => { - setCode(initialCode); - }, [initialCode]); - - const handleCodeChange = (value) => { - setCode(value); - typeof onCodeChange === 'function' && onCodeChange(value) - }; - - // Set the language extension - const getLanguageExtension = () => { - switch (mode) { - case 'html': - return [html()]; - case 'xml': - return [xml()]; - case 'css': - return [css()]; - case 'json': - return [json()]; - case 'javascript': - case 'js': - return [ - javascript(), - ...(eslintInstance ? [linter(esLint(eslintInstance, config))] : []) - ]; - default: - return []; - } - }; +// Kept visually identical to the impl's own `loading` prop so the user only +// ever sees one consistent "Loading editor…" surface across both phases: +// (1) chunk download, then (2) Monaco CDN bootstrap. +const LoadingFallback = () =>
Loading editor…
; +const B4aCodeEditor = forwardRef((props, ref) => { + const fallback = useMemo(() => , []); return ( - EditorView.scrollIntoView(range.from, { y: 'center' }), - }) - ]} - onChange={(value) => { - handleCodeChange(value) - }} - onCreateEditor={() => { - if (window && window.document.querySelector('.cm-editor')) { - const el = window.document.querySelector('.cm-editor'); - el.style.outline = 'none' - } - }} - basicSetup={{ lineNumbers: true, highlightActiveLine: true }} - theme={myTheme} - editable={!readOnly} - readOnly={readOnly} - /> + + + ); -}) +}); + +B4aCodeEditor.displayName = 'B4aCodeEditor'; export default B4aCodeEditor; diff --git a/src/components/CodeEditor/B4aCodeEditorImpl.react.js b/src/components/CodeEditor/B4aCodeEditorImpl.react.js new file mode 100644 index 0000000000..178a52a534 --- /dev/null +++ b/src/components/CodeEditor/B4aCodeEditorImpl.react.js @@ -0,0 +1,163 @@ +import React, { useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from 'react'; +import Editor from '@monaco-editor/react'; + +const MONACO_THEME = 'vs-dark'; + +const languageMap = { + bash: 'shell', + dart: 'dart', + graphql: 'graphql', + html: 'html', + java: 'java', + javascript: 'javascript', + js: 'javascript', + json: 'json', + kotlin: 'kotlin', + php: 'php', + plaintext: 'plaintext', + shell: 'shell', + swift: 'swift', + text: 'plaintext', + css: 'css', + xml: 'xml', +}; + +const loadingFallbackStyle = { + color: '#d4d4d4', + background: '#1e1e1e', + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '12px', + boxSizing: 'border-box', +}; + +const B4aCodeEditorImpl = forwardRef( + ({ code: initialCode, onCodeChange, mode, readOnly = false, fontSize = 12 }, ref) => { + const editorRef = useRef(null); + const monacoRef = useRef(null); + const [code, setCode] = useState(initialCode ?? ''); + + useEffect(() => { + const next = initialCode ?? ''; + // Avoid re-applying the value when the incoming prop is just an echo of + // what the user typed (parent re-renders with the same source). This + // prevents Monaco from doing a full setValue on every keystroke, which + // is heavy (revalidation, undo reset) and can cause cursor jumps. + const current = editorRef.current ? editorRef.current.getValue() : code; + if (current === next) { + return; + } + setCode(next); + }, [initialCode]); + + useImperativeHandle(ref, () => ({ + get editor() { + return editorRef.current; + }, + get monaco() { + return monacoRef.current; + }, + get value() { + return editorRef.current ? editorRef.current.getValue() : ''; + }, + set value(next) { + if (editorRef.current && typeof next === 'string') { + editorRef.current.setValue(next); + } + }, + focus() { + editorRef.current && editorRef.current.focus(); + }, + })); + + const handleMount = (editor, monaco) => { + editorRef.current = editor; + monacoRef.current = monaco; + + if (monaco.languages.typescript) { + const jsDefaults = monaco.languages.typescript.javascriptDefaults; + jsDefaults.setDiagnosticsOptions({ + noSemanticValidation: false, + noSyntaxValidation: false, + }); + jsDefaults.setCompilerOptions({ + target: monaco.languages.typescript.ScriptTarget.ES2022, + allowNonTsExtensions: true, + allowJs: true, + checkJs: false, + }); + jsDefaults.addExtraLib( + [ + 'declare var Parse: any;', + 'declare var process: any;', + 'declare var require: any;', + 'declare var module: any;', + 'declare var __dirname: string;', + 'declare var __filename: string;', + ].join('\n'), + 'file:///b4a-globals.d.ts' + ); + } + }; + + const handleChange = value => { + const next = value ?? ''; + setCode(next); + if (typeof onCodeChange === 'function') { + onCodeChange(next); + } + }; + + const language = languageMap[mode] || 'plaintext'; + + const options = useMemo( + () => ({ + readOnly, + fontSize, + lineNumbers: 'on', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + automaticLayout: true, + tabSize: 2, + renderLineHighlight: 'all', + fontFamily: + '\'Roboto Mono\', Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace', + fontLigatures: false, + smoothScrolling: true, + cursorSmoothCaretAnimation: 'on', + bracketPairColorization: { enabled: true }, + guides: { bracketPairs: true, indentation: true }, + padding: { top: 8, bottom: 8 }, + wordWrap: 'off', + fixedOverflowWidgets: true, + }), + [readOnly, fontSize] + ); + + const loadingElement = useMemo( + () =>
Loading editor…
, + [] + ); + + return ( + + ); + } +); + +B4aCodeEditorImpl.displayName = 'B4aCodeEditorImpl'; + +export default B4aCodeEditorImpl; diff --git a/src/dashboard/Data/AppOverview/AppOverview.scss b/src/dashboard/Data/AppOverview/AppOverview.scss index 59ce750c00..8689c30824 100644 --- a/src/dashboard/Data/AppOverview/AppOverview.scss +++ b/src/dashboard/Data/AppOverview/AppOverview.scss @@ -1082,12 +1082,35 @@ padding-top: 0.5rem; background: $regal-blue; border-radius: 4px; + margin-bottom: 1rem; & .codeBlockHeader { display: flex; align-items: center; justify-content: space-between; - margin: 0rem 1.5rem; + margin: 0rem 1.5rem 0.5rem 1.5rem; + } + + & .languageLabel { + @include InterFont; + color: $light-blue; + font-size: 0.8125rem; + font-weight: 500; + } + + & .codeEditorBlock { + background: #111214; + border-radius: 0 0 4px 4px; + overflow: hidden; + } + + & .copyButton { + background: transparent; + border: 0; + cursor: pointer; + padding: 4px; + border-radius: 4px; + line-height: 0; } .copyButtonWrapper { @@ -1440,6 +1463,14 @@ display: inline-flex; align-items: center; gap: 0.5em; + + & .copyButton { + background: transparent; + border: 0; + cursor: pointer; + line-height: 0; + padding: 2px; + } } } } @@ -1586,4 +1617,4 @@ &:hover { text-decoration: underline; } -} \ No newline at end of file +} diff --git a/src/dashboard/Data/AppOverview/AppOverviewCodeEditorBlock.react.js b/src/dashboard/Data/AppOverview/AppOverviewCodeEditorBlock.react.js new file mode 100644 index 0000000000..2ddc575d80 --- /dev/null +++ b/src/dashboard/Data/AppOverview/AppOverviewCodeEditorBlock.react.js @@ -0,0 +1,65 @@ +import React, { useMemo, useState } from 'react'; +import B4aCodeEditor from 'components/CodeEditor/B4aCodeEditor.react'; +import Icon from 'components/Icon/Icon.react'; +import styles from 'dashboard/Data/AppOverview/AppOverview.scss'; + +const getEditorHeight = code => { + const lineCount = code.split('\n').length; + return Math.min(Math.max(lineCount * 19 + 16, 96), 420); +}; + +const AppOverviewCodeEditorBlock = ({ language, value, fileName, inline = false }) => { + const [copied, setCopied] = useState(false); + const codeText = useMemo(() => String(value || '').trim(), [value]); + const mode = language || 'plaintext'; + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(codeText); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy text: ', err); + } + }; + + if (inline) { + return ( +
+ {codeText} + +
+ ); + } + + return ( +
+
+
{fileName || mode}
+
+ {copied &&
Copied!
} + +
+
+
+ +
+
+ ); +}; + +export default AppOverviewCodeEditorBlock; diff --git a/src/dashboard/Data/AppOverview/ConnectAppModal.react.js b/src/dashboard/Data/AppOverview/ConnectAppModal.react.js index 5a3c7c298b..33cbe2d432 100644 --- a/src/dashboard/Data/AppOverview/ConnectAppModal.react.js +++ b/src/dashboard/Data/AppOverview/ConnectAppModal.react.js @@ -1,31 +1,10 @@ -import React, { useEffect, useState, Suspense, lazy } from 'react'; +import React, { useState } from 'react'; import Popover from 'components/Popover/Popover.react'; import Position from 'lib/Position'; import styles from 'dashboard/Data/AppOverview/AppOverview.scss'; import Icon from 'components/Icon/Icon.react'; import ReactMarkdown from 'react-markdown'; -import Prism from 'prismjs'; -// Import Prism Line Numbers plugin -import 'prismjs/plugins/line-numbers/prism-line-numbers'; -import 'prismjs/plugins/line-numbers/prism-line-numbers.css'; - -import 'prismjs/components/prism-markup-templating.js'; -import 'prismjs/components/prism-javascript'; -import 'prismjs/components/prism-bash'; -import 'prismjs/components/prism-graphql'; -import 'prismjs/components/prism-java'; -import 'prismjs/components/prism-php'; -import 'prismjs/components/prism-dart'; -import 'prismjs/components/prism-kotlin'; -import 'prismjs/components/prism-swift'; - -import 'prismjs/plugins/line-numbers/prism-line-numbers' -import 'prismjs/plugins/line-numbers/prism-line-numbers.css' - -// eslint-disable-next-line no-unused-vars -import customPrisma from 'stylesheets/b4a-prisma.css'; - -const CodeBlock = lazy(() => import('components/CodeBlock/CodeBlock.react')); +import AppOverviewCodeEditorBlock from './AppOverviewCodeEditorBlock.react'; const LanguageDocMap = { rest: { @@ -925,16 +904,15 @@ const ConnectAppModal = ({ closeModal }) => {
- Loading...
}> - ( - + + inline ? {value} : ( + ), - }} - children={selectedLanguage.content} - /> - + }} + children={selectedLanguage.content} + />
diff --git a/src/dashboard/Data/AppOverview/MCPSetupModal.js b/src/dashboard/Data/AppOverview/MCPSetupModal.js index a156e8217a..269597f36b 100644 --- a/src/dashboard/Data/AppOverview/MCPSetupModal.js +++ b/src/dashboard/Data/AppOverview/MCPSetupModal.js @@ -5,75 +5,10 @@ import styles from 'dashboard/Data/AppOverview/AppOverview.scss'; import Icon from 'components/Icon/Icon.react'; import Button from 'components/Button/Button.react'; import B4aTabToggle from 'components/Toggle/B4aTabToggle.react'; - -import Prism from 'prismjs'; -import 'prismjs/components/prism-markup-templating.js'; -// eslint-disable-next-line no-unused-vars -import customPrisma from 'stylesheets/b4a-prisma.css'; +import AppOverviewCodeEditorBlock from './AppOverviewCodeEditorBlock.react'; const origin = new Position(0, 0); -const CodeBlock = ({ language, value, fileName, inline = false }) => { - const [copied, setCopied] = useState(false); - - useEffect(() => { - if (typeof Prism !== 'undefined') { - Prism.highlightAll(); - } - }, [value, language]); - - const copyToClipboard = async () => { - try { - await navigator.clipboard.writeText(value); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy text: ', err); - } - }; - - if (inline) { - return ( -
- {value} -
- ); - } - - return ( -
-
-
{fileName ? fileName : language}
-
- {copied && ( -
- Copied! -
- )} - -
-
-
-        {value}
-      
-
- ); -}; - const getManualJsonContent = (ide, mcpKey, isLinux) => { if (ide === 'cursor' && isLinux) { return `{ @@ -274,7 +209,7 @@ const getIDEContent = (ide, automatic, mcpKey) => {
2. Alternative: Terminal installation
Or copy and run the command below in your terminal to install {ide}:
- +
3. Verify your connection
{getVerifyContent(ide)} @@ -284,7 +219,7 @@ const getIDEContent = (ide, automatic, mcpKey) => { <>
1. Run the installation command
Copy and run the command below in your terminal to install {ide}.
- +
2. Verify your connection
{getVerifyContent(ide)} @@ -298,10 +233,10 @@ const getIDEContent = (ide, automatic, mcpKey) => { {getManualInstructions(ide)}
macOS / Linux - +
Windows - +
); } @@ -373,7 +308,7 @@ const MCPSetupModal = ({ closeModal, context, selectedIDE }) => {
1. Tell your agent what you need
In your AI agent chat, You can use Back4App MCP to interact with your Back4App account.
Here is an example to get a list of your apps:
- +
2. Refer to docs for more information
https://www.back4app.com/docs/mcp
@@ -405,4 +340,3 @@ const MCPSetupModal = ({ closeModal, context, selectedIDE }) => { }; export default MCPSetupModal; -