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