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.title}
+ +
+
+ + {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 +
+ + Select... + + +
+ +
+ + {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 @@ - + @@ -219,8 +219,8 @@ - - + + @@ -362,7 +362,7 @@ - + @@ -410,7 +410,7 @@ - + @@ -454,7 +454,7 @@ - + @@ -555,7 +555,7 @@ - + @@ -600,11 +600,11 @@ - - + + - + @@ -621,20 +621,35 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/web/static/fa/fonts/fontawesome-webfont.ttf b/src/web/static/fa/fonts/fontawesome-webfont.ttf index d7994e13..26dea795 100644 Binary files a/src/web/static/fa/fonts/fontawesome-webfont.ttf and b/src/web/static/fa/fonts/fontawesome-webfont.ttf differ diff --git a/src/web/static/fa/fonts/fontawesome-webfont.woff b/src/web/static/fa/fonts/fontawesome-webfont.woff index 6fd4ede0..dc35ce3c 100644 Binary files a/src/web/static/fa/fonts/fontawesome-webfont.woff and b/src/web/static/fa/fonts/fontawesome-webfont.woff differ diff --git a/src/web/static/fa/fonts/fontawesome-webfont.woff2 b/src/web/static/fa/fonts/fontawesome-webfont.woff2 index 5560193c..500e5172 100644 Binary files a/src/web/static/fa/fonts/fontawesome-webfont.woff2 and b/src/web/static/fa/fonts/fontawesome-webfont.woff2 differ