diff --git a/.gitignore b/.gitignore index 196d3a76..3f798c97 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ coverage/ .idea/ /public/assets +/public/system # damn vim backup files *~ diff --git a/Gemfile b/Gemfile index dd1e8e80..e281b595 100644 --- a/Gemfile +++ b/Gemfile @@ -29,6 +29,8 @@ gem 'font-kit-rails' gem 'nprogress-rails' gem 'font-awesome-rails', '~> 4.2.0.0' gem 'rails-assets-growl' +gem "paperclip", "~> 4.2" +gem 'delayed_paperclip' gem 'ruby-progressbar' diff --git a/Gemfile.lock b/Gemfile.lock index 2ea048b5..cc81ddf5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -67,7 +67,11 @@ GEM xpath (~> 2.0) celluloid (0.16.0) timers (~> 4.0.0) + climate_control (0.0.3) + activesupport (>= 3.0) cliver (0.3.2) + cocaine (0.5.5) + climate_control (>= 0.0.3, < 1.0) coffee-rails (4.1.0) coffee-script (>= 2.2.0) railties (>= 4.0.0, < 5.0) @@ -80,6 +84,8 @@ GEM crass (1.0.1) daemons (1.1.9) database_cleaner (1.3.0) + delayed_paperclip (2.9.0) + paperclip (>= 3.3) devise (3.4.1) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -167,6 +173,11 @@ GEM multi_json (~> 1.3) omniauth-oauth (~> 1.0) orm_adapter (0.5.0) + paperclip (4.2.1) + activemodel (>= 3.0.0) + activesupport (>= 3.0.0) + cocaine (~> 0.5.3) + mime-types pg (0.17.1) poltergeist (1.5.1) capybara (~> 2.1) @@ -339,6 +350,7 @@ DEPENDENCIES capybara coffee-rails (~> 4.1.0) database_cleaner + delayed_paperclip devise factory_girl_rails faker @@ -354,6 +366,7 @@ DEPENDENCIES nprogress-rails omniauth omniauth-twitter + paperclip (~> 4.2) pg poltergeist questiongenerator! diff --git a/app/assets/javascripts/application.js.erb.coffee b/app/assets/javascripts/application.js.erb.coffee index f37ea0b7..ef98ffe4 100644 --- a/app/assets/javascripts/application.js.erb.coffee +++ b/app/assets/javascripts/application.js.erb.coffee @@ -7,6 +7,7 @@ #= require nprogress-turbolinks #= require growl #= require cheet +#= require jquery.guillotine #= require_tree . NProgress.configure diff --git a/app/assets/javascripts/settings.coffee b/app/assets/javascripts/settings.coffee index 4f6517bb..5742e5b9 100644 --- a/app/assets/javascripts/settings.coffee +++ b/app/assets/javascripts/settings.coffee @@ -2,4 +2,50 @@ ($ document).on "submit", "form#edit_user", (evt) -> if ($ "input#user_current_password").val().length == 0 evt.preventDefault() - $("button[data-target=#modal-passwd]").trigger 'click' \ No newline at end of file + $("button[data-target=#modal-passwd]").trigger 'click' + + +# Profile pic +($ document).on 'change', 'input#user_profile_picture[type=file]', -> + input = ($ this)[0] + + ($ '#profile-picture-crop-controls').slideUp 400, -> + if input.files and input.files[0] + fr = new FileReader() + ($ fr).on 'load', (e) -> + cropper = ($ '#profile-picture-cropper') + preview = ($ '#profile-picture-preview') + + updateVars = (data, action) -> + ($ '#crop_x').val Math.floor(data.x / data.scale) + ($ '#crop_y').val Math.floor(data.y / data.scale) + ($ '#crop_w').val Math.floor(data.w / data.scale) + ($ '#crop_h').val Math.floor(data.h / data.scale) +# rx = 100 / data.w +# ry = 100 / data.h +# ($ '#profile-picture-preview').css +# width: Math.round(rx * preview[0].naturalWidth) + 'px' +# height: Math.round(ry * preview[0].naturalHeight) + 'px' +# marginLeft: '-' + Math.round(rx * data.x) + 'px' +# marginTop: '-' + Math.round(ry * data.y) + 'px' + + cropper.on 'load', -> + side = if cropper[0].naturalWidth > cropper[0].naturalHeight + cropper[0].naturalHeight + else + cropper[0].naturalWidth + + cropper.guillotine + width: side + height: side + onChange: updateVars + + updateVars cropper.guillotine('getData'), 'drag' # just because + + ($ '#cropper-zoom-out').click -> cropper.guillotine 'zoomOut' + ($ '#cropper-zoom-in').click -> cropper.guillotine 'zoomIn' + ($ '#profile-picture-crop-controls').slideDown() + + cropper.attr 'src', e.target.result + + fr.readAsDataURL(input.files[0]) \ No newline at end of file diff --git a/app/assets/stylesheets/application.css.scss b/app/assets/stylesheets/application.css.scss index f929ab0d..e88e57cc 100644 --- a/app/assets/stylesheets/application.css.scss +++ b/app/assets/stylesheets/application.css.scss @@ -1,6 +1,7 @@ /* *= require rails_bootstrap_forms *= require growl + *= require jquery.guillotine *= require_self */ diff --git a/app/controllers/user_controller.rb b/app/controllers/user_controller.rb index cc560d54..8124e1e8 100644 --- a/app/controllers/user_controller.rb +++ b/app/controllers/user_controller.rb @@ -1,4 +1,6 @@ class UserController < ApplicationController + before_filter :authenticate_user!, only: %w(edit update) + def show @user = User.where('LOWER(screen_name) = ?', params[:username].downcase).first! @answers = @user.answers.reverse_order.paginate(page: params[:page]) @@ -9,14 +11,17 @@ class UserController < ApplicationController end def edit - authenticate_user! end def update - authenticate_user! - user_attributes = params.require(:user).permit(:display_name, :motivation_header, :website, :location, :bio) - unless current_user.update_attributes(user_attributes) - flash[:error] = 'fork it' + user_attributes = params.require(:user).permit(:display_name, :profile_picture, :motivation_header, :website, + :location, :bio, :crop_x, :crop_y, :crop_w, :crop_h) + if current_user.update_attributes(user_attributes) + text = 'Your profile has been updated!' + text += ' It might take a few minutes until your new profile picture is shown everywhere.' if user_attributes[:profile_picture] + flash[:success] = text + else + flash[:error] = 'An error occurred. ;_;' end redirect_to edit_user_profile_path end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index cf7e294a..5873f7db 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -59,11 +59,13 @@ module ApplicationHelper ((!current_user.nil?) && ((current_user == user) || current_user.mod?)) ? true : false end + # @deprecated Use {User#profile_picture.url} instead. def gravatar_url(user) + return user.profile_picture.url :medium # return '/cage.png' - return '//www.gravatar.com/avatar' if user.nil? - return "//www.gravatar.com/avatar/#{Digest::MD5.hexdigest(user)}" if user.is_a? String - "//www.gravatar.com/avatar/#{Digest::MD5.hexdigest(user.email)}" + #return '//www.gravatar.com/avatar' if user.nil? + #return "//www.gravatar.com/avatar/#{Digest::MD5.hexdigest(user)}" if user.is_a? String + #"//www.gravatar.com/avatar/#{Digest::MD5.hexdigest(user.email)}" end def ios_web_app? diff --git a/app/models/user.rb b/app/models/user.rb index 4a805a4d..05048789 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -40,6 +40,12 @@ class User < ActiveRecord::Base # validates :website, format: { with: WEBSITE_REGEX } + has_attached_file :profile_picture, styles: { large: "500x500#", medium: "256x256#", small: "80x80#" }, + default_url: "/images/:style/no_avatar.png", use_timestamp: false, + processors: [:cropper] + validates_attachment_content_type :profile_picture, :content_type => /\Aimage\/.*\Z/ + process_in_background :profile_picture + before_save do self.display_name = 'WRYYYYYYYY' if display_name == 'Dio Brando' self.website = if website.match %r{\Ahttps?://} @@ -150,4 +156,8 @@ class User < ActiveRecord::Base def report_comment(report, content) ModerationComment.create!(user: self, report: report, content: content) end + + def cropping? + !crop_x.blank? && !crop_y.blank? && !crop_w.blank? && !crop_h.blank? + end end diff --git a/app/views/user/_account.html.haml b/app/views/user/_account.html.haml index e7f0f04d..be98fc87 100644 --- a/app/views/user/_account.html.haml +++ b/app/views/user/_account.html.haml @@ -1,11 +1,6 @@ .container.j2-page = render 'user/settings_tabs' .col-md-9.col-xs-12.col-sm-9 - .alert.alert-info - We currently only support avatars using - = succeed ',' do - %a{href: "https://en.gravatar.com"} Gravatar - after you set yours up, use the E-Mail you are using for it on here as well, we will directly use this image then! = render 'layouts/messages' .panel.panel-default .panel-body diff --git a/app/views/user/edit.html.haml b/app/views/user/edit.html.haml index a39e380f..bd2540c7 100644 --- a/app/views/user/edit.html.haml +++ b/app/views/user/edit.html.haml @@ -4,10 +4,27 @@ = render 'layouts/messages' .panel.panel-default .panel-body - = bootstrap_form_for(current_user, url: {action: "edit"}, method: "patch") do |f| + = bootstrap_form_for(current_user, url: {action: "edit"}, :html => { :multipart => true }, method: "patch") do |f| = f.text_field :display_name, label: "Your name" + .media + .pull-left + %img.img-rounded.profile--img{src: current_user.profile_picture.url(:medium)} + .media-body + = f.file_field :profile_picture + + .row#profile-picture-crop-controls{style: 'display: none;'} + .col-sm-10.col-md-8 + %strong Adjust your new image + %img#profile-picture-cropper{src: current_user.profile_picture.url(:medium)} + .col-sm-2.col-md-4 + .btn-group + %button#cropper-zoom-out.btn.btn-inverse{type: :button} + %i.fa.fa-search-minus + %button#cropper-zoom-in.btn.btn-inverse{type: :button} + %i.fa.fa-search-plus + = f.text_field :motivation_header, label: "Motivation header", placeholder: 'Ask me anything!' = f.text_field :website, label: "Website", placeholder: 'http://bad-dragon.com' @@ -16,4 +33,7 @@ = f.text_area :bio, label: "Bio", placeholder: 'In Bio war ich nie gut x--DD' + - for attrib in %i(crop_x crop_y crop_w crop_h) + = f.hidden_field attrib, id: attrib + = f.submit "Save settings", class: 'btn btn-primary' \ No newline at end of file diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 6762520e..525b6171 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -6,4 +6,5 @@ staging: production: :concurrency: 25 :queues: - - share \ No newline at end of file + - share + - paperclip \ No newline at end of file diff --git a/db/migrate/20141229085904_add_attachment_profile_picture_to_users.rb b/db/migrate/20141229085904_add_attachment_profile_picture_to_users.rb new file mode 100644 index 00000000..5191d385 --- /dev/null +++ b/db/migrate/20141229085904_add_attachment_profile_picture_to_users.rb @@ -0,0 +1,11 @@ +class AddAttachmentProfilePictureToUsers < ActiveRecord::Migration + def self.up + change_table :users do |t| + t.attachment :profile_picture + end + end + + def self.down + remove_attachment :users, :profile_picture + end +end diff --git a/db/migrate/20141229105013_add_profile_picture_processing_to_users.rb b/db/migrate/20141229105013_add_profile_picture_processing_to_users.rb new file mode 100644 index 00000000..a5f7e85d --- /dev/null +++ b/db/migrate/20141229105013_add_profile_picture_processing_to_users.rb @@ -0,0 +1,5 @@ +class AddProfilePictureProcessingToUsers < ActiveRecord::Migration + def change + add_column :users, :profile_picture_processing, :boolean + end +end diff --git a/db/migrate/20141229133149_add_crop_values_to_users.rb b/db/migrate/20141229133149_add_crop_values_to_users.rb new file mode 100644 index 00000000..f3580dcf --- /dev/null +++ b/db/migrate/20141229133149_add_crop_values_to_users.rb @@ -0,0 +1,10 @@ +class AddCropValuesToUsers < ActiveRecord::Migration + def change + # this is a ugly hack and will stay until I find a way to pass parameters + # to the paperclip Sidekiq worker. oh well. + add_column :users, :crop_x, :integer + add_column :users, :crop_y, :integer + add_column :users, :crop_w, :integer + add_column :users, :crop_h, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index a939558f..a5bcd502 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20141228202825) do +ActiveRecord::Schema.define(version: 20141229133149) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -133,12 +133,12 @@ ActiveRecord::Schema.define(version: 20141228202825) do add_index "smiles", ["user_id"], name: "index_smiles_on_user_id", using: :btree create_table "users", force: true do |t| - t.string "email", default: "", null: false - t.string "encrypted_password", default: "", null: false + t.string "email", default: "", null: false + t.string "encrypted_password", default: "", null: false t.string "reset_password_token" t.datetime "reset_password_sent_at" t.datetime "remember_created_at" - t.integer "sign_in_count", default: 0, null: false + t.integer "sign_in_count", default: 0, null: false t.datetime "current_sign_in_at" t.datetime "last_sign_in_at" t.string "current_sign_in_ip" @@ -146,19 +146,28 @@ ActiveRecord::Schema.define(version: 20141228202825) do t.datetime "created_at" t.datetime "updated_at" t.string "screen_name" - t.integer "friend_count", default: 0, null: false - t.integer "follower_count", default: 0, null: false - t.integer "asked_count", default: 0, null: false - t.integer "answered_count", default: 0, null: false - t.integer "commented_count", default: 0, null: false + t.integer "friend_count", default: 0, null: false + t.integer "follower_count", default: 0, null: false + t.integer "asked_count", default: 0, null: false + t.integer "answered_count", default: 0, null: false + t.integer "commented_count", default: 0, null: false t.string "display_name" - t.integer "smiled_count", default: 0, null: false - t.boolean "admin", default: false, null: false - t.string "motivation_header", default: "", null: false - t.string "website", default: "", null: false - t.string "location", default: "", null: false - t.text "bio", default: "", null: false - t.boolean "moderator", default: false, null: false + t.integer "smiled_count", default: 0, null: false + t.boolean "admin", default: false, null: false + t.string "motivation_header", default: "", null: false + t.string "website", default: "", null: false + t.string "location", default: "", null: false + t.text "bio", default: "", null: false + t.boolean "moderator", default: false, null: false + t.string "profile_picture_file_name" + t.string "profile_picture_content_type" + t.integer "profile_picture_file_size" + t.datetime "profile_picture_updated_at" + t.boolean "profile_picture_processing" + t.integer "crop_x" + t.integer "crop_y" + t.integer "crop_w" + t.integer "crop_h" end add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree diff --git a/lib/assets/javascripts/jquery.guillotine.js b/lib/assets/javascripts/jquery.guillotine.js new file mode 100644 index 00000000..3d6cb0b1 --- /dev/null +++ b/lib/assets/javascripts/jquery.guillotine.js @@ -0,0 +1,417 @@ +// Generated by CoffeeScript 1.8.0 + +/* + * jQuery Guillotine Plugin v1.3.0 + * http://matiasgagliano.github.com/guillotine/ + * + * Copyright 2014, MatÃas Gagliano. + * Dual licensed under the MIT or GPLv3 licenses. + * http://opensource.org/licenses/MIT + * http://opensource.org/licenses/GPL-3.0 + * + */ + +(function() { + "use strict"; + var $, Guillotine, canTransform, defaults, events, getPointerPosition, hardwareAccelerate, isTouch, pluginName, scope, touchRegExp, validEvent, whitelist, + __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }, + __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; + + $ = jQuery; + + pluginName = 'guillotine'; + + scope = 'guillotine'; + + events = { + start: "touchstart." + scope + " mousedown." + scope + " pointerdown." + scope, + move: "touchmove." + scope + " mousemove." + scope + " pointermove." + scope, + stop: "touchend." + scope + " mouseup." + scope + " pointerup." + scope + }; + + defaults = { + width: 400, + height: 300, + zoomStep: 0.1, + init: null, + eventOnChange: null, + onChange: null + }; + + touchRegExp = /touch/i; + + isTouch = function(e) { + return touchRegExp.test(e.type); + }; + + validEvent = function(e) { + if (isTouch(e)) { + return e.originalEvent.changedTouches.length === 1; + } else { + return e.which === 1; + } + }; + + getPointerPosition = function(e) { + if (isTouch(e)) { + e = e.originalEvent.touches[0]; + } + return { + x: e.pageX, + y: e.pageY + }; + }; + + canTransform = function() { + var hasTransform, helper, prefix, prefixes, prop, test, tests, value, _i, _len; + hasTransform = false; + prefixes = 'webkit,Moz,O,ms,Khtml'.split(','); + tests = { + transform: 'transform' + }; + for (_i = 0, _len = prefixes.length; _i < _len; _i++) { + prefix = prefixes[_i]; + tests[prefix + 'Transform'] = "-" + (prefix.toLowerCase()) + "-transform"; + } + helper = document.createElement('img'); + document.body.insertBefore(helper, null); + for (test in tests) { + prop = tests[test]; + if (helper.style[test] === void 0) { + continue; + } + helper.style[test] = 'rotate(90deg)'; + value = window.getComputedStyle(helper).getPropertyValue(prop); + if ((value != null) && value.length && value !== 'none') { + hasTransform = true; + break; + } + } + document.body.removeChild(helper); + canTransform = hasTransform ? (function() { + return true; + }) : (function() { + return false; + }); + return canTransform(); + }; + + hardwareAccelerate = function(el) { + return $(el).css({ + '-webkit-perspective': 1000, + 'perspective': 1000, + '-webkit-backface-visibility': 'hidden', + 'backface-visibility': 'hidden' + }); + }; + + Guillotine = (function() { + function Guillotine(element, options) { + this._drag = __bind(this._drag, this); + this._unbind = __bind(this._unbind, this); + this._start = __bind(this._start, this); + var _ref; + this.op = $.extend(true, {}, defaults, options, $(element).data(pluginName)); + this.enabled = true; + this.zoomInFactor = 1 + this.op.zoomStep; + this.zoomOutFactor = 1 / this.zoomInFactor; + _ref = [0, 0, 0, 0, 0], this.width = _ref[0], this.height = _ref[1], this.left = _ref[2], this.top = _ref[3], this.angle = _ref[4]; + this.data = { + scale: 1, + angle: 0, + x: 0, + y: 0, + w: this.op.width, + h: this.op.height + }; + this._wrap(element); + if (this.op.init != null) { + this._init(); + } + if (this.width < 1 || this.height < 1) { + this._fit() && this._center(); + } + hardwareAccelerate(this.$el); + this.$el.on(events.start, this._start); + } + + Guillotine.prototype._wrap = function(element) { + var canvas, el, guillotine, height, img, paddingTop, width, _ref, _ref1, _ref2; + el = $(element); + if (el.prop('tagName') === 'IMG') { + img = document.createElement('img'); + img.src = el.attr('src'); + _ref = [img.width, img.height], width = _ref[0], height = _ref[1]; + } else { + _ref1 = [el.width(), el.height()], width = _ref1[0], height = _ref1[1]; + } + _ref2 = [width / this.op.width, height / this.op.height], this.width = _ref2[0], this.height = _ref2[1]; + canvas = $('