diff --git a/package-lock.json b/package-lock.json index 539865e51687870fe2f1ff72a72222a4deb50831..c0f1da61c37a420776d6eeb93bcc5663903a8e2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8534,6 +8534,18 @@ "prepend-http": "^1.0.0", "query-string": "^4.1.0", "sort-keys": "^1.0.0" + }, + "dependencies": { + "query-string": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz", + "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=", + "dev": true, + "requires": { + "object-assign": "^4.1.0", + "strict-uri-encode": "^1.0.0" + } + } } }, "npm-conf": { @@ -10131,13 +10143,21 @@ "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" }, "query-string": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz", - "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.2.0.tgz", + "integrity": "sha512-5wupExkIt8RYL4h/FE+WTg3JHk62e6fFPWtAZA9J5IWK1PfTfKkMS93HBUHcFpeYi9KsY5pFbh+ldvEyaz5MyA==", "dev": true, "requires": { - "object-assign": "^4.1.0", - "strict-uri-encode": "^1.0.0" + "decode-uri-component": "^0.2.0", + "strict-uri-encode": "^2.0.0" + }, + "dependencies": { + "strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY=", + "dev": true + } } }, "querystring": { diff --git a/package.json b/package.json index 62474ed48fb1ba846a4e0c8d9fee03d9d115bbf0..f8b4a9dcf8032859dc4e33164196017aec20ff84 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "node-sass": "^4.5.3", "postcss-flexboxfixer": "0.0.5", "postcss-loader": "^2.0.6", + "query-string": "^6.2.0", "raw-loader": "^0.5.1", "react": "^16.3.1", "react-ace": "^6.1.4", diff --git a/src/backend/controllers/index.js b/src/backend/controllers/index.js index 72e26d16c7888e235e3568e1295c045b122f3d7c..a971818b761c2eccfe914aac72e8050f4e81d49b 100644 --- a/src/backend/controllers/index.js +++ b/src/backend/controllers/index.js @@ -1,3 +1,4 @@ export { default as auth } from './auth'; export { default as algorithms } from './algorithms'; export { default as tracers } from './tracers'; +export { default as visualizations } from './visualizations'; diff --git a/src/backend/controllers/tracers.js b/src/backend/controllers/tracers.js index 84de9ea93985a036996d15eff6839ff0388d577f..e9b9828b00cf185e56d8ff191c5828ba8e267082 100644 --- a/src/backend/controllers/tracers.js +++ b/src/backend/controllers/tracers.js @@ -14,7 +14,6 @@ const router = express.Router(); const trace = lang => (req, res, next) => { const { code } = req.body; const tempPath = path.resolve(__dirname, '..', 'public', 'codes', uuid.v4()); - const tracesPath = path.resolve(tempPath, 'traces.json'); fs.outputFile(path.resolve(tempPath, `Main.${lang}`), code) .then(() => { const builder = builderMap[lang]; @@ -37,11 +36,13 @@ const trace = lang => (req, res, next) => { throw error; }).finally(() => clearTimeout(timer)); }) - .then(() => fs.pathExists(tracesPath)) - .then(exists => { - if (!exists) throw new Error('Traces Not Found'); - res.sendFile(tracesPath); - }) + .then(() => new Promise((resolve, reject) => { + const visualizationPath = path.resolve(tempPath, 'traces.json'); + res.sendFile(visualizationPath, err => { + if (err) return reject(new Error('Visualization Not Found')); + resolve(); + }); + })) .catch(next) .finally(() => fs.remove(tempPath)); }; diff --git a/src/backend/controllers/visualizations.js b/src/backend/controllers/visualizations.js new file mode 100644 index 0000000000000000000000000000000000000000..968f78a4adfb86fa1bb55bc3eda8d3b7efb9d3fc --- /dev/null +++ b/src/backend/controllers/visualizations.js @@ -0,0 +1,42 @@ +import express from 'express'; +import path from 'path'; +import uuid from 'uuid'; +import fs from 'fs-extra'; +import Promise from 'bluebird'; + +const router = express.Router(); + +const uploadPath = path.resolve(__dirname, '..', 'public', 'visualizations'); +const getVisualizationPath = visualizationId => path.resolve(uploadPath, `${visualizationId}.json`); + +fs.remove(uploadPath).catch(console.error); + +const uploadVisualization = (req, res, next) => { + const { content } = req.body; + const visualizationId = uuid.v4(); + const tracesPath = getVisualizationPath(visualizationId); + const url = `https://algorithm-visualizer.org/scratch-paper/new?visualizationId=${visualizationId}`; + fs.outputFile(tracesPath, content) + .then(() => res.send(url)) + .catch(next); +}; + +const getVisualization = (req, res, next) => { + const { visualizationId } = req.params; + const visualizationPath = getVisualizationPath(visualizationId); + new Promise((resolve, reject) => { + res.sendFile(visualizationPath, err => { + if (err) return reject(new Error('Visualization Expired')); + resolve(); + }); + }).catch(next) + .finally(() => fs.remove(visualizationPath)); +}; + +router.route('/') + .post(uploadVisualization); + +router.route('/:visualizationId') + .get(getVisualization); + +export default router; diff --git a/src/frontend/apis/index.js b/src/frontend/apis/index.js index c628fa83befe99c90c2e72ec34f651341272c629..a0dbf62d496809238e4228f5ec3e6ece6d987cc4 100644 --- a/src/frontend/apis/index.js +++ b/src/frontend/apis/index.js @@ -52,6 +52,10 @@ const AlgorithmApi = { getAlgorithm: GET('/algorithms/:categoryKey/:algorithmKey'), }; +const VisualizationApi = { + getVisualization: GET('/visualizations/:visualizationId'), +}; + const GitHubApi = { auth: token => Promise.resolve(axios.defaults.headers.common['Authorization'] = token && `token ${token}`), getUser: GET('https://api.github.com/user'), @@ -73,6 +77,7 @@ const TracerApi = { method: 'set', args: [code], }]), + json: ({ code }) => new Promise(resolve => resolve(JSON.parse(code))), js: ({ code }, params, cancelToken) => new Promise((resolve, reject) => { const worker = new Worker('/api/tracers/js'); if (cancelToken) { @@ -97,6 +102,7 @@ const TracerApi = { export { AlgorithmApi, + VisualizationApi, GitHubApi, TracerApi, }; diff --git a/src/frontend/common/util.js b/src/frontend/common/util.js index 0415667971560fbf90de91053f7dc0d3fc8c93ff..bd5fa70da1d7c64dd602268660d172b136320702 100644 --- a/src/frontend/common/util.js +++ b/src/frontend/common/util.js @@ -21,9 +21,21 @@ const refineGist = gist => { return { login, gistId, title, files }; }; +const createFile = (name, content, contributors) => ({ name, content, contributors }); + +const createProjectFile = (name, content) => createFile(name, content, [{ + login: 'algorithm-visualizer', + avatar_url: 'https://github.com/algorithm-visualizer.png', +}]); + +const createUserFile = (name, content) => createFile(name, content, undefined); + export { classes, distance, extension, refineGist, + createFile, + createProjectFile, + createUserFile, }; diff --git a/src/frontend/components/App/index.jsx b/src/frontend/components/App/index.jsx index 0d00c75eec5ed203d7fe322d6f1d0418b0e333ff..ed1e36d94ba1254636ee673d2f048daf7493fcd6 100644 --- a/src/frontend/components/App/index.jsx +++ b/src/frontend/components/App/index.jsx @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import Promise from 'bluebird'; import { Helmet } from 'react-helmet'; import AutosizeInput from 'react-input-autosize'; +import queryString from 'query-string'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import faPlus from '@fortawesome/fontawesome-free-solid/faPlus'; import { @@ -16,9 +17,9 @@ import { ToastContainer, VisualizationViewer, } from '/components'; -import { AlgorithmApi, GitHubApi } from '/apis'; +import { AlgorithmApi, GitHubApi, VisualizationApi } from '/apis'; import { actions } from '/reducers'; -import { extension, refineGist } from '/common/util'; +import { createUserFile, extension, refineGist } from '/common/util'; import { exts, languages } from '/common/config'; import { CONTRIBUTING_MD } from '/files'; import styles from './stylesheet.scss'; @@ -43,7 +44,9 @@ class App extends BaseComponent { window.signIn = this.signIn.bind(this); window.signOut = this.signOut.bind(this); - this.loadAlgorithm(this.props.match.params); + const { params } = this.props.match; + const { search } = this.props.location; + this.loadAlgorithm(params, queryString.parse(search)); const accessToken = Cookies.get('access_token'); if (accessToken) this.signIn(accessToken); @@ -64,12 +67,13 @@ class App extends BaseComponent { componentWillReceiveProps(nextProps) { const { params } = nextProps.match; - if (params !== this.props.match.params) { + const { search } = nextProps.location; + if (params !== this.props.match.params || search !== this.props.location.search) { const { categoryKey, algorithmKey, gistId } = params; const { algorithm, scratchPaper } = nextProps.current; if (algorithm && algorithm.categoryKey === categoryKey && algorithm.algorithmKey === algorithmKey) return; if (scratchPaper && scratchPaper.gistId === gistId) return; - this.loadAlgorithm(params); + this.loadAlgorithm(params, queryString.parse(search)); } } @@ -138,7 +142,7 @@ class App extends BaseComponent { .catch(this.handleError); } - loadAlgorithm({ categoryKey, algorithmKey, gistId }) { + loadAlgorithm({ categoryKey, algorithmKey, gistId }, { visualizationId }) { const { ext } = this.props.env; const fetch = () => { if (window.__PRELOADED_ALGORITHM__) { @@ -147,6 +151,16 @@ class App extends BaseComponent { } else if (categoryKey && algorithmKey) { return AlgorithmApi.getAlgorithm(categoryKey, algorithmKey) .then(({ algorithm }) => this.props.setAlgorithm(algorithm)); + } else if (gistId === 'new' && visualizationId) { + return VisualizationApi.getVisualization(visualizationId) + .then(content => { + this.props.setScratchPaper({ + login: undefined, + gistId, + title: 'Untitled', + files: [CONTRIBUTING_MD, createUserFile('traces.json', JSON.stringify(content))], + }); + }); } else if (gistId === 'new') { const language = languages.find(language => language.ext === ext); this.props.setScratchPaper({ @@ -175,7 +189,8 @@ class App extends BaseComponent { selectDefaultTab() { const { ext } = this.props.env; const { files } = this.props.current; - let editorTabIndex = files.findIndex(file => extension(file.name) === ext); + let editorTabIndex = files.findIndex(file => extension(file.name) === 'json'); + if (!~editorTabIndex) files.findIndex(file => extension(file.name) === ext); if (!~editorTabIndex) editorTabIndex = files.findIndex(file => exts.includes(extension(file.name))); if (!~editorTabIndex) editorTabIndex = Math.min(0, files.length - 1); this.handleChangeEditorTabIndex(editorTabIndex); diff --git a/src/frontend/components/CodeEditor/index.jsx b/src/frontend/components/CodeEditor/index.jsx index 71f7743869d7a0e3822c314598375b699db1be1c..260b338acc5dd9d5f91ccdda9ef6c6d6473354df 100644 --- a/src/frontend/components/CodeEditor/index.jsx +++ b/src/frontend/components/CodeEditor/index.jsx @@ -2,6 +2,7 @@ import React from 'react'; import AceEditor from 'react-ace'; import 'brace/mode/plain_text'; import 'brace/mode/markdown'; +import 'brace/mode/json'; import 'brace/mode/javascript'; import 'brace/mode/c_cpp'; import 'brace/mode/java'; @@ -43,7 +44,10 @@ class CodeEditor extends React.Component { const fileExt = extension(file.name); const language = languages.find(language => language.ext === fileExt); - const mode = language ? language.mode : fileExt === 'md' ? 'markdown' : 'plain_text'; + const mode = language ? language.mode : + fileExt === 'md' ? 'markdown' : + fileExt === 'json' ? 'json' : + 'plain_text'; return (