Compare commits

...

6 commits

Author SHA1 Message Date
Devin Howard
97b17c678e split out topic card uploads scss file 2018-02-05 21:43:26 -08:00
Devin Howard
cebf6626ee fix uploader icons 2018-02-05 21:43:26 -08:00
Devin Howard
ed589c79c4 unfinished, not rebased, topic card uploads 2018-02-05 21:43:26 -08:00
Devin Howard
3b3cec9fcc rubocop 2018-02-05 21:43:10 -08:00
Devin Howard
0ffdaf200b fix api docs 2018-02-05 21:39:30 -08:00
Devin Howard
f2d909e2d7 add normal/API endpoints for attachments 2018-02-05 21:39:30 -08:00
43 changed files with 941 additions and 202 deletions

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="8" height="8" data-icon="document" viewBox="0 0 8 8">
<path d="M0 0v8h7v-4h-4v-4h-3zm4 0v3h3l-3-3zm-3 2h1v1h-1v-1zm0 2h1v1h-1v-1zm0 2h4v1h-4v-1z" />
</svg>

After

Width:  |  Height:  |  Size: 218 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="8" height="8" data-icon="file" viewBox="0 0 8 8">
<path d="M0 0v8h7v-4h-4v-4h-3zm4 0v3h3l-3-3z" />
</svg>

After

Width:  |  Height:  |  Size: 168 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="8" height="8" data-icon="image" viewBox="0 0 8 8">
<path d="M0 0v8h8v-8h-8zm1 1h6v3l-1-1-1 1 2 2v1h-1l-4-4-1 1v-3z" />
</svg>

After

Width:  |  Height:  |  Size: 188 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="8" height="8" data-icon="musical-note" viewBox="0 0 8 8">
<path d="M8 0c-5 0-6 1-6 1v4.093999999999999c-.154-.054-.327-.094-.5-.094-.828 0-1.5.672-1.5 1.5s.672 1.5 1.5 1.5 1.5-.672 1.5-1.5v-3.969c.732-.226 1.99-.438 4-.5v2.063c-.154-.054-.327-.094-.5-.094-.828 0-1.5.672-1.5 1.5s.672 1.5 1.5 1.5 1.5-.672 1.5-1.5v-5.5z"
/>
</svg>

After

Width:  |  Height:  |  Size: 394 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="8" height="8" data-icon="question-mark" data-container-transform="translate(2)" viewBox="0 0 8 8">
<path d="M4.469 0c-.854 0-1.48.256-1.875.656s-.54.901-.594 1.281l1 .125c.036-.26.125-.497.313-.688.188-.19.491-.375 1.156-.375.664 0 1.019.163 1.219.344.199.181.281.405.281.656 0 .833-.313 1.063-.813 1.5-.5.438-1.188 1.083-1.188 2.25v.25h1v-.25c0-.833.344-1.063.844-1.5.5-.438 1.156-1.083 1.156-2.25 0-.479-.168-1.02-.594-1.406-.426-.387-1.071-.594-1.906-.594zm-.5 7v1h1v-1h-1z"
/>
</svg>

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -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['exploremaps_sprite.png'] = '<%= asset_path 'exploremaps_sprite.png' %>'
Metamaps.ServerData['map_control_sprite.png'] = '<%= asset_path 'map_control_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['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.Metacodes = <%= Metacode.all.to_json.gsub(%r[(icon.*?)(\"},)], '\1?purple=stupid\2').html_safe %>
Metamaps.ServerData.REALTIME_SERVER = '<%= ENV['REALTIME_SERVER'] %>' Metamaps.ServerData.REALTIME_SERVER = '<%= ENV['REALTIME_SERVER'] %>'
Metamaps.ServerData.RAILS_ENV = '<%= ENV['RAILS_ENV'] %>' Metamaps.ServerData.RAILS_ENV = '<%= ENV['RAILS_ENV'] %>'

View file

@ -197,6 +197,7 @@ $mid-gray-opacity: rgba(66, 66, 66, 0.6);
.CardOnGraph .links { .CardOnGraph .links {
position: relative; position: relative;
background-color: #e0e0e0;
z-index: 2; z-index: 2;
.linkItem { .linkItem {
@ -625,156 +626,6 @@ background-color: #E0E0E0;
z-index:100; z-index:100;
} }
#embedlyLinkLoader {
margin: 0 auto;
width: 28px;
}
.CardOnGraph .link-adder {
width:100%;
height:47px;
position: relative;
}
.link-adder a {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
margin-left: 40px;
padding-top:9px;
font-size: 16px;
line-height: 16px;
}
#addlink, #addupload {
display: inline-block;
width: 102px;
height: 12px;
text-align: left;
padding: 18px 0 18px 48px;
font-size: 12px;
color: #9e9e9e;
cursor: pointer;
position: relative;
}
#addlink:hover, #addupload:hover {
color: #616161;
}
.attachmentIcon {
background-repeat: no-repeat;
background-position: 0 0;
width: 24px;
height: 24px;
position: absolute;
top: 12px;
left: 12px;
}
#linkIcon {
background-image: url(<%= asset_data_uri('link_sprite.png') %>);
}
#uploadIcon {
background-image: url(<%= asset_data_uri('upload_sprite.png') %>);
}
#addlink:hover #linkIcon, #addupload:hover #uploadIcon {
background-position: 0 -24px;
}
.addLink {
position: relative;
}
#addLinkInput {
height: 32px;
width: 268px;
padding: 8px 16px 8px 16px;
position: relative;
border: none;
line-height: 14px;
}
#addLinkInput input{
padding: 9px 27px 9px 31px;
height: 12px;
width: 210px;
margin: 0 0 0 0;
border: none;
outline: none;
font-size: 12px;
line-height: 12px;
background: white;
color: black;
font-family: 'din-regular', helvetica, sans-serif;
}
#addLinkIcon {
position: absolute;
top: 12px;
left: 20px;
width: 24px;
height: 24px;
background-repeat: no-repeat;
background-position: 0 0;
background-image: url(<%= asset_data_uri('link_sprite.png') %>);
pointer-events: none;
z-index: 1;
}
#addLinkReset {
position: absolute;
top: 8px;
right: 15px;
width: 32px;
height: 32px;
cursor: pointer;
float:none;
background-image: url(<%= asset_data_uri('remove.png') %>);
background-repeat: no-repeat;
background-position: center center;
}
.embeds.nonEmbedlyLink {
padding-top: 24px;
}
#embedlyLink {
border-left: 8px solid #CCC;
overflow: hidden;
padding: 8px;
padding-left: 12px;
-moz-box-shadow: 1px 1px 5px 0 #ccc;
-webkit-box-shadow: 1px 1px 5px 0 #ccc;
box-shadow: 1px 1px 5px 0 #ccc;
-moz-border-radius-topright: 5px;
-webkit-border-top-right-radius: 5px;
border-top-right-radius: 5px;
-moz-border-radius-bottomright: 8px;
-webkit-border-bottom-right-radius: 8px;
border-bottom-right-radius: 8px;
margin: 8px;
}
.linkActions {
position: relative;
}
.CardOnGraph .embeds {
position: relative;
overflow: hidden;
}
#linkremove {
background-image: url(<%= asset_data_uri 'remove.png' %>);
background-repeat: no-repeat;
background-position: center center;
width: 24px;
height: 24px;
position: absolute;
top: 3px;
right: 0;
cursor: pointer;
}
.cardSettings { .cardSettings {
position: absolute; position: absolute;
left: 12px; left: 12px;

View file

@ -0,0 +1,254 @@
$attachment_button_size: 32px;
.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;
}
&.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: $attachment_button_size $attachment_button_size;
background-position: 0 center;
width: $attachment_button_size;
height: $attachment_button_size;
& > div {
width: $attachment_button_size;
height: $attachment_button_size;
margin: 0 auto;
box-sizing: border-box;
padding-top: $attachment_button_size;
background-size: $attachment_button_size;
&:hover {
background-position: 0 $attachment_button_size;
}
}
}
.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-chooser {
width:100%;
height:47px;
position: relative;
}
.link-chooser a {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
margin-left: 40px;
padding-top:9px;
font-size: 16px;
line-height: 16px;
}
#addlink, #addupload {
display: inline-block;
width: 102px;
height: 12px;
text-align: left;
padding: 18px 0 18px 48px;
font-size: 12px;
color: #9e9e9e;
cursor: pointer;
position: relative;
}
#addlink:hover, #addupload:hover {
color: #616161;
}
.attachmentIcon {
background-repeat: no-repeat;
background-position: 0 0;
width: 24px;
height: 24px;
position: absolute;
top: 12px;
left: 12px;
}
#linkIcon {
background-image: url(<%= asset_data_uri('link_sprite.png') %>);
}
#uploadIcon {
background-image: url(<%= asset_data_uri('upload_sprite.png') %>);
}
#addlink:hover #linkIcon, #addupload:hover #uploadIcon {
background-position: 0 -24px;
}
.addLink {
position: relative;
}
#addLinkInput {
height: 32px;
width: 268px;
padding: 8px 16px 8px 16px;
position: relative;
border: none;
line-height: 14px;
}
#addLinkInput input{
padding: 9px 27px 9px 31px;
height: 12px;
width: 210px;
margin: 0 0 0 0;
border: none;
outline: none;
font-size: 12px;
line-height: 12px;
background: white;
color: black;
font-family: 'din-regular', helvetica, sans-serif;
}
#addLinkIcon {
position: absolute;
top: 12px;
left: 20px;
width: 24px;
height: 24px;
background-repeat: no-repeat;
background-position: 0 0;
background-image: url(<%= asset_data_uri('link_sprite.png') %>);
pointer-events: none;
z-index: 1;
}
.attachment-cancel {
position: absolute;
top: 8px;
right: 15px;
width: 32px;
height: 32px;
cursor: pointer;
float:none;
background-image: url(<%= asset_data_uri('remove.png') %>);
background-repeat: no-repeat;
background-position: center center;
}
.embeds.nonEmbedlyLink {
padding-top: 24px;
}
#embedlyLink {
border-left: 8px solid #CCC;
overflow: hidden;
padding: 8px;
padding-left: 12px;
-moz-box-shadow: 1px 1px 5px 0 #ccc;
-webkit-box-shadow: 1px 1px 5px 0 #ccc;
box-shadow: 1px 1px 5px 0 #ccc;
-moz-border-radius-topright: 5px;
-webkit-border-top-right-radius: 5px;
border-top-right-radius: 5px;
-moz-border-radius-bottomright: 8px;
-webkit-border-bottom-right-radius: 8px;
border-bottom-right-radius: 8px;
margin: 8px;
}
.linkActions {
position: relative;
}
.CardOnGraph .embeds {
position: relative;
overflow: hidden;
}
#linkremove {
background-image: url(<%= asset_data_uri 'remove.png' %>);
background-repeat: no-repeat;
background-position: center center;
width: 24px;
height: 24px;
position: absolute;
top: 3px;
right: 0;
cursor: pointer;
}

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
module Api
module V2
class AttachmentsController < RestfulController
def searchable_columns
[:file]
end
end
end
end

View file

@ -0,0 +1,37 @@
# frozen_string_literal: true
class AttachmentsController < ApplicationController
before_action :set_attachment, only: [:destroy]
after_action :verify_authorized
def create
@attachment = Attachment.new(attachment_params)
authorize @attachment
respond_to do |format|
if @attachment.save
format.json { render json: @attachment, status: :created }
else
format.json { render json: @attachment.errors, status: :unprocessable_entity }
end
end
end
def destroy
@attachment.destroy
respond_to do |format|
format.json { head :no_content }
end
end
private
def set_attachment
@attachment = Attachment.find(params[:id])
authorize @attachment
end
def attachment_params
params.require(:attachment).permit(:id, :file, :attachable_id, :attachable_type)
end
end

View file

@ -15,7 +15,9 @@ class Attachment < ApplicationRecord
end end
} }
validates :attachable, presence: true
validates_attachment_content_type :file, content_type: Attachable.allowed_types validates_attachment_content_type :file, content_type: Attachable.allowed_types
validates_attachment_size :file, :in => 0.megabytes..5.megabytes
def image? def image?
Attachable.image_types.include?(file.instance.file_content_type) Attachable.image_types.include?(file.instance.file_content_type)

View file

@ -33,7 +33,8 @@ module Attachable
end end
def audio_types def audio_types
['audio/ogg', 'audio/mp3'] # .ogg files might be labelled as video
['audio/ogg', 'video/ogg', 'audio/mpeg', 'audio/wav', 'video/webm']
end end
def text_types def text_types

View file

@ -69,10 +69,23 @@ class Topic < ApplicationRecord
Pundit.policy_scope(user, maps).map(&:id) Pundit.policy_scope(user, maps).map(&:id)
end end
def attachments_json
attachments.map do |a|
{
id: a.id,
file_name: a.file_file_name,
content_type: a.file_content_type,
file_size: a.file_file_size,
url: a.file.url
}
end
end
def as_json(options = {}) def as_json(options = {})
super(methods: %i[user_name user_image collaborator_ids]) super(methods: %i[user_name user_image collaborator_ids])
.merge(inmaps: inmaps(options[:user]), inmapsLinks: inmaps_links(options[:user]), .merge(inmaps: inmaps(options[:user]), inmapsLinks: inmaps_links(options[:user]),
map_count: map_count(options[:user]), synapse_count: synapse_count(options[:user])) map_count: map_count(options[:user]), synapse_count: synapse_count(options[:user]),
attachments: attachments_json)
end end
def as_rdf def as_rdf

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
class AttachmentPolicy < ApplicationPolicy
class Scope < Scope
def resolve
scope.where(attachable: TopicPolicy::Scope.new(user, Topic).resolve)
end
end
def index?
true
end
def create?
Pundit.policy(user, record.attachable).update?
end
def destroy?
Pundit.policy(user, record.attachable).update?
end
end

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
module Api
module V2
class AttachmentSerializer < ApplicationSerializer
attributes :id,
:file,
:attachable_type,
:attachable_id,
:created_at,
:updated_at
end
end
end

View file

@ -14,7 +14,8 @@ module Api
def self.embeddable def self.embeddable
{ {
user: {}, user: {},
metacode: {} metacode: {},
attachments: {}
} }
end end

View file

@ -7,6 +7,8 @@ Metamaps::Application.routes.draw do
root to: 'main#home', via: :get root to: 'main#home', via: :get
get 'request', to: 'main#requestinvite', as: :request get 'request', to: 'main#requestinvite', as: :request
resources :attachments, only: %i[create destroy], shallow: true
namespace :explore do namespace :explore do
get 'active' get 'active'
get 'featured' get 'featured'
@ -121,6 +123,7 @@ Metamaps::Application.routes.draw do
namespace :api, path: '/api', default: { format: :json } do namespace :api, path: '/api', default: { format: :json } do
namespace :v2, path: '/v2' do namespace :v2, path: '/v2' do
resources :attachments, only: %i[index show]
resources :metacodes, only: %i[index show] resources :metacodes, only: %i[index show]
resources :mappings, only: %i[index create show update destroy] resources :mappings, only: %i[index create show update destroy]
resources :maps, only: %i[index create show update destroy] do resources :maps, only: %i[index create show update destroy] do

View file

@ -22,6 +22,7 @@ traits:
searchable: !include traits/searchable.raml searchable: !include traits/searchable.raml
schemas: schemas:
attachment: !include schemas/_attachment.json
map: !include schemas/_map.json map: !include schemas/_map.json
mapping: !include schemas/_mapping.json mapping: !include schemas/_mapping.json
metacode: !include schemas/_metacode.json metacode: !include schemas/_metacode.json
@ -35,6 +36,7 @@ schemas:
# item: !include resourceTypes/item.raml # item: !include resourceTypes/item.raml
# collection: !include resourceTypes/collection.raml # collection: !include resourceTypes/collection.raml
/attachments: !include apis/attachments.raml
/maps: !include apis/maps.raml /maps: !include apis/maps.raml
/mappings: !include apis/mappings.raml /mappings: !include apis/mappings.raml
/metacodes: !include apis/metacodes.raml /metacodes: !include apis/metacodes.raml

View file

@ -0,0 +1,16 @@
get:
is: [ searchable: { searchFields: "file" }, orderable, pageable ]
securedBy: [ null, token, oauth_2_0 ]
responses:
200:
body:
application/json:
example: !include ../examples/attachments.json
/{id}:
get:
securedBy: [ null, token, oauth_2_0 ]
responses:
200:
body:
application/json:
example: !include ../examples/attachment.json

View file

@ -0,0 +1,10 @@
{
"data": {
"id": 1,
"file": "https://example.org/file.png",
"attachable_type": "Topic",
"attachable_id": 187,
"created_at": "2017-03-01T05:48:09.533Z",
"updated_at": "2017-03-01T05:48:09.533Z"
}
}

View file

@ -0,0 +1,28 @@
{
"data": [
{
"id": 1,
"file": "https://example.org/file.png",
"attachable_type": "Topic",
"attachable_id": 187,
"created_at": "2017-03-01T05:48:09.533Z",
"updated_at": "2017-03-01T05:48:09.533Z"
},
{
"id": 2,
"file": "https://example.org/file.docx",
"attachable_type": "Message",
"attachable_id": 1043,
"created_at": "2017-03-01T05:50:19.533Z",
"updated_at": "2017-03-01T05:50:19.533Z"
}
],
"page": {
"current_page": 1,
"next_page": 2,
"prev_page": 0,
"total_pages": 156,
"total_count": 312,
"per": 2
}
}

View file

@ -0,0 +1,34 @@
{
"name": "Attachment",
"type": "object",
"properties": {
"id": {
"$ref": "_id.json"
},
"file": {
"format": "uri",
"type": "string"
},
"attachable_type": {
"pattern": "(Topic|Message)",
"type": "string"
},
"attachable_id": {
"$ref": "_id.json"
},
"created_at": {
"$ref": "_datetimestamp.json"
},
"updated_at": {
"$ref": "_datetimestamp.json"
}
},
"required": [
"id",
"file",
"attachable_type",
"attachable_id",
"created_at",
"updated_at"
]
}

View file

@ -0,0 +1,12 @@
{
"name": "Attachment Envelope",
"type": "object",
"properties": {
"data": {
"$ref": "_attachment.json"
}
},
"required": [
"data"
]
}

View file

@ -0,0 +1,19 @@
{
"name": "Attachments",
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "_attachment.json"
}
},
"page": {
"$ref": "_page.json"
}
},
"required": [
"data",
"page"
]
}

View file

@ -37,12 +37,14 @@ const ReactApp = {
mobileTitle: '', mobileTitle: '',
mobileTitleWidth: 0, mobileTitleWidth: 0,
metacodeSets: [], metacodeSets: [],
attachmentFileTypeIcons: {},
init: function(serverData, openLightbox) { init: function(serverData, openLightbox) {
const self = ReactApp const self = ReactApp
self.serverData = serverData self.serverData = serverData
self.mobileTitle = serverData.mobileTitle self.mobileTitle = serverData.mobileTitle
self.openLightbox = openLightbox self.openLightbox = openLightbox
self.metacodeSets = serverData.metacodeSets self.metacodeSets = serverData.metacodeSets
self.attachmentFileTypeIcons = serverData.attachmentFileTypeIcons
routes = makeRoutes(serverData.ActiveMapper) routes = makeRoutes(serverData.ActiveMapper)
self.resize() self.resize()
window && window.addEventListener('resize', self.resize) window && window.addEventListener('resize', self.resize)
@ -154,10 +156,13 @@ const ReactApp = {
getTopicCardProps: function() { getTopicCardProps: function() {
const self = ReactApp const self = ReactApp
return { return {
openTopic: TopicCard.openTopic,
metacodeSets: self.metacodeSets, metacodeSets: self.metacodeSets,
onTopicFollow: Topic.onTopicFollow,
openTopic: TopicCard.openTopic,
updateTopic: (topic, obj) => topic.save(obj), updateTopic: (topic, obj) => topic.save(obj),
onTopicFollow: Topic.onTopicFollow uploadAttachment: TopicCard.uploadAttachment,
removeAttachment: TopicCard.removeAttachment,
fileTypeIcons: self.attachmentFileTypeIcons
} }
}, },
getContextMenuProps: function() { getContextMenuProps: function() {

View file

@ -1,3 +1,5 @@
/* global $ */
import { ReactApp } from '../GlobalUI' import { ReactApp } from '../GlobalUI'
const TopicCard = { const TopicCard = {
@ -9,6 +11,56 @@ const TopicCard = {
hideCard: function() { hideCard: function() {
TopicCard.openTopic = null TopicCard.openTopic = null
ReactApp.render() 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() })
}
})
} }
} }

View file

@ -1,20 +1,101 @@
import React, { Component } from 'react' import React, { Component } from 'react'
import PropTypes from 'prop-types' 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 { 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 = () => { render = () => {
const { topic, authorizedToEdit, updateTopic } = this.props const { topic, authorizedToEdit, updateTopic } = this.props
const link = topic.get('link') const link = topic.get('link')
const attachments = topic.get('attachments')
const file = attachments && attachments.length ? attachments[0] : null
let childComponent
if (link) {
childComponent = (
<EmbedlyCard link={link}
authorizedToEdit={authorizedToEdit}
removeLink={this.clearState}
/>
)
} else if (file) {
childComponent = (
<FileAttachment file={file}
authorizedToEdit={authorizedToEdit}
removeAttachment={this.props.removeAttachment}
fileTypeIcons={this.props.fileTypeIcons}
/>
)
} else if (!authorizedToEdit) {
childComponent = null
} else if (this.state.addingPhoto) {
childComponent = (
<PhotoUploader updateTopic={updateTopic}
uploadAttachment={this.props.uploadAttachment}
cancel={this.clearState}
/>
)
} else if (this.state.addingLink) {
childComponent = (
<EmbedlyLinkChooser updateTopic={updateTopic}
cancel={this.clearState}
/>
)
} else if (this.state.addingAudio) {
childComponent = (
<AudioUploader updateTopic={updateTopic}
uploadAttachment={this.props.uploadAttachment}
cancel={this.clearState}
/>
)
} else if (this.state.addingFile) {
childComponent = (
<FileUploader updateTopic={updateTopic}
uploadAttachment={this.props.uploadAttachment}
cancel={this.clearState}
/>
)
} else {
childComponent = (
<div className="attachment-type-chooser">
<div className="photo-upload"><div onClick={this.choose('addingPhoto')}>Photo</div></div>
<div className="link-upload"><div onClick={this.choose('addingLink')}>Link</div></div>
<div className="audio-upload"><div onClick={this.choose('addingAudio')}>Audio</div></div>
<div className="file-upload"><div onClick={this.choose('addingFile')}>Upload</div></div>
</div>
)
}
return ( return (
<div className="attachments"> <div className="attachments">
<EmbedlyLink topicId={topic.id} {childComponent}
link={link}
authorizedToEdit={authorizedToEdit}
updateTopic={updateTopic}
/>
</div> </div>
) )
} }
@ -23,7 +104,10 @@ class Attachments extends Component {
Attachments.propTypes = { Attachments.propTypes = {
topic: PropTypes.object, // Backbone object topic: PropTypes.object, // Backbone object
authorizedToEdit: PropTypes.bool, authorizedToEdit: PropTypes.bool,
updateTopic: PropTypes.func updateTopic: PropTypes.func,
uploadAttachment: PropTypes.func,
removeAttachment: PropTypes.func,
fileTypeIcons: PropTypes.objectOf(PropTypes.string)
} }
export default Attachments export default Attachments

View file

@ -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 (
<div className="audio-uploader">
<Recorder command={this.state.command}
onStop={this.onStop}
onError={this.handleRecordingError}
/>
{this.state.command === 'start' && (
<div className="upload-audio-recording">
<div className="stop upload-audio-stop" onClick={this.command('stop')} />
<div className="upload-audio-recording-text">&nbsp;&nbsp;Recording...</div>
</div>
)}
{this.state.command === 'none' && (
<div className="start upload-audio-start" onClick={this.command('start')}>
Click to record <br />
(max 30 seconds)
</div>
)}
<div className="attachment-cancel" onClick={this.props.cancel} />
</div>
)
}
}
AudioUploader.propTypes = {
uploadAttachment: PropTypes.func,
cancel: PropTypes.func
}
export default AudioUploader

View file

@ -1,4 +1,4 @@
/* global $, embedly */ /* global embedly */
import React, { Component } from 'react' import React, { Component } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
@ -38,29 +38,33 @@ class EmbedlyCard extends Component {
} }
render = () => { render = () => {
const { link } = this.props
const { embedlyLinkLoaded, embedlyLinkStarted, embedlyLinkError } = this.state const { embedlyLinkLoaded, embedlyLinkStarted, embedlyLinkError } = this.state
const notReady = embedlyLinkStarted && !embedlyLinkLoaded && !embedlyLinkError const notReady = embedlyLinkStarted && !embedlyLinkLoaded && !embedlyLinkError
return ( return (
<div> <div className="embeds">
<a style={{ display: notReady ? 'none' : 'block' }} <a style={{ display: notReady ? 'none' : 'block' }}
href={link} href={this.props.link}
id="embedlyLink" id="embedlyLink"
target="_blank" target="_blank"
data-card-description="0" data-card-description="0"
> >
{link} {this.props.link}
</a> </a>
{notReady && <div id="embedlyLinkLoader">loading...</div>} {notReady && <div id="embedlyLinkLoader">loading...</div>}
{this.props.authorizedToEdit && (
<div id="linkremove" onClick={this.props.removeLink} />
)}
</div> </div>
) )
} }
} }
EmbedlyCard.propTypes = { EmbedlyCard.propTypes = {
link: PropTypes.string link: PropTypes.string,
authorizedToEdit: PropTypes.bool,
removeLink: PropTypes.func
} }
export default EmbedlyCard export default EmbedlyCard

View file

@ -1,10 +1,7 @@
/* global embedly */
import React, { Component } from 'react' import React, { Component } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import Card from './Card' class EmbedlyLinkChooser extends Component {
class EmbedlyLink extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
@ -13,12 +10,9 @@ class EmbedlyLink extends Component {
} }
} }
removeLink = () => {
this.props.updateTopic({ link: null })
}
resetLink = () => { resetLink = () => {
this.setState({ linkEdit: '' }) this.setState({ linkEdit: '' })
this.props.cancel()
} }
onLinkChangeHandler = e => { onLinkChangeHandler = e => {
@ -35,17 +29,11 @@ class EmbedlyLink extends Component {
} }
render = () => { render = () => {
const { link, authorizedToEdit, topicId } = this.props
const { linkEdit } = this.state const { linkEdit } = this.state
const hasAttachment = !!link
if (!hasAttachment && !authorizedToEdit) return null
return ( return (
<div className={hasAttachment ? 'embeds' : 'link-adder'}> <div className="link-chooser">
<div className="addLink" <div className="addLink">
style={{ display: hasAttachment ? 'none' : 'block' }}
>
<div id="addLinkIcon"></div> <div id="addLinkIcon"></div>
<div id="addLinkInput"> <div id="addLinkInput">
<input ref={input => (this.linkInput = input)} <input ref={input => (this.linkInput = input)}
@ -53,26 +41,17 @@ class EmbedlyLink extends Component {
value={linkEdit} value={linkEdit}
onChange={this.onLinkChangeHandler} onChange={this.onLinkChangeHandler}
onKeyUp={this.onLinkKeyUpHandler}></input> onKeyUp={this.onLinkKeyUpHandler}></input>
{linkEdit && <div id="addLinkReset" onClick={this.resetLink}></div>} <div className="attachment-cancel" onClick={this.resetLink}></div>
</div> </div>
</div> </div>
{link && <Card key={topicId} link={link} />}
{authorizedToEdit && (
<div id="linkremove"
style={{ display: hasAttachment ? 'block' : 'none' }}
onClick={this.removeLink}
/>
)}
</div> </div>
) )
} }
} }
EmbedlyLink.propTypes = { EmbedlyLinkChooser.propTypes = {
topicId: PropTypes.number, updateTopic: PropTypes.func,
link: PropTypes.string, cancel: PropTypes.func
authorizedToEdit: PropTypes.bool,
updateTopic: PropTypes.func
} }
export default EmbedlyLink export default EmbedlyLinkChooser

View file

@ -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 (
<div className={`file ${this.getFileType(file.content_type)}-file-type`}
style={{ clear: 'both' }}
>
<a href={file.url} target="_blank">
<img src={this.getFileIcon(file)} className="filetype-icon" />
{file.file_name}
</a>
<div className="attachment-cancel" onClick={this.props.removeAttachment} />
</div>
)
}
}
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

View file

@ -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 (
<div className="upload-file">
<Dropzone className="upload-file-dropzone"
onDrop={this.handleFileUpload}
>
Drag file here <br />
(maximum 5mb)
</Dropzone>
<div className="attachment-cancel" onClick={this.props.cancel} />
</div>
)
}
}
FileUploader.propTypes = {
updateTopic: PropTypes.func,
uploadAttachment: PropTypes.func,
cancel: PropTypes.func
}
export default FileUploader

View file

@ -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 (
<div className="upload-photo">
<Dropzone className="upload-photo-dropzone"
onDrop={this.handleFileUpload}
>
Drag photo here <br />
or click to upload
</Dropzone>
<div className="attachment-cancel" onClick={this.props.cancel} />
</div>
)
}
}
PhotoUploader.propTypes = {
updateTopic: PropTypes.func,
uploadAttachment: PropTypes.func,
cancel: PropTypes.func
}
export default PhotoUploader

View file

@ -10,12 +10,17 @@ import Info from './Info'
class ReactTopicCard extends Component { class ReactTopicCard extends Component {
render = () => { render = () => {
const { currentUser, onTopicFollow, updateTopic } = this.props const {
currentUser, onTopicFollow, updateTopic, uploadAttachment,
removeAttachment
} = this.props
const topic = this.props.openTopic const topic = this.props.openTopic
if (!topic) return null if (!topic) return null
const wrappedUpdateTopic = obj => updateTopic(topic, obj) const wrappedUpdateTopic = obj => updateTopic(topic, obj)
const wrappedUploadAttachment = file => uploadAttachment(topic, file)
const wrappedRemoveAttachment = () => removeAttachment(topic)
const authorizedToEdit = topic.authorizeToEdit(currentUser) const authorizedToEdit = topic.authorizeToEdit(currentUser)
const hasAttachment = topic.get('link') && topic.get('link') !== '' const hasAttachment = topic.get('link') && topic.get('link') !== ''
@ -48,9 +53,13 @@ class ReactTopicCard extends Component {
authorizedToEdit={authorizedToEdit} authorizedToEdit={authorizedToEdit}
onChange={wrappedUpdateTopic} onChange={wrappedUpdateTopic}
/> />
<Attachments topic={topic} <Attachments key={topic.id}
topic={topic}
authorizedToEdit={authorizedToEdit} authorizedToEdit={authorizedToEdit}
updateTopic={wrappedUpdateTopic} updateTopic={wrappedUpdateTopic}
uploadAttachment={wrappedUploadAttachment}
removeAttachment={wrappedRemoveAttachment}
fileTypeIcons={this.props.fileTypeIcons}
/> />
<Info topic={topic} /> <Info topic={topic} />
<div className='clearfloat' /> <div className='clearfloat' />
@ -75,7 +84,9 @@ ReactTopicCard.propTypes = {
name: PropTypes.string name: PropTypes.string
})) }))
})), })),
redrawCanvas: PropTypes.func redrawCanvas: PropTypes.func,
uploadAttachment: PropTypes.func,
fileTypeIcons: PropTypes.objectOf(PropTypes.string)
} }
export default ReactTopicCard export default ReactTopicCard

View file

@ -49,6 +49,7 @@
"react-draggable": "3.0.3", "react-draggable": "3.0.3",
"react-dropzone": "4.1.2", "react-dropzone": "4.1.2",
"react-onclickoutside": "6.5.0", "react-onclickoutside": "6.5.0",
"react-recorder": "1.0.0",
"react-router": "3.0.5", "react-router": "3.0.5",
"redux": "3.7.2", "redux": "3.7.2",
"riek": "1.1.0", "riek": "1.1.0",

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'topics API', type: :request do
let(:user) { create(:user, admin: true) }
let(:token) { create(:token, user: user).token }
let(:attachment) { create(:attachment) }
it 'GET /api/v2/attachments' do
create_list(:attachment, 5)
get '/api/v2/attachments', params: { access_token: token }
expect(response).to have_http_status(:success)
expect(response).to match_json_schema(:attachments)
expect(JSON.parse(response.body)['data'].count).to eq 5
end
it 'GET /api/v2/attachments/:id' do
get "/api/v2/attachments/#{attachment.id}"
expect(response).to have_http_status(:success)
expect(response).to match_json_schema(:attachment)
expect(JSON.parse(response.body)['data']['id']).to eq attachment.id
end
context 'RAML example' do
let(:resource) { get_json_example(:attachment) }
let(:collection) { get_json_example(:attachments) }
it 'resource matches schema' do
expect(resource).to match_json_schema(:attachment)
end
it 'collection matches schema' do
expect(collection).to match_json_schema(:attachments)
end
end
end

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
FactoryGirl.define do
factory :attachment do
association :attachable, factory: :topic
end
end

View file

@ -23,7 +23,7 @@ RSpec.describe MapPolicy, type: :policy do
context 'private' do context 'private' do
let(:map) { create(:map, permission: :private) } let(:map) { create(:map, permission: :private) }
permissions :show?, :create?, :update?, :destroy? do permissions :show?, :create?, :update?, :destroy? do
it 'permits access' do it 'denies access' do
expect(subject).to_not permit(nil, map) expect(subject).to_not permit(nil, map)
end end
end end