github.com/slspeek/camlistore_namedsearch@v0.0.0-20140519202248-ed6f70f7721a/server/camlistored/ui/index.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.IndexPage');
    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.BlobDetail');
    28  goog.require('cam.BlobItemContainerReact');
    29  goog.require('cam.BlobItemFoursquareContent');
    30  goog.require('cam.BlobItemGenericContent');
    31  goog.require('cam.BlobItemVideoContent');
    32  goog.require('cam.BlobItemImageContent');
    33  goog.require('cam.BlobItemDemoContent');
    34  goog.require('cam.ContainerDetail');
    35  goog.require('cam.DetailView');
    36  goog.require('cam.DirectoryDetail');
    37  goog.require('cam.Navigator');
    38  goog.require('cam.NavReact');
    39  goog.require('cam.PermanodeDetail');
    40  goog.require('cam.reactUtil');
    41  goog.require('cam.SearchSession');
    42  goog.require('cam.ServerConnection');
    43  
    44  cam.IndexPage = React.createClass({
    45  	displayName: 'IndexPage',
    46  
    47  	NAV_WIDTH_CLOSED_: 36,
    48  	NAV_WIDTH_OPEN_: 239,
    49  
    50  	THUMBNAIL_SIZES_: [75, 100, 150, 200, 250, 300],
    51  
    52  	SEARCH_PREFIX_: {
    53  		RAW: 'raw'
    54  	},
    55  
    56  	BLOB_ITEM_HANDLERS_: [
    57  		cam.BlobItemDemoContent.getHandler,
    58  		cam.BlobItemFoursquareContent.getHandler,
    59  		cam.BlobItemImageContent.getHandler,
    60  		cam.BlobItemVideoContent.getHandler,
    61  		cam.BlobItemGenericContent.getHandler // must be last
    62  	],
    63  
    64  	propTypes: {
    65  		availWidth: React.PropTypes.number.isRequired,
    66  		availHeight: React.PropTypes.number.isRequired,
    67  		config: React.PropTypes.object.isRequired,
    68  		eventTarget: React.PropTypes.shape({addEventListener:React.PropTypes.func.isRequired}).isRequired,
    69  		history: React.PropTypes.shape({pushState:React.PropTypes.func.isRequired, replaceState:React.PropTypes.func.isRequired, go:React.PropTypes.func.isRequired, state:React.PropTypes.object}).isRequired,
    70  		location: React.PropTypes.shape({href:React.PropTypes.string.isRequired, reload:React.PropTypes.func.isRequired}).isRequired,
    71  		serverConnection: React.PropTypes.instanceOf(cam.ServerConnection).isRequired,
    72  		timer: cam.NavReact.originalSpec.propTypes.timer,
    73  	},
    74  
    75  	componentWillMount: function() {
    76  		this.baseURL_ = null;
    77  		this.currentSet_ = null;
    78  		this.dragEndTimer_ = 0;
    79  		this.navigator_ = null;
    80  		this.searchSession_ = null;
    81  
    82  		// TODO(aa): Move this to index.css once conversion to React is complete (index.css is shared between React and non-React).
    83  		goog.dom.getDocumentScrollElement().style.overflow = 'hidden';
    84  
    85  		this.eh_ = new goog.events.EventHandler(this);
    86  
    87  		var newURL = new goog.Uri(this.props.location.href);
    88  		this.baseURL_ = newURL.resolve(new goog.Uri(CAMLISTORE_CONFIG.uiRoot));
    89  
    90  		this.navigator_ = new cam.Navigator(this.props.eventTarget, this.props.location, this.props.history);
    91  		this.navigator_.onNavigate = this.handleNavigate_;
    92  
    93  		this.handleNavigate_(newURL);
    94  	},
    95  
    96  	componentDidMount: function() {
    97  		this.eh_.listen(this.props.eventTarget, 'keypress', this.handleKeyPress_);
    98  	},
    99  
   100  	componentWillUnmount: function() {
   101  		this.eh_.dispose();
   102  		this.clearDragTimer_();
   103  	},
   104  
   105  	getInitialState: function() {
   106  		return {
   107  			currentURL: null,
   108  			dropActive: false,
   109  			isNavOpen: false,
   110  			selection: {},
   111  			thumbnailSizeIndex: 3,
   112  		};
   113  	},
   114  
   115  	render: function() {
   116  		return React.DOM.div({onDragEnter:this.handleDragStart_, onDragOver:this.handleDragStart_, onDrop:this.handleDrop_}, [
   117  			this.getNav_(),
   118  			this.getBlobItemContainer_(),
   119  			this.getDetailView_(),
   120  		]);
   121  	},
   122  
   123  	handleDragStart_: function(e) {
   124  		this.clearDragTimer_();
   125  		e.preventDefault();
   126  		this.dragEndTimer_ = window.setTimeout(this.handleDragStop_, 2000);
   127  		goog.dom.classlist.add(this.getDOMNode().parentElement, 'cam-dropactive');
   128  	},
   129  
   130  	handleDragStop_: function() {
   131  		this.clearDragTimer_();
   132  		goog.dom.classlist.remove(this.getDOMNode().parentElement, 'cam-dropactive');
   133  	},
   134  
   135  	clearDragTimer_: function() {
   136  		if (this.dragEndTimer_) {
   137  			window.clearTimeout(this.dragEndTimer_);
   138  			this.dragEndTimer_ = 0;
   139  		}
   140  	},
   141  
   142  	handleDrop_: function(e) {
   143  		if (!e.nativeEvent.dataTransfer.files) {
   144  			return;
   145  		}
   146  
   147  		e.preventDefault();
   148  
   149  		var files = e.nativeEvent.dataTransfer.files;
   150  		var numComplete = 0;
   151  		var sc = this.props.serverConnection;
   152  
   153  		console.log('Uploading %d files...', files.length);
   154  		goog.labs.Promise.all(Array.prototype.map.call(files, function(file) {
   155  			var upload = new goog.labs.Promise(sc.uploadFile.bind(sc, file));
   156  			var createPermanode = new goog.labs.Promise(sc.createPermanode.bind(sc));
   157  			return goog.labs.Promise.all([upload, createPermanode]).then(function(results) {
   158  				// TODO(aa): Icky manual destructuring of results. Seems like there must be a better way?
   159  				var fileRef = results[0];
   160  				var permanodeRef = results[1];
   161  				return new goog.labs.Promise(sc.newSetAttributeClaim.bind(sc, permanodeRef, 'camliContent', fileRef));
   162  			}).thenCatch(function(e) {
   163  				console.error('File upload fall down go boom. file: %s, error: %s', file.name, e);
   164  			}).then(function() {
   165  				console.log('%d of %d files complete.', ++numComplete, files.length);
   166  			});
   167  		})).then(function() {
   168  			console.log('All complete');
   169  		});
   170  	},
   171  
   172  	handleNavigate_: function(newURL) {
   173  		if (this.state.currentURL) {
   174  			if (this.state.currentURL.getPath() != newURL.getPath()) {
   175  				return false;
   176  			}
   177  		}
   178  
   179  		if (!this.isSearchMode_(newURL) && !this.isDetailMode_(newURL)) {
   180  			return false;
   181  		}
   182  
   183  		this.updateSearchSession_(newURL);
   184  		this.setState({currentURL: newURL});
   185  		return true;
   186  	},
   187  
   188  	updateSearchSession_: function(newURL) {
   189  		var query = newURL.getParameterValue('q');
   190  		if (!query) {
   191  			query = ' ';
   192  		}
   193  
   194  		// TODO(aa): Remove this when the server can do something like the 'raw' operator.
   195  		if (goog.string.startsWith(query, this.SEARCH_PREFIX_.RAW + ':')) {
   196  			query = JSON.parse(query.substring(this.SEARCH_PREFIX_.RAW.length + 1));
   197  		}
   198  
   199  		if (this.searchSession_ && JSON.stringify(this.searchSession_.getQuery()) == JSON.stringify(query)) {
   200  			return;
   201  		}
   202  
   203  		if (this.searchSession_) {
   204  			this.searchSession_.close();
   205  		}
   206  
   207  		this.searchSession_ = new cam.SearchSession(this.props.serverConnection, newURL.clone(), query);
   208  	},
   209  
   210  	getNav_: function() {
   211  		if (!this.isSearchMode_(this.state.currentURL)) {
   212  			return null;
   213  		}
   214  		return cam.NavReact({key:'nav', ref:'nav', timer:this.props.timer, open:this.state.isNavOpen, onOpen:this.handleNavOpen_, onClose:this.handleNavClose_}, [
   215  			cam.NavReact.SearchItem({key:'search', ref:'search', iconSrc:'magnifying_glass.svg', onSearch:this.setSearch_}, 'Search'),
   216  			this.getCreateSetWithSelectionItem_(),
   217  			cam.NavReact.Item({key:'roots', iconSrc:'icon_27307.svg', onClick:this.handleShowSearchRoots_}, 'Search roots'),
   218  			this.getSelectAsCurrentSetItem_(),
   219  			this.getAddToCurrentSetItem_(),
   220  			this.getClearSelectionItem_(),
   221  			this.getDeleteSelectionItem_(),
   222  			cam.NavReact.Item({key:'up', iconSrc:'up.svg', onClick:this.handleEmbiggen_}, 'Moar bigger'),
   223  			cam.NavReact.Item({key:'down', iconSrc:'down.svg', onClick:this.handleEnsmallen_}, 'Less bigger'),
   224  			cam.NavReact.LinkItem({key:'logo', iconSrc:'/favicon.ico', href:this.baseURL_.toString(), extraClassName:'cam-logo'}, 'Camlistore'),
   225  		]);
   226  	},
   227  
   228  	handleNavOpen_: function() {
   229  		this.setState({isNavOpen:true});
   230  	},
   231  
   232  	handleNavClose_: function() {
   233  		this.refs.search.clear();
   234  		this.refs.search.blur();
   235  		this.setState({isNavOpen:false});
   236  	},
   237  
   238  	handleNewPermanode_: function() {
   239  		this.props.serverConnection.createPermanode(this.getDetailURL_.bind(this));
   240  	},
   241  
   242  	handleShowSearchRoots_: function() {
   243  		this.setSearch_(this.SEARCH_PREFIX_.RAW + ':' + JSON.stringify({
   244  			permanode: {
   245  				attr: 'camliRoot',
   246  				numValue: {
   247  					min: 1
   248  				}
   249  			}
   250  		}));
   251  	},
   252  
   253  	handleSelectAsCurrentSet_: function() {
   254  		this.currentSet_ = goog.object.getAnyKey(this.state.selection);
   255  		this.setState({selection:{}});
   256  	},
   257  
   258  	handleAddToSet_: function() {
   259  		this.addMembersToSet_(this.currentSet_, goog.object.getKeys(this.state.selection));
   260  	},
   261  
   262  	handleCreateSetWithSelection_: function() {
   263  		var selection = goog.object.getKeys(this.state.selection);
   264  		this.props.serverConnection.createPermanode(function(permanode) {
   265  			this.props.serverConnection.newSetAttributeClaim(permanode, 'title', 'New set', function() {
   266  				this.addMembersToSet_(permanode, selection);
   267  			}.bind(this));
   268  		}.bind(this));
   269  	},
   270  
   271  	addMembersToSet_: function(permanode, blobrefs) {
   272  		var numComplete = -1;
   273  		var callback = function() {
   274  			if (++numComplete == blobrefs.length) {
   275  				this.setState({selection:{}});
   276  				this.searchSession_.refreshIfNecessary();
   277  			}
   278  		}.bind(this);
   279  
   280  		callback();
   281  
   282  		blobrefs.forEach(function(br) {
   283  			this.props.serverConnection.newAddAttributeClaim(permanode, 'camliMember', br, callback);
   284  		}.bind(this));
   285  	},
   286  
   287  	handleClearSelection_: function() {
   288  		this.setState({selection:{}});
   289  	},
   290  
   291  	handleDeleteSelection_: function() {
   292  		var blobrefs = goog.object.getKeys(this.state.selection);
   293  		var msg = 'Delete';
   294  		if (blobrefs.length > 1) {
   295  			msg += goog.string.subs(' %s items?', blobrefs.length);
   296  		} else {
   297  			msg += ' item?';
   298  		}
   299  		if (!confirm(msg)) {
   300  			return null;
   301  		}
   302  
   303  		var numDeleted = 0;
   304  		blobrefs.forEach(function(br) {
   305  			this.props.serverConnection.newDeleteClaim(br, function() {
   306  				if (++numDeleted == blobrefs.length) {
   307  					this.setState({selection:{}});
   308  					this.searchSession_.refreshIfNecessary();
   309  				}
   310  			}.bind(this));
   311  		}.bind(this));
   312  	},
   313  
   314  	handleEmbiggen_: function() {
   315  		var newSizeIndex = this.state.thumbnailSizeIndex + 1;
   316  		if (newSizeIndex < this.THUMBNAIL_SIZES_.length) {
   317  			this.setState({thumbnailSizeIndex:newSizeIndex});
   318  		}
   319  	},
   320  
   321  	handleEnsmallen_: function() {
   322  		var newSizeIndex = this.state.thumbnailSizeIndex - 1;
   323  		if (newSizeIndex >= 0) {
   324  			this.setState({thumbnailSizeIndex:newSizeIndex});
   325  		}
   326  	},
   327  
   328  	handleKeyPress_: function(e) {
   329  		if (e.target.tagName == 'INPUT' || e.target.tagName == 'TEXTAREA') {
   330  			return;
   331  		}
   332  
   333  		switch (String.fromCharCode(e.charCode)) {
   334  			case '/': {
   335  				this.refs.nav.open();
   336  				this.refs.search.focus();
   337  				e.preventDefault();
   338  				break;
   339  			}
   340  
   341  			case '|': {
   342  				window.__debugConsoleClient = {
   343  					getSelectedItems: function() {
   344  						return this.state.selection;
   345  					}.bind(this),
   346  				};
   347  				window.open('debug_console.html', 'debugconsole', 'width=400,height=300');
   348  				break;
   349  			}
   350  		}
   351  	},
   352  
   353  	handleDetailURL_: function(blobref) {
   354  		return this.getDetailURL_(blobref);
   355  	},
   356  
   357  	getDetailURL_: function(blobref) {
   358  		var detailURL = this.state.currentURL.clone();
   359  		detailURL.setParameterValue('p', blobref);
   360  		detailURL.setParameterValue('newui', '1');
   361  		return detailURL;
   362  	},
   363  
   364  	setSearch_: function(query) {
   365  		var searchURL = this.baseURL_.clone();
   366  		searchURL.setParameterValue('q', query);
   367  		this.navigator_.navigate(searchURL);
   368  	},
   369  
   370  	getSelectAsCurrentSetItem_: function() {
   371  		if (goog.object.getCount(this.state.selection) != 1) {
   372  			return null;
   373  		}
   374  
   375  		var blobref = goog.object.getAnyKey(this.state.selection);
   376  		if (this.searchSession_.getMeta(blobref).camliType != 'permanode') {
   377  			return null;
   378  		}
   379  
   380  		return cam.NavReact.Item({key:'selectascurrent', iconSrc:'target.svg', onClick:this.handleSelectAsCurrentSet_}, 'Select as current set');
   381  	},
   382  
   383  	getAddToCurrentSetItem_: function() {
   384  		if (!this.currentSet_ || !goog.object.getAnyKey(this.state.selection)) {
   385  			return null;
   386  		}
   387  		return cam.NavReact.Item({key:'addtoset', iconSrc:'icon_16716.svg', onClick:this.handleAddToSet_}, 'Add to current set');
   388  	},
   389  
   390  	getCreateSetWithSelectionItem_: function() {
   391  		var numItems = goog.object.getCount(this.state.selection);
   392  		var label = 'Create set';
   393  		if (numItems == 1) {
   394  			label += ' with item';
   395  		} else if (numItems > 1) {
   396  			label += goog.string.subs(' with %s items', numItems);
   397  		}
   398  		return cam.NavReact.Item({key:'createsetwithselection', iconSrc:'circled_plus.svg', onClick:this.handleCreateSetWithSelection_}, label);
   399  	},
   400  
   401  	getClearSelectionItem_: function() {
   402  		if (!goog.object.getAnyKey(this.state.selection)) {
   403  			return null;
   404  		}
   405  		return cam.NavReact.Item({key:'clearselection', iconSrc:'clear.svg', onClick:this.handleClearSelection_}, 'Clear selection');
   406  	},
   407  
   408  	getDeleteSelectionItem_: function() {
   409  		if (!goog.object.getAnyKey(this.state.selection)) {
   410  			return null;
   411  		}
   412  		var numItems = goog.object.getCount(this.state.selection);
   413  		var label = 'Delete';
   414  		if (numItems == 1) {
   415  			label += ' selected item';
   416  		} else if (numItems > 1) {
   417  			label += goog.string.subs(' (%s) selected items', numItems);
   418  		}
   419  		// TODO(mpl): better icon in another CL, with Font Awesome.
   420  		return cam.NavReact.Item({key:'deleteselection', iconSrc:'trash.svg', onClick:this.handleDeleteSelection_}, label);
   421  	},
   422  
   423  	handleSelectionChange_: function(newSelection) {
   424  		this.setState({selection:newSelection});
   425  	},
   426  
   427  	isSearchMode_: function(url) {
   428  		// This is super finicky. We should improve the URL scheme and give things that are different different paths.
   429  		var query = url.getQueryData();
   430  		return query.getCount() == 0 || (query.getCount() == 1 && query.containsKey('q'));
   431  	},
   432  
   433  	isDetailMode_: function(url) {
   434  		var query = url.getQueryData();
   435  		return query.containsKey('p') && query.get('newui') == '1';
   436  	},
   437  
   438  	getBlobItemContainer_: function() {
   439  		if (!this.isSearchMode_(this.state.currentURL)) {
   440  			return null;
   441  		}
   442  
   443  		return cam.BlobItemContainerReact({
   444  			key: 'blobitemcontainer',
   445  			ref: 'blobItemContainer',
   446  			detailURL: this.handleDetailURL_,
   447  			handlers: this.BLOB_ITEM_HANDLERS_,
   448  			history: this.props.history,
   449  			onSelectionChange: this.handleSelectionChange_,
   450  			searchSession: this.searchSession_,
   451  			selection: this.state.selection,
   452  			style: this.getBlobItemContainerStyle_(),
   453  			thumbnailSize: this.THUMBNAIL_SIZES_[this.state.thumbnailSizeIndex],
   454  		});
   455  	},
   456  
   457  	getBlobItemContainerStyle_: function() {
   458  		// TODO(aa): Constant values can go into CSS when we switch over to react.
   459  		var style = {
   460  			left: this.NAV_WIDTH_CLOSED_,
   461  			overflowX: 'hidden',
   462  			overflowY: 'scroll',
   463  			position: 'absolute',
   464  			top: 0,
   465  			width: this.getContentWidth_(),
   466  		};
   467  
   468  		var closedWidth = style.width;
   469  		var openWidth = closedWidth - this.NAV_WIDTH_OPEN_;
   470  		var openScale = openWidth / closedWidth;
   471  
   472  		// TODO(aa): This can move to CSS when the conversion to React is complete.
   473  		style[cam.reactUtil.getVendorProp('transformOrigin')] = 'right top 0';
   474  
   475  		// The 3d transform is important. See: https://code.google.com/p/camlistore/issues/detail?id=284.
   476  		var scale = this.state.isNavOpen ? openScale : 1;
   477  		style[cam.reactUtil.getVendorProp('transform')] = goog.string.subs('scale3d(%s, %s, 1)', scale, scale);
   478  
   479  		style.height = this.state.isNavOpen ? this.props.availHeight / scale : this.props.availHeight;
   480  
   481  		return style;
   482  	},
   483  
   484  	getDetailView_: function() {
   485  		if (!this.isDetailMode_(this.state.currentURL)) {
   486  			return null;
   487  		}
   488  
   489  		var searchURL = this.baseURL_.clone();
   490  		if (this.state.currentURL.getQueryData().containsKey('q')) {
   491  			searchURL.setParameterValue('q', this.state.currentURL.getParameterValue('q'));
   492  		}
   493  
   494  		return cam.DetailView({
   495  			key: 'detailview',
   496  			aspects: {
   497  				'image': cam.ImageDetail.getAspect,
   498  				'container': cam.ContainerDetail.getAspect.bind(null, this.handleDetailURL_, this.BLOB_ITEM_HANDLERS_, this.props.history, this.getChildSearchSession_, this.THUMBNAIL_SIZES_[this.state.thumbnailSizeIndex]),
   499  				'directory': cam.DirectoryDetail.getAspect.bind(null, this.baseURL_),
   500  				'permanode': cam.PermanodeDetail.getAspect.bind(null, this.baseURL_),
   501  				'blob': cam.BlobDetail.getAspect.bind(null, this.baseURL_),
   502  			},
   503  			blobref: this.state.currentURL.getParameterValue('p'),
   504  			history: this.props.history,
   505  			searchSession: this.searchSession_,
   506  			searchURL: searchURL,
   507  			getDetailURL: this.handleDetailURL_,
   508  			navigator: this.navigator_,
   509  			keyEventTarget: this.props.eventTarget,
   510  			width: this.props.availWidth,
   511  			height: this.props.availHeight,
   512  		});
   513  	},
   514  
   515  	getChildSearchSession_: function(blobref) {
   516  		return new cam.SearchSession(this.props.serverConnection, this.baseURL_.clone(), {
   517  			permanode: {
   518  				relation: {
   519  					relation: 'parent',
   520  					any: { blobRefPrefix: blobref }
   521  				}
   522  			}
   523  		});
   524  	},
   525  
   526  	getContentWidth_: function() {
   527  		return this.props.availWidth - this.NAV_WIDTH_CLOSED_;
   528  	},
   529  });