diff --git a/app/assets/images/no_project_icon.png b/app/assets/images/no_project_icon.png new file mode 100644 index 0000000000000..8e9529c67ec3a Binary files /dev/null and b/app/assets/images/no_project_icon.png differ diff --git a/app/assets/javascripts/project.js.coffee b/app/assets/javascripts/project.js.coffee index aba40742e5f0c..9d477dc42dc53 100644 --- a/app/assets/javascripts/project.js.coffee +++ b/app/assets/javascripts/project.js.coffee @@ -57,3 +57,13 @@ $ -> $("a[href=" + defaultView + "]").tab "show" else $("a[data-toggle='tab']:first").tab "show" + + # avatar + $('.js-choose-project-avatar-button').bind "click", -> + form = $(this).closest("form") + form.find(".js-project-avatar-input").click() + + $('.js-project-avatar-input').bind "change", -> + form = $(this).closest("form") + filename = $(this).val().replace(/^.*[\\\/]/, '') + form.find(".js-avatar-filename").text(filename) diff --git a/app/assets/stylesheets/generic/avatar.scss b/app/assets/stylesheets/generic/avatar.scss index 4f038b977e2c6..661dc4805e1ef 100644 --- a/app/assets/stylesheets/generic/avatar.scss +++ b/app/assets/stylesheets/generic/avatar.scss @@ -21,3 +21,16 @@ &.s90 { width: 90px; height: 90px; margin-right: 15px; } &.s160 { width: 160px; height: 160px; margin-right: 20px; } } + +.identicon { + text-align: center; + vertical-align: top; + + &.s16 { font-size: 12px; line-height: 1.33; } + &.s24 { font-size: 18px; line-height: 1.33; } + &.s26 { font-size: 20px; line-height: 1.33; } + &.s32 { font-size: 24px; line-height: 1.33; } + &.s60 { font-size: 45px; line-height: 1.33; } + &.s90 { font-size: 68px; line-height: 1.33; } + &.s160 { font-size: 120px; line-height: 1.33; } +} \ No newline at end of file diff --git a/app/assets/stylesheets/sections/dashboard.scss b/app/assets/stylesheets/sections/dashboard.scss index d181d83e857d6..c0ea4852672c1 100644 --- a/app/assets/stylesheets/sections/dashboard.scss +++ b/app/assets/stylesheets/sections/dashboard.scss @@ -94,20 +94,33 @@ overflow: hidden; } +.project-avatar { + float: left; +} + .project-access-icon { - margin-left: 10px; float: left; - margin-right: 15px; font-size: 20px; margin-bottom: 15px; - + border: 1px solid #EEE; + padding: 8px 12px; + border-radius: 20px; + background: #f5f5f5; + text-align: center; + position: relative; + left: -32px; + top: 38px; i { color: #888; } } +.dash-project-avatar { + float: left; +} .dash-project-access-icon { float: left; margin-right: 3px; + color: #999; width: 16px; } diff --git a/app/controllers/projects/avatars_controller.rb b/app/controllers/projects/avatars_controller.rb new file mode 100644 index 0000000000000..a482b90880da9 --- /dev/null +++ b/app/controllers/projects/avatars_controller.rb @@ -0,0 +1,29 @@ +class Projects::AvatarsController < Projects::ApplicationController + layout 'project' + + before_filter :project + + def show + @blob = @project.repository.blob_at_branch('master', @project.avatar_in_git) + if @blob + headers['X-Content-Type-Options'] = 'nosniff' + send_data( + @blob.data, + type: @blob.mime_type, + disposition: 'inline', + filename: @blob.name + ) + else + not_found! + end + end + + def destroy + @project.remove_avatar! + + @project.save + @project.reset_events_cache + + redirect_to edit_project_path(@project) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 021bd0a494c74..95d8219795f17 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -49,6 +49,33 @@ def current_action?(*args) args.any? { |v| v.to_s.downcase == action_name } end + def project_icon(project_id, options = {}) + project = Project.find_with_namespace(project_id) + if project.avatar.present? + image_tag project.avatar.url, options + elsif options[:only_uploaded] + image_tag '/assets/no_project_icon.png', options + elsif project.avatar_in_git + image_tag project_avatar_path(project), options + else # generated icon + project_identicon(project, options) + end + end + + def project_identicon(project, options = {}) + options[:class] ||= '' + options[:class] << ' identicon' + bg_color = Digest::MD5.hexdigest(project.name)[0, 6] + brightness = bg_color[0, 2].hex + bg_color[2, 2].hex + bg_color[4, 2].hex + text_color = (brightness > 375) ? '#000' : '#fff' + content_tag(:div, + class: options[:class], + style: "background-color: ##{bg_color}; "\ + "color: #{text_color}") do + project.name[0, 1].upcase + end + end + def group_icon(group_path) group = Group.find_by(path: group_path) if group && group.avatar.present? diff --git a/app/models/project.rb b/app/models/project.rb index 613f98ba44bdf..a43a8779b28f0 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -24,6 +24,7 @@ # import_status :string(255) # repository_size :float default(0.0) # star_count :integer default(0), not null +# avatar :string(255) # class Project < ActiveRecord::Base @@ -116,6 +117,12 @@ class Project < ActiveRecord::Base validates :star_count, numericality: { greater_than_or_equal_to: 0 } validate :check_limit, on: :create + validate :avatar_type, + if: ->(project) { project.avatar && project.avatar_changed? } + validates :avatar, file_size: { maximum: 100.kilobytes.to_i } + + mount_uploader :avatar, AttachmentUploader + # Scopes scope :without_user, ->(user) { where("projects.id NOT IN (:ids)", ids: user.authorized_projects.map(&:id) ) } scope :without_team, ->(team) { team.projects.present? ? where("projects.id NOT IN (:ids)", ids: team.projects.map(&:id)) : scoped } @@ -328,6 +335,19 @@ def ci_service @ci_service ||= ci_services.select(&:activated?).first end + def avatar_type + unless avatar.image? + errors.add :avatar, 'only images allowed' + end + end + + def avatar_in_git + @avatar_file ||= 'logo.png' if repository.blob_at_branch('master', 'logo.png') + @avatar_file ||= 'logo.jpg' if repository.blob_at_branch('master', 'logo.jpg') + @avatar_file ||= 'logo.gif' if repository.blob_at_branch('master', 'logo.gif') + @avatar_file + end + # For compatibility with old code def code path @@ -561,6 +581,7 @@ def hook_attrs # Since we do cache @event we need to reset cache in special cases: # * when project was moved # * when project was renamed + # * when the project avatar changes # Events cache stored like events/23-20130109142513. # The cache key includes updated_at timestamp. # Thus it will automatically generate a new fragment diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index a59311bf942da..91f141cff6fa2 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -17,6 +17,9 @@ def execute project.path = @from_project.path project.namespace = current_user.namespace project.creator = current_user + if @from_project.avatar && @from_project.avatar.image? + project.avatar = @from_project.avatar + end # If the project cannot save, we do not want to trigger the project destroy # as this can have the side effect of deleting a repo attached to an existing diff --git a/app/views/dashboard/_project.html.haml b/app/views/dashboard/_project.html.haml index 89ed510275431..7f19fb5a81c92 100644 --- a/app/views/dashboard/_project.html.haml +++ b/app/views/dashboard/_project.html.haml @@ -1,4 +1,6 @@ = link_to project_path(project), class: dom_class(project) do + .dash-project-avatar + = project_icon(project.to_param, alt: '', class: 'avatar s24') .dash-project-access-icon = visibility_level_icon(project.visibility_level) %span.str-truncated diff --git a/app/views/dashboard/projects.html.haml b/app/views/dashboard/projects.html.haml index f124c688be19a..9e894472c85b3 100644 --- a/app/views/dashboard/projects.html.haml +++ b/app/views/dashboard/projects.html.haml @@ -32,6 +32,8 @@ - @projects.each do |project| %li.my-project-row %h4.project-title + .project-avatar + = project_icon(project.to_param, alt: '', class: 'avatar s60') .project-access-icon = visibility_level_icon(project.visibility_level) = link_to project_path(project), class: dom_class(project) do @@ -70,4 +72,3 @@ .nothing-here-block There are no projects here. .bottom = paginate @projects, theme: "gitlab" - diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 672a91e0eef02..0353d491348dc 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -1,6 +1,8 @@ - empty_repo = @project.empty_repo? .project-home-panel{:class => ("empty-project" if empty_repo)} .project-home-row + .project-avtatar + = project_icon(@project.to_param, alt: '', class: 'avatar s32') .project-home-desc - if @project.description.present? = escaped_autolink(@project.description) @@ -22,7 +24,6 @@ - else = link_to fork_project_path(@project), title: "Fork project", method: "POST" do = link_to_toggle_fork - .star-buttons %span.star.js-toggler-container{class: @show_star ? 'on' : ''} - if current_user diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index f48f4bb295370..25e1124ca6992 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -7,7 +7,8 @@ %p.light Some settings, such as "Transfer Project", are hidden inside the danger area below. %hr .panel-body - = form_for @project, remote: true, html: { class: "edit_project form-horizontal" } do |f| + = form_for @project, remote: true, html: { multipart: true, class: "edit_project form-horizontal" }, authenticity_token: true do |f| + %fieldset .form-group.project_name_holder = f.label :name, class: 'control-label' do @@ -80,6 +81,31 @@ = f.check_box :snippets_enabled %span.descr Share code pastes with others out of git repository + %fieldset.features + %legend + Project avatar: + .form-group + .col-sm-2 + .col-sm-10 + = project_icon(@project.to_param, alt: '', class: 'avatar s160', only_uploaded: true) + %p.light + - if @project.avatar_in_git + Project avatar in repository: #{ @project.avatar_in_git } + %p.light + - if @project.avatar? + You can change your project avatar here + - else + You can upload an project avatar here + %a.choose-btn.btn.btn-small.js-choose-project-avatar-button + %i.icon-paper-clip + %span Choose File ... +   + %span.file_name.js-avatar-filename File name... + = f.file_field :avatar, class: "js-project-avatar-input hidden" + .light The maximum file size allowed is 100KB. + - if @project.avatar? + %hr + = link_to 'Remove avatar', project_avatar_path(@project), data: { confirm: "Project avatar will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-small remove-avatar" .form-actions = f.submit 'Save changes', class: "btn btn-save" diff --git a/config/routes.rb b/config/routes.rb index 2534153758bd9..ac4ae1d37a741 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -333,6 +333,8 @@ post :preview end end + + resource :avatar, only: [:show, :destroy] end end diff --git a/db/migrate/20141025111418_add_avatar_to_projects.rb b/db/migrate/20141025111418_add_avatar_to_projects.rb new file mode 100644 index 0000000000000..9523ac722f2fa --- /dev/null +++ b/db/migrate/20141025111418_add_avatar_to_projects.rb @@ -0,0 +1,5 @@ +class AddAvatarToProjects < ActiveRecord::Migration + def change + add_column :projects, :avatar, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 8ddebc5132a2b..89298bd783258 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: 20141007100818) do +ActiveRecord::Schema.define(version: 20141025111418) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -262,6 +262,7 @@ t.string "import_status" t.float "repository_size", default: 0.0 t.integer "star_count", default: 0, null: false + t.string "avatar" end add_index "projects", ["creator_id"], name: "index_projects_on_creator_id", using: :btree diff --git a/features/project/project.feature b/features/project/project.feature index 7bb24e013a979..3e1fd54bee84c 100644 --- a/features/project/project.feature +++ b/features/project/project.feature @@ -5,6 +5,19 @@ Feature: Project And project "Shop" has push event And I visit project "Shop" page + Scenario: I edit the project avatar + Given I visit edit project "Shop" page + When I change the project avatar + And I should see new project avatar + And I should see the "Remove avatar" button + + Scenario: I remove the project avatar + Given I visit edit project "Shop" page + And I have an project avatar + When I remove my project avatar + Then I should see the default project avatar + And I should not see the "Remove avatar" button + @javascript Scenario: I should see project activity When I visit project "Shop" page diff --git a/features/steps/project/project.rb b/features/steps/project/project.rb index f7fff8e64f9d4..dc663a857c61b 100644 --- a/features/steps/project/project.rb +++ b/features/steps/project/project.rb @@ -17,12 +17,53 @@ class Spinach::Features::Project < Spinach::FeatureSteps end step 'change project path settings' do - fill_in "project_path", with: "new-path" - click_button "Rename" + fill_in 'project_path', with: 'new-path' + click_button 'Rename' end step 'I should see project with new path settings' do - project.path.should == "new-path" + project.path.should == 'new-path' + end + + step 'I change the project avatar' do + attach_file( + :project_avatar, + File.join(Rails.root, 'public', 'gitlab_logo.png') + ) + click_button 'Save changes' + @project.reload + end + + step 'I should see new project avatar' do + @project.avatar.should be_instance_of AttachmentUploader + url = @project.avatar.url + url.should == "/uploads/project/avatar/#{ @project.id }/gitlab_logo.png" + end + + step 'I should see the "Remove avatar" button' do + page.should have_link('Remove avatar') + end + + step 'I have an project avatar' do + attach_file( + :project_avatar, + File.join(Rails.root, 'public', 'gitlab_logo.png') + ) + click_button 'Save changes' + @project.reload + end + + step 'I remove my project avatar' do + click_link 'Remove avatar' + @project.reload + end + + step 'I should see the default project avatar' do + @project.avatar?.should be_false + end + + step 'I should not see the "Remove avatar" button' do + page.should_not have_link('Remove avatar') end step 'I should see project "Shop" version' do diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 2db67cfdf95e1..b578e7c91e80e 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -56,6 +56,28 @@ end end + describe 'project_icon' do + avatar_file_path = File.join(Rails.root, 'public', 'gitlab_logo.png') + + it 'should return an url for the avatar' do + project = create(:project) + project.avatar = File.open(avatar_file_path) + project.save! + project_icon(project.to_param).to_s.should == + "/uploads/project/avatar/#{ project.id }/gitlab_logo.png" + end + + it 'should give uploaded icon when present' do + project = create(:project) + project.save! + + Project.any_instance.stub(:avatar_in_git).and_return(true) + + project_icon(project.to_param).to_s.should match( + image_tag(project_avatar_path(project))) + end + end + describe "avatar_icon" do avatar_file_path = File.join(Rails.root, 'public', 'gitlab_logo.png') diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 48b58400a1ecf..44a3850b64c16 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -24,6 +24,7 @@ # import_status :string(255) # repository_size :float default(0.0) # star_count :integer default(0), not null +# avatar :string(255) # require 'spec_helper' @@ -365,4 +366,18 @@ expect(project.star_count).to eq(0) end end + + describe :avatar_type do + let(:project) { create(:project) } + + it 'should be true if avatar is image' do + project.update_attribute(:avatar, 'uploads/avatar.png') + project.avatar_type.should be_true + end + + it 'should be false if avatar is html page' do + project.update_attribute(:avatar, 'uploads/avatar.html') + project.avatar_type.should == ['only images allowed'] + end + end end diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index 4b2eb42c709b1..41c51b3fbc259 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -462,3 +462,11 @@ get("/gitlab/gitlabhq/graphs/master").should route_to('projects/graphs#show', project_id: 'gitlab/gitlabhq', id: 'master') end end + +# project_avatar DELETE /project/avatar(.:format) projects/avatars#destroy +describe Projects::AvatarsController, 'routing' do + it 'to #destroy' do + delete('/gitlab/gitlabhq/avatar').should route_to( + 'projects/avatars#destroy', project_id: 'gitlab/gitlabhq') + end +end