implemented new profile photo upload with cropping function

This commit is contained in:
Frédéric Bolvin 2017-08-23 20:21:24 +02:00
parent 03ee954c10
commit d7abaaced0
16 changed files with 387 additions and 63 deletions

View file

@ -2,7 +2,8 @@
"env": {
"browser": true,
"jasmine": true,
"jquery": true
"jquery": true,
"es6": true
},
"globals": {
@ -96,7 +97,7 @@
"no-catch-shadow": 0,
"no-class-assign": 2,
"no-cond-assign": 2,
"no-confusing-arrow": 2,
"no-confusing-arrow": [2, {"allowParens": true}],
"no-console": 2,
"no-const-assign": 2,
"no-constant-condition": 2,

View file

@ -89,6 +89,7 @@ gem "entypo-rails", "3.0.0"
# JavaScript
gem "sprockets-es6", "0.9.2"
gem "handlebars_assets", "0.23.2"
gem "jquery-rails", "4.3.1"
gem "js-routes", "1.4.1"
@ -110,6 +111,7 @@ source "https://rails-assets.org" do
gem "rails-assets-backbone", "1.3.3"
gem "rails-assets-bootstrap-markdown", "2.10.0"
gem "rails-assets-corejs-typeahead", "1.1.1"
gem "rails-assets-cropperjs", "1.2.1"
gem "rails-assets-fine-uploader", "5.13.0"
# jQuery plugins

View file

@ -62,6 +62,10 @@ GEM
attr_required (1.0.1)
autoprefixer-rails (7.1.4.1)
execjs
babel-source (5.8.35)
babel-transpiler (0.7.0)
babel-source (>= 4.0, < 6)
execjs (~> 2.0)
bcrypt (3.1.11)
bindata (2.4.1)
bootstrap-sass (3.3.7)
@ -516,6 +520,7 @@ GEM
rails-assets-bootstrap (~> 3)
rails-assets-corejs-typeahead (1.1.1)
rails-assets-jquery (>= 1.11)
rails-assets-cropperjs (1.2.1)
rails-assets-diaspora_jsxc (0.1.5.develop.7)
rails-assets-emojione (~> 2.0.1)
rails-assets-favico.js (>= 0.3.10, < 0.4)
@ -670,6 +675,10 @@ GEM
sprockets (3.7.1)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-es6 (0.9.2)
babel-source (>= 5.8.11)
babel-transpiler
sprockets (>= 3.0.0)
sprockets-rails (3.2.1)
actionpack (>= 4.0)
activesupport (>= 4.0)
@ -848,6 +857,7 @@ DEPENDENCIES
rails-assets-blueimp-gallery (= 2.27.0)!
rails-assets-bootstrap-markdown (= 2.10.0)!
rails-assets-corejs-typeahead (= 1.1.1)!
rails-assets-cropperjs (= 1.2.1)!
rails-assets-diaspora_jsxc (= 0.1.5.develop.7)!
rails-assets-fine-uploader (= 5.13.0)!
rails-assets-highlightjs (= 9.12.0)!
@ -888,6 +898,7 @@ DEPENDENCIES
spring (= 2.0.2)
spring-commands-cucumber (= 1.0.1)
spring-commands-rspec (= 1.0.4)
sprockets-es6 (= 0.9.2)
sprockets-rails (= 3.2.1)
string-direction (= 1.2.0)
timecop (= 0.9.1)
@ -904,4 +915,4 @@ DEPENDENCIES
will_paginate (= 3.1.6)
BUNDLED WITH
1.15.4
1.16.1

View file

@ -0,0 +1,252 @@
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
Diaspora.ProfilePhotoUploader = class {
/**
* Initializes a new instance of ProfilePhotoUploader
*/
constructor() {
// get several elements we will use a few times
this.fileInput = document.querySelector("#file-upload");
this.picture = document.querySelector("#profile_photo_upload .avatar");
this.info = document.querySelector("#fileInfo");
this.cropContainer = document.querySelector(".crop-container");
this.spinner = document.querySelector("#file-upload-spinner");
/**
* Creates a button
* @param {string} icon - The entypo icon class.
* @param {function} onClick - Is called when button has been clicked.
*/
this.createButton = (icon, onClick) =>
($(`<button class="btn btn-default" type="button"><i class="entypo-${icon}"></i></button>`)
.on("click", onClick));
/**
* Shows a message using flash messages or alert for mobile.
* @param {string} type - The type of the message, e.g. "error" or "success".
* @param text - The text to display.
*/
this.showMessage = (type, text) => (app.flashMessages ? app.flashMessages[type](text) : alert(text));
this.initFineUploader();
}
/**
* Initializes the fine uploader component
*/
initFineUploader() {
this.fineUploader = new qq.FineUploaderBasic({
element: this.fileInput,
validation: {
allowedExtensions: ["jpg", "jpeg", "png"]
},
request: {
endpoint: Routes.photos(),
params: {
/* eslint-disable camelcase */
authenticity_token: $("meta[name='csrf-token']").attr("content"),
/* eslint-enable camelcase */
photo: {"pending": true, "aspect_ids": "all", "set_profile_photo": true}
}
},
button: this.fileInput,
autoUpload: false,
messages: {
typeError: Diaspora.I18n.t("photo_uploader.invalid_ext"),
sizeError: Diaspora.I18n.t("photo_uploader.size_error"),
emptyError: Diaspora.I18n.t("photo_uploader.empty")
},
callbacks: {
onProgress: (id, fileName, loaded, total) => {
(this.info.innerText = `${fileName} ${Math.round(loaded / total * 100)}%`);
},
onSubmit: (id, name) => this.onPictureSelected(id, name),
onComplete: (id, name, responseJSON) => this.onUploadCompleted(id, name, responseJSON),
onError: (id, name) => this.showMessage("error", Diaspora.I18n.t("photo_uploader.error", {file: name}))
},
text: {
fileInputTitle: ""
},
scaling: {
sendOriginal: false,
sizes: [
{maxSize: 1600}
]
}
});
}
/**
* Called when a picture from user's device has been selected.
* @param {number} id - The current file's id.
* @param {string} name - The current file's name.
*/
onPictureSelected(id, name) {
this.setLoading(true);
this.fileName = name;
const file = this.fileInput.querySelector("input").files[0];
// ensure browser's file reader support
if (FileReader && file) {
const fileReader = new FileReader();
fileReader.onload = () => this.initCropper(fileReader.result);
fileReader.readAsDataURL(file);
} else {
this.setLoading(false);
}
}
/**
* Initializes the cropper and all controls.
* @param {object|string} imageData - The base64 image data
*/
initCropper(imageData) {
// cache the current picture source if the user cancels
this.previousPicture = this.picture.getAttribute("src");
this.mimeType = imageData.split(";base64")[0].substring(5);
this.picture.onload = () => {
// set the preferred size style of the cropper based on picture orientation
const isPortrait = this.picture.naturalHeight > this.picture.naturalWidth;
this.picture.setAttribute("style", (isPortrait ? "max-height:600px;max-width:none;" : "max-width:600px;"));
this.buildControls();
this.setLoading(false);
// eslint-disable-next-line no-undef
this.cropper = new Cropper(this.picture, {
aspectRatio: 1,
zoomable: false,
autoCropArea: 1,
preview: ".preview"
});
};
this.picture.setAttribute("src", imageData);
}
/**
* Creates image manipulation controls and previews.
*/
buildControls() {
this.controls = {
rotateLeft: this.createButton("ccw", () => this.cropper.rotate(-45)),
rotateRight: this.createButton("cw", () => this.cropper.rotate(45)),
reset: this.createButton("cycle", () => this.cropper.reset()),
accept: this.createButton("check", () => this.cropImage()),
cancel: this.createButton("trash", () => this.cancel())
};
this.controlRow = $("<div class='controls'>").appendTo(this.cropContainer);
// rotation buttons on the left
this.controlRow.append($("<div class='btn-group buttons-left' role='group'>").append([
this.controls.rotateLeft,
this.controls.rotateRight
]));
// preview images in the middle
this.controlRow.append("<div class='preview'>");
// main buttons on the right
this.controlRow.append($("<div class='btn-group buttons-right' role='group'>").append([
this.controls.reset,
this.controls.cancel,
this.controls.accept
]));
}
/**
* Called when the user clicked accept button. Sets file data and triggers file upload.
*/
cropImage() {
const canvas = this.cropper.getCroppedCanvas();
// replace the stored file with the new canvas
this.fineUploader.clearStoredFiles();
this.fineUploader.addFiles([{
canvas: canvas,
name: this.fileName,
quality: 100,
type: this.mimeType
}]);
// reset all controls
this.cancel();
this.picture.setAttribute("src", canvas.toDataURL(this.mimeType));
// finally start uploading
this.setLoading(true);
this.fineUploader.uploadStoredFiles();
}
/**
* Is called after the file upload has been completed and the profile photo changed.
* @param {number} id - The current file's id.
* @param {string} fileName - The current file's name.
* @param {object} responseJSON - The server's json response.
*/
onUploadCompleted(id, fileName, responseJSON) {
this.setLoading(false);
this.fileInput.classList.remove("hidden");
if (responseJSON.data !== undefined) {
/* flash message prompt */
this.showMessage("success", Diaspora.I18n.t("photo_uploader.looking_good"));
this.info.innerText = Diaspora.I18n.t("photo_uploader.completed", {"file": fileName});
const photoId = responseJSON.data.photo.id;
const url = responseJSON.data.photo.unprocessed_image.url;
const oldPhoto = $("#photo_id");
if (oldPhoto.length === 0) {
$("#update_profile_form")
.prepend(`<input type="hidden" value="${photoId}" id="photo_id" name="photo_id"/>`);
} else {
oldPhoto.val(photoId);
}
this.picture.setAttribute("src", url);
$(`.avatar[alt="${gon.user.diaspora_id}"]`).attr("src", url);
} else {
this.cancel();
}
}
/**
* Toggles loading state by hiding or showing several elements
* @param {boolean} loading - True if loading state should be enabled.
*/
setLoading(loading) {
if (loading) {
this.fileInput.classList.add("hidden");
this.picture.classList.add("hidden");
this.spinner.classList.remove("hidden");
} else {
this.picture.classList.remove("hidden");
this.spinner.classList.add("hidden");
}
}
/**
* Destroys the cropper and resets all elements to initial state.
*/
cancel() {
this.cropper.destroy();
this.picture.onload = null;
this.picture.setAttribute("style", "");
this.picture.setAttribute("src", this.previousPicture);
this.controlRow.remove();
this.fileInput.classList.remove("hidden");
this.info.innerText = "";
this.mimeType = null;
this.name = null;
}
};
// @license-end

View file

@ -44,3 +44,4 @@
//= require bootstrap-markdown/bootstrap-markdown
//= require helpers/markdown_editor
//= require jquery.are-you-sure
//= require cropperjs/dist/cropper.js

View file

@ -16,7 +16,6 @@
//= require bootstrap
//= require diaspora
//= require helpers/i18n
//= require helpers/profile_photo_uploader
//= require helpers/tags_autocomplete
//= require bootstrap-markdown/bootstrap-markdown
//= require helpers/markdown_editor
@ -32,4 +31,5 @@
//= require mobile/mobile_conversations
//= require mobile/mobile_post_actions
//= require mobile/mobile_drawer
//= require mobile/mobile_profile_photo_uploader
// @license-end

View file

@ -42,6 +42,7 @@
// profile and settings pages
@import 'settings';
@import 'cropperjs/dist/cropper';
// new SPV
@import 'header';

View file

@ -1,9 +1,4 @@
#hello-there {
#profile_photo_upload .avatar {
max-height: 200px;
max-width: 200px;
}
.well .avatar {
min-width: 50px;
}

View file

@ -631,6 +631,12 @@ h1.session {
}
}
}
.spinner {
height: 50px;
margin-top: 10px;
width: 50px;
}
}
#birth-date {

View file

@ -13,6 +13,7 @@
padding-right: 10px;
}
}
// scss-lint:enable SelectorFormat
.enclosed-checkbox label {
@ -28,9 +29,31 @@
max-width: 200px;
width: auto;
}
.crop-container {
.controls {
display: flex;
justify-content: space-between;
margin-top: 10px;
}
}
.preview {
border-radius: 4px;
height: 50px;
overflow: hidden;
width: 50px;
}
.spinner {
height: 50px;
width: 50px;
}
}
.settings-visibility { margin-left: 10px; }
.settings-visibility {
margin-left: 10px;
}
.page-profiles.action-edit textarea {
max-width: 100%;

View file

@ -5,14 +5,14 @@
# the COPYRIGHT file.
class PhotosController < ApplicationController
before_action :authenticate_user!, except: %i(show index)
before_action :authenticate_user!, except: %i[show index]
respond_to :html, :json
def show
@photo = if user_signed_in?
current_user.photos_from(Person.find_by_guid(params[:person_id])).where(id: params[:id]).first
else
Photo.where(id: params[:id], public: true).first
current_user.photos_from(Person.find_by(guid: params[:person_id])).where(id: params[:id]).first
else
Photo.where(id: params[:id], public: true).first
end
raise ActiveRecord::RecordNotFound unless @photo
@ -20,7 +20,7 @@ class PhotosController < ApplicationController
def index
@post_type = :photos
@person = Person.find_by_guid(params[:person_id])
@person = Person.find_by(guid: params[:person_id])
authenticate_user! if @person.try(:remote?) && !user_signed_in?
@presenter = PersonPresenter.new(@person, current_user)
@ -35,10 +35,10 @@ class PhotosController < ApplicationController
render "people/show", layout: "with_header"
end
format.mobile { render "people/show" }
format.json{ render_for_api :backbone, :json => @posts, :root => :photos }
format.json { render_for_api :backbone, json: @posts, root: :photos }
end
else
flash[:error] = I18n.t 'people.show.does_not_exist'
flash[:error] = I18n.t "people.show.does_not_exist"
redirect_to people_path
end
end
@ -51,21 +51,23 @@ class PhotosController < ApplicationController
def make_profile_photo
author_id = current_user.person_id
@photo = Photo.where(:id => params[:photo_id], :author_id => author_id).first
@photo = Photo.where(id: params[:photo_id], author_id: author_id).first
if @photo
profile_hash = {:image_url => @photo.url(:thumb_large),
:image_url_medium => @photo.url(:thumb_medium),
:image_url_small => @photo.url(:thumb_small)}
profile_hash = {image_url: @photo.url(:thumb_large),
image_url_medium: @photo.url(:thumb_medium),
image_url_small: @photo.url(:thumb_small)}
if current_user.update_profile(profile_hash)
respond_to do |format|
format.js{ render :json => { :photo_id => @photo.id,
:image_url => @photo.url(:thumb_large),
:image_url_medium => @photo.url(:thumb_medium),
:image_url_small => @photo.url(:thumb_small),
:author_id => author_id},
:status => 201}
format.js {
render json: {photo_id: @photo.id,
image_url: @photo.url(:thumb_large),
image_url_medium: @photo.url(:thumb_medium),
image_url_small: @photo.url(:thumb_small),
author_id: author_id},
status: 201
}
end
else
head :unprocessable_entity
@ -76,7 +78,7 @@ class PhotosController < ApplicationController
end
def destroy
photo = current_user.photos.where(:id => params[:id]).first
photo = current_user.photos.where(id: params[:id]).first
if photo
current_user.retract(photo)
@ -84,16 +86,16 @@ class PhotosController < ApplicationController
respond_to do |format|
format.json { head :no_content }
format.html do
flash[:notice] = I18n.t 'photos.destroy.notice'
if StatusMessage.find_by_guid(photo.status_message_guid)
respond_with photo, :location => post_path(photo.status_message)
flash[:notice] = I18n.t "photos.destroy.notice"
if StatusMessage.find_by(guid: photo.status_message_guid)
respond_with photo, location: post_path(photo.status_message)
else
respond_with photo, :location => person_photos_path(current_user.person)
respond_with photo, location: person_photos_path(current_user.person)
end
end
end
else
respond_with photo, :location => person_photos_path(current_user.person)
respond_with photo, location: person_photos_path(current_user.person)
end
end
@ -105,23 +107,27 @@ class PhotosController < ApplicationController
def file_handler(params)
# For XHR file uploads, request.params[:qqfile] will be the path to the temporary file
# For regular form uploads (such as those made by Opera), request.params[:qqfile] will be an UploadedFile which can be returned unaltered.
if not request.params[:qqfile].is_a?(String)
params[:qqfile]
# For regular form uploads (such as those made by Opera), request.params[:qqfile] will be an
# UploadedFile which can be returned unaltered.
if !request.params[:qqfile].is_a?(String)
qqfile = params[:qqfile]
# Cropped or manipulated files have their real filename only in qqfilename. Take care of this.
qqfile.original_filename = params[:qqfilename] if qqfile.original_filename == "blob"
qqfile
else
######################## dealing with local files #############
# get file name
file_name = params[:qqfile]
# get file content type
att_content_type = (request.content_type.to_s == "") ? "application/octet-stream" : request.content_type.to_s
att_content_type = request.content_type.to_s == "" ? "application/octet-stream" : request.content_type.to_s
# create tempora##l file
file = Tempfile.new(file_name, {:encoding => 'BINARY'})
file = Tempfile.new(file_name, encoding: "BINARY")
# put data into this file from raw post request
file.print request.raw_post.force_encoding('BINARY')
file.print request.raw_post.force_encoding("BINARY")
# create several required methods for this temporal file
Tempfile.send(:define_method, "content_type") {return att_content_type}
Tempfile.send(:define_method, "original_filename") {return file_name}
Tempfile.send(:define_method, "content_type") { return att_content_type }
Tempfile.send(:define_method, "original_filename") { return file_name }
file
end
end
@ -149,36 +155,35 @@ class PhotosController < ApplicationController
end
if photo_params[:set_profile_photo]
profile_params = {:image_url => @photo.url(:thumb_large),
:image_url_medium => @photo.url(:thumb_medium),
:image_url_small => @photo.url(:thumb_small)}
profile_params = {image_url: @photo.url(:thumb_large),
image_url_medium: @photo.url(:thumb_medium),
image_url_small: @photo.url(:thumb_small)}
current_user.update_profile(profile_params)
end
respond_to do |format|
format.json{ render(:layout => false , :json => {"success" => true, "data" => @photo}.to_json )}
format.html{ render(:layout => false , :json => {"success" => true, "data" => @photo}.to_json )}
format.json { render(layout: false, json: {"success" => true, "data" => @photo}.to_json) }
format.html { render(layout: false, json: {"success" => true, "data" => @photo}.to_json) }
end
else
respond_with @photo, :location => photos_path, :error => message
respond_with @photo, location: photos_path, error: message
end
end
def rescuing_photo_errors
begin
yield
rescue TypeError
message = I18n.t 'photos.create.type_error'
respond_with @photo, :location => photos_path, :error => message
yield
rescue TypeError
return_photo_error I18n.t "photos.create.type_error"
rescue CarrierWave::IntegrityError
return_photo_error I18n.t "photos.create.integrity_error"
rescue RuntimeError
return_photo_error I18n.t "photos.create.runtime_error"
end
rescue CarrierWave::IntegrityError
message = I18n.t 'photos.create.integrity_error'
respond_with @photo, :location => photos_path, :error => message
rescue RuntimeError => e
message = I18n.t 'photos.create.runtime_error'
respond_with @photo, :location => photos_path, :error => message
raise e
def return_photo_error(message)
respond_to do |format|
format.json { render(layout: false, json: {"success" => false, "error" => message}.to_json) }
format.html { render(layout: false, json: {"success" => false, "error" => message}.to_json) }
end
end
end

View file

@ -3,14 +3,15 @@
-# the COPYRIGHT file.
.profile-photo-upload#profile_photo_upload
= owner_image_tag(:thumb_large)
.crop-container
= owner_image_tag(:thumb_large)
.small-horizontal-spacer
.clearfix
.text-center
#file-upload.btn.btn-primary
=t('.upload')
= image_tag('mobile-spinner.gif', :class => 'hidden', :style => "z-index:-1", :id => 'file-upload-spinner')
.spinner.hidden#file-upload-spinner
%p
#fileInfo

View file

@ -44,12 +44,31 @@ Feature: editing your profile
And I press "update_profile"
Then I should see "#kamino" within "ul#as-selections-tags"
And I should see "#starwars" within "ul#as-selections-tags"
And I should see a ".crop-container" within "#profile_photo_upload"
And the "#profile_public_details" bootstrap-switch should be on
When I attach the file "spec/fixtures/bad_urls.txt" to "qqfile" within "#file-upload"
Then I should see a flash message indicating failure
When I attach the file "spec/fixtures/button.png" to hidden "qqfile" within "#file-upload"
Then I should see a ".cropper-container" within ".crop-container"
And I should see a ".controls" within ".crop-container"
And I should see a ".preview" within ".controls"
And I should see 2 ".btn" within ".buttons-left"
And I should see 3 ".btn" within ".buttons-right"
When I press the 2nd ".btn" within ".buttons-right"
Then I should see a ".avatar" within ".crop-container"
But I should not see a ".cropper-container" within ".crop-container"
When I attach the file "spec/fixtures/button.png" to hidden "qqfile" within "#file-upload"
Then I should see a ".cropper-container" within ".crop-container"
And I should see a ".controls" within ".crop-container"
And I should see a ".preview" within ".controls"
And I should see 2 ".btn" within ".buttons-left"
And I should see 3 ".btn" within ".buttons-right"
When I press the 3rd ".btn" within ".buttons-right"
Then I should see "button.png completed"
And I should see a "img" within "#profile_photo_upload"

View file

@ -182,6 +182,12 @@ Then /^(?:|I )should see a "([^\"]*)"(?: within "([^\"]*)")?$/ do |selector, sco
end
end
Then /^I should see (\d+) "([^\"]*)"(?: within "([^\"]*)")?$/ do |count, selector, scope_selector|
with_scope(scope_selector) do
expect(current_scope).to have_selector(selector, count: count)
end
end
Then /^(?:|I )should not see a "([^\"]*)"(?: within "([^\"]*)")?$/ do |selector, scope_selector|
with_scope(scope_selector) do
current_scope.should have_no_css(selector, :visible => true)

View file

@ -96,7 +96,8 @@ When /^(?:|I )attach the file "([^"]*)" to (?:hidden )?"([^"]*)"(?: within "([^"
attach_file(field, Rails.root.join(path).to_s)
end
# wait for the image to be ready
page.assert_selector(".loading", count: 0)
page.assert_no_selector(".loading")
page.assert_no_selector("#file-upload-spinner")
end
Then /^(?:|I )should see (\".+?\"[\s]*)(?:[\s]+within[\s]* "([^"]*)")?$/ do |vars, selector|