diff --git a/app/assets/javascripts/application.js.erb b/app/assets/javascripts/application.js.erb index 651b50b..9092084 100644 --- a/app/assets/javascripts/application.js.erb +++ b/app/assets/javascripts/application.js.erb @@ -7,12 +7,18 @@ var _Lobsters = Class.extend({ curUser: null, + storyDownvoteReasons: { <%= Vote::STORY_REASONS.map{|k,v| + "#{k.inspect}: #{v.inspect}" }.join(", ") %> }, commentDownvoteReasons: { <%= Vote::COMMENT_REASONS.map{|k,v| "#{k.inspect}: #{v.inspect}" }.join(", ") %> }, upvoteStory: function(voterEl) { Lobsters.vote("story", voterEl, 1); }, + downvoteStory: function(voterEl) { + Lobsters._showDownvoteWhyAt("story", voterEl, function(k) { + Lobsters.vote("story", voterEl, -1, k); }); + }, hideStory: function(hiderEl) { if (!Lobsters.curUser) return Lobsters.bounceToLogin(); @@ -37,10 +43,17 @@ var _Lobsters = Class.extend({ Lobsters.vote("comment", voterEl, 1); }, downvoteComment: function(voterEl) { + Lobsters._showDownvoteWhyAt("comment", voterEl, function(k) { + Lobsters.vote("comment", voterEl, -1, k); }); + }, + _showDownvoteWhyAt: function(thingType, voterEl, onChooseWhy) { + if (!Lobsters.curUser) + return Lobsters.bounceToLogin(); + var li = $(voterEl).closest(".story, .comment"); if (li.hasClass("downvoted")) { /* already upvoted, neutralize */ - Lobsters.vote("comment", voterEl, -1, null); + Lobsters.vote(thingType, voterEl, -1, null); return; } @@ -58,7 +71,13 @@ var _Lobsters = Class.extend({ var d = $("
"); - $.each(Lobsters.commentDownvoteReasons, function(k, v) { + var reasons; + if (thingType == "comment") + reasons = Lobsters.commentDownvoteReasons; + else + reasons = Lobsters.storyDownvoteReasons; + + $.each(reasons, function(k, v) { var a = $("" + v + ""); @@ -67,7 +86,7 @@ var _Lobsters = Class.extend({ $("#downvote_why_shadow").remove(); if (k != "") - Lobsters.vote("comment", voterEl, -1, k); + onChooseWhy(k); return false; }); @@ -234,6 +253,10 @@ $(document).ready(function() { return false; }); + $("li.story a.downvoter").click(function() { + Lobsters.downvoteStory(this); + return false; + }); $("li.story a.upvoter").click(function() { Lobsters.upvoteStory(this); return false; diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 493b00f..9e0949d 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -368,6 +368,7 @@ ol.search_results li.story { div.voters { float: left; + margin-top: -4px; width: 40px; } @@ -423,8 +424,8 @@ li.story { clear: both; } li.story div.story_liner { - padding-top: 0.3em; - padding-bottom: 0.3em; + padding-top: 0.4em; + padding-bottom: 0.4em; } .comment { clear: both; diff --git a/app/assets/stylesheets/mobile.css b/app/assets/stylesheets/mobile.css index 136ea30..24fa09b 100644 --- a/app/assets/stylesheets/mobile.css +++ b/app/assets/stylesheets/mobile.css @@ -58,8 +58,9 @@ } div.voters { - width: 31px; margin-left: 0.25em; + margin-top: 0px; + width: 31px; } div.voters a.upvoter { margin-top: -5px; @@ -133,7 +134,7 @@ color: #333; display: block; font-size: 9pt; - margin: -0.5em 0.5em 0 0.5em; + margin: 0 0.5em; padding: 2px; position: relative; text-align: center; diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 9416c81..c6e8259 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -1,6 +1,6 @@ class StoriesController < ApplicationController before_filter :require_logged_in_user_or_400, - :only => [ :upvote, :unvote, :hide, :unhide, :preview ] + :only => [ :upvote, :downvote, :unvote, :hide, :unhide, :preview ] before_filter :require_logged_in_user, :only => [ :destroy, :create, :edit, :fetch_url_title, :new ] @@ -201,6 +201,25 @@ class StoriesController < ApplicationController render :text => "ok" end + def downvote + if !(story = find_story) + return render :text => "can't find story", :status => 400 + end + + if !Vote::STORY_REASONS[params[:reason]] + return render :text => "invalid reason", :status => 400 + end + + if !@user.can_downvote?(story) + return render :text => "not permitted to downvote", :status => 400 + end + + Vote.vote_thusly_on_story_or_comment_for_user_because(-1, story.id, + nil, @user.id, params[:reason]) + + render :text => "ok" + end + def hide if !(story = find_story) return render :text => "can't find story", :status => 400 diff --git a/app/models/story.rb b/app/models/story.rb index 9cb9a14..e0f1970 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -10,6 +10,8 @@ class Story < ActiveRecord::Base validates_length_of :description, :maximum => (64 * 1024) validates_presence_of :user_id + DOWNVOTABLE_DAYS = 14 + # after this many minutes old, a story cannot be edited MAX_EDIT_MINS = 30 @@ -117,7 +119,8 @@ class Story < ActiveRecord::Base end def calculated_hotness - order = Math.log([ score.abs, 1 ].max, 10) + # don't immediately kill stories at 0 by bumping up score by one + order = Math.log([ (score + 1).abs, 1 ].max, 10) if score > 0 sign = 1 elsif score < 0 @@ -230,6 +233,14 @@ class Story < ActiveRecord::Base :vote => 0).count end + def is_downvotable? + if self.created_at + Time.now - self.created_at <= DOWNVOTABLE_DAYS.days + else + false + end + end + def is_editable_by_user?(user) if user && user.is_moderator? return true @@ -401,4 +412,27 @@ class Story < ActiveRecord::Base def url_or_comments_url self.url.blank? ? self.comments_url : self.url end + + def vote_summary_for(user) + r_counts = {} + r_whos = {} + Vote.where(:story_id => self.id, :comment_id => nil).each do |v| + r_counts[v.reason.to_s] ||= 0 + r_counts[v.reason.to_s] += v.vote + if user && user.is_moderator? + r_whos[v.reason.to_s] ||= [] + r_whos[v.reason.to_s].push v.user.username + end + end + + r_counts.keys.sort.map{|k| + if k == "" + "+#{r_counts[k]}" + else + "#{r_counts[k]} " + + (Vote::STORY_REASONS[k] || Vote::OLD_STORY_REASONS[k]) + + (user && user.is_moderator?? " (#{r_whos[k].join(", ")})" : "") + end + }.join(", ") + end end diff --git a/app/models/user.rb b/app/models/user.rb index 6bb3172..c1e1a84 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -103,8 +103,12 @@ class User < ActiveRecord::Base if is_new? return false elsif obj.is_a?(Story) - # user can unvote - return obj.vote == -1 + if obj.is_downvotable? + return true + elsif obj.vote == -1 + # user can unvote + return true + end elsif obj.is_a?(Comment) if obj.is_downvotable? return true diff --git a/app/models/vote.rb b/app/models/vote.rb index 4f4860e..9775d8c 100644 --- a/app/models/vote.rb +++ b/app/models/vote.rb @@ -11,6 +11,18 @@ class Vote < ActiveRecord::Base "" => "Cancel", } + STORY_REASONS = { + "O" => "Off-topic", + "A" => "Already Posted", + "T" => "Poorly Tagged", + "L" => "Poorly Titled", + "S" => "Spam", + "" => "Cancel", + } + OLD_STORY_REASONS = { + "Q" => "Low Quality", + } + def self.votes_by_user_for_stories_hash(user, stories) votes = {} diff --git a/app/views/stories/_listdetail.html.erb b/app/views/stories/_listdetail.html.erb index be903c9..e8b25d9 100644 --- a/app/views/stories/_listdetail.html.erb +++ b/app/views/stories/_listdetail.html.erb @@ -10,6 +10,11 @@ class="story <%= story.vote == 1 ? "upvoted" : "" %> <%= story.vote == -1 ? <%= link_to "", login_url, :class => "upvoter" %> <% end %>
<%= story.score %>
+ <% if @user && @user.can_downvote?(story) %> + + <% else %> + + <% end %>
@@ -83,6 +88,12 @@ class="story <%= story.vote == 1 ? "upvoted" : "" %> <%= story.vote == -1 ? (story.comments_count == 1 ? "" : "s") %> <% end %> + + <% if defined?(single_story) && single_story %> + <% if story.downvotes > 0 %> + | <%= story.vote_summary_for(@user).downcase %> + <% end %> + <% end %> <% end %>
diff --git a/config/routes.rb b/config/routes.rb index 4111175..5d93a03 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -42,6 +42,7 @@ Lobsters::Application.routes.draw do resources :stories do post "upvote" + post "downvote" post "unvote" post "undelete" post "hide"