diff options
18 files changed, 473 insertions, 335 deletions
diff --git a/api/file.go b/api/file.go index 3ef50fbbd..219cf6103 100644 --- a/api/file.go +++ b/api/file.go @@ -33,7 +33,7 @@ func InitFile(r *mux.Router) { sr := r.PathPrefix("/files").Subrouter() sr.Handle("/upload", ApiUserRequired(uploadFile)).Methods("POST") - sr.Handle("/get/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiAppHandler(getFile)).Methods("GET") + sr.Handle("/get/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiAppHandler(getFile)).Methods("GET", "HEAD") sr.Handle("/get_public_link", ApiUserRequired(getPublicLink)).Methods("POST") } @@ -261,7 +261,10 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "max-age=2592000, public") w.Header().Set("Content-Length", strconv.Itoa(len(f))) - w.Write(f) + + if r.Method != "HEAD" { + w.Write(f) + } } func asyncGetFile(path string, fileData chan []byte) { diff --git a/web/react/components/file_attachment.jsx b/web/react/components/file_attachment.jsx new file mode 100644 index 000000000..3cd791887 --- /dev/null +++ b/web/react/components/file_attachment.jsx @@ -0,0 +1,121 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var utils = require('../utils/utils.jsx'); + +module.exports = React.createClass({ + displayName: "FileAttachment", + canSetState: false, + propTypes: { + // a list of file pathes displayed by the parent FileAttachmentList + filenames: React.PropTypes.arrayOf(React.PropTypes.string).isRequired, + // the index of this attachment preview in the parent FileAttachmentList + index: React.PropTypes.number.isRequired, + // the identifier of the modal dialog used to preview files + modalId: React.PropTypes.string.isRequired, + // handler for when the thumbnail is clicked + handleImageClick: React.PropTypes.func + }, + getInitialState: function() { + return {fileSize: -1}; + }, + componentDidMount: function() { + this.canSetState = true; + + var filename = this.props.filenames[this.props.index]; + + if (filename) { + var fileInfo = utils.splitFileLocation(filename); + var type = utils.getFileType(fileInfo.ext); + + // This is a temporary patch to fix issue with old files using absolute paths + if (fileInfo.path.indexOf("/api/v1/files/get") != -1) { + fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; + } + fileInfo.path = utils.getWindowLocationOrigin() + "/api/v1/files/get" + fileInfo.path; + + if (type === "image") { + var self = this; + $('<img/>').attr('src', fileInfo.path+'_thumb.jpg').load(function(path, name){ return function() { + $(this).remove(); + if (name in self.refs) { + var imgDiv = self.refs[name].getDOMNode(); + + $(imgDiv).removeClass('post__load'); + $(imgDiv).addClass('post__image'); + + var re1 = new RegExp(' ', 'g'); + var re2 = new RegExp('\\(', 'g'); + var re3 = new RegExp('\\)', 'g'); + var url = path.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29'); + $(imgDiv).css('background-image', 'url('+url+'_thumb.jpg)'); + } + }}(fileInfo.path, filename)); + } + } + }, + componentWillUnmount: function() { + // keep track of when this component is mounted so that we can asynchronously change state without worrying about whether or not we're mounted + this.canSetState = false; + }, + shouldComponentUpdate: function(nextProps, nextState) { + // the only time this object should update is when it receives an updated file size which we can usually handle without re-rendering + if (nextState.fileSize != this.state.fileSize) { + if (this.refs.fileSize) { + // update the UI element to display the file size without re-rendering the whole component + this.refs.fileSize.getDOMNode().innerHTML = utils.fileSizeToString(nextState.fileSize); + + return false; + } else { + // we can't find the element that should hold the file size so we must not have rendered yet + return true; + } + } else { + return true; + } + }, + render: function() { + var filenames = this.props.filenames; + var filename = filenames[this.props.index]; + + var fileInfo = utils.splitFileLocation(filename); + var type = utils.getFileType(fileInfo.ext); + + var thumbnail; + if (type === "image") { + thumbnail = <div ref={filename} className="post__load" style={{backgroundImage: 'url(/static/images/load.gif)'}}/>; + } else { + thumbnail = <div className={"file-icon "+utils.getIconClassName(type)}/>; + } + + var fileSizeString = ""; + if (this.state.fileSize < 0) { + var self = this; + + // asynchronously request the size of the file so that we can display it next to the thumbnail + utils.getFileSize(utils.getFileUrl(filename), function(fileSize) { + if (self.canSetState) { + self.setState({fileSize: fileSize}); + } + }); + } else { + fileSizeString = utils.fileSizeToString(this.state.fileSize); + } + + return ( + <div className="post-image__column" key={filename}> + <a className="post-image__thumbnail" href="#" onClick={this.props.handleImageClick} + data-img-id={this.props.index} data-toggle="modal" data-target={"#" + this.props.modalId }> + {thumbnail} + </a> + <div className="post-image__details"> + <div className="post-image__name">{decodeURIComponent(utils.getFileName(filename))}</div> + <div> + <span className="post-image__type">{fileInfo.ext.toUpperCase()}</span> + <span className="post-image__size">{fileSizeString}</span> + </div> + </div> + </div> + ); + } +}); diff --git a/web/react/components/file_attachment_list.jsx b/web/react/components/file_attachment_list.jsx new file mode 100644 index 000000000..b92442957 --- /dev/null +++ b/web/react/components/file_attachment_list.jsx @@ -0,0 +1,49 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var ViewImageModal = require('./view_image.jsx'); +var FileAttachment = require('./file_attachment.jsx'); +var Constants = require('../utils/constants.jsx'); + +module.exports = React.createClass({ + displayName: "FileAttachmentList", + propTypes: { + // a list of file pathes displayed by this + filenames: React.PropTypes.arrayOf(React.PropTypes.string).isRequired, + // the identifier of the modal dialog used to preview files + modalId: React.PropTypes.string.isRequired, + // the channel that this is part of + channelId: React.PropTypes.string, + // the user that owns the post that this is attached to + userId: React.PropTypes.string + }, + getInitialState: function() { + return {startImgId: 0}; + }, + render: function() { + var filenames = this.props.filenames; + var modalId = this.props.modalId; + + var postFiles = []; + for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) { + postFiles.push(<FileAttachment key={i} filenames={filenames} index={i} modalId={modalId} handleImageClick={this.handleImageClick} />); + } + + return ( + <div> + <div className="post-image__columns"> + {postFiles} + </div> + <ViewImageModal + channelId={this.props.channelId} + userId={this.props.userId} + modalId={modalId} + startId={this.state.startImgId} + filenames={filenames} /> + </div> + ); + }, + handleImageClick: function(e) { + this.setState({startImgId: parseInt($(e.target.parentNode).attr('data-img-id'))}); + } +}); diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index 641ffeef2..860c96d84 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -1,63 +1,23 @@ // Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. // See License.txt for license information. -var CreateComment = require( './create_comment.jsx' ); +var FileAttachmentList = require('./file_attachment_list.jsx'); var UserStore = require('../stores/user_store.jsx'); var utils = require('../utils/utils.jsx'); -var ViewImageModal = require('./view_image.jsx'); -var Constants = require('../utils/constants.jsx'); module.exports = React.createClass({ - handleImageClick: function(e) { - this.setState({startImgId: parseInt($(e.target.parentNode).attr('data-img-id'))}); - }, componentWillReceiveProps: function(nextProps) { var linkData = utils.extractLinks(nextProps.post.message); this.setState({ links: linkData["links"], message: linkData["text"] }); }, - componentDidMount: function() { - var filenames = this.props.post.filenames; - var self = this; - if (filenames) { - var re1 = new RegExp(' ', 'g'); - var re2 = new RegExp('\\(', 'g'); - var re3 = new RegExp('\\)', 'g'); - for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) { - var fileInfo = utils.splitFileLocation(filenames[i]); - if (Object.keys(fileInfo).length === 0) continue; - - var type = utils.getFileType(fileInfo.ext); - - // This is a temporary patch to fix issue with old files using absolute paths - if (fileInfo.path.indexOf("/api/v1/files/get") != -1) { - fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; - } - fileInfo.path = utils.getWindowLocationOrigin() + "/api/v1/files/get" + fileInfo.path; - - if (type === "image") { - $('<img/>').attr('src', fileInfo.path+'_thumb.jpg').load(function(path, name){ return function() { - $(this).remove(); - if (name in self.refs) { - var imgDiv = self.refs[name].getDOMNode(); - $(imgDiv).removeClass('post__load'); - $(imgDiv).addClass('post__image'); - var url = path.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29'); - $(imgDiv).css('background-image', 'url('+url+'_thumb.jpg)'); - } - }}(fileInfo.path, filenames[i])); - } - } - } - }, getInitialState: function() { var linkData = utils.extractLinks(this.props.post.message); - return { startImgId: 0, links: linkData["links"], message: linkData["text"] }; + return { links: linkData["links"], message: linkData["text"] }; }, render: function() { var post = this.props.post; var filenames = this.props.post.filenames; var parentPost = this.props.parentPost; - var postImageModalId = "view_image_modal_" + post.id; var inner = utils.textToJsx(this.state.message); var comment = ""; @@ -99,44 +59,8 @@ module.exports = React.createClass({ postClass += " post-comment"; } - var postFiles = []; - var images = []; - if (filenames) { - for (var i = 0; i < filenames.length; i++) { - var fileInfo = utils.splitFileLocation(filenames[i]); - if (Object.keys(fileInfo).length === 0) continue; - - var type = utils.getFileType(fileInfo.ext); - - // This is a temporary patch to fix issue with old files using absolute paths - if (fileInfo.path.indexOf("/api/v1/files/get") != -1) { - fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; - } - fileInfo.path = utils.getWindowLocationOrigin() + "/api/v1/files/get" + fileInfo.path; - - if (type === "image") { - if (i < Constants.MAX_DISPLAY_FILES) { - postFiles.push( - <div className="post-image__column" key={filenames[i]}> - <a href="#" onClick={this.handleImageClick} data-img-id={images.length.toString()} data-toggle="modal" data-target={"#" + postImageModalId }><div ref={filenames[i]} className="post__load" style={{backgroundImage: 'url(/static/images/load.gif)'}}></div></a> - </div> - ); - } - images.push(filenames[i]); - } else if (i < Constants.MAX_DISPLAY_FILES) { - postFiles.push( - <div className="post-image__column custom-file" key={fileInfo.name+i}> - <a href={fileInfo.path + (fileInfo.ext ? "." + fileInfo.ext : "")} download={fileInfo.name + (fileInfo.ext ? "." + fileInfo.ext : "")}> - <div className={"file-icon "+utils.getIconClassName(type)}/> - </a> - </div> - ); - } - } - } - var embed; - if (postFiles.length === 0 && this.state.links) { + if (filenames.length === 0 && this.state.links) { embed = utils.getEmbed(this.state.links[0]); } @@ -145,21 +69,13 @@ module.exports = React.createClass({ { comment } <p key={post.id+"_message"} className={postClass}><span>{inner}</span></p> { filenames && filenames.length > 0 ? - <div className="post-image__columns"> - { postFiles } - </div> - : "" } - { embed } - - { images.length > 0 ? - <ViewImageModal + <FileAttachmentList + filenames={filenames} + modalId={"view_image_modal_" + post.id} channelId={post.channel_id} - userId={post.user_id} - modalId={postImageModalId} - startId={this.state.startImgId} - imgCount={post.img_count} - filenames={images} /> + userId={post.user_id} /> : "" } + { embed } </div> ); } diff --git a/web/react/components/post_right.jsx b/web/react/components/post_right.jsx index 8097a181e..ad8b54012 100644 --- a/web/react/components/post_right.jsx +++ b/web/react/components/post_right.jsx @@ -10,7 +10,7 @@ var utils = require('../utils/utils.jsx'); var SearchBox =require('./search_bar.jsx'); var CreateComment = require( './create_comment.jsx' ); var Constants = require('../utils/constants.jsx'); -var ViewImageModal = require('./view_image.jsx'); +var FileAttachmentList = require('./file_attachment_list.jsx'); var ActionTypes = Constants.ActionTypes; RhsHeaderPost = React.createClass({ @@ -55,28 +55,20 @@ RhsHeaderPost = React.createClass({ }); RootPost = React.createClass({ - handleImageClick: function(e) { - this.setState({startImgId: parseInt($(e.target.parentNode).attr('data-img-id'))}); - }, - getInitialState: function() { - return { startImgId: 0 }; - }, render: function() { - - var postImageModalId = "rhs_view_image_modal_" + this.props.post.id; - var message = utils.textToJsx(this.props.post.message); - var filenames = this.props.post.filenames; - var isOwner = UserStore.getCurrentId() == this.props.post.user_id; - var timestamp = UserStore.getProfile(this.props.post.user_id).update_at; - var channel = ChannelStore.get(this.props.post.channel_id); + var post = this.props.post; + var message = utils.textToJsx(post.message); + var isOwner = UserStore.getCurrentId() == post.user_id; + var timestamp = UserStore.getProfile(post.user_id).update_at; + var channel = ChannelStore.get(post.channel_id); var type = "Post"; - if (this.props.post.root_id.length > 0) { + if (post.root_id.length > 0) { type = "Comment"; } var currentUserCss = ""; - if (UserStore.getCurrentId() === this.props.post.user_id) { + if (UserStore.getCurrentId() === post.user_id) { currentUserCss = "current--user"; } @@ -84,60 +76,24 @@ RootPost = React.createClass({ channelName = (channel.type === 'D') ? "Private Message" : channel.display_name; } - if (filenames) { - var postFiles = []; - var images = []; - var re1 = new RegExp(' ', 'g'); - var re2 = new RegExp('\\(', 'g'); - var re3 = new RegExp('\\)', 'g'); - for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) { - var fileInfo = utils.splitFileLocation(filenames[i]); - var ftype = utils.getFileType(fileInfo.ext); - - // This is a temporary patch to fix issue with old files using absolute paths - if (fileInfo.path.indexOf("/api/v1/files/get") != -1) { - fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; - } - fileInfo.path = utils.getWindowLocationOrigin() + "/api/v1/files/get" + fileInfo.path; - - if (ftype === "image") { - var url = fileInfo.path.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29'); - postFiles.push( - <div className="post-image__column" key={fileInfo.path}> - <a href="#" onClick={this.handleImageClick} data-img-id={images.length.toString()} data-toggle="modal" data-target={"#" + postImageModalId }><div ref={fileInfo.path} className="post__image" style={{backgroundImage: 'url(' + url + '_thumb.jpg)'}}></div></a> - </div> - ); - images.push(filenames[i]); - } else { - postFiles.push( - <div className="post-image__column custom-file" key={fileInfo.path}> - <a href={fileInfo.path+"."+fileInfo.ext} download={fileInfo.name+"."+fileInfo.ext}> - <div className={"file-icon "+utils.getIconClassName(ftype)}/> - </a> - </div> - ); - } - } - } - return ( <div className={"post post--root " + currentUserCss}> <div className="post-right-channel__name">{ channelName }</div> <div className="post-profile-img__container"> - <img className="post-profile-img" src={"/api/v1/users/" + this.props.post.user_id + "/image?time=" + timestamp} height="36" width="36" /> + <img className="post-profile-img" src={"/api/v1/users/" + post.user_id + "/image?time=" + timestamp} height="36" width="36" /> </div> <div className="post__content"> <ul className="post-header"> - <li className="post-header-col"><strong><UserProfile userId={this.props.post.user_id} /></strong></li> - <li className="post-header-col"><time className="post-right-root-time">{ utils.displayDate(this.props.post.create_at)+' '+utils.displayTime(this.props.post.create_at) }</time></li> + <li className="post-header-col"><strong><UserProfile userId={post.user_id} /></strong></li> + <li className="post-header-col"><time className="post-right-root-time">{ utils.displayDate(post.create_at)+' '+utils.displayTime(post.create_at) }</time></li> <li className="post-header-col post-header__reply"> <div className="dropdown"> { isOwner ? <div> <a href="#" className="dropdown-toggle theme" type="button" data-toggle="dropdown" aria-expanded="false" /> <ul className="dropdown-menu" role="menu"> - <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#edit_post" data-title={type} data-message={this.props.post.message} data-postid={this.props.post.id} data-channelid={this.props.post.channel_id}>Edit</a></li> - <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#delete_post" data-title={type} data-postid={this.props.post.id} data-channelid={this.props.post.channel_id} data-comments={this.props.commentCount}>Delete</a></li> + <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#edit_post" data-title={type} data-message={post.message} data-postid={post.id} data-channelid={post.channel_id}>Edit</a></li> + <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#delete_post" data-title={type} data-postid={post.id} data-channelid={post.channel_id} data-comments={this.props.commentCount}>Delete</a></li> </ul> </div> : "" } @@ -146,19 +102,12 @@ RootPost = React.createClass({ </ul> <div className="post-body"> <p>{message}</p> - { filenames.length > 0 ? - <div className="post-image__columns"> - { postFiles } - </div> - : "" } - { images.length > 0 ? - <ViewImageModal - channelId={this.props.post.channel_id} - userId={this.props.post.user_id} - modalId={postImageModalId} - startId={this.state.startImgId} - imgCount={this.props.post.img_count} - filenames={images} /> + { post.filenames && post.filenames.length > 0 ? + <FileAttachmentList + filenames={post.filenames} + modalId={"rhs_view_image_modal_" + post.id} + channelId={post.channel_id} + userId={post.user_id} /> : "" } </div> </div> @@ -169,86 +118,42 @@ RootPost = React.createClass({ }); CommentPost = React.createClass({ - handleImageClick: function(e) { - this.setState({startImgId: parseInt($(e.target.parentNode).attr('data-img-id'))}); - }, - getInitialState: function() { - return { startImgId: 0 }; - }, render: function() { + var post = this.props.post; var commentClass = "post"; var currentUserCss = ""; - if (UserStore.getCurrentId() === this.props.post.user_id) { + if (UserStore.getCurrentId() === post.user_id) { currentUserCss = "current--user"; } - var postImageModalId = "rhs_comment_view_image_modal_" + this.props.post.id; - var filenames = this.props.post.filenames; - var isOwner = UserStore.getCurrentId() == this.props.post.user_id; + var isOwner = UserStore.getCurrentId() == post.user_id; var type = "Post" - if (this.props.post.root_id.length > 0) { + if (post.root_id.length > 0) { type = "Comment" } - if (filenames) { - var postFiles = []; - var images = []; - var re1 = new RegExp(' ', 'g'); - var re2 = new RegExp('\\(', 'g'); - var re3 = new RegExp('\\)', 'g'); - for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) { - - var fileInfo = utils.splitFileLocation(filenames[i]); - var type = utils.getFileType(fileInfo.ext); - - // This is a temporary patch to fix issue with old files using absolute paths - if (fileInfo.path.indexOf("/api/v1/files/get") != -1) { - fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; - } - fileInfo.path = utils.getWindowLocationOrigin() + "/api/v1/files/get" + fileInfo.path; - - if (type === "image") { - var url = fileInfo.path.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29'); - postFiles.push( - <div className="post-image__column" key={fileInfo.path}> - <a href="#" onClick={this.handleImageClick} data-img-id={images.length.toString()} data-toggle="modal" data-target={"#" + postImageModalId }><div ref={fileInfo.path} className="post__image" style={{backgroundImage: 'url(' + url + '_thumb.jpg)'}}></div></a> - </div> - ); - images.push(filenames[i]); - } else { - postFiles.push( - <div className="post-image__column custom-file" key={fileInfo.path}> - <a href={fileInfo.path+"."+fileInfo.ext} download={fileInfo.name+"."+fileInfo.ext}> - <div className={"file-icon "+utils.getIconClassName(type)}/> - </a> - </div> - ); - } - } - } - - var message = utils.textToJsx(this.props.post.message); + var message = utils.textToJsx(post.message); var timestamp = UserStore.getCurrentUser().update_at; return ( <div className={commentClass + " " + currentUserCss}> <div className="post-profile-img__container"> - <img className="post-profile-img" src={"/api/v1/users/" + this.props.post.user_id + "/image?time=" + timestamp} height="36" width="36" /> + <img className="post-profile-img" src={"/api/v1/users/" + post.user_id + "/image?time=" + timestamp} height="36" width="36" /> </div> <div className="post__content"> <ul className="post-header"> - <li className="post-header-col"><strong><UserProfile userId={this.props.post.user_id} /></strong></li> - <li className="post-header-col"><time className="post-right-comment-time">{ utils.displayDateTime(this.props.post.create_at) }</time></li> + <li className="post-header-col"><strong><UserProfile userId={post.user_id} /></strong></li> + <li className="post-header-col"><time className="post-right-comment-time">{ utils.displayDateTime(post.create_at) }</time></li> <li className="post-header-col post-header__reply"> { isOwner ? <div className="dropdown" onClick={function(e){$('.post-list-holder-by-time').scrollTop($(".post-list-holder-by-time").scrollTop() + 50);}}> <a href="#" className="dropdown-toggle theme" type="button" data-toggle="dropdown" aria-expanded="false" /> <ul className="dropdown-menu" role="menu"> - <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#edit_post" data-title={type} data-message={this.props.post.message} data-postid={this.props.post.id} data-channelid={this.props.post.channel_id}>Edit</a></li> - <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#delete_post" data-title={type} data-postid={this.props.post.id} data-channelid={this.props.post.channel_id} data-comments={0}>Delete</a></li> + <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#edit_post" data-title={type} data-message={post.message} data-postid={post.id} data-channelid={post.channel_id}>Edit</a></li> + <li role="presentation"><a href="#" role="menuitem" data-toggle="modal" data-target="#delete_post" data-title={type} data-postid={post.id} data-channelid={post.channel_id} data-comments={0}>Delete</a></li> </ul> </div> : "" } @@ -256,19 +161,12 @@ CommentPost = React.createClass({ </ul> <div className="post-body"> <p>{message}</p> - { filenames.length > 0 ? - <div className="post-image__columns"> - { postFiles } - </div> - : "" } - { images.length > 0 ? - <ViewImageModal - channelId={this.props.post.channel_id} - userId={this.props.post.user_id} - modalId={postImageModalId} - startId={this.state.startImgId} - imgCount={this.props.post.img_count} - filenames={images} /> + { post.filenames && post.filenames.length > 0 ? + <FileAttachmentList + filenames={post.filenames} + modalId={"rhs_comment_view_image_modal_" + post.id} + channelId={post.channel_id} + userId={post.user_id} /> : "" } </div> </div> diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx index 7b096c629..4b2f8f650 100644 --- a/web/react/components/view_image.jsx +++ b/web/react/components/view_image.jsx @@ -5,6 +5,8 @@ var Client = require('../utils/client.jsx'); var utils = require('../utils/utils.jsx'); module.exports = React.createClass({ + displayName: "ViewImageModal", + canSetState: false, handleNext: function() { var id = this.state.imgId + 1; if (id > this.props.filenames.length-1) { @@ -31,42 +33,41 @@ module.exports = React.createClass({ return; }; - var src = ""; - if (this.props.imgCount > 0) { - src = this.props.filenames[id]; + var filename = this.props.filenames[id]; + + var fileInfo = utils.splitFileLocation(filename); + var fileType = utils.getFileType(fileInfo.ext); + + if (fileType === "image") { + var self = this; + var img = new Image(); + img.load(this.getPreviewImagePath(filename), + function(){ + var progress = self.state.progress; + progress[id] = img.completedPercentage; + self.setState({ progress: progress }); + }); + img.onload = function(imgid) { + return function() { + var loaded = self.state.loaded; + loaded[imgid] = true; + self.setState({ loaded: loaded }); + $(self.refs.image.getDOMNode()).css("max-height",imgHeight); + }; + }(id); + var images = this.state.images; + images[id] = img; + this.setState({ images: images }); } else { - var fileInfo = utils.splitFileLocation(this.props.filenames[id]); - // This is a temporary patch to fix issue with old files using absolute paths - if (fileInfo.path.indexOf("/api/v1/files/get") !== -1) { - fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; - } - fileInfo.path = utils.getWindowLocationOrigin() + "/api/v1/files/get" + fileInfo.path; - src = fileInfo['path'] + '_preview.jpg'; + // there's nothing to load for non-image files + var loaded = this.state.loaded; + loaded[id] = true; + this.setState({ loaded: loaded }); } - - var self = this; - var img = new Image(); - img.load(src, - function(){ - var progress = self.state.progress; - progress[id] = img.completedPercentage; - self.setState({ progress: progress }); - }); - img.onload = function(imgid) { - return function() { - var loaded = self.state.loaded; - loaded[imgid] = true; - self.setState({ loaded: loaded }); - $(self.refs.image.getDOMNode()).css("max-height",imgHeight); - }; - }(id); - var images = this.state.images; - images[id] = img; - this.setState({ images: images }); }, componentDidUpdate: function() { - if (this.refs.image) { - if (this.state.loaded[this.state.imgId]) { + if (this.state.loaded[this.state.imgId]) { + if (this.refs.imageWrap) { $(this.refs.imageWrap.getDOMNode()).removeClass("default"); } } @@ -91,6 +92,12 @@ module.exports = React.createClass({ $(self.refs.imageFooter.getDOMNode()).removeClass("footer--show"); } ); + + // keep track of whether or not this component is mounted so we can safely set the state asynchronously + this.canSetState = true; + }, + componentWillUnmount: function() { + this.canSetState = false; }, getPublicLink: function(e) { data = {}; @@ -112,62 +119,78 @@ module.exports = React.createClass({ loaded.push(false); progress.push(0); } - return { imgId: this.props.startId, viewed: false, loaded: loaded, progress: progress, images: {} }; + return { imgId: this.props.startId, viewed: false, loaded: loaded, progress: progress, images: {}, fileSizes: {} }; }, render: function() { if (this.props.filenames.length < 1 || this.props.filenames.length-1 < this.state.imgId) { return <div/>; } - var fileInfo = utils.splitFileLocation(this.props.filenames[this.state.imgId]); + var filename = this.props.filenames[this.state.imgId]; + var fileUrl = utils.getFileUrl(filename); - var name = fileInfo['name'] + '.' + fileInfo['ext']; + var name = decodeURIComponent(utils.getFileName(filename)); - var loading = ""; + var content; var bgClass = ""; - var img = {}; - if (!this.state.loaded[this.state.imgId]) { + if (this.state.loaded[this.state.imgId]) { + var fileInfo = utils.splitFileLocation(filename); + var fileType = utils.getFileType(fileInfo.ext); + + if (fileType === "image") { + // image files just show a preview of the file + content = ( + <a href={fileUrl} target="_blank"> + <img ref="image" src={this.getPreviewImagePath(filename)}/> + </a> + ); + } else { + // non-image files include a section providing details about the file + var infoString = "File type " + fileInfo.ext.toUpperCase(); + if (this.state.fileSizes[filename] && this.state.fileSizes[filename] >= 0) { + infoString += ", Size " + utils.fileSizeToString(this.state.fileSizes[filename]); + } + + content = ( + <div className="file-details__container"> + <a className={"file-details__preview"} href={fileUrl} target="_blank"> + <span className="file-details__preview-helper" /> + <img ref="image" src={this.getPreviewImagePath(filename)} /> + </a> + <div className="file-details"> + <div className="file-details__name">{name}</div> + <div className="file-details__info">{infoString}</div> + </div> + </div> + ); + + // asynchronously request the actual size of this file + if (!(filename in this.state.fileSizes)) { + var self = this; + + utils.getFileSize(utils.getFileUrl(filename), function(fileSize) { + if (self.canSetState) { + var fileSizes = self.state.fileSizes; + fileSizes[filename] = fileSize; + self.setState(fileSizes); + } + }); + } + } + } else { + // display a progress indicator when the preview for an image is still loading var percentage = Math.floor(this.state.progress[this.state.imgId]); - loading = ( - <div key={name+"_loading"}> - <img ref="placeholder" className="loader-image" src="/static/images/load.gif" /> + content = ( + <div> + <img className="loader-image" src="/static/images/load.gif" /> { percentage > 0 ? <span className="loader-percent" >{"Previewing " + percentage + "%"}</span> : ""} </div> ); bgClass = "black-bg"; - } else if (this.state.viewed) { - for (var id in this.state.images) { - var info = utils.splitFileLocation(this.props.filenames[id]); - var preview_filename = ""; - if (this.props.imgCount > 0) { - preview_filename = this.props.filenames[this.state.imgId]; - } else { - // This is a temporary patch to fix issue with old files using absolute paths - if (info.path.indexOf("/api/v1/files/get") !== -1) { - info.path = info.path.split("/api/v1/files/get")[1]; - } - info.path = utils.getWindowLocationOrigin() + "/api/v1/files/get" + info.path; - preview_filename = info['path'] + '_preview.jpg'; - } - - var imgClass = "hidden"; - if (this.state.loaded[id] && this.state.imgId == id) imgClass = ""; - - img[info['path']] = <a key={info['path']} className={imgClass} href={info.path+"."+info.ext} target="_blank"><img ref="image" src={preview_filename}/></a>; - } } - var imgFragment = React.addons.createFragment(img); - - // This is a temporary patch to fix issue with old files using absolute paths - var download_link = this.props.filenames[this.state.imgId]; - if (download_link.indexOf("/api/v1/files/get") !== -1) { - download_link = download_link.split("/api/v1/files/get")[1]; - } - download_link = utils.getWindowLocationOrigin() + "/api/v1/files/get" + download_link; - return ( <div className="modal fade image_modal" ref="modal" id={this.props.modalId} tabIndex="-1" role="dialog" aria-hidden="true"> <div className="modal-dialog modal-image"> @@ -175,7 +198,7 @@ module.exports = React.createClass({ <div ref="imageBody" className="modal-body image-body"> <div ref="imageWrap" className={"image-wrapper default " + bgClass}> <div className="modal-close" data-dismiss="modal"></div> - {imgFragment} + {content} <div ref="imageFooter" className="modal-button-bar"> <span className="pull-left text">{"Image "+(this.state.imgId+1)+" of "+this.props.filenames.length}</span> <div className="image-links"> @@ -185,10 +208,9 @@ module.exports = React.createClass({ <span className="text"> | </span> </div> : "" } - <a href={download_link} download={decodeURIComponent(name)} className="text">Download</a> + <a href={fileUrl} download={name} className="text">Download</a> </div> </div> - {loading} </div> { this.props.filenames.length > 1 ? <a className="modal-prev-bar" href="#" onClick={this.handlePrev}> @@ -205,5 +227,23 @@ module.exports = React.createClass({ </div> </div> ); + }, + // Returns the path to a preview image that can be used to represent a file. + getPreviewImagePath: function(filename) { + var fileInfo = utils.splitFileLocation(filename); + var fileType = utils.getFileType(fileInfo.ext); + + if (fileType === "image") { + // This is a temporary patch to fix issue with old files using absolute paths + if (fileInfo.path.indexOf("/api/v1/files/get") !== -1) { + fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1]; + } + fileInfo.path = utils.getWindowLocationOrigin() + "/api/v1/files/get" + fileInfo.path; + + return fileInfo.path + '_preview.jpg'; + } else { + // only images have proper previews, so just use a placeholder icon for non-images + return utils.getPreviewImagePathForFileType(fileType); + } } }); diff --git a/web/react/utils/utils.jsx b/web/react/utils/utils.jsx index 9a663a936..e51f7f3f4 100644 --- a/web/react/utils/utils.jsx +++ b/web/react/utils/utils.jsx @@ -533,6 +533,19 @@ module.exports.getFileType = function(ext) { return "other"; }; +module.exports.getPreviewImagePathForFileType = function(fileType) { + fileType = fileType.toLowerCase(); + + var icon; + if (fileType in Constants.ICON_FROM_TYPE) { + icon = Constants.ICON_FROM_TYPE[fileType]; + } else { + icon = Constants.ICON_FROM_TYPE["other"]; + } + + return "/static/images/icons/" + icon + ".png"; +}; + module.exports.getIconClassName = function(fileType) { fileType = fileType.toLowerCase(); @@ -557,6 +570,23 @@ module.exports.splitFileLocation = function(fileLocation) { return {'ext': ext, 'name': filename, 'path': filePath}; } +// Asynchronously gets the size of a file by requesting its headers. If successful, it calls the +// provided callback with the file size in bytes as the argument. +module.exports.getFileSize = function(url, callback) { + var request = new XMLHttpRequest(); + + request.open('HEAD', url, true); + request.onreadystatechange = function() { + if (request.readyState == 4 && request.status == 200) { + if (callback) { + callback(parseInt(request.getResponseHeader("content-length"))); + } + } + }; + + request.send(); +}; + module.exports.toTitleCase = function(str) { return str.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();}); } @@ -847,4 +877,39 @@ module.exports.getWindowLocationOrigin = function() { windowLocationOrigin = window.location.protocol + "//" + window.location.hostname + (window.location.port ? ':' + window.location.port: ''); } return windowLocationOrigin; +} + +// Converts a file size in bytes into a human-readable string of the form "123MB". +module.exports.fileSizeToString = function(bytes) { + // it's unlikely that we'll have files bigger than this + if (bytes > 1024 * 1024 * 1024 * 1024) { + return Math.floor(bytes / (1024 * 1024 * 1024 * 1024)) + "TB"; + } else if (bytes > 1024 * 1024 * 1024) { + return Math.floor(bytes / (1024 * 1024 * 1024)) + "GB"; + } else if (bytes > 1024 * 1024) { + return Math.floor(bytes / (1024 * 1024)) + "MB"; + } else if (bytes > 1024) { + return Math.floor(bytes / 1024) + "KB"; + } else { + return bytes + "B"; + } +}; + +// Converts a filename (like those attached to Post objects) to a url that can be used to retrieve attachments from the server. +module.exports.getFileUrl = function(filename) { + var url = filename; + + // This is a temporary patch to fix issue with old files using absolute paths + if (url.indexOf("/api/v1/files/get") != -1) { + url = filename.split("/api/v1/files/get")[1]; + } + url = module.exports.getWindowLocationOrigin() + "/api/v1/files/get" + url; + + return url; +}; + +// Gets the name of a file (including extension) from a given url or file path. +module.exports.getFileName = function(path) { + var split = path.split('/'); + return split[split.length - 1]; }; diff --git a/web/sass-files/sass/partials/_files.scss b/web/sass-files/sass/partials/_files.scss index 56d03e171..ea7548267 100644 --- a/web/sass-files/sass/partials/_files.scss +++ b/web/sass-files/sass/partials/_files.scss @@ -14,10 +14,6 @@ position: relative; border: 1px solid #DDD; @include clearfix; - &.custom-file { - width: 85px; - height: 100px; - } &:hover .remove-preview:after { @include opacity(1); } @@ -71,60 +67,56 @@ width:300px; height:300px; } + +@mixin file-icon($path) { + background: #fff url($path); + background-position: center; + background-repeat: no-repeat; + @include background-size(60px auto); +} .file-icon { width: 100%; height: 100%; &.audio { - background: url("../images/icons/audio.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/audio.png"); } &.video { - background: url("../images/icons/video.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/video.png"); } &.ppt { - background: url("../images/icons/ppt.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/ppt.png"); } &.generic { - background: url("../images/icons/generic.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/generic.png"); } &.code { - background: url("../images/icons/code.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/code.png"); } &.excel { - background: url("../images/icons/excel.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/excel.png"); } &.word { - background: url("../images/icons/word.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/word.png"); } &.pdf { - background: url("../images/icons/pdf.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/pdf.png"); } &.patch { - background: url("../images/icons/patch.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/patch.png"); } &.image { - background: url("../images/icons/image.png"); - @include background-size(100% 100%); + @include file-icon("../images/icons/image.png"); } } .post-image__column { position: relative; - width: 120px; + width: 240px; height: 100px; float: left; margin: 5px 10px 5px 0; - &.custom-file { - width: 85px; - height: 100px; - } + @include display-flex; + display: -ms-flexbox; + border: 1px solid lightgrey; .post__load { height: 100%; width: 100%; @@ -133,15 +125,69 @@ background-position: center; } .post__image { - height: 100%; width: 100%; - border: 1px solid #E2E2E2; + height: 100%; background-color: #FFF; background-repeat: no-repeat; background-position: top left; } + .post-image__thumbnail { + width: 50%; + height: 100%; + } + .post-image__details { + width: 50%; + height: 100%; + background: white; + border-left: 1px solid #ddd; + font-size: 13px; + padding: 7px; + .post-image__name { + margin-bottom: 3px; + } + .post-image__type { + color: grey; + } + .post-image__size { + margin-left: 4px; + color: grey; + } + } a { text-decoration: none; color: grey; } } + +.file-details__container { + @include display-flex; + display: -ms-flexbox; + + .file-details { + width: 320px; + height: 270px; + padding: 14px; + text-align: left; + vertical-align: top; + + .file-details__name { + font-size: 16px; + } + .file-details__info { + color: grey; + } + } + .file-details__preview { + width: 320px; + height: 270px; + border-right: 1px solid #ddd; + vertical-align: center; + + // helper to center the image icon in the preview window + .file-details__preview-helper { + height: 100%; + display: inline-block; + vertical-align: middle; + } + } +} diff --git a/web/static/images/icons/audio.png b/web/static/images/icons/audio.png Binary files differindex 2b6d37f8d..bd25b7f84 100644 --- a/web/static/images/icons/audio.png +++ b/web/static/images/icons/audio.png diff --git a/web/static/images/icons/code.png b/web/static/images/icons/code.png Binary files differindex 80db302ee..c59e4b8dc 100644 --- a/web/static/images/icons/code.png +++ b/web/static/images/icons/code.png diff --git a/web/static/images/icons/excel.png b/web/static/images/icons/excel.png Binary files differindex 70ddadcbf..275c65c4d 100644 --- a/web/static/images/icons/excel.png +++ b/web/static/images/icons/excel.png diff --git a/web/static/images/icons/generic.png b/web/static/images/icons/generic.png Binary files differindex d9e82c232..0eb82c2d2 100644 --- a/web/static/images/icons/generic.png +++ b/web/static/images/icons/generic.png diff --git a/web/static/images/icons/image.png b/web/static/images/icons/image.png Binary files differindex a3acdef4c..799317731 100644 --- a/web/static/images/icons/image.png +++ b/web/static/images/icons/image.png diff --git a/web/static/images/icons/patch.png b/web/static/images/icons/patch.png Binary files differindex 18af126d4..a0affc9ee 100644 --- a/web/static/images/icons/patch.png +++ b/web/static/images/icons/patch.png diff --git a/web/static/images/icons/pdf.png b/web/static/images/icons/pdf.png Binary files differindex e4582570e..8c7507a1c 100644 --- a/web/static/images/icons/pdf.png +++ b/web/static/images/icons/pdf.png diff --git a/web/static/images/icons/ppt.png b/web/static/images/icons/ppt.png Binary files differindex 3571b4649..51553a11c 100644 --- a/web/static/images/icons/ppt.png +++ b/web/static/images/icons/ppt.png diff --git a/web/static/images/icons/video.png b/web/static/images/icons/video.png Binary files differindex e61a9e5f4..f53da93e4 100644 --- a/web/static/images/icons/video.png +++ b/web/static/images/icons/video.png diff --git a/web/static/images/icons/word.png b/web/static/images/icons/word.png Binary files differindex 20f830665..658937817 100644 --- a/web/static/images/icons/word.png +++ b/web/static/images/icons/word.png |