/* Copyright 2014 The Camlistore Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // IndexPage is the top level React component class that owns all the other // components of the web UI. // See the React documentation and in particular // https://facebook.github.io/react/docs/component-specs.html to learn about // components. goog.provide('cam.IndexPage'); goog.require('goog.array'); goog.require('goog.dom'); goog.require('goog.dom.classlist'); goog.require('goog.events.EventHandler'); goog.require('goog.format'); goog.require('goog.functions'); goog.require('goog.labs.Promise'); goog.require('goog.object'); goog.require('goog.string'); goog.require('goog.Uri'); goog.require('cam.BlobDetail'); goog.require('cam.BlobItemContainerReact'); goog.require('cam.BlobItemDemoContent'); goog.require('cam.BlobItemFoursquareContent'); goog.require('cam.BlobItemGenericContent'); goog.require('cam.BlobItemImageContent'); goog.require('cam.BlobItemTwitterContent'); goog.require('cam.BlobItemVideoContent'); goog.require('cam.blobref'); goog.require('cam.DetailView'); goog.require('cam.Dialog'); goog.require('cam.DirectoryDetail'); goog.require('cam.MapAspect'); goog.require('cam.Header'); goog.require('cam.Navigator'); goog.require('cam.PermanodeDetail'); goog.require('cam.permanodeUtils'); goog.require('cam.reactUtil'); goog.require('cam.SearchSession'); goog.require('cam.ServerConnection'); goog.require('cam.Sidebar'); goog.require('cam.TagsControl'); cam.IndexPage = React.createClass({ displayName: 'IndexPage', SIDEBAR_OPEN_WIDTH_: 250, HEADER_HEIGHT_: 38, SEARCH_PREFIX_: { RAW: 'raw' }, THUMBNAIL_SIZE_: 200, SEARCH_SESSION_CACHE_SIZE_: 3, // Note that these are ordered by priority. BLOB_ITEM_HANDLERS_: [ cam.BlobItemDemoContent.getHandler, cam.BlobItemFoursquareContent.getHandler, cam.BlobItemTwitterContent.getHandler, cam.BlobItemImageContent.getHandler, cam.BlobItemVideoContent.getHandler, cam.BlobItemGenericContent.getHandler ], BLOBREF_PATTERN_: new RegExp('^' + cam.blobref.PATTERN + '$'), propTypes: { availWidth: React.PropTypes.number.isRequired, availHeight: React.PropTypes.number.isRequired, config: React.PropTypes.object.isRequired, eventTarget: React.PropTypes.shape({addEventListener:React.PropTypes.func.isRequired}).isRequired, history: React.PropTypes.shape({pushState:React.PropTypes.func.isRequired, replaceState:React.PropTypes.func.isRequired, go:React.PropTypes.func.isRequired, state:React.PropTypes.object}).isRequired, openWindow: React.PropTypes.func.isRequired, location: React.PropTypes.shape({href:React.PropTypes.string.isRequired, reload:React.PropTypes.func.isRequired}).isRequired, scrolling: cam.BlobItemContainerReact.propTypes.scrolling, serverConnection: React.PropTypes.instanceOf(cam.ServerConnection).isRequired, timer: cam.Header.propTypes.timer, }, // Invoked once right before initial rendering. This is essentially IndexPage's // constructor. We populate non-React helpers that live for the entire lifetime // of IndexPage here. componentWillMount: function() { this.baseURL_ = null; this.dragEndTimer_ = 0; this.navigator_ = null; this.searchSessionCache_ = []; this.targetSearchSession_ = null; this.childSearchSession_ = null; this.eh_ = new goog.events.EventHandler(this); var newURL = new goog.Uri(this.props.location.href); this.baseURL_ = newURL.resolve(new goog.Uri(this.props.config.uiRoot)); this.navigator_ = new cam.Navigator(this.props.eventTarget, this.props.location, this.props.history); this.navigator_.onWillNavigate = this.handleWillNavigate_; this.navigator_.onDidNavigate = this.handleDidNavigate_; this.handleWillNavigate_(newURL); this.handleDidNavigate_(); }, // Invoked right after initial rendering. componentDidMount: function() { // TODO(aa): This supports some of the old iframed pages. We can remove it once they are dead. goog.global.getSearchSession = function() { return this.childSearchSession_; }.bind(this); this.eh_.listen(this.props.eventTarget, 'keypress', this.handleKeyPress_); this.eh_.listen(this.props.eventTarget, 'keyup', this.handleKeyUp_); this.eh_.listen(this.props.eventTarget, 'touchstart', this.handleTouchstart_); this.eh_.listen(this.props.eventTarget, 'touchend', this.handleTouchend_); }, componentWillUnmount: function() { this.eh_.dispose(); this.clearDragTimer_(); }, // Invoked once before everything else on initial rendering. Values are // subsequently in this.state. We use this to set the initial state and // also to document what state fields are possible getInitialState: function() { return { backwardPiggy: false, currentURL: null, // currentSearch exists in Index just to wire together the searchbox of Header, and the Map // aspect, so the Map aspect can add the zoom level to the predicate in the search box. currentSearch: '', currentSet: '', dropActive: false, selection: {}, serverStatus: null, // we keep track of where a touch started, so we can // tell when the touch ends if we consider it a swipe. We // reset it to -1 whenever a touch ends. touchStartPosition: -1, // TODO: This should be calculated by whether selection is empty, and not need separate state. sidebarVisible: false, uploadDialogVisible: false, totalBytesToUpload: 0, totalBytesComplete: 0, // messageDialogContents is for displaying a message to // the user. It is the child of getMessageDialog_(). To // display a message, set messageDialogContents to whatever // you want (div, string, etc), and set // messageDialogVisible to true. messageDialogContents: null, messageDialogVisible: false, }; }, // render() is called by React every time a component is determined to need // re-rendering. This is typically caused by a call to setState() or a parent // component re-rendering. render: function() { var aspects = this.getAspects_(); var selectedAspect = goog.array.findIndex(aspects, function(v) { return v.fragment == this.state.currentURL.getFragment(); }, this); if (selectedAspect == -1) { selectedAspect = 0; } var contentSize = new goog.math.Size(this.props.availWidth, this.props.availHeight - this.HEADER_HEIGHT_); return React.DOM.div({onDragEnter:this.handleDragStart_, onDragOver:this.handleDragStart_, onDrop:this.handleDrop_}, this.getHeader_(aspects, selectedAspect), React.DOM.div( { className: 'cam-content-wrap', style: { top: this.HEADER_HEIGHT_, }, }, aspects[selectedAspect] && aspects[selectedAspect].createContent(contentSize, this.state.backwardPiggy) ), this.getSidebar_(aspects[selectedAspect]), this.getUploadDialog_(), this.getMessageDialog_() ); }, setSelection_: function(selection) { this.props.history.replaceState(cam.object.extend(this.props.history.state, { selection: selection, }), '', this.props.location.href); this.setState({selection: selection}); this.setState({sidebarVisible: !goog.object.isEmpty(selection)}); }, getTargetBlobref_: function(opt_url) { var url = opt_url || this.state.currentURL; var suffix = url.getPath().substr(this.baseURL_.getPath().length); // TODO(aa): Need to implement something like ref.go that knows about the other hash types. var match = suffix.match(this.BLOBREF_PATTERN_); return match && match[0]; }, getAspects_: function() { var target = this.getTargetBlobref_(); var getAspect = function(f) { return f(target, this.targetSearchSession_); }.bind(this); var specificAspects = [ cam.ImageDetail.getAspect, // TODO(mpl): redo DirectoryDetail to look like a Blobs Container cam.DirectoryDetail.getAspect.bind(null, this.baseURL_, this.props.serverConnection), ].map(getAspect).filter(goog.functions.identity); var generalAspects = [ this.getSearchAspect_.bind(null, specificAspects), cam.MapAspect.getAspect.bind(null, this.props.config, this.props.availWidth, this.props.availHeight - this.HEADER_HEIGHT_, this.handleZoomLevelChange_, this.childSearchSession_), cam.PermanodeDetail.getAspect.bind(null, this.props.serverConnection, this.props.timer), cam.BlobDetail.getAspect.bind(null, this.getDetailURL_, this.props.serverConnection), ].map(getAspect).filter(goog.functions.identity); return specificAspects.concat(generalAspects); }, getSearchAspect_: function(specificAspects, blobref, targetSearchSession) { if (blobref) { var m = targetSearchSession.getMeta(blobref); if (!m || !m.permanode) { // We have a target, but it's not a permanode. So don't show the contents view. // TODO(aa): Maybe we do want to for directories though? return null; } // If the permanode already has children, we always show the container view. // Otherwise, show the container view only if there is no more specific type. var showSearchAspect = false; if (cam.permanodeUtils.isContainer(m.permanode)) { showSearchAspect = true; } else if (!cam.permanodeUtils.getCamliNodeType(m.permanode) && specificAspects.length == 0) { showSearchAspect = true; } if (!showSearchAspect) { return null; } } // This can happen when a user types a raw (JSON) query that is invalid. if (!this.childSearchSession_) { return null; } return { title: blobref ? 'Contents' : 'Search', fragment: blobref ? 'contents': 'search', createContent: this.getBlobItemContainer_.bind(null, this), }; }, // handleZoomLevelChange_ is called by the map aspect when the zoom level // changes, so the locrect predicate used as a zoom can be changed in currentSearch, // and then propagated to the predicate in the searchbox of the header. handleZoomLevelChange_: function(currentSearch) { this.setState({currentSearch: currentSearch}); }, handleDragStart_: function(e) { this.clearDragTimer_(); e.preventDefault(); this.dragEndTimer_ = window.setTimeout(this.handleDragStop_, 2000); this.setState({ dropActive: true, uploadDialogVisible: false, }); }, handleDragStop_: function() { this.clearDragTimer_(); this.setState({dropActive: false}); }, clearDragTimer_: function() { if (this.dragEndTimer_) { window.clearTimeout(this.dragEndTimer_); this.dragEndTimer_ = 0; } }, onUploadStart_: function(files) { var numFiles = files.length; var totalBytes = Array.prototype.reduce.call(files, function(sum, file) { return sum + file.size; }, 0); this.setState({ dropActive: false, totalBytesToUpload: totalBytes, totalBytesComplete: 0, }); console.log('Uploading %d files (%d bytes)...', numFiles, totalBytes); }, onUploadProgressUpdate_: function(file) { var completedBytes = this.state.totalBytesComplete + file.size; this.setState({ totalBytesComplete: completedBytes }); console.log('Completed %d of %d bytes', completedBytes, this.state.totalBytesToUpload); }, onUploadComplete_: function() { console.log('Upload complete!'); this.setState({ totalBytesToUpload: 0, totalBytesComplete: 0, uploadDialogVisible: false }); }, handleInputFiles_: function(e) { console.log(e.nativeEvent.target.files); if (!e.nativeEvent.target.files) { return; } e.preventDefault(); var files = e.nativeEvent.target.files; this.handleFilesUpload_(e, files); }, handleDrop_: function(e, files) { if (!e.nativeEvent.dataTransfer.files) { return; } e.preventDefault(); var files = e.nativeEvent.dataTransfer.files; this.handleFilesUpload_(e, files); }, handleFilesUpload_: function(e, files) { var sc = this.props.serverConnection; var parent = this.getTargetBlobref_(); this.onUploadStart_(files); goog.labs.Promise.all( Array.prototype.map.call(files, function(file) { return uploadFile(file) .then(fetchPermanodeIfExists) .then(createPermanodeIfNotExists) .then(updatePermanodeRef) .then(checkExistingCamliMembership) .then(createPermanodeAssociations) .thenCatch(function(e) { console.error('File upload fall down go boom. file: %s, error: %s', file.name, e); }) .then(this.onUploadProgressUpdate_.bind(this, file)); }.bind(this)) ).thenCatch(function(e) { console.error('File upload failed with error: %s', e); }).then(this.onUploadComplete_); function uploadFile(file) { // capture status of upload promise chain var status = { fileRef: '', isCamliMemberOfParent: false, parentRef: parent, permanodeRef: '', permanodeCreated: false }; var uploadFile = new goog.labs.Promise(sc.uploadFile.bind(sc, file)); return goog.labs.Promise.all([new goog.labs.Promise.resolve(status), uploadFile]); } function fetchPermanodeIfExists(results) { var status = results[0]; status.fileRef = results[1]; var getPermanode = new goog.labs.Promise(sc.getPermanodeWithContent.bind(sc, status.fileRef)); return goog.labs.Promise.all([new goog.labs.Promise.resolve(status), getPermanode]); } function createPermanodeIfNotExists(results) { var status = results[0]; var permanodeRef = results[1]; if (!permanodeRef) { status.permanodeCreated = true; var createPermanode = new goog.labs.Promise(sc.createPermanode.bind(sc)); return goog.labs.Promise.all([new goog.labs.Promise.resolve(status), createPermanode]); } return goog.labs.Promise.all([new goog.labs.Promise.resolve(status), new goog.labs.Promise.resolve(permanodeRef)]); } function updatePermanodeRef(results) { var status = results[0]; status.permanodeRef = results[1]; return goog.labs.Promise.all([new goog.labs.Promise.resolve(status)]); } // TODO(mpl): this implementation means that when we're dropping on a set, we send // one additional query for each permanode that already exists. So in the worst case, // it amounts to one additional query per dropped item (with a small payload/response). // Alternatively, we could ask (either by tweaking the search session, or // "manually") the server for all the set members and cache the response, which means // only one additional query, and we can then do all the tests locally. However, the // response size scales with the number of members in the set, so I don't know if it's // better. A working example is at // https://camlistore-review.googlesource.com/#/c/5345/2 . We should benchmark and/or // ask Brad. // check, when appropriate, if the permanode is already part of the set we're dropping in. function checkExistingCamliMembership(results) { var status = results[0]; // Permanode did not exist before, so it couldn't be a member of any set. if (!status.parentRef || status.permanodeCreated) { return goog.labs.Promise.all([new goog.labs.Promise.resolve(status), new goog.labs.Promise.resolve(false)]); } console.log('checking membership'); var hasMembership = new goog.labs.Promise(sc.isCamliMember.bind(sc, status.permanodeRef, status.parentRef)); return goog.labs.Promise.all([new goog.labs.Promise.resolve(status), hasMembership]); } function createPermanodeAssociations(results) { var status = results[0]; status.isCamliMemberOfParent = results[1]; var promises = []; // associate uploaded file to new permanode if (status.permanodeCreated) { var setCamliContent = new goog.labs.Promise(sc.newSetAttributeClaim.bind(sc, status.permanodeRef, 'camliContent', status.fileRef)); promises.push(setCamliContent); } // add CamliMember relationship if viewing a set if (status.parentRef && !status.isCamliMemberOfParent) { var setCamliMember = new goog.labs.Promise(sc.newAddAttributeClaim.bind(sc, status.parentRef, 'camliMember', status.permanodeRef)); promises.push(setCamliMember); } return goog.labs.Promise.all(promises); } }, handleWillNavigate_: function(newURL) { if (!goog.string.startsWith(newURL.toString(), this.baseURL_.toString())) { return false; } // reset the currentSearch on navigation, since the map aspect modifies it on // panning/zooming. var targetBlobref = this.getTargetBlobref_(newURL); var currentSearch = ''; if (targetBlobref) { currentSearch = 'ref:' + targetBlobref; } else if (newURL) { currentSearch = newURL.getParameterValue('q') || ''; } this.updateTargetSearchSession_(targetBlobref, newURL); this.updateChildSearchSession_(targetBlobref, newURL); this.pruneSearchSessionCache_(); this.setState({ backwardPiggy: false, currentURL: newURL, currentSearch: currentSearch, }); return true; }, handleDidNavigate_: function() { var s = this.props.history.state && this.props.history.state.selection; this.setSelection_(s || {}); }, updateTargetSearchSession_: function(targetBlobref, newURL) { this.targetSearchSession_ = null; if (targetBlobref) { var opt_sort = "blobref"; var query = this.queryFromSearchParam_("ref:"+targetBlobref); var parentPermanode = newURL.getParameterValue('p'); if (parentPermanode) { query = this.queryFromParentPermanode_(parentPermanode); opt_sort = "-created"; } else { var queryString = newURL.getParameterValue('q'); if (queryString) { opt_sort = null; query = this.queryFromSearchParam_(queryString); } } this.targetSearchSession_ = this.getSearchSession_(targetBlobref, query, opt_sort); } }, updateChildSearchSession_: function(targetBlobref, newURL) { var query = ' '; if (targetBlobref) { query = this.queryFromParentPermanode_(targetBlobref); } else { var queryString = newURL.getParameterValue('q'); if (queryString) { query = this.queryFromSearchParam_(queryString); } } this.childSearchSession_ = this.getSearchSession_(null, query); }, queryFromSearchParam_: function(queryString) { // TODO(aa): Remove this when the server can do something like the 'raw' operator. if (goog.string.startsWith(queryString, this.SEARCH_PREFIX_.RAW + ':')) { try { return JSON.parse(queryString.substring(this.SEARCH_PREFIX_.RAW.length + 1)); } catch (e) { console.error('Raw search is invalid JSON', e); return null; } } else { return queryString; } }, queryFromParentPermanode_: function(blobRef) { return { permanode: { relation: { relation: 'parent', any: { blobRefPrefix: blobRef }, }, }, }; }, // Finds an existing cached SearchSession that meets criteria, or creates a new one. // // If opt_query is present, the returned query must be exactly equivalent. // If opt_targetBlobref is present, the returned query must have current results that contain opt_targetBlobref. Otherwise, the returned query must contain the first result. // // If only opt_targetBlobref is set, then any query that happens to currently contain that blobref is acceptable to the caller. getSearchSession_: function(opt_targetBlobref, opt_query, opt_sort) { // This whole business of reusing search session relies on the assumption that we use the same describe rules for both detail queries and search queries. var queryString = JSON.stringify(opt_query); var cached = goog.array.findIndex(this.searchSessionCache_, function(ss) { if (opt_targetBlobref) { if (!ss.getMeta(opt_targetBlobref)) { return false; } if (!opt_query && !opt_sort) { return true; } } if (JSON.stringify(ss.getQuery()) != queryString) { return false; } if (!opt_sort) { opt_sort = "-created" } if (ss.getSort() != opt_sort) { return false; } if (!opt_targetBlobref) { return !ss.getAround(); } // If there's a targetBlobref, we require that it is not at the very edge of the results so that we can implement lefr/right in detail views. var targetIndex = goog.array.findIndex(ss.getCurrentResults().blobs, function(b) { return b.blob == opt_targetBlobref; }); return (targetIndex > 0) && (targetIndex < (ss.getCurrentResults().blobs.length - 1)); }); if (cached > -1) { this.searchSessionCache_.splice(0, 0, this.searchSessionCache_.splice(cached, 1)[0]); return this.searchSessionCache_[0]; } console.log('Creating new search session for query %s', queryString); var ss = new cam.SearchSession(this.props.serverConnection, this.baseURL_.clone(), opt_query, opt_targetBlobref, opt_sort); this.eh_.listen(ss, cam.SearchSession.SEARCH_SESSION_CHANGED, function() { this.forceUpdate(); }); this.eh_.listen(ss, cam.SearchSession.SEARCH_SESSION_STATUS, function(e) { this.setState({ serverStatus: e.status, }); }); this.eh_.listen(ss, cam.SearchSession.SEARCH_SESSION_ERROR, function() { this.forceUpdate(); }); ss.loadMoreResults(); this.searchSessionCache_.splice(0, 0, ss); return ss; }, pruneSearchSessionCache_: function() { for (var i = this.SEARCH_SESSION_CACHE_SIZE_; i < this.searchSessionCache_.length; i++) { this.searchSessionCache_[i].close(); } this.searchSessionCache_.length = Math.min(this.searchSessionCache_.length, this.SEARCH_SESSION_CACHE_SIZE_); }, getHeader_: function(aspects, selectedAspectIndex) { var chooser = this.getChooser_(aspects); return React.createElement(cam.Header, { getCurrentSearch: function() { return this.state.currentSearch; }.bind(this), setCurrentSearch: function(e) { this.setState({currentSearch: e.target.value}); }.bind(this), errors: this.getErrors_(), height: 38, helpURL: this.baseURL_.resolve(new goog.Uri(this.props.config.helpRoot)), homeURL: this.baseURL_, importersURL: this.baseURL_.resolve(new goog.Uri(this.props.config.importerRoot)), mainControls: chooser.map(function(val, idx) { return React.DOM.a( { key: val.title, className: classNames({ 'cam-header-main-control-active': idx == selectedAspectIndex, }), href: this.state.currentURL.clone().setFragment(val.fragment).toString(), }, val.title ); }, this), onUpload: this.handleUpload_, onNewPermanode: this.handleCreateSetWithSelection_, onSearch: this.setSearch_, favoritesURL: this.getFavoritesURL_(), statusURL: this.baseURL_.resolve(new goog.Uri(this.props.config.statusRoot)), ref: 'header', timer: this.props.timer, width: this.props.availWidth, config: this.props.config, } ) }, getChooser_: function(aspects) { // We don't show the chooser if there's only one thing to choose from. if (aspects.length == 1) { return []; } return aspects; }, handleNewPermanode_: function() { this.props.serverConnection.createPermanode(this.getDetailURL_.bind(this)); }, getFavoritesURL_: function() { return this.baseURL_.clone().setParameterValue( 'q', this.SEARCH_PREFIX_.RAW + ':' + JSON.stringify({ permanode: { attr: 'camliRoot', numValue: { min: 1 } } }) ); }, handleSelectAsCurrentSet_: function() { this.setState({ currentSet: goog.object.getAnyKey(this.state.selection), }); this.setSelection_({}); alert('Now, select the items to add to this set and click "Add to picked set" in the sidebar.\n\n' + 'Sorry this is lame, we\'re working on it.'); }, handleAddToSet_: function() { this.addMembersToSet_(this.state.currentSet, goog.object.getKeys(this.state.selection)); alert('Done!'); }, handleUpload_: function() { this.setState({ uploadDialogVisible: true, }); }, handleCreateSetWithSelection_: function() { var selection = goog.object.getKeys(this.state.selection); this.props.serverConnection.createPermanode(function(permanode) { this.props.serverConnection.newSetAttributeClaim(permanode, 'title', 'New set', function() { this.addMembersToSet_(permanode, selection); }.bind(this)); }.bind(this)); }, addMembersToSet_: function(permanode, blobrefs) { var numComplete = -1; var callback = function() { if (++numComplete == blobrefs.length) { this.setSelection_({}); this.refreshIfNecessary_(); this.navigator_.navigate(this.getDetailURL_(permanode)); } }.bind(this); callback(); blobrefs.forEach(function(br) { this.props.serverConnection.newAddAttributeClaim(permanode, 'camliMember', br, callback); }.bind(this)); }, handleClearSelection_: function() { this.setSelection_({}); }, handleDeleteSelection_: function() { // TODO(aa): Use promises. var blobrefs = goog.object.getKeys(this.state.selection); var msg = 'Delete'; if (blobrefs.length > 1) { msg += goog.string.subs(' %s items?', blobrefs.length); } else { msg += ' item?'; } if (!confirm(msg)) { return null; } var numDeleted = 0; blobrefs.forEach(function(br) { this.props.serverConnection.newDeleteClaim(br, function() { if (++numDeleted == blobrefs.length) { this.setSelection_({}); this.refreshIfNecessary_(); } }.bind(this)); }.bind(this)); }, handleRemoveSelectionFromSet_: function() { var target = this.getTargetBlobref_(); var permanode = this.targetSearchSession_.getMeta(target).permanode; var sc = this.props.serverConnection; var changes = []; for (var k in permanode.attr) { var values = permanode.attr[k]; for (var i = 0; i < values.length; i++) { if (this.state.selection[values[i]]) { if (k == 'camliMember' || goog.string.startsWith(k, 'camliPath:')) { changes.push(new goog.labs.Promise(sc.newDelAttributeClaim.bind(sc, target, k, values[i]))); } else { console.error('Unexpected attribute: ', k); } } } } goog.labs.Promise.all(changes).then(this.refreshIfNecessary_); }, handleOpenWindow_: function(url) { this.props.openWindow(url); }, handleTouchstart_: function(e) { if (!this.targetSearchSession_) { return; } var touches = e.getBrowserEvent().changedTouches; for (var i = 0; i < touches.length; i++) { // TODO(mpl): maybe disregard (as a swipe) a touch that // starts e.g. on the top bar? But then the proper solution // is probably to register the touch listener on the image // container, instead of on the index page? this.setState({touchStartPosition: touches[i].pageX}); // assume we only care about one finger/event for now break } }, handleTouchend_: function(e) { if (!this.targetSearchSession_) { return; } if (this.state.touchStartPosition < 0) { return; } var touches = e.getBrowserEvent().changedTouches; var halfScreen = this.props.availWidth / 2; for (var i = 0; i < touches.length; i++) { var swipeLength = touches[i].pageX - this.state.touchStartPosition; if (Math.abs(swipeLength) < halfScreen) { // do nothing if half-hearted swipe this.setState({touchStartPosition: -1}); return; } // swipe left == nav right var isRight = (swipeLength < 0); this.navigateLeftRight_(isRight); this.setState({ backwardPiggy: (swipeLength > 0), touchStartPosition: -1, }); // assume we only care about one finger/event for now break; } }, handleKeyPress_: function(e) { if (e.target.tagName == 'INPUT' || e.target.tagName == 'TEXTAREA') { return; } switch (String.fromCharCode(e.charCode)) { case '/': { this.refs['header'].focusSearch(); e.preventDefault(); break; } case '|': { window.__debugConsoleClient = { getSelectedItems: function() { return this.state.selection; }.bind(this), serverConnection: this.props.serverConnection, }; window.open('debug_console.html', 'debugconsole', 'width=400,height=300'); break; } } }, handleKeyUp_: function(e) { var isEsc = (e.keyCode == 27); var isRight = (e.keyCode == 39); var isLeft = (e.keyCode == 37); if (isEsc) { // TODO: This isn't right, it should go back to the context URL if there is one. this.navigator_.navigate(this.baseURL_); return; } if (!isRight && !isLeft) { return; } if (!this.targetSearchSession_) { return; } this.navigateLeftRight_(isRight); this.setState({ backwardPiggy: isLeft, }); }, navigateLeftRight_: function(isRight) { var blobs = this.targetSearchSession_.getCurrentResults().blobs; var target = this.getTargetBlobref_(); var idx = goog.array.findIndex(blobs, function(item) { return item.blob == target; }); if (isRight) { if (idx >= (blobs.length - 1)) { return; } idx++; } else { if (idx <= 0) { return; } idx--; } var url = this.getDetailURL_(blobs[idx].blob, this.state.currentURL.getFragment()); ['q', 'p'].forEach(function(p) { var v = this.state.currentURL.getParameterValue(p); if (v) { url.setParameterValue(p, v); } }, this); this.navigator_.navigate(url); }, handleDetailURL_: function(blobref) { return this.getChildDetailURL_(blobref); }, getChildDetailURL_: function(blobref, opt_fragment) { var query = this.state.currentURL.getParameterValue('q'); var targetBlobref = this.getTargetBlobref_(); var url = this.getDetailURL_(blobref, opt_fragment); if (targetBlobref) { url.setParameterValue('p', targetBlobref); } else { url.setParameterValue('q', query || ' '); } return url; }, getDetailURL_: function(blobref, opt_fragment) { var query = this.state.currentURL.getParameterValue('q'); var targetBlobref = this.getTargetBlobref_(); // TODO(mpl): now that "ref:refprefix" searches are fully supported, maybe we // could replace the mix of path+query URLs, with just query based URLs? return url = this.baseURL_.clone().setPath(this.baseURL_.getPath() + blobref).setFragment(opt_fragment || ''); }, setSearch_: function(query) { var searchURL; var match = query.match(/^ref:(.+)/); if (match) { searchURL = this.getDetailURL_(match[1]); } else { searchURL = this.baseURL_.clone().setParameterValue('q', query); } this.navigator_.navigate(searchURL); }, getSelectAsCurrentSetItem_: function() { if (goog.object.getCount(this.state.selection) != 1) { return null; } var blobref = goog.object.getAnyKey(this.state.selection); var m = this.childSearchSession_.getMeta(blobref); if (!m || m.camliType != 'permanode') { return null; } return React.DOM.button( { key:'selectascurrent', onClick:this.handleSelectAsCurrentSet_ }, 'Add items to set' ); }, getAddToCurrentSetItem_: function() { if (!this.state.currentSet) { return null; } return React.DOM.button( { key:'addtoset', onClick:this.handleAddToSet_ }, 'Add to picked set' ); }, getCreateSetWithSelectionItem_: function() { return React.DOM.button( { key:'createsetwithselection', onClick:this.handleCreateSetWithSelection_ }, 'Create set with items' ); }, getClearSelectionItem_: function() { return React.DOM.button( { key:'clearselection', onClick:this.handleClearSelection_ }, 'Clear selection' ); }, getDeleteSelectionItem_: function() { return React.DOM.button( { key:'deleteselection', onClick:this.handleDeleteSelection_ }, 'Delete items' ); }, getRemoveSelectionFromSetItem_: function() { if (!goog.object.getAnyKey(this.state.selection)) { return null; } var target = this.getTargetBlobref_(); if (!target) { return null; } var meta = this.targetSearchSession_.getMeta(target); if (!meta || !meta.permanode) { return null; } if (!cam.permanodeUtils.isContainer(meta.permanode)) { return null; } return React.DOM.button( { key: 'removeSelectionFromSet', onClick: this.handleRemoveSelectionFromSet_, }, 'Remove from set' ); }, getViewOriginalSelectionItem_: function() { if (goog.object.getCount(this.state.selection) != 1) { return null; } var blobref = goog.object.getAnyKey(this.state.selection); var rm = this.childSearchSession_.getResolvedMeta(blobref); if (!rm || !rm.file) { return null; } var fileName = ''; if (rm.file.fileName) { fileName = goog.string.subs('/%s', rm.file.fileName); } var downloadUrl = goog.string.subs('%s%s%s', this.props.config.downloadHelper, rm.blobRef, fileName); return React.DOM.button( { key:'viewSelection', onClick: this.handleOpenWindow_.bind(null, downloadUrl), }, 'View original' ); }, getDownloadSelectionItem_: function() { return goreact.DownloadItemsBtn('donwloadBtnSidebar', this.props.config, // TODO(mpl): I'm doing the selection business in javascript for now, // since we already have the search session results handy. // It shouldn't be any problem to move it to Go later. function() { var selection = goog.object.getKeys(this.state.selection); var files = []; selection.forEach(function(br) { var meta = this.childSearchSession_.getResolvedMeta(br); if (!meta || !meta.file || !meta.file.fileName) { // TODO(mpl): only do direct files for now. maybe recurse later. return; } files.push(meta.blobRef); }.bind(this)) return files; }.bind(this)); }, getShareSelectionItem_: function() { return goreact.ShareItemsBtn('shareBtnSidebar', this.props.config, // TODO(mpl): I'm doing the selection business in javascript for now, // since we already have the search session results handy. // It shouldn't be any problem to move it to Go later. function() { var selection = goog.object.getKeys(this.state.selection); var files = []; selection.forEach(function(br) { var meta = this.childSearchSession_.getResolvedMeta(br); if (!meta) { return; } if (meta.dir) { files.push({'blobRef': meta.blobRef, 'isDir': 'true'}); return; } if (meta.file) { files.push({'blobRef': meta.blobRef, 'isDir': 'false'}); return; } }.bind(this)) return files; }.bind(this), function(sharedURL, anchorText) { // TODO(mpl): Port the dialog to Go. this.setState({ messageDialogVisible: true, messageDialogContents: React.DOM.div({ style: { textAlign: 'center', position: 'relative', },}, React.DOM.div({}, 'Share URL:'), React.DOM.div({}, React.DOM.a({href: sharedURL}, anchorText)) ), }); }.bind(this)); }, getSidebar_: function(selectedAspect) { if (selectedAspect) { if (selectedAspect.fragment == 'search' || selectedAspect.fragment == 'contents') { var count = goog.object.getCount(this.state.selection); return React.createElement(cam.Sidebar, { isExpanded: this.state.sidebarVisible, header: React.DOM.span( { className: 'header', }, goog.string.subs('%s selected item%s', count, count > 1 ? 's' : '') ), mainControls: [ { "displayTitle": "Update tags", "control": this.getTagsControl_() } ].filter(goog.functions.identity), selectionControls: [ this.getClearSelectionItem_(), this.getCreateSetWithSelectionItem_(), this.getSelectAsCurrentSetItem_(), this.getAddToCurrentSetItem_(), this.getRemoveSelectionFromSetItem_(), this.getDeleteSelectionItem_(), this.getViewOriginalSelectionItem_(), this.getDownloadSelectionItem_(), this.getShareSelectionItem_(), ].filter(goog.functions.identity), selectedItems: this.state.selection }); } } return null; }, getTagsControl_: function() { return React.createElement(cam.TagsControl, { selectedItems: this.state.selection, searchSession: this.childSearchSession_, serverConnection: this.props.serverConnection }); }, getMessageDialog_: function() { if (!this.state.messageDialogVisible) { return null; } var borderWidth = 18; // TODO(mpl): make it dynamically proportional to the size of // the contents. For now, I know I want to display a ~40 chars wide // message, hence the rough 50em*16px/em. var w = 50*16; var h = 10*16; return React.createElement(cam.Dialog, { availWidth: this.props.availWidth, availHeight: this.props.availHeight, width: w, height: h, borderWidth: borderWidth, onClose: function() { this.setState({ messageDialogVisible: false, messageDialogContents: null, }); }.bind(this), }, this.state.messageDialogContents ); }, isUploading_: function() { return this.state.totalBytesToUpload > 0; }, getUploadDialog_: function() { if (!this.state.uploadDialogVisible && !this.state.dropActive && !this.state.totalBytesToUpload) { return null; } var piggyWidth = 88; var piggyHeight = 62; var borderWidth = 18; var w = this.props.availWidth * 0.8; var h = this.props.availHeight * 0.8; var iconProps = { key: 'icon', sheetWidth: 10, spriteWidth: piggyWidth, spriteHeight: piggyHeight, style: { marginRight: 3, position: 'relative', display: 'inline-block', } }; function getInputFiles() { if (this.isUploading_()) { return null; } return React.DOM.div( {}, getInputFilesText.call(this), getInputFilesButton.call(this) ) } function getInputFilesText() { // TODO: It does not have to be a div (it could be just // a string), but it's easier to make it a div than to // figure out the CSS to display it on its own line, // horizontally centered. return React.DOM.div( {}, function() { return 'or select files: '; }.call(this) ) } function getInputFilesButton() { return React.DOM.input( { type: "file", id: "fileupload", multiple: "true", name: "file", onChange: this.handleInputFiles_ }) } function getIcon() { if (this.isUploading_()) { return React.createElement(cam.SpritedAnimation, cam.object.extend(iconProps, { numFrames: 48, src: 'glitch/npc_piggy__x1_chew_png_1354829433.png', })); } else if (this.state.dropActive) { return React.createElement(cam.SpritedAnimation, cam.object.extend(iconProps, { loopDelay: 4000, numFrames: 48, src: 'glitch/npc_piggy__x1_look_screen_png_1354829434.png', startFrame: 6, })); } else { return React.createElement(cam.SpritedImage, cam.object.extend(iconProps, { index: 0, src: 'glitch/npc_piggy__x1_look_screen_png_1354829434.png', })); } } function getText() { // TODO: It does not have to be a div (it could be just // a string), but it's easier to make it a div than to // figure out the CSS to display it on its own line, // horizontally centered. return React.DOM.div( {}, function() { if (this.isUploading_()) { return goog.string.subs('Uploaded %s (%s%)', goog.format.numBytesToString(this.state.totalBytesComplete, 2), getUploadProgressPercent.call(this)); } else { return 'Drop files here to upload,'; } }.call(this) ) } function getUploadProgressPercent() { if (!this.state.totalBytesToUpload) { return 0; } return Math.round(100 * (this.state.totalBytesComplete / this.state.totalBytesToUpload)); } return React.createElement(cam.Dialog, { availWidth: this.props.availWidth, availHeight: this.props.availHeight, width: w, height: h, borderWidth: borderWidth, onClose: this.state.uploadDialogVisible ? this.handleCloseUploadDialog_ : null, }, React.DOM.div( { className: 'cam-index-upload-dialog', style: { textAlign: 'center', position: 'relative', left: -piggyWidth / 2, top: (h - piggyHeight - borderWidth * 2) / 2, }, }, getIcon.call(this), getText.call(this), getInputFiles.call(this) ) ); }, handleCloseUploadDialog_: function() { this.setState({ uploadDialogVisible: false, }); }, handleSelectionChange_: function(newSelection) { this.setSelection_(newSelection); }, getBlobItemContainer_: function() { var sidebarClosedWidth = this.props.availWidth; var sidebarOpenWidth = sidebarClosedWidth - this.SIDEBAR_OPEN_WIDTH_; var scale = sidebarOpenWidth / sidebarClosedWidth; return React.createElement(cam.BlobItemContainerReact, { key: 'blobitemcontainer', ref: 'blobItemContainer', availHeight: this.props.availHeight, availWidth: this.props.availWidth, detailURL: this.handleDetailURL_, handlers: this.BLOB_ITEM_HANDLERS_, history: this.props.history, onSelectionChange: this.handleSelectionChange_, scale: scale, scaleEnabled: this.state.sidebarVisible, scrolling: this.props.scrolling, searchSession: this.childSearchSession_, selection: this.state.selection, style: this.getBlobItemContainerStyle_(), thumbnailSize: this.THUMBNAIL_SIZE_, }); }, getBlobItemContainerStyle_: function() { return { left: 0, overflowY: this.state.dropActive ? 'hidden' : '', position: 'absolute', top: 0, }; }, getContentWidth_: function() { return this.props.availWidth; }, refreshIfNecessary_: function() { if (this.targetSearchSession_) { this.targetSearchSession_.refreshIfNecessary(); } if (this.childSearchSession_) { this.childSearchSession_.refreshIfNecessary(); } }, getErrors_: function() { var errors = (this.state.serverStatus && this.state.serverStatus.errors) || []; if ((this.targetSearchSession_ && this.targetSearchSession_.hasSocketError()) || (this.childSearchSession_ && this.childSearchSession_.hasSocketError())) { errors.push({ error: 'WebSocket error - click to reload', onClick: this.props.location.reload.bind(this.props.location, true), }); } return errors; }, });