diff --git a/modules/resource-editor/.eslintrc b/modules/resource-editor/.eslintrc
new file mode 100644
index 00000000..72b951c7
--- /dev/null
+++ b/modules/resource-editor/.eslintrc
@@ -0,0 +1,56 @@
+env:
+ es6: true
+ node: true
+ browser: true
+extends: 'eslint:recommended'
+ecmaFeatures:
+ arrowFunctions: true
+ classes: true
+ defaultParams: true
+ destructuring: true
+ forOf: true
+ modules: true
+ objectLiteralComputedProperties: true
+ objectLiteralDuplicateProperties: true
+ objectLiteralShorthandMethods: true
+ objectLiteralShorthandProperties: true
+ restParams: true
+ spread: true
+ templateStrings: true
+ jsx: true
+ experimentalObjectRestSpread: true
+
+plugins:
+ - react
+
+parser: babel-eslint
+
+rules:
+ # two spaces with indents for switch/case
+ indent: [2, 2, {"SwitchCase": 1}]
+
+ # don't require strict mode
+ strict: 0
+
+ # allow console.log()
+ no-console: 0
+
+ # allow while(true)
+ no-constant-condition: 0
+
+ # unused arguments must be prefixed with an underscore
+ no-unused-vars: [2, {"vars": "all", "argsIgnorePattern": "^_"}]
+
+ # use single-quotes, unless the string has single-quotes inside it
+ # in which case double quotes are ok
+ quotes: [2, "single", "avoid-escape"]
+
+ # always require semi-colons
+ semi: [2, "always"]
+
+ # require unix line-endings
+ linebreak-style: [2, "unix"]
+
+ # ignore unused React imports
+ react/jsx-uses-react: 1
+
diff --git a/modules/resource-editor/debuggerUI/index.js b/modules/resource-editor/debuggerUI/index.js
new file mode 100644
index 00000000..829da4fe
--- /dev/null
+++ b/modules/resource-editor/debuggerUI/index.js
@@ -0,0 +1,24 @@
+var ResourceEditorUI = Class(function() {
+
+ this.init = function() {
+ this._childWindow = null;
+
+ devkit.addModuleButton({
+ iconClassName: 'fa fa-folder-open'
+ }).on('Select', this.toggleVisibility.bind(this));
+ };
+
+ this.toggleVisibility = function() {
+ if (this._childWindow && !this._childWindow.closed) {
+ this._childWindow.focus();
+ } else {
+ var url = location.protocol + '//' + location.host;
+ url += devkit.getSimulator().getURL();
+ url += 'modules/resource-editor/extension/ui/';
+ this._childWindow = window.open(url, '_blank');
+ }
+ };
+
+});
+
+module.exports = new ResourceEditorUI();
diff --git a/modules/resource-editor/package.json b/modules/resource-editor/package.json
new file mode 100644
index 00000000..efb95edc
--- /dev/null
+++ b/modules/resource-editor/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "resource-editor",
+ "author": "Martin Hunt",
+ "version": "0.0.1",
+ "devkit": {
+ "extensions": {
+ "debuggerUI": ["debuggerUI"],
+ "standaloneUI": {
+ "ui": {
+ "src": "ui",
+ "html5History": true
+ }
+ }
+ },
+ "pluginBuilder": {
+ "generic": [
+ { "src": "ui" }
+ ],
+ "jsio": [
+ { "src": "debuggerUI" }
+ ]
+ }
+ },
+ "dependencies": {
+ "bluebird": "^3.0.6",
+ "classnames": "^2.2.1",
+ "filesize": "^3.1.4",
+ "history": "=1.13",
+ "http-fs": "git+https://github.com/weebygames/http-fs.git",
+ "html5-upload-reader": "git+https://github.com/weebygames/html5-upload-reader.git#master",
+ "qrcode.react": "^0.5.2",
+ "react": "^0.14.3",
+ "react-dom": "^0.14.3",
+ "react-file-drop": "^0.1.7",
+ "react-http-fs-file-tree": "git+https://github.com/weebygames/react-http-fs-file-tree.git#v0.0.1",
+ "react-router": "^1.0.0"
+ },
+ "devDependencies": {
+ "babel-preset-react": "^6.3.13",
+ "babel-preset-stage-0": "^6.3.13"
+ }
+}
diff --git a/modules/resource-editor/ui/components/ContextMenu.js b/modules/resource-editor/ui/components/ContextMenu.js
new file mode 100644
index 00000000..a5ebefad
--- /dev/null
+++ b/modules/resource-editor/ui/components/ContextMenu.js
@@ -0,0 +1,111 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+
+class ContextMenu extends React.Component {
+ render() {
+ const files = this.props.files;
+ const fs = this.props.fs;
+
+ let entries = this.props.entries.map(renderEntry.bind(this));
+
+ let menuStyle = {
+ left: this.props.x,
+ top: this.props.y
+ };
+
+ return (
+
+ {entries}
+
+ );
+ }
+}
+
+
+class ContextMenuEntry extends React.Component {
+ handleOnClick(_event) {
+ _event.stopPropagation();
+ this.props.closeMenu(null, this.props.entry.data);
+ }
+
+ render() {
+ const entry = this.props.entry;
+
+ if (entry.entries) {
+ let content = entry.entries.map(renderEntry.bind(this));
+ content.push();
+ return {content}
;
+ }
+
+ return
+ {entry.title}
+
;
+ }
+}
+
+let renderEntry = function(entry) {
+ return React.createElement(ContextMenuEntry, {
+ entry: entry,
+ closeMenu: this.props.closeMenu
+ });
+}
+
+
+/** Returns a promise that will reject with the closure method, or resolve with
+ * the selected entry data
+ */
+export default function openContextMenu(x, y, entries) {
+ // Make the overlay
+ let overlay = document.createElement('div');
+ overlay.className = 'context-menu-container';
+
+ // Close handler
+ let _onClose;
+ let closeMenu = function(err, res) {
+ ReactDOM.unmountComponentAtNode(overlay);
+ document.body.removeChild(overlay);
+
+ // Remove listeners
+ overlay.removeEventListener('click', handleMouseOut);
+ window.removeEventListener('keydown', handleKeyOut);
+
+ if (err) {
+ _onClose.reject(err);
+ } else {
+ _onClose.resolve(res);
+ }
+ }
+
+ // Close listeners
+ let handleMouseOut = function(_event) {
+ if (_event.target === overlay) {
+ closeMenu('clickOut');
+ }
+ };
+ overlay.addEventListener('click', handleMouseOut);
+ let handleKeyOut = function(e) {
+ if (e.keyCode == 27) {
+ closeMenu('escOut');
+ }
+ };
+ window.addEventListener('keydown', handleKeyOut);
+
+ // Make the menu component
+ let menuCmpt = React.createElement(ContextMenu, {
+ x: x,
+ y: y,
+ entries: entries,
+ closeMenu: closeMenu
+ });
+
+ // Add to screen and render
+ document.body.appendChild(overlay);
+ ReactDOM.render(menuCmpt, overlay);
+ return new Promise((resolve, reject) => {
+ _onClose = {resolve, reject};
+ });
+}
diff --git a/modules/resource-editor/ui/components/FileInspector.js b/modules/resource-editor/ui/components/FileInspector.js
new file mode 100644
index 00000000..5afecaeb
--- /dev/null
+++ b/modules/resource-editor/ui/components/FileInspector.js
@@ -0,0 +1,41 @@
+import path from 'path';
+import React from 'react';
+import filesize from 'filesize';
+
+import mime from 'mime';
+import FilePreview from './FilePreview';
+
+const FILE_SIZE_OPTS = {
+ spacer: ''
+};
+
+export default class FileInspector extends React.Component {
+ constructor() {
+ super();
+ this.state = {};
+ }
+
+ handleImageSize = (width, height) => {
+ this.setState({dimensions: width + 'x' + height});
+ }
+
+ render() {
+ const file = this.props.file;
+ if (!file) { return ; }
+
+ const mimeType = mime.lookup(file.path);
+
+ return
+
+
+
directory: {path.dirname(file.path)}
+
mime type: {mimeType}
+ {('size' in file) &&
file size: {filesize(file.size, FILE_SIZE_OPTS).toUpperCase()}
}
+ {this.state.dimensions &&
dimensions: {this.state.dimensions}
}
+
+
;
+ }
+}
diff --git a/modules/resource-editor/ui/components/FilePreview.js b/modules/resource-editor/ui/components/FilePreview.js
new file mode 100644
index 00000000..379aebfe
--- /dev/null
+++ b/modules/resource-editor/ui/components/FilePreview.js
@@ -0,0 +1,174 @@
+import path from 'path';
+import React from 'react';
+import classnames from 'classnames';
+import Promise from 'bluebird';
+
+import {imageLoader} from '../util/ImageLoader';
+import {IMAGE_TYPES, AUDIO_TYPES} from '../util/FileTypes';
+
+const ICONS = {
+ '.zip': 'fa-file-archive-o',
+ '.tar': 'fa-file-archive-o',
+ '.pdf': 'fa-file-pdf-o',
+ '.mp4': 'fa-film',
+ '.mov': 'fa-film',
+ '.js': 'fa-file-code-o',
+ '.json': 'fa-file-text-o',
+ '.txt': 'fa-file-text-o',
+ '.otf': 'fa-font',
+ '.ttf': 'fa-font',
+ '.woff': 'fa-font',
+ '.woff2': 'fa-font',
+ '.eot': 'fa-font'
+};
+
+export default class FilePreview extends React.Component {
+ constructor() {
+ super();
+ this.state = {};
+ }
+
+ componentDidMount() {
+ this.updateProps(this.props);
+ if (this.state.src) { this.refresh(); }
+ }
+
+ componentWillReceiveProps(props) {
+ this.updateProps(props);
+ }
+
+ componentWillUnmount() {
+ if (this._audio) {
+ this._audio.pause();
+ }
+ }
+
+ getCwd() {
+ let cwd = this.props.cwd;
+ if (!cwd && this.props.fs) {
+ cwd = path.join(this.props.fs.MOUNT_POINT, this.props.fs.CWD);
+ }
+ return cwd;
+ }
+
+ updateProps(props) {
+ const file = props.file;
+ if (file.path !== this._path) {
+ this._path = file.path;
+
+ const extname = path.extname(file.path);
+ const isImage = extname in IMAGE_TYPES;
+
+ this.setState({
+ canPlay: extname in AUDIO_TYPES,
+ icon: !isImage && ICONS[extname]
+ });
+
+ if (isImage) {
+ this._getSource(file)
+ .then(src => this.setState({src}));
+ }
+ }
+ }
+
+ _getSource(file, force) {
+ const filePath = file.path;
+ const extname = path.extname(filePath);
+ const isImage = extname in IMAGE_TYPES;
+
+ const isUpload = file.data && file.data instanceof File;
+ if (isUpload && (isImage || force)) {
+ return this._readSource(file);
+ }
+
+ return Promise.resolve(filePath
+ ? path.join(this.getCwd(), filePath)
+ : '');
+ }
+
+ _readSource(file) {
+ return new Promise((resolve, reject) => {
+ var reader = new FileReader();
+ reader.onload = res => resolve(res.target.result);
+ reader.onerror = err => reject(err);
+ reader.readAsDataURL(file.data);
+ });
+ }
+
+ handleIconClick = (event) => {
+ const filePath = this.props.file.path;
+ const extname = path.extname(filePath);
+ if (extname in AUDIO_TYPES) {
+ event.stopPropagation();
+
+ if (this._audio) {
+ if (this._audio.paused) {
+ this._audio.play();
+ this.setState({playing: true});
+ } else {
+ this._audio.pause();
+ this.setState({playing: false});
+ }
+ } else {
+ this.setState({playing: true});
+ this._getSource(this.props.file, true)
+ .then(src => {
+ this._audio = new Audio();
+ this._audio.src = src;
+ this._audio.addEventListener('ended', _event => this.setState({playing: false}));
+ this._audio.play();
+ });
+ }
+ }
+ }
+
+ handleClick = (event) => {
+ this.props.onClick && this.props.onClick(this.props.file, event);
+ }
+
+ refresh() {
+ let src = this.state.src;
+ if (src === this._src) { return; }
+
+ this._src = src;
+ imageLoader.load(src)
+ .then(({width, height}) => {
+ let thumbnail = this.refs.thumbnail;
+ if (!thumbnail) { return; }
+
+ let isContain = width > thumbnail.offsetWidth
+ || height > thumbnail.offsetHeight;
+ thumbnail.style.backgroundSize = isContain ? 'contain' : 'initial';
+
+ this.props.onImageSize && this.props.onImageSize(width, height);
+ }, () => {
+ console.error(`couldn't load ${this.props.file.path}`);
+ });
+ }
+
+ render() {
+ let file = this.props.file;
+ let src = this.state.src || '';
+ if (src) { this.refresh(); }
+
+ return
+
+
+
+ {!src && (this.state.icon || this.state.canPlay) &&
+ }
+
+
+
+
+
;
+ }
+}
diff --git a/modules/resource-editor/ui/components/FolderModal.js b/modules/resource-editor/ui/components/FolderModal.js
new file mode 100644
index 00000000..fe972386
--- /dev/null
+++ b/modules/resource-editor/ui/components/FolderModal.js
@@ -0,0 +1,50 @@
+import React from 'react';
+import Modal from './Modal';
+import FileTree from 'react-http-fs-file-tree';
+
+export default class FolderModal extends React.Component {
+ constructor() {
+ super();
+ this.state = {};
+ }
+
+ handleSelect = (_event) => {
+ Modal.submit(this.state.folder);
+ };
+
+ handleFolder = (folder) => {
+ this.setState({folder});
+ };
+
+ handleClose = () => {
+ Modal.cancel();
+ };
+
+ render() {
+ return
+
+
+
+ {this.props.description &&
+ {this.props.description}
+
}
+
+
+
+
+
+ destination: resources/{this.state.folder && this.state.folder.path}
+
+
+
+
;
+ }
+}
diff --git a/modules/resource-editor/ui/components/FolderViewer.js b/modules/resource-editor/ui/components/FolderViewer.js
new file mode 100644
index 00000000..6096a799
--- /dev/null
+++ b/modules/resource-editor/ui/components/FolderViewer.js
@@ -0,0 +1,215 @@
+import path from 'path';
+import React from 'react';
+import classnames from 'classnames';
+import FilePreview from './FilePreview';
+import FileDrop from 'react-file-drop';
+
+import Menu, {MenuButton} from './Menu';
+import FolderModal from './FolderModal';
+import Modal, {AlertModal, ConfirmModal} from './Modal';
+
+export default class extends React.Component {
+
+ constructor() {
+ super();
+
+ this.state = {
+ selected: {},
+ selectMultiple: false
+ };
+ }
+
+ componentWillReceiveProps(props) {
+ if (props.folder !== this._folder) {
+ this._folder = props.folder;
+ this.setState({selected: {}});
+ }
+ }
+
+ refresh = () => {
+ // TODO!
+ };
+
+ handleFile = (file, event) => {
+ const hasMeta = event.ctrlKey || event.altKey || event.metaKey || event.shiftKey;
+ const filePath = file.path;
+ const selected = this.state.selected;
+ const isSelected = !(filePath in selected);
+ if (isSelected) {
+ selected[filePath] = true;
+ } else {
+ delete selected[filePath];
+ }
+
+ if (!this.state.selectMultiple && !hasMeta) {
+ Object.keys(selected).forEach(selectedPath => {
+ if (filePath !== selectedPath) {
+ delete selected[selectedPath];
+ }
+ });
+ }
+
+ this.setState({selected});
+
+ if (isSelected && this.props.onFile) {
+ this.props.onFile(file);
+ }
+ }
+
+ handleMultiSelect = (event) => {
+ event.preventDefault();
+
+ const selectMultiple = !this.state.selectMultiple;
+ const newState = {selectMultiple};
+ if (!selectMultiple) {
+ newState.selected = {};
+ let lastKey = null;
+ for (let key in this.state.selected) { lastKey = key; }
+ if (lastKey) {
+ newState.selected[lastKey] = true;
+ }
+ }
+
+ this.setState(newState);
+ }
+
+ handleSelectAll = () => {
+ const selected = this.state.selected;
+ this.props.files.forEach(file => selected[file.path] = true);
+ this.setState({selected});
+ }
+
+ handleUnselectAll = () => {
+ this.setState({selected: {}});
+ }
+
+ handleDrop = (files, event) => {
+ event.stopPropagation();
+
+ let items = event.dataTransfer && event.dataTransfer.items;
+ this.props.onDrop && this.props.onDrop(this.props.folder, items || files);
+ }
+
+ handleDelete = () => {
+ const filePaths = Object.keys(this.state.selected);
+ const fs = this.props.fs;
+ const files = this.props.files.filter(file => file.path in this.state.selected);
+
+ Modal.open()
+ .then(() => {
+ return Promise.all(filePaths.map(filePath => fs.unlink(filePath)))
+ .catch(e => {
+ Modal.open();
+ })
+ .then(() => this.refresh());
+ }, () => console.log('delete cancelled'));
+ }
+
+ handleCopy = () => {
+ const filePaths = Object.keys(this.state.selected);
+ const fs = this.props.fs;
+
+ Modal.open()
+ .then(folder => filePaths.map(filePath => {
+ const dest = path.join(folder.path, path.basename(filePath));
+ return fs.copy(filePath, dest);
+ }),
+ () => {
+ console.log('copy cancelled');
+ });
+ }
+
+ handleMove = () => {
+ const filePaths = Object.keys(this.state.selected);
+
+ Modal.open()
+ .then(folder => filePaths.map(filePath => {
+ const dest = path.join(folder.path, path.basename(filePath));
+ return this.props.fs.move(filePath, dest);
+ }),
+ () => {
+ console.log('move cancelled');
+ });
+ }
+
+ render() {
+ const files = this.props.files;
+ const selectedCount = Object.keys(this.state.selected).length;
+ const hasSelection = selectedCount > 0;
+ let selectedText;
+ if (hasSelection) {
+ selectedText = selectedCount + ' item';
+ if (selectedCount > 1) {
+ selectedText += 's';
+ }
+ }
+
+ return
+
+
+
+ {hasSelection &&
+
+
+
+
+ }
+
+
+
+
+ {files && files.filter(file => !file.isDirectory).map(file => )}
+
+
+ ;
+ }
+}
diff --git a/modules/resource-editor/ui/components/Menu.js b/modules/resource-editor/ui/components/Menu.js
new file mode 100644
index 00000000..c4faf9f0
--- /dev/null
+++ b/modules/resource-editor/ui/components/Menu.js
@@ -0,0 +1,156 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import classnames from 'classnames';
+
+export let overlay = null;
+
+let onClose;
+
+export default class Menu extends React.Component {
+ constructor() {
+ super();
+
+ this.state = {};
+ }
+
+ handleClick = () => {
+ if (currentMenu === this) {
+ Menu.close();
+ } else {
+ Menu.open(this, this._getDropdown());
+ }
+ }
+
+ componentDidMount() {
+ if (currentMenu === this) {
+ this._renderMenu();
+ }
+ }
+
+ componentDidUpdate() {
+ if (currentMenu === this) {
+ this._renderMenu();
+ }
+ }
+
+ _getDropdown() {
+ return
+ {this._children}
+
;
+ }
+
+ _renderMenu() {
+ Menu.rerender(this._getDropdown());
+ }
+
+ render() {
+ const children = React.Children.toArray(this.props.children);
+ const index = children.findIndex(child =>
+ child.type === MenuButton
+ || child.type.prototype instanceof MenuButton);
+
+ let menuButton;
+ if (index !== -1) {
+ menuButton = children.splice(index, 1);
+ }
+
+ this._children = children;
+
+ return
+ {menuButton}
+
;
+ }
+}
+
+export class MenuButton extends React.Component {
+ render() {
+ return
+ {this.props.children}
+
;
+ }
+}
+
+let currentMenu = null;
+const EVENT_CAPTURES = ['mousedown', 'click', 'touchstart', 'touchend'];
+const CLOSE_EVENTS = {'click': true, 'touchend': true};
+
+Menu.open = function (component, menu) {
+ if (!overlay) {
+ overlay = document.createElement('div');
+ overlay.className = 'menu-overlay';
+
+ const onClick = (event) => setTimeout(() => {
+ if (!event.defaultPrevented) {
+ Menu.close();
+ }
+ });
+
+ overlay.addEventListener('click', onClick);
+ overlay.addEventListener('touchend', onClick);
+ }
+
+ if (currentMenu) {
+ Menu.close();
+ }
+
+ currentMenu = component;
+
+ document.body.appendChild(overlay);
+ const dropdown = ReactDOM.findDOMNode(Menu.rerender(menu));
+ const button = ReactDOM.findDOMNode(component);
+ const rect = button.getBoundingClientRect();
+ dropdown.style.top = rect.bottom + 'px';
+ dropdown.style.left = rect.left + 'px';
+
+ EVENT_CAPTURES.forEach(type => window.addEventListener(type, onEvent, true));
+
+ function onEvent(event) {
+ let el = event.target;
+ while (el.parentNode) {
+ if (el === overlay || el === button) { return; }
+ el = el.parentNode;
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+
+ if (event.type in CLOSE_EVENTS) {
+ Menu.close();
+ }
+
+ return false;
+ }
+
+ function unmount() {
+ currentMenu = null;
+ ReactDOM.unmountComponentAtNode(overlay);
+ document.body.removeChild(overlay);
+ EVENT_CAPTURES.forEach(type => window.removeEventListener(type, onEvent, true));
+ }
+
+ return new Promise((resolve, reject) => {
+ onClose = {
+ resolve: res => {
+ unmount();
+ resolve(res);
+ },
+ reject: err => {
+ unmount();
+ reject(err);
+ }
+ };
+ });
+};
+
+Menu.rerender = function (menu) {
+ return ReactDOM.render(menu, overlay);
+};
+
+Menu.close = function (res) {
+ onClose && onClose.resolve(res);
+};
+
+Menu.cancel = function (err) {
+ onClose && onClose.reject(err);
+};
diff --git a/modules/resource-editor/ui/components/Modal.js b/modules/resource-editor/ui/components/Modal.js
new file mode 100644
index 00000000..95f3306b
--- /dev/null
+++ b/modules/resource-editor/ui/components/Modal.js
@@ -0,0 +1,91 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import classnames from 'classnames';
+import FilePreview from './FilePreview';
+
+export let overlay = null;
+
+let onClose;
+
+let Modal = {
+ open: function (modal) {
+ if (!overlay) {
+ overlay = document.createElement('div');
+ overlay.className = 'modal-overlay';
+ }
+
+ document.body.appendChild(overlay);
+ ReactDOM.render(modal, overlay);
+ return new Promise((resolve, reject) => {
+ onClose = {resolve, reject};
+ });
+ },
+ submit: function (res) {
+ _close();
+ onClose && onClose.resolve(res);
+ },
+ cancel: function (err) {
+ _close();
+ onClose && onClose.reject(err);
+ }
+};
+
+export default Modal;
+
+function _close() {
+ ReactDOM.unmountComponentAtNode(overlay);
+ document.body.removeChild(overlay);
+}
+
+class FileLineItem extends React.Component {
+ render() {
+ const file = this.props.file;
+ return
+
+
+
;
+ }
+}
+
+export class ConfirmModal extends React.Component {
+ render() {
+ const files = this.props.files;
+ const fs = this.props.fs;
+
+ return
+
+
{this.props.title || ''}
+
+
+
+
{this.props.description || ''}
+ {this.props.files &&
+ {files.map(file => )}
+
}
+
+
+
+
+
+
+
;
+ }
+}
+
+export class AlertModal extends React.Component {
+ render() {
+ return
+
+
{this.props.title || ''}
+
+
+
+
{this.props.description || ''}
+
+
+
+
+
+
;
+ }
+}
diff --git a/modules/resource-editor/ui/components/PathCrumbs.js b/modules/resource-editor/ui/components/PathCrumbs.js
new file mode 100644
index 00000000..4d29f8ee
--- /dev/null
+++ b/modules/resource-editor/ui/components/PathCrumbs.js
@@ -0,0 +1,33 @@
+import React from 'react';
+
+export default class PathCrumbs extends React.Component {
+ constructor() {
+ super();
+ this.state = {};
+ }
+
+ handleClick(index) {
+ return () => {
+ const folder = this.props.folder.path;
+ const selectedPath = folder.split('/').slice(0, index + 1).join('/') || '/';
+ this.props.onNavigate && this.props.onNavigate(selectedPath);
+ };
+ }
+
+ render() {
+ const folder = this.props.folder;
+ let crumbs = [];
+ if (folder) {
+ crumbs = folder.path.split('/').filter(a => a);
+ }
+
+ return
+
/
+ {crumbs.map((crumb, index) =>
+ {crumb}
+
)}
+
;
+ }
+}
diff --git a/modules/resource-editor/ui/components/UploadModal.js b/modules/resource-editor/ui/components/UploadModal.js
new file mode 100644
index 00000000..60d9c0b0
--- /dev/null
+++ b/modules/resource-editor/ui/components/UploadModal.js
@@ -0,0 +1,110 @@
+import path from 'path';
+import React from 'react';
+import Promise from 'bluebird';
+import classnames from 'classnames';
+import FilePreview from './FilePreview';
+import Modal from './Modal';
+
+class FileLineItem extends React.Component {
+ constructor() {
+ super();
+ this.state = {};
+ }
+
+ componentWillReceiveProps(props) {
+ this.updateStatus(props.file);
+ }
+
+ componentDidMount() {
+ this.updateStatus(this.props.file);
+ }
+
+ handleSkip = () => {
+ this.props.file.skip = !this.props.file.skip;
+ this.setState({skip: this.props.file.skip});
+ }
+
+ updateStatus(file) {
+ const fs = this.props.fs;
+ const folder = this.props.folder;
+ fs.exists(path.join(folder.path, file.path))
+ .then(exists => {
+ if (file.path === this.props.file.path) {
+ this.setState({exists});
+ }
+ });
+ }
+
+ render() {
+ const file = this.props.file;
+ return
+
+
+
+
+ {this.state.skip
+ ?
+ : !this.state.exists
+ ?
+ :
+
overwrite
+
(file already exists)
+
}
+
+
;
+ }
+}
+
+export default class UploadModal extends React.Component {
+ constructor() {
+ super();
+ this.state = {};
+ }
+
+ upload() {
+ const fs = this.props.fs;
+ const folder = this.props.folder;
+ const files = this.props.files;
+
+ return Promise.resolve(files)
+ .filter(file => !/^\./.test(file.data.name))
+ .map(file => {
+ const destPath = path.join(folder.path, file.path);
+ console.log(file.path, '->', destPath);
+ return fs.outputFile(destPath, file.data);
+ })
+ .all();
+ }
+
+ handleUpload = () => {
+ this.upload()
+ .finally(() => Modal.close());
+ }
+
+ handleClose = () => {
+ Modal.close();
+ }
+
+ render() {
+ const folder = this.props.folder;
+ const files = this.props.files;
+ const fs = this.props.fs;
+
+ return
+
+
Upload Files to "{folder.path}"
+
+
+
+
The following files will be uploaded:
+
+ {files.map(file => )}
+
+
+
+
;
+ }
+}
diff --git a/modules/resource-editor/ui/css/ContextMenu.styl b/modules/resource-editor/ui/css/ContextMenu.styl
new file mode 100644
index 00000000..7b24e322
--- /dev/null
+++ b/modules/resource-editor/ui/css/ContextMenu.styl
@@ -0,0 +1,34 @@
+item-padding = 18px
+
+.context-menu-container
+ position fixed
+ top 0
+ left 0
+ right 0
+ bottom 0
+ z-index 900
+
+ .ContextMenu
+ position absolute
+ background-color #fff
+ padding 6px 0
+ color #666
+ font-size 16px
+ line-height 20px
+ min-width 150px
+ border-radius 4px
+ box-shadow 1px 1px 4px 1px rgba(50, 50, 50, 0.25)
+
+ .ContextMenuEntry
+ width 100%
+ padding 0 item-padding
+
+ &:hover
+ background-color #A8D7FF
+
+ .divider
+ width 100%
+ height 2px
+ background-color #E6E6E6
+ margin 6px 0
+ border-radius 4px
diff --git a/modules/resource-editor/ui/css/index.styl b/modules/resource-editor/ui/css/index.styl
new file mode 100644
index 00000000..6a0c9d83
--- /dev/null
+++ b/modules/resource-editor/ui/css/index.styl
@@ -0,0 +1,283 @@
+@import 'nib'
+
+html, body
+ padding 0
+ margin 0
+ width 100%
+ height 100%
+ overflow hidden
+ font-family 'Helvetica Neue', Helvetica, sans-serif
+ font-weight 200
+ font-size 14px
+
+body, div, span, label
+ -webkit-tap-highlight-color rgba(0,0,0,0)
+ user-select none
+
+div, ul, li, a, i, input, dt, dd
+ box-sizing border-box
+ cursor default
+
+button
+ border-radius 4px
+ border none
+ background #FFF
+ padding 8px 16px
+ font-weight normal
+ font-size 14px
+
+button.primary
+ background #68b743
+ color #FFF
+
+#main
+ width 100%
+ height 100%
+ background #EEE
+ color #444
+
+.flex
+ flex 1
+ flex-shrink 0
+
+.row
+ display flex
+ flex-direction row
+ width 100%
+
+.column
+ display flex
+ width 100%
+ flex-direction column
+
+.full-height
+ position relative
+
+ > *
+ position absolute
+ top 0
+ bottom 0
+
+.MainContainer
+ align-items stretch
+ height 100%
+
+ > .FileTree
+ border-right 1px solid #CCC
+ width 250px
+ flex-shrink 0
+ overflow auto
+
+.FileNode
+ cursor default
+
+ .label
+ padding 8px 5px 5px
+ height 35px
+
+ .file-icon
+ margin-right 5px
+
+ &.selected
+ & > div > .label
+ background-color #D4D4D4
+
+ .file-drop-target.file-drop-dragging-over-target
+ > .label
+ background #AAF
+ color #FFF
+
+ &::after
+ content '\f063'
+ font normal normal normal 14px/1 FontAwesome
+ float right
+ margin-right 3px
+ margin-top 3px
+
+.FolderViewer
+ background #F5F5F4
+ position relative
+
+ > .file-drop-target
+ position absolute
+ top 0
+ bottom 0
+ width 100%
+ display flex
+ flex-direction column
+
+ .toolbar
+ overflow hidden
+ background-image linear-gradient(180deg, #FFF 33px, #E1E1E1 33px, #E1E1E1 34px)
+ background-size 100% 34px
+ min-height 34px
+ width 100%
+
+ .Menu, button
+ display inline-block
+ background #FFF
+ border-radius 0
+ border none
+ border-right 1px solid #E1E1E1
+ padding 0px 14px
+ position relative
+ color #888
+ margin-bottom 1px
+ outline none
+ line-height 33px
+ font-weight 200
+
+ &:active
+ background #EEE
+
+ button
+ .fa
+ margin-right 8px
+ font-size larger
+
+ .icon-move
+ position relative
+
+ i:nth-child(2)
+ position absolute
+ font-size 70%
+ top 0.4em
+ left 0.3em
+
+ &:focus
+ z-index 2
+
+ .contents
+ padding 5px
+ overflow auto
+
+.FilePreview
+ width 202px
+ height 250px
+ margin-right 5px
+ display inline-block
+
+ .thumbnailWrapper
+ padding 10px
+ background white
+ border 1px solid #E1E1E1
+ transition all 0.1s
+
+ &.selected .thumbnailWrapper
+ border-color #AAF
+ box-shadow inset 0px 0px 0px 2px #AAF
+ border-radius 3px
+
+ .thumbnail
+ width 180px
+ height 180px
+ background-size contain
+ background-position center
+ background-repeat no-repeat
+ display inline-flex
+ align-items center
+ justify-content space-around
+
+ .fa
+ font-size 30px
+
+ &.canPlay:hover
+ color #AAF
+
+ &:not(.small) .thumbnail .fa
+ font-size 40px
+
+ label
+ display block
+ margin-top 5px
+ text-align center
+ text-overflow ellipsis
+ overflow hidden
+ white-space nowrap
+
+ &.small
+ width 50px
+ height 50px
+
+ label
+ display none
+
+ .thumbnailWrapper
+ padding 2px
+
+ .thumbnail
+ width 44px
+ height 44px
+
+.PathCrumbs
+ border-bottom 1px solid #CCC
+
+ .crumb
+ display inline-block
+ line-height 20px
+ border-right 1px solid #CCC
+ padding 5px 10px
+
+.FileInspector
+ border-left 1px solid #CCC
+ overflow hidden
+
+ .FilePreview
+ margin -1px -1px 10px -1px
+
+ .metadata
+ padding 8px
+
+.menu-overlay
+ position fixed
+ top 0
+ left 0
+ width 100%
+ height 0
+
+ .MenuDropdown
+ position absolute
+ background #FFF
+ border 1px solid #E1E1E1
+ box-shadow 0px 1px 1px rgba(0, 0, 0, 0.2)
+ display flex
+ flex-direction column
+ align-items stretch
+ border-radius 0px 0px 3px 3px
+ overflow hidden
+
+ > *
+ flex-shrink 0
+ text-align left
+ border-radius 0
+ outline none
+ color #888
+
+ &:hover
+ background #EEE
+
+ hr
+ height 1px
+ width 100%
+ overflow hidden
+ border none
+ background #E1E1E1
+ display block
+
+ .selectMultiple
+ &:before
+ content '\f096'
+ width 1em
+ display inline-block
+ font normal normal normal 14px/1 FontAwesome
+ color #AAA
+ margin-right 10px
+
+ &.enabled
+ color #444
+
+ &:before
+ content '\f046'
+
+@import './modals';
+@import './ContextMenu';
diff --git a/modules/resource-editor/ui/css/modals.styl b/modules/resource-editor/ui/css/modals.styl
new file mode 100644
index 00000000..30a78d55
--- /dev/null
+++ b/modules/resource-editor/ui/css/modals.styl
@@ -0,0 +1,125 @@
+.modal-overlay
+ position fixed
+ top 0
+ left 0
+ right 0
+ bottom 0
+ background rgba(0, 0, 0, 0.4)
+ display flex
+ align-items center
+ justify-content center
+ z-index 1000
+
+ .modal
+ display flex
+ flex-direction column
+ background white
+ border-radius 5px
+ min-width 400px
+ max-height 80%
+ box-shadow 0px 10px 30px rgba(0, 0, 0, 0.2)
+
+ .title
+ background #AAF
+ color #FFF
+ font-weight normal
+ padding-left 20px
+ border-radius 5px 5px 0px 0px
+ line-height 40px
+
+ .fa
+ line-height 40px
+ height 100%
+ width 45px
+ padding-left 20px
+
+ .contents
+ flex 1
+ flex-shrink 0
+
+ .description
+ padding 15px 20px
+ background #EEE
+ border-bottom 1px solid #CCC
+
+ .footer
+ padding 10px 10px 10px 20px
+ position relative
+ z-index 2
+ display flex
+ background #EEE
+ border-radius 0px 0px 5px 5px
+ box-shadow 0px -1px 2px rgba(0, 0, 0, 0.4)
+
+ button
+ margin-left 10px
+
+ .fa
+ margin-left 5px
+
+.UploadModal
+ overflow hidden
+
+ .contents
+ .FileList
+ overflow auto
+ max-height 430px
+ padding-bottom 2px
+
+ .FileLineItem
+ padding 3px 20px 3px 3px
+ align-items center
+
+ &.skip
+ label
+ opacity 0.5
+
+ i
+ color #C22
+
+ .warning
+ color #C22
+
+ .message
+ font-size smaller
+
+ button
+ margin-right 5px
+ visibility hidden
+
+ &:hover
+ button
+ visibility visible
+
+
+.FolderModal
+ max-height 80%
+ max-width 90%
+ height 500px
+ width 500px
+
+ .contents
+ display flex
+ flex-direction column
+
+ .FileTree
+ overflow auto
+ flex 1
+
+ .FileNode.selected
+ > .label
+ background #AAF
+
+ .selection
+ display inline-block
+ margin-right 10px
+ background #FFF
+ border-radius 5px
+ border 1px solid #DDD
+ font-weight normal
+ padding 4px 6px
+ word-break break-word
+
+.DeleteFilesModal
+ .title
+ background
\ No newline at end of file
diff --git a/modules/resource-editor/ui/index.html b/modules/resource-editor/ui/index.html
new file mode 100644
index 00000000..7f3c7624
--- /dev/null
+++ b/modules/resource-editor/ui/index.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/resource-editor/ui/index.js b/modules/resource-editor/ui/index.js
new file mode 100644
index 00000000..a3a3cbe3
--- /dev/null
+++ b/modules/resource-editor/ui/index.js
@@ -0,0 +1,145 @@
+import path from 'path';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import FileTree from 'react-http-fs-file-tree';
+import remoteFS from 'http-fs/src/clients/fs';
+import {extractFilesFromItems} from 'html5-upload-reader';
+
+import FolderViewer from './components/FolderViewer';
+import PathCrumbs from './components/PathCrumbs';
+import FileInspector from './components/FileInspector';
+import UploadModal from './components/UploadModal';
+import Modal from './components/Modal';
+import openContextMenu from './components/ContextMenu';
+
+import { createHistory } from 'history';
+
+export default class ResourceEditor extends React.Component {
+ constructor() {
+ super();
+
+ this.state = {};
+
+ this._onFileSystem = remoteFS('../../../../http-fs/', {cwd: 'resources'})
+ .then(fs => this.setState({fs}));
+
+ var match = location.pathname.match(/^(.*?\/extension\/ui\/)(.*?)$/);
+ this.basePathname = match[1];
+ this.initialPath = match[2];
+
+ this.history = createHistory();
+ this.history.listen(location => {
+ console.log(location.pathname);
+ });
+ }
+
+ componentDidMount() {
+ if (this.initialPath) {
+ this._onFileSystem.then(() => {
+ this.refs.fileTree.selectPath(this.initialPath);
+ });
+ }
+ }
+
+ handleFolder = (folder, files) => {
+ this.setState({folder, files});
+ this.updatePath(folder.path);
+ }
+
+ handleFile = (file) => {
+ this.setState({file});
+ }
+
+ handleFilesLoaded = (folder, isSelected, subfiles) => {
+ if (this.state.folder && folder.path === this.state.folder.path) {
+ this.setState({files: subfiles});
+ }
+ }
+
+ handleDrop = (folder, items) => {
+ extractFilesFromItems(items)
+ .then(files => {
+ // remove files that start with a dot
+ files = files.filter(file => file.data && file.data.name && !/^\./.test(file.data.name));
+
+ Modal.open();
+ });
+ }
+
+ handleNavigate = (folderPath) => {
+ const fileTree = this.refs.fileTree;
+ if (!fileTree) { return; }
+
+ fileTree.selectPath(folderPath);
+ }
+
+ handleContextMenu = (e, file) => {
+ e.preventDefault();
+ let entries = [
+ {
+ entries: [
+ {
+ title: 'Rename',
+ data: 'rename'
+ },
+ {
+ title: 'Move',
+ data: 'move'
+ }
+ ]
+ },
+ {
+ title: 'Delete',
+ data: 'delete'
+ }
+ ];
+ openContextMenu(e.clientX, e.clientY, entries)
+ .then(function(data) {
+ if (data) { console.log('context menu:', data); }
+ })
+ .catch(function(err) {
+ // pass
+ });
+ }
+
+ updatePath(folderPath) {
+ this.history.push({
+ pathname: path.join(this.basePathname, folderPath)
+ });
+ }
+
+ render() {
+ return ;
+ }
+}
+
+ReactDOM.render(, document.getElementById('main'));
diff --git a/modules/resource-editor/ui/util/FileTypes.js b/modules/resource-editor/ui/util/FileTypes.js
new file mode 100644
index 00000000..a6cbe393
--- /dev/null
+++ b/modules/resource-editor/ui/util/FileTypes.js
@@ -0,0 +1,12 @@
+export let IMAGE_TYPES = {
+ '.png': true,
+ '.jpg': true,
+ '.jpeg': true,
+ '.gif': true,
+ '.bmp': true
+};
+
+export let AUDIO_TYPES = {
+ '.mp3': true,
+ '.ogg': true
+};
diff --git a/modules/resource-editor/ui/util/ImageLoader.js b/modules/resource-editor/ui/util/ImageLoader.js
new file mode 100644
index 00000000..cf13f816
--- /dev/null
+++ b/modules/resource-editor/ui/util/ImageLoader.js
@@ -0,0 +1,24 @@
+export default class ImageLoader {
+ constructor() {
+ this.cache = {};
+ }
+
+ load(src) {
+ if (src in this.cache) { return this.cache[src]; }
+ return (this.cache[src] = new Promise((resolve, reject) => {
+ let img = new Image();
+ let clear = () => { img = img.onload = img.onerror = null; };
+ img.src = src;
+ img.onload = (_event) => {
+ resolve({width: img.width, height: img.height});
+ clear();
+ };
+ img.onerror = (event) => {
+ reject(event);
+ clear();
+ };
+ }));
+ }
+}
+
+export let imageLoader = new ImageLoader();
diff --git a/src/serve/appRoutes.js b/src/serve/appRoutes.js
index aaa63981..7fddf6fc 100644
--- a/src/serve/appRoutes.js
+++ b/src/serve/appRoutes.js
@@ -1,5 +1,5 @@
var path = require('path');
-var fs = require('fs');
+var fs = require('fs-extra');
var EventEmitter = require('events').EventEmitter;
var Promise = require('bluebird');
var readFile = Promise.promisify(fs.readFile);
diff --git a/src/web/static/fa/font-awesome.css b/src/web/static/fa/font-awesome.css
index 798d0b6f..ba491716 100644
--- a/src/web/static/fa/font-awesome.css
+++ b/src/web/static/fa/font-awesome.css
@@ -1,13 +1,13 @@
/*!
- * Font Awesome 4.4.0 by @davegandy - http://fontawesome.io - @fontawesome
+ * Font Awesome 4.5.0 by @davegandy - http://fontawesome.io - @fontawesome
* License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
*/
/* FONT PATH
* -------------------------- */
@font-face {
font-family: 'FontAwesome';
- src: url('./fonts/fontawesome-webfont.eot?v=4.4.0');
- src: url('./fonts/fontawesome-webfont.eot?#iefix&v=4.4.0') format('embedded-opentype'), url('./fonts/fontawesome-webfont.woff2?v=4.4.0') format('woff2'), url('./fonts/fontawesome-webfont.woff?v=4.4.0') format('woff'), url('./fonts/fontawesome-webfont.ttf?v=4.4.0') format('truetype'), url('./fonts/fontawesome-webfont.svg?v=4.4.0#fontawesomeregular') format('svg');
+ src: url('fonts/fontawesome-webfont.eot?v=4.5.0');
+ src: url('fonts/fontawesome-webfont.eot?#iefix&v=4.5.0') format('embedded-opentype'), url('fonts/fontawesome-webfont.woff2?v=4.5.0') format('woff2'), url('fonts/fontawesome-webfont.woff?v=4.5.0') format('woff'), url('fonts/fontawesome-webfont.ttf?v=4.5.0') format('truetype'), url('fonts/fontawesome-webfont.svg?v=4.5.0#fontawesomeregular') format('svg');
font-weight: normal;
font-style: normal;
}
@@ -2024,3 +2024,63 @@
.fa-fonticons:before {
content: "\f280";
}
+.fa-reddit-alien:before {
+ content: "\f281";
+}
+.fa-edge:before {
+ content: "\f282";
+}
+.fa-credit-card-alt:before {
+ content: "\f283";
+}
+.fa-codiepie:before {
+ content: "\f284";
+}
+.fa-modx:before {
+ content: "\f285";
+}
+.fa-fort-awesome:before {
+ content: "\f286";
+}
+.fa-usb:before {
+ content: "\f287";
+}
+.fa-product-hunt:before {
+ content: "\f288";
+}
+.fa-mixcloud:before {
+ content: "\f289";
+}
+.fa-scribd:before {
+ content: "\f28a";
+}
+.fa-pause-circle:before {
+ content: "\f28b";
+}
+.fa-pause-circle-o:before {
+ content: "\f28c";
+}
+.fa-stop-circle:before {
+ content: "\f28d";
+}
+.fa-stop-circle-o:before {
+ content: "\f28e";
+}
+.fa-shopping-bag:before {
+ content: "\f290";
+}
+.fa-shopping-basket:before {
+ content: "\f291";
+}
+.fa-hashtag:before {
+ content: "\f292";
+}
+.fa-bluetooth:before {
+ content: "\f293";
+}
+.fa-bluetooth-b:before {
+ content: "\f294";
+}
+.fa-percent:before {
+ content: "\f295";
+}
diff --git a/src/web/static/fa/fonts/FontAwesome.otf b/src/web/static/fa/fonts/FontAwesome.otf
index 681bdd4d..3ed7f8b4 100644
Binary files a/src/web/static/fa/fonts/FontAwesome.otf and b/src/web/static/fa/fonts/FontAwesome.otf differ
diff --git a/src/web/static/fa/fonts/fontawesome-webfont.eot b/src/web/static/fa/fonts/fontawesome-webfont.eot
index a30335d7..9b6afaed 100644
Binary files a/src/web/static/fa/fonts/fontawesome-webfont.eot and b/src/web/static/fa/fonts/fontawesome-webfont.eot differ
diff --git a/src/web/static/fa/fonts/fontawesome-webfont.svg b/src/web/static/fa/fonts/fontawesome-webfont.svg
index 6fd19abc..d05688e9 100644
--- a/src/web/static/fa/fonts/fontawesome-webfont.svg
+++ b/src/web/static/fa/fonts/fontawesome-webfont.svg
@@ -1,6 +1,6 @@
-