diff --git a/app/assets/images/attachmentFileTypeIcons/open-iconic-document.svg b/app/assets/images/attachmentFileTypeIcons/open-iconic-document.svg new file mode 100644 index 00000000..c3e2b061 --- /dev/null +++ b/app/assets/images/attachmentFileTypeIcons/open-iconic-document.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/assets/images/attachmentFileTypeIcons/open-iconic-file.svg b/app/assets/images/attachmentFileTypeIcons/open-iconic-file.svg new file mode 100644 index 00000000..6a5932db --- /dev/null +++ b/app/assets/images/attachmentFileTypeIcons/open-iconic-file.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/assets/images/attachmentFileTypeIcons/open-iconic-image.svg b/app/assets/images/attachmentFileTypeIcons/open-iconic-image.svg new file mode 100644 index 00000000..092665c1 --- /dev/null +++ b/app/assets/images/attachmentFileTypeIcons/open-iconic-image.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/assets/images/attachmentFileTypeIcons/open-iconic-musical-note.svg b/app/assets/images/attachmentFileTypeIcons/open-iconic-musical-note.svg new file mode 100644 index 00000000..bc66c5c9 --- /dev/null +++ b/app/assets/images/attachmentFileTypeIcons/open-iconic-musical-note.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/app/assets/images/attachmentFileTypeIcons/open-iconic-question-mark.svg b/app/assets/images/attachmentFileTypeIcons/open-iconic-question-mark.svg new file mode 100644 index 00000000..4eb6ff36 --- /dev/null +++ b/app/assets/images/attachmentFileTypeIcons/open-iconic-question-mark.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/app/assets/images/recording-button.png b/app/assets/images/recording-button.png new file mode 100644 index 00000000..7ea03b22 Binary files /dev/null and b/app/assets/images/recording-button.png differ diff --git a/app/assets/images/upload_icons/CameraIcons.png b/app/assets/images/upload_icons/CameraIcons.png new file mode 100644 index 00000000..ee0b77ef Binary files /dev/null and b/app/assets/images/upload_icons/CameraIcons.png differ diff --git a/app/assets/images/upload_icons/CloudIcons.png b/app/assets/images/upload_icons/CloudIcons.png new file mode 100644 index 00000000..eb887a59 Binary files /dev/null and b/app/assets/images/upload_icons/CloudIcons.png differ diff --git a/app/assets/images/upload_icons/LinkIcons.png b/app/assets/images/upload_icons/LinkIcons.png new file mode 100644 index 00000000..1f0fbdd4 Binary files /dev/null and b/app/assets/images/upload_icons/LinkIcons.png differ diff --git a/app/assets/images/upload_icons/MicIcons.png b/app/assets/images/upload_icons/MicIcons.png new file mode 100644 index 00000000..4cae5e4c Binary files /dev/null and b/app/assets/images/upload_icons/MicIcons.png differ diff --git a/app/assets/javascripts/Metamaps.ServerData.js.erb b/app/assets/javascripts/Metamaps.ServerData.js.erb index bd412a93..0c96df3c 100644 --- a/app/assets/javascripts/Metamaps.ServerData.js.erb +++ b/app/assets/javascripts/Metamaps.ServerData.js.erb @@ -13,6 +13,13 @@ Metamaps.ServerData['sounds/MM_sounds.ogg'] = '<%= asset_path 'sounds/MM_sounds. Metamaps.ServerData['exploremaps_sprite.png'] = '<%= asset_path 'exploremaps_sprite.png' %>' Metamaps.ServerData['map_control_sprite.png'] = '<%= asset_path 'map_control_sprite.png' %>' Metamaps.ServerData['user_sprite.png'] = '<%= asset_path 'user_sprite.png' %>' +Metamaps.ServerData.attachmentFileTypeIcons = { + pdf: '<%= asset_path('attachmentFileTypeIcons//open-iconic-document.svg') %>', + text: '<%= asset_path('attachmentFileTypeIcons//open-iconic-file.svg') %>', + image: '<%= asset_path('attachmentFileTypeIcons//open-iconic-image.svg') %>', + audio: '<%= asset_path('attachmentFileTypeIcons//open-iconic-musical-note.svg') %>', + unknown: '<%= asset_path('attachmentFileTypeIcons//open-iconic-question-mark.svg') %>' +} Metamaps.ServerData.Metacodes = <%= Metacode.all.to_json.gsub(%r[(icon.*?)(\"},)], '\1?purple=stupid\2').html_safe %> Metamaps.ServerData.REALTIME_SERVER = '<%= ENV['REALTIME_SERVER'] %>' Metamaps.ServerData.RAILS_ENV = '<%= ENV['RAILS_ENV'] %>' diff --git a/app/assets/stylesheets/base.scss.erb b/app/assets/stylesheets/base.scss.erb index b4b7bb0c..f7e8f8ee 100644 --- a/app/assets/stylesheets/base.scss.erb +++ b/app/assets/stylesheets/base.scss.erb @@ -197,6 +197,7 @@ $mid-gray-opacity: rgba(66, 66, 66, 0.6); .CardOnGraph .links { position: relative; + background-color: #e0e0e0; z-index: 2; .linkItem { @@ -625,18 +626,117 @@ background-color: #E0E0E0; z-index:100; } +.attachments { + border-top: 1px solid #bdbdbd; + position: relative; + min-height: 3em; + + .file { + margin-top: 0.75em; + max-width: 85%; + + .filetype-icon { + width: 16px; + height: 16px; + padding: 0.5em; + padding-top: 0; + float: left; + } + } +} + +.upload-audio-start, +.upload-file-dropzone, +.upload-photo-dropzone, +.CardOnGraph .attachment-type-chooser > div { + text-align: center; + color: #cccccc; + font-size: 12px; + cursor: pointer; + + &:hover { + color: #999999; + } + + & > div { + width: 48px; + height: 48px; + margin: 0 auto; + box-sizing: border-box; + padding-top: 75%; + } + &.photo-upload > div { + background-image: url(<%= asset_path('upload_icons/CameraIcons.png') %>); + } + &.link-upload > div { + background-image: url(<%= asset_path('upload_icons/LinkIcons.png') %>); + } + &.audio-upload > div { + background-image: url(<%= asset_path('upload_icons/MicIcons.png') %>); + } + &.file-upload > div { + background-image: url(<%= asset_path('upload_icons/CloudIcons.png') %>); + } +} + +.photo-upload, +.link-upload, +.audio-upload, +.file-upload { + background-repeat: no-repeat; + background-size: 48px 48px; + background-position: 0 center; + width: 48px; + height: 48px; + + &:hover { + /*background-position: ;*/ + } +} + +.upload-audio-start, +.upload-file-dropzone, +.upload-photo-dropzone { + padding-top: 0.75em; +} + +.upload-audio-recording { + font-size: small; + color: #aaa; + text-align: center; +} + +$recording_button_size: 48px; +.upload-audio-stop { + background-image: url(<%= asset_path('recording-button.png') %>); + background-size: $recording_button_size; + width: $recording_button_size; + height: $recording_button_size; + margin: 0 auto; + cursor: pointer; +} + +.CardOnGraph .attachment-type-chooser { + padding-top: .75em; + + & > div { + display: inline-block; + width: 25%; + } +} + #embedlyLinkLoader { margin: 0 auto; width: 28px; } -.CardOnGraph .link-adder { +.CardOnGraph .link-chooser { width:100%; height:47px; position: relative; } -.link-adder a { +.link-chooser a { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -720,7 +820,7 @@ font-family: 'din-regular', helvetica, sans-serif; z-index: 1; } -#addLinkReset { +.attachment-cancel { position: absolute; top: 8px; right: 15px; diff --git a/app/models/attachment.rb b/app/models/attachment.rb index a18e0956..dd6a569c 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -17,7 +17,7 @@ class Attachment < ApplicationRecord validates :attachable, presence: true validates_attachment_content_type :file, content_type: Attachable.allowed_types - validates_attachment_size :file, in: 0.megabytes..5.megabytes + validates_attachment_size :file, :in => 0.megabytes..5.megabytes def image? Attachable.image_types.include?(file.instance.file_content_type) diff --git a/frontend/src/Metamaps/GlobalUI/ReactApp.js b/frontend/src/Metamaps/GlobalUI/ReactApp.js index 6237f08a..63820a53 100644 --- a/frontend/src/Metamaps/GlobalUI/ReactApp.js +++ b/frontend/src/Metamaps/GlobalUI/ReactApp.js @@ -37,12 +37,14 @@ const ReactApp = { mobileTitle: '', mobileTitleWidth: 0, metacodeSets: [], + attachmentFileTypeIcons: {}, init: function(serverData, openLightbox) { const self = ReactApp self.serverData = serverData self.mobileTitle = serverData.mobileTitle self.openLightbox = openLightbox self.metacodeSets = serverData.metacodeSets + self.attachmentFileTypeIcons = serverData.attachmentFileTypeIcons routes = makeRoutes(serverData.ActiveMapper) self.resize() window && window.addEventListener('resize', self.resize) @@ -154,10 +156,13 @@ const ReactApp = { getTopicCardProps: function() { const self = ReactApp return { - openTopic: TopicCard.openTopic, metacodeSets: self.metacodeSets, + onTopicFollow: Topic.onTopicFollow, + openTopic: TopicCard.openTopic, updateTopic: (topic, obj) => topic.save(obj), - onTopicFollow: Topic.onTopicFollow + uploadAttachment: TopicCard.uploadAttachment, + removeAttachment: TopicCard.removeAttachment, + fileTypeIcons: self.attachmentFileTypeIcons } }, getContextMenuProps: function() { diff --git a/frontend/src/Metamaps/Views/TopicCard.js b/frontend/src/Metamaps/Views/TopicCard.js index 943869e9..dadc81b5 100644 --- a/frontend/src/Metamaps/Views/TopicCard.js +++ b/frontend/src/Metamaps/Views/TopicCard.js @@ -1,3 +1,5 @@ +/* global $ */ + import { ReactApp } from '../GlobalUI' const TopicCard = { @@ -9,6 +11,56 @@ const TopicCard = { hideCard: function() { TopicCard.openTopic = null ReactApp.render() + }, + uploadAttachment: (topic, file) => { + const data = new window.FormData() + data.append('attachment[file]', file) + data.append('attachment[attachable_type]', 'Topic') + data.append('attachment[attachable_id]', topic.id) + return new Promise((resolve, reject) => { + $.ajax({ + url: '/attachments', + type: 'POST', + data, + processData: false, + contentType: false, + success: (data) => { + console.log('file upolad success', data) + topic.fetch({ success: () => { + ReactApp.render() + resolve(true) + }}) + }, + error: (error) => { + console.error(error) + window.alert('File upload failed') + topic.fetch({ success: () => { + ReactApp.render() + resolve(false) + }}) + } + }) + }) + }, + removeAttachment: (topic) => { + const attachments = topic.get('attachments') + if (!attachments || attachments.length < 1) { + return + } + + $.ajax({ + url: `/attachments/${attachments[0].id}`, + type: 'DELETE', + success: () => { + console.log('delete success, syncing topic') + topic.fetch({ success: () => ReactApp.render() }) + }, + error: error => { + console.error(error) + window.alert('Failed to remove attachment') + topic.fetch({ success: () => ReactApp.render() }) + } + }) } } diff --git a/frontend/src/components/TopicCard/Attachments.js b/frontend/src/components/TopicCard/Attachments.js index fcb5bea8..539d7271 100644 --- a/frontend/src/components/TopicCard/Attachments.js +++ b/frontend/src/components/TopicCard/Attachments.js @@ -1,20 +1,101 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import EmbedlyLink from './EmbedlyLink' +import EmbedlyLinkChooser from './EmbedlyLinkChooser' +import EmbedlyCard from './EmbedlyCard' +import FileUploader from './FileUploader' +import PhotoUploader from './PhotoUploader' +import AudioUploader from './AudioUploader' +import FileAttachment from './FileAttachment' class Attachments extends Component { + constructor(props) { + super(props) + + this.state = this.defaultState + } + + defaultState = { + addingPhoto: false, + addingLink: false, + addingAudio: false, + addingFile: false + } + + clearState = () => { + this.setState(this.defaultState) + } + + // onClick handler for the 4 buttons, which triggers showing the proper uploader + choose = key => () => { + this.setState(Object.assign({}, this.defaultState, { [key]: true })) + } + render = () => { const { topic, authorizedToEdit, updateTopic } = this.props const link = topic.get('link') + const attachments = topic.get('attachments') + const file = attachments && attachments.length ? attachments[0] : null + + let childComponent + if (link) { + childComponent = ( + + ) + } else if (file) { + childComponent = ( + + ) + } else if (!authorizedToEdit) { + childComponent = null + } else if (this.state.addingPhoto) { + childComponent = ( + + ) + } else if (this.state.addingLink) { + childComponent = ( + + ) + } else if (this.state.addingAudio) { + childComponent = ( + + ) + } else if (this.state.addingFile) { + childComponent = ( + + ) + } else { + childComponent = ( +
+
Photo
+
Link
+
Audio
+
Upload
+
+ ) + } return (
- + {childComponent}
) } @@ -23,7 +104,10 @@ class Attachments extends Component { Attachments.propTypes = { topic: PropTypes.object, // Backbone object authorizedToEdit: PropTypes.bool, - updateTopic: PropTypes.func + updateTopic: PropTypes.func, + uploadAttachment: PropTypes.func, + removeAttachment: PropTypes.func, + fileTypeIcons: PropTypes.objectOf(PropTypes.string) } export default Attachments diff --git a/frontend/src/components/TopicCard/AudioUploader.js b/frontend/src/components/TopicCard/AudioUploader.js new file mode 100644 index 00000000..a579e24b --- /dev/null +++ b/frontend/src/components/TopicCard/AudioUploader.js @@ -0,0 +1,81 @@ +import React, { Component, PropTypes } from 'react' + +import Recorder from 'react-recorder' + +class AudioUploader extends Component { + constructor(props) { + super(props) + + this.state = { + command: 'none' + } + } + + timeLimit30sTimeoutId = null + + enforce30sTimeLimit = cmd => { + window.clearTimeout(this.timeLimit30sTimeoutId) + if (cmd === 'start') { + this.timeLimit30sTimeoutId = window.setTimeout(() => { + this.command('stop')() + }, 30000) + } + } + + command = cmd => () => { + this.enforce30sTimeLimit(cmd) + this.setState({ command: cmd }) + } + + onStop = blob => { + const now = new Date() + const date = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}-${now.getHours()}:${now.getMinutes()}` + const filename = `metamaps-recorded-audio-${date}.wav` + const file = new window.File([blob], filename, { lastModifiedDate: now }) + + this.props.uploadAttachment(file).then(success => { + if (!success) { + this.command('none') + } + }) + } + + handleRecordingError = () => { + window.alert(`Audio recording failed. Some possible reasons include: + not using an HTTPS connection, + missing microphone, + you haven't allowed your browser access to your microphone, + or you need to reload the page.`) + } + + render() { + return ( +
+ + {this.state.command === 'start' && ( +
+
+
  Recording...
+
+ )} + {this.state.command === 'none' && ( +
+ Click to record
+ (max 30 seconds) +
+ )} +
+
+ ) + } +} + +AudioUploader.propTypes = { + uploadAttachment: PropTypes.func, + cancel: PropTypes.func +} + +export default AudioUploader diff --git a/frontend/src/components/TopicCard/EmbedlyLink/Card.js b/frontend/src/components/TopicCard/EmbedlyCard.js similarity index 82% rename from frontend/src/components/TopicCard/EmbedlyLink/Card.js rename to frontend/src/components/TopicCard/EmbedlyCard.js index da7474cb..e8b05e16 100644 --- a/frontend/src/components/TopicCard/EmbedlyLink/Card.js +++ b/frontend/src/components/TopicCard/EmbedlyCard.js @@ -1,4 +1,4 @@ -/* global $, embedly */ +/* global embedly */ import React, { Component } from 'react' import PropTypes from 'prop-types' @@ -38,29 +38,33 @@ class EmbedlyCard extends Component { } render = () => { - const { link } = this.props const { embedlyLinkLoaded, embedlyLinkStarted, embedlyLinkError } = this.state const notReady = embedlyLinkStarted && !embedlyLinkLoaded && !embedlyLinkError return ( -
+
- {link} + {this.props.link} {notReady &&
loading...
} + {this.props.authorizedToEdit && ( +
+ )}
) } } EmbedlyCard.propTypes = { - link: PropTypes.string + link: PropTypes.string, + authorizedToEdit: PropTypes.bool, + removeLink: PropTypes.func } export default EmbedlyCard diff --git a/frontend/src/components/TopicCard/EmbedlyLink/index.js b/frontend/src/components/TopicCard/EmbedlyLinkChooser.js similarity index 50% rename from frontend/src/components/TopicCard/EmbedlyLink/index.js rename to frontend/src/components/TopicCard/EmbedlyLinkChooser.js index 0f6df211..d35dc893 100644 --- a/frontend/src/components/TopicCard/EmbedlyLink/index.js +++ b/frontend/src/components/TopicCard/EmbedlyLinkChooser.js @@ -1,10 +1,7 @@ -/* global embedly */ import React, { Component } from 'react' import PropTypes from 'prop-types' -import Card from './Card' - -class EmbedlyLink extends Component { +class EmbedlyLinkChooser extends Component { constructor(props) { super(props) @@ -13,12 +10,9 @@ class EmbedlyLink extends Component { } } - removeLink = () => { - this.props.updateTopic({ link: null }) - } - resetLink = () => { this.setState({ linkEdit: '' }) + this.props.cancel() } onLinkChangeHandler = e => { @@ -35,17 +29,11 @@ class EmbedlyLink extends Component { } render = () => { - const { link, authorizedToEdit, topicId } = this.props const { linkEdit } = this.state - const hasAttachment = !!link - - if (!hasAttachment && !authorizedToEdit) return null return ( -
-
+
+
(this.linkInput = input)} @@ -53,26 +41,17 @@ class EmbedlyLink extends Component { value={linkEdit} onChange={this.onLinkChangeHandler} onKeyUp={this.onLinkKeyUpHandler}> - {linkEdit &&
} +
- {link && } - {authorizedToEdit && ( -
- )}
) } } -EmbedlyLink.propTypes = { - topicId: PropTypes.number, - link: PropTypes.string, - authorizedToEdit: PropTypes.bool, - updateTopic: PropTypes.func +EmbedlyLinkChooser.propTypes = { + updateTopic: PropTypes.func, + cancel: PropTypes.func } -export default EmbedlyLink +export default EmbedlyLinkChooser diff --git a/frontend/src/components/TopicCard/FileAttachment.js b/frontend/src/components/TopicCard/FileAttachment.js new file mode 100644 index 00000000..d7053a4b --- /dev/null +++ b/frontend/src/components/TopicCard/FileAttachment.js @@ -0,0 +1,57 @@ +import React, { Component, PropTypes } from 'react' + +class FileAttachment extends Component { + getFileType = contentType => { + if (contentType === 'text/plain') { + return 'text' + } else if (contentType === 'application/pdf') { + return 'pdf' + } else if (contentType.match(/^image\//)) { + return 'image' + } else if (contentType.match(/^audio\//) || + contentType === 'video/ogg' || + contentType === 'video/webm') { + return 'audio' + } else { + return 'unknown' + } + } + + getFileIcon = file => { + const type = this.getFileType(file.content_type) + + if (this.props.fileTypeIcons[type]) { + return this.props.fileTypeIcons[type] + } else { + return this.props.fileTypeIcons.unknown + } + } + + render() { + const { file } = this.props + return ( +
+ + + {file.file_name} + +
+
+ ) + } +} + +FileAttachment.propTypes = { + file: PropTypes.shape({ + content_type: PropTypes.string, + file_name: PropTypes.string, + url: PropTypes.string + }), + authorizedToEdit: PropTypes.bool, + removeAttachment: PropTypes.func, + fileTypeIcons: PropTypes.objectOf(PropTypes.string) +} + +export default FileAttachment diff --git a/frontend/src/components/TopicCard/FileUploader.js b/frontend/src/components/TopicCard/FileUploader.js new file mode 100644 index 00000000..80682291 --- /dev/null +++ b/frontend/src/components/TopicCard/FileUploader.js @@ -0,0 +1,34 @@ +import React, { Component, PropTypes } from 'react' +import Dropzone from 'react-dropzone' + +class FileUploader extends Component { + handleFileUpload = (acceptedFiles, rejectedFiles) => { + if (acceptedFiles.length >= 1) { + this.props.uploadAttachment(acceptedFiles[0]) + } else { + window.alert('File upload failed, please try again.') + } + } + + render() { + return ( +
+ + Drag file here
+ (maximum 5mb) +
+
+
+ ) + } +} + +FileUploader.propTypes = { + updateTopic: PropTypes.func, + uploadAttachment: PropTypes.func, + cancel: PropTypes.func +} + +export default FileUploader diff --git a/frontend/src/components/TopicCard/PhotoUploader.js b/frontend/src/components/TopicCard/PhotoUploader.js new file mode 100644 index 00000000..9cd66fe5 --- /dev/null +++ b/frontend/src/components/TopicCard/PhotoUploader.js @@ -0,0 +1,34 @@ +import React, { Component, PropTypes } from 'react' +import Dropzone from 'react-dropzone' + +class PhotoUploader extends Component { + handleFileUpload = (acceptedFiles, rejectedFiles) => { + if (acceptedFiles.length >= 1) { + this.props.uploadAttachment(acceptedFiles[0]) + } else { + window.alert('File upload failed, please try again.') + } + } + + render() { + return ( +
+ + Drag photo here
+ or click to upload +
+
+
+ ) + } +} + +PhotoUploader.propTypes = { + updateTopic: PropTypes.func, + uploadAttachment: PropTypes.func, + cancel: PropTypes.func +} + +export default PhotoUploader diff --git a/frontend/src/components/TopicCard/index.js b/frontend/src/components/TopicCard/index.js index 69ffa4e7..82751ca8 100644 --- a/frontend/src/components/TopicCard/index.js +++ b/frontend/src/components/TopicCard/index.js @@ -10,12 +10,17 @@ import Info from './Info' class ReactTopicCard extends Component { render = () => { - const { currentUser, onTopicFollow, updateTopic } = this.props + const { + currentUser, onTopicFollow, updateTopic, uploadAttachment, + removeAttachment + } = this.props const topic = this.props.openTopic if (!topic) return null const wrappedUpdateTopic = obj => updateTopic(topic, obj) + const wrappedUploadAttachment = file => uploadAttachment(topic, file) + const wrappedRemoveAttachment = () => removeAttachment(topic) const authorizedToEdit = topic.authorizeToEdit(currentUser) const hasAttachment = topic.get('link') && topic.get('link') !== '' @@ -48,9 +53,13 @@ class ReactTopicCard extends Component { authorizedToEdit={authorizedToEdit} onChange={wrappedUpdateTopic} /> -
@@ -75,7 +84,9 @@ ReactTopicCard.propTypes = { name: PropTypes.string })) })), - redrawCanvas: PropTypes.func + redrawCanvas: PropTypes.func, + uploadAttachment: PropTypes.func, + fileTypeIcons: PropTypes.objectOf(PropTypes.string) } export default ReactTopicCard diff --git a/package.json b/package.json index 5cece282..0830ef5d 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "react-draggable": "3.0.3", "react-dropzone": "4.1.2", "react-onclickoutside": "6.5.0", + "react-recorder": "1.0.0", "react-router": "3.0.5", "redux": "3.7.2", "riek": "1.1.0",