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 });