github.com/olivere/camlistore@v0.0.0-20140121221811-1b7ac2da0199/server/camlistored/ui/index_react.js (about)

     1  /*
     2  Copyright 2014 The Camlistore Authors
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8  	http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  goog.provide('cam.IndexPageReact');
    18  
    19  goog.require('goog.dom');
    20  goog.require('goog.dom.classlist');
    21  goog.require('goog.events.EventHandler');
    22  goog.require('goog.labs.Promise');
    23  goog.require('goog.object');
    24  goog.require('goog.string');
    25  goog.require('goog.Uri');
    26  
    27  goog.require('cam.BlobItemContainerReact');
    28  goog.require('cam.DetailView');
    29  goog.require('cam.Navigator');
    30  goog.require('cam.NavReact');
    31  goog.require('cam.reactUtil');
    32  goog.require('cam.SearchSession');
    33  goog.require('cam.ServerConnection');
    34  
    35  cam.IndexPageReact = React.createClass({
    36  	displayName: 'IndexPageReact',
    37  
    38  	NAV_WIDTH_CLOSED_: 36,
    39  	NAV_WIDTH_OPEN_: 239,
    40  
    41  	THUMBNAIL_SIZES_: [75, 100, 150, 200, 250, 300],
    42  
    43  	SEARCH_PREFIX_: {
    44  		RAW: 'raw'
    45  	},
    46  
    47  	propTypes: {
    48  		availWidth: React.PropTypes.number.isRequired,
    49  		availHeight: React.PropTypes.number.isRequired,
    50  		config: React.PropTypes.object.isRequired,
    51  		eventTarget: cam.reactUtil.quacksLike({addEventListener:React.PropTypes.func.isRequired}).isRequired,
    52  		history: cam.reactUtil.quacksLike({pushState:React.PropTypes.func.isRequired, replaceState:React.PropTypes.func.isRequired, go:React.PropTypes.func.isRequired, state:React.PropTypes.object}).isRequired,
    53  		location: cam.reactUtil.quacksLike({href:React.PropTypes.string.isRequired, reload:React.PropTypes.func.isRequired}).isRequired,
    54  		serverConnection: React.PropTypes.instanceOf(cam.ServerConnection).isRequired,
    55  		timer: cam.NavReact.originalSpec.propTypes.timer,
    56  	},
    57  
    58  	componentWillMount: function() {
    59  		this.baseURL_ = null;
    60  		this.currentSet_ = null;
    61  		this.dragEndTimer_ = 0;
    62  		this.navigator_ = null;
    63  		this.searchSession_ = null;
    64  
    65  		// TODO(aa): Move this to index.css once conversion to React is complete (index.css is shared between React and non-React).
    66  		goog.dom.getDocumentScrollElement().style.overflow = 'hidden';
    67  
    68  		this.eh_ = new goog.events.EventHandler(this);
    69  
    70  		var newURL = new goog.Uri(this.props.location.href);
    71  		this.baseURL_ = newURL.resolve(new goog.Uri(CAMLISTORE_CONFIG.uiRoot));
    72  
    73  		this.navigator_ = new cam.Navigator(this.props.eventTarget, this.props.location, this.props.history, true);
    74  		this.navigator_.onNavigate = this.handleNavigate_;
    75  
    76  		this.handleNavigate_(newURL);
    77  	},
    78  
    79  	componentDidMount: function() {
    80  		this.eh_.listen(this.props.eventTarget, 'keypress', this.handleKeyPress_);
    81  	},
    82  
    83  	componentWillUnmount: function() {
    84  		this.eh_.dispose();
    85  		this.clearDragTimer_();
    86  	},
    87  
    88  	getInitialState: function() {
    89  		return {
    90  			currentURL: null,
    91  			dropActive: false,
    92  			isNavOpen: false,
    93  			selection: {},
    94  			thumbnailSizeIndex: 3,
    95  		};
    96  	},
    97  
    98  	render: function() {
    99  		return React.DOM.div({onDragEnter:this.handleDragStart_, onDragOver:this.handleDragStart_, onDrop:this.handleDrop_}, [
   100  			this.getNav_(),
   101  			this.getBlobItemContainer_(),
   102  			this.getDetailView_(),
   103  		]);
   104  	},
   105  
   106  	handleDragStart_: function(e) {
   107  		this.clearDragTimer_();
   108  		e.preventDefault();
   109  		this.dragEndTimer_ = window.setTimeout(this.handleDragStop_, 2000);
   110  		goog.dom.classlist.add(this.getDOMNode().parentElement, 'cam-dropactive');
   111  	},
   112  
   113  	handleDragStop_: function() {
   114  		this.clearDragTimer_();
   115  		goog.dom.classlist.remove(this.getDOMNode().parentElement, 'cam-dropactive');
   116  	},
   117  
   118  	clearDragTimer_: function() {
   119  		if (this.dragEndTimer_) {
   120  			window.clearTimeout(this.dragEndTimer_);
   121  			this.dragEndTimer_ = 0;
   122  		}
   123  	},
   124  
   125  	handleDrop_: function(e) {
   126  		if (!e.nativeEvent.dataTransfer.files) {
   127  			return;
   128  		}
   129  
   130  		e.preventDefault();
   131  
   132  		var files = e.nativeEvent.dataTransfer.files;
   133  		var numComplete = 0;
   134  		var sc = this.props.serverConnection;
   135  
   136  		console.log('Uploading %d files...', files.length);
   137  		goog.labs.Promise.all(Array.prototype.map.call(files, function(file) {
   138  			var upload = new goog.labs.Promise(sc.uploadFile.bind(sc, file));
   139  			var createPermanode = new goog.labs.Promise(sc.createPermanode.bind(sc));
   140  			return goog.labs.Promise.all([upload, createPermanode]).then(function(results) {
   141  				// TODO(aa): Icky manual destructuring of results. Seems like there must be a better way?
   142  				var fileRef = results[0];
   143  				var permanodeRef = results[1];
   144  				return new goog.labs.Promise(sc.newSetAttributeClaim.bind(sc, permanodeRef, 'camliContent', fileRef));
   145  			}).thenCatch(function(e) {
   146  				console.error('File upload fall down go boom. file: %s, error: %s', file.name, e);
   147  			}).then(function() {
   148  				console.log('%d of %d files complete.', ++numComplete, files.length);
   149  			});
   150  		})).then(function() {
   151  			console.log('All complete');
   152  		});
   153  	},
   154  
   155  	handleNavigate_: function(newURL) {
   156  		if (this.state.currentURL) {
   157  			if (this.state.currentURL.getPath() != newURL.getPath()) {
   158  				return false;
   159  			}
   160  		}
   161  
   162  		this.updateSearchSession_(newURL);
   163  		this.setState({currentURL: newURL});
   164  		return true;
   165  	},
   166  
   167  	updateSearchSession_: function(newURL) {
   168  		var query = newURL.getParameterValue('q');
   169  		if (!query) {
   170  			query = ' ';
   171  		}
   172  
   173  		// TODO(aa): Remove this when the server can do something like the 'raw' operator.
   174  		if (goog.string.startsWith(query, this.SEARCH_PREFIX_.RAW + ':')) {
   175  			query = JSON.parse(query.substring(this.SEARCH_PREFIX_.RAW.length + 1));
   176  		}
   177  
   178  		if (this.searchSession_ && JSON.stringify(this.searchSession_.getQuery()) == JSON.stringify(query)) {
   179  			return;
   180  		}
   181  
   182  		if (this.searchSession_) {
   183  			this.searchSession_.close();
   184  		}
   185  
   186  		this.searchSession_ = new cam.SearchSession(this.props.serverConnection, newURL.clone(), query);
   187  	},
   188  
   189  	getNav_: function() {
   190  		if (!this.inSearchMode_()) {
   191  			return null;
   192  		}
   193  		return cam.NavReact({key:'nav', ref:'nav', timer:this.props.timer, open:this.state.isNavOpen, onOpen:this.handleNavOpen_, onClose:this.handleNavClose_}, [
   194  			cam.NavReact.SearchItem({key:'search', ref:'search', iconSrc:'magnifying_glass.svg', onSearch:this.setSearch_}, 'Search'),
   195  			cam.NavReact.Item({key:'newpermanode', iconSrc:'new_permanode.svg', onClick:this.handleNewPermanode_}, 'New permanode'),
   196  			cam.NavReact.Item({key:'roots', iconSrc:'icon_27307.svg', onClick:this.handleShowSearchRoots_}, 'Search roots'),
   197  			this.getSelectAsCurrentSetItem_(),
   198  			this.getAddToCurrentSetItem_(),
   199  			this.getCreateSetWithSelectionItem_(),
   200  			this.getClearSelectionItem_(),
   201  			cam.NavReact.Item({key:'up', iconSrc:'up.svg', onClick:this.handleEmbiggen_}, 'Moar bigger'),
   202  			cam.NavReact.Item({key:'down', iconSrc:'down.svg', onClick:this.handleEnsmallen_}, 'Less bigger'),
   203  			cam.NavReact.LinkItem({key:'logo', iconSrc:'/favicon.ico', href:this.baseURL_.toString(), extraClassName:'cam-logo'}, 'Camlistore'),
   204  		]);
   205  	},
   206  
   207  	handleNavOpen_: function() {
   208  		this.setState({isNavOpen:true});
   209  	},
   210  
   211  	handleNavClose_: function() {
   212  		this.refs.search.clear();
   213  		this.refs.search.blur();
   214  		this.setState({isNavOpen:false});
   215  	},
   216  
   217  	handleNewPermanode_: function() {
   218  		this.props.serverConnection.createPermanode(function(p) {
   219  			this.navigator_.navigate(this.getDetailURL_(false, p));
   220  		}.bind(this));
   221  	},
   222  
   223  	handleShowSearchRoots_: function() {
   224  		this.setSearch_(this.SEARCH_PREFIX_.RAW + ':' + JSON.stringify({
   225  			permanode: {
   226  				attr: 'camliRoot',
   227  				numValue: {
   228  					min: 1
   229  				}
   230  			}
   231  		}));
   232  	},
   233  
   234  	handleSelectAsCurrentSet_: function() {
   235  		this.currentSet_ = goog.object.getAnyKey(this.state.selection);
   236  		this.setState({selection:{}});
   237  	},
   238  
   239  	handleAddToSet_: function() {
   240  		this.addMembersToSet_(this.currentSet_, goog.object.getKeys(this.state.selection));
   241  	},
   242  
   243  	handleCreateSetWithSelection_: function() {
   244  		var selection = goog.object.getKeys(this.state.selection);
   245  		this.props.serverConnection.createPermanode(function(permanode) {
   246  			this.props.serverConnection.newSetAttributeClaim(permanode, 'title', 'New set', function() {
   247  				this.addMembersToSet_(permanode, selection);
   248  			}.bind(this));
   249  		}.bind(this));
   250  	},
   251  
   252  	addMembersToSet_: function(permanode, blobrefs) {
   253  		var numComplete = 0;
   254  		var callback = function() {
   255  			if (++numComplete == blobrefs.length) {
   256  				this.setState({selection:{}});
   257  				this.searchSession_.refreshIfNecessary();
   258  			}
   259  		}.bind(this);
   260  
   261  		blobrefs.forEach(function(br) {
   262  			this.props.serverConnection.newAddAttributeClaim(permanode, 'camliMember', br, callback);
   263  		}.bind(this));
   264  	},
   265  
   266  	handleClearSelection_: function() {
   267  		this.setState({selection:{}});
   268  	},
   269  
   270  	handleEmbiggen_: function() {
   271  		var newSizeIndex = this.state.thumbnailSizeIndex + 1;
   272  		if (newSizeIndex < this.THUMBNAIL_SIZES_.length) {
   273  			this.setState({thumbnailSizeIndex:newSizeIndex});
   274  		}
   275  	},
   276  
   277  	handleEnsmallen_: function() {
   278  		var newSizeIndex = this.state.thumbnailSizeIndex - 1;
   279  		if (newSizeIndex >= 0) {
   280  			this.setState({thumbnailSizeIndex:newSizeIndex});
   281  		}
   282  	},
   283  
   284  	handleKeyPress_: function(e) {
   285  		if (String.fromCharCode(e.charCode) == '/') {
   286  			this.refs.nav.open();
   287  			this.refs.search.focus();
   288  			e.preventDefault();
   289  		}
   290  	},
   291  
   292  	handleDetailURL_: function(item) {
   293  		return this.getDetailURL_(Boolean(item.im), item.blobref);
   294  	},
   295  
   296  	getDetailURL_: function(newUI, blobref) {
   297  		var detailURL = this.state.currentURL.clone();
   298  		detailURL.setParameterValue('p', blobref);
   299  		if (newUI) {
   300  			detailURL.setParameterValue('newui', '1');
   301  		}
   302  		return detailURL;
   303  	},
   304  
   305  	setSearch_: function(query) {
   306  		var searchURL = this.baseURL_.clone();
   307  		searchURL.setParameterValue('q', query);
   308  		this.navigator_.navigate(searchURL);
   309  	},
   310  
   311  	getSelectAsCurrentSetItem_: function() {
   312  		if (goog.object.getCount(this.state.selection) != 1) {
   313  			return null;
   314  		}
   315  
   316  		var blobref = goog.object.getAnyKey(this.state.selection);
   317  		var data = new cam.BlobItemReactData(blobref, this.searchSession_.getCurrentResults().description.meta);
   318  		if (!data.isDynamicCollection) {
   319  			return null;
   320  		}
   321  
   322  		return cam.NavReact.Item({key:'selectascurrent', iconSrc:'target.svg', onClick:this.handleSelectAsCurrentSet_}, 'Select as current set');
   323  	},
   324  
   325  	getAddToCurrentSetItem_: function() {
   326  		if (!this.currentSet_ || !goog.object.getAnyKey(this.state.selection)) {
   327  			return null;
   328  		}
   329  		return cam.NavReact.Item({key:'addtoset', iconSrc:'icon_16716.svg', onClick:this.handleAddToSet_}, 'Add to current set');
   330  	},
   331  
   332  	getCreateSetWithSelectionItem_: function() {
   333  		var numItems = goog.object.getCount(this.state.selection);
   334  		if (numItems == 0) {
   335  			return null;
   336  		}
   337  		var label = numItems == 1 ? 'Create set with item' : goog.string.subs('Create set with %s items', numItems);
   338  		return cam.NavReact.Item({key:'createsetwithselection', iconSrc:'circled_plus.svg', onClick:this.handleCreateSetWithSelection_}, label);
   339  	},
   340  
   341  	getClearSelectionItem_: function() {
   342  		if (!goog.object.getAnyKey(this.state.selection)) {
   343  			return null;
   344  		}
   345  		return cam.NavReact.Item({key:'clearselection', iconSrc:'clear.svg', onClick:this.handleClearSelection_}, 'Clear selection');
   346  	},
   347  
   348  	handleSelectionChange_: function(newSelection) {
   349  		this.setState({selection:newSelection});
   350  	},
   351  
   352  	inSearchMode_: function() {
   353  		// This is super finicky. We should improve the URL scheme and give things that are different different paths.
   354  		var query = this.state.currentURL.getQueryData();
   355  		return query.getCount() == 0 || (query.getCount() == 1 && query.containsKey('q'));
   356  	},
   357  
   358  	inDetailMode_: function() {
   359  		var query = this.state.currentURL.getQueryData();
   360  		return query.containsKey('p') && query.get('newui') == '1';
   361  	},
   362  
   363  	getBlobItemContainer_: function() {
   364  		if (!this.inSearchMode_()) {
   365  			return null;
   366  		}
   367  
   368  		return cam.BlobItemContainerReact({
   369  			key: 'blobitemcontainer',
   370  			ref: 'blobItemContainer',
   371  			detailURL: this.handleDetailURL_,
   372  			history: this.props.history,
   373  			onSelectionChange: this.handleSelectionChange_,
   374  			searchSession: this.searchSession_,
   375  			selection: this.state.selection,
   376  			style: this.getBlobItemContainerStyle_(),
   377  			thumbnailSize: this.THUMBNAIL_SIZES_[this.state.thumbnailSizeIndex],
   378  			thumbnailVersion: Number(this.props.config.thumbVersion),
   379  		});
   380  	},
   381  
   382  	getBlobItemContainerStyle_: function() {
   383  		// TODO(aa): Constant values can go into CSS when we switch over to react.
   384  		var style = {
   385  			left: this.NAV_WIDTH_CLOSED_,
   386  			overflowX: 'hidden',
   387  			overflowY: 'scroll',
   388  			position: 'absolute',
   389  			top: 0,
   390  			width: this.getContentWidth_(),
   391  		};
   392  
   393  		var closedWidth = style.width;
   394  		var openWidth = closedWidth - this.NAV_WIDTH_OPEN_;
   395  		var openScale = openWidth / closedWidth;
   396  
   397  		// TODO(aa): This can move to CSS when the conversion to React is complete.
   398  		style[cam.reactUtil.getVendorProp('transformOrigin')] = 'right top 0';
   399  
   400  		// The 3d transform is important. See: https://code.google.com/p/camlistore/issues/detail?id=284.
   401  		var scale = this.state.isNavOpen ? openScale : 1;
   402  		style[cam.reactUtil.getVendorProp('transform')] = goog.string.subs('scale3d(%s, %s, 1)', scale, scale);
   403  
   404  		style.height = this.state.isNavOpen ? this.props.availHeight / scale : this.props.availHeight;
   405  
   406  		return style;
   407  	},
   408  
   409  	getDetailView_: function() {
   410  		if (!this.inDetailMode_()) {
   411  			return null;
   412  		}
   413  
   414  		var searchURL = this.baseURL_.clone();
   415  		if (this.state.currentURL.getQueryData().containsKey('q')) {
   416  			searchURL.setParameterValue('q', this.state.currentURL.getParameterValue('q'));
   417  		}
   418  
   419  		var oldURL = this.baseURL_.clone();
   420  		oldURL.setParameterValue('p', this.state.currentURL.getParameterValue('p'));
   421  
   422  		return cam.DetailView({
   423  			key: 'detailview',
   424  			blobref: this.state.currentURL.getParameterValue('p'),
   425  			history: this.props.history,
   426  			searchSession: this.searchSession_,
   427  			searchURL: searchURL,
   428  			oldURL: oldURL,
   429  			getDetailURL: this.getDetailURL_.bind(this, false),
   430  			navigator: this.navigator_,
   431  			keyEventTarget: this.props.eventTarget,
   432  			width: this.props.availWidth,
   433  			height: this.props.availHeight,
   434  		});
   435  	},
   436  
   437  	getContentWidth_: function() {
   438  		return this.props.availWidth - this.NAV_WIDTH_CLOSED_;
   439  	},
   440  });