votes: show votes when hovering over post score.

Make it so you can hover over a post's score to see the list of public
upvotes. Also show the upvote count, the downvote count, and the upvote
ratio.
This commit is contained in:
evazion
2021-11-18 01:05:24 -06:00
parent a9997d0d2b
commit 5585d1f7d6
20 changed files with 262 additions and 58 deletions

View File

@@ -7,7 +7,9 @@
<% end %> <% end %>
<% end %> <% end %>
<span class="post-score"><%= post.score %></span> <span class="post-score">
<%= link_to post.score, post_votes_path(search: { post_id: post.id }, variant: :compact) %>
</span>
<% if can_vote? %> <% if can_vote? %>
<% if downvoted? %> <% if downvoted? %>

View File

@@ -6,5 +6,11 @@
text-align: center; text-align: center;
min-width: 1.25em; min-width: 1.25em;
white-space: nowrap; white-space: nowrap;
vertical-align: middle;
a {
color: var(--text-color);
&:hover { text-decoration: underline; }
}
} }
} }

View File

@@ -0,0 +1,34 @@
# frozen_string_literal: true
# This component represents the tooltip that displays when you hover over a post's score.
class PostVotesTooltipComponent < ApplicationComponent
attr_reader :post, :current_user
delegate :upvote_icon, :downvote_icon, to: :helpers
def initialize(post:, current_user:)
super
@post = post
@current_user = current_user
end
def votes
@post.votes.includes(:user).order(id: :desc)
end
def vote_icon(vote)
vote.is_positive? ? upvote_icon : downvote_icon
end
def upvote_ratio
return nil if votes.length == 0
sprintf("(%.1f%%)", 100.0 * votes.select(&:is_positive?).length / votes.length)
end
def voter_name(vote)
if policy(vote).can_see_voter?
link_to_user(vote.user, classes: "align-middle")
else
tag.i("hidden", class: "align-middle")
end
end
end

View File

@@ -0,0 +1,13 @@
<div class="post-votes-tooltip thin-scrollbar">
<div class="text-center text-muted">
+<%= post.up_score %> / <%= post.down_score %> <%= upvote_ratio %>
</div>
<div class="post-voters">
<% votes.each do |vote| %>
<div class="post-voter truncate">
<%= vote_icon(vote) %> <%= voter_name(vote) %>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,65 @@
import Utility from "../../javascript/src/javascripts/utility.js";
import { delegate, hideAll } from 'tippy.js';
import 'tippy.js/dist/tippy.css';
class PostVotesTooltipComponent {
// Trigger on the post score link; see PostVotesComponent.
static TARGET_SELECTOR = "span.post-votes span.post-score a";
static SHOW_DELAY = 125;
static HIDE_DELAY = 125;
static DURATION = 250;
static instance = null;
static initialize() {
if ($(PostVotesTooltipComponent.TARGET_SELECTOR).length === 0) {
return;
}
PostVotesTooltipComponent.instance = delegate("body", {
allowHTML: true,
appendTo: document.querySelector("#post-votes-tooltips"),
delay: [PostVotesTooltipComponent.SHOW_DELAY, PostVotesTooltipComponent.HIDE_DELAY],
duration: PostVotesTooltipComponent.DURATION,
interactive: true,
maxWidth: "none",
target: PostVotesTooltipComponent.TARGET_SELECTOR,
theme: "common-tooltip",
touch: false,
onShow: PostVotesTooltipComponent.onShow,
onHide: PostVotesTooltipComponent.onHide,
});
}
static async onShow(instance) {
let $target = $(instance.reference);
let $tooltip = $(instance.popper);
let postId = $target.parents("[data-id]").data("id");
hideAll({ exclude: instance });
try {
$tooltip.addClass("tooltip-loading");
instance._request = $.get(`/post_votes?search[post_id]=${postId}`, { variant: "tooltip" });
let html = await instance._request;
instance.setContent(html);
$tooltip.removeClass("tooltip-loading");
} catch (error) {
if (error.status !== 0 && error.statusText !== "abort") {
Utility.error(`Error displaying votes for post #${postId} (error: ${error.status} ${error.statusText})`);
}
}
}
static async onHide(instance) {
if (instance._request?.state() === "pending") {
instance._request.abort();
}
}
}
$(document).ready(PostVotesTooltipComponent.initialize);
export default PostVotesTooltipComponent;

View File

@@ -0,0 +1,16 @@
.post-votes-tooltip {
font-size: var(--text-xs);
max-height: 240px;
.upvote-icon {
color: var(--post-upvote-color);
}
.downvote-icon {
color: var(--post-downvote-color);
}
.post-voter {
max-width: 160px;
}
}

View File

@@ -4,6 +4,7 @@ class PostVotesController < ApplicationController
def index def index
@post_votes = authorize PostVote.visible(CurrentUser.user).paginated_search(params, count_pages: true) @post_votes = authorize PostVote.visible(CurrentUser.user).paginated_search(params, count_pages: true)
@post_votes = @post_votes.includes(:user, post: [:uploader, :media_asset]) if request.format.html? @post_votes = @post_votes.includes(:user, post: [:uploader, :media_asset]) if request.format.html?
@post = Post.find(params.dig(:search, :post_id)) if params.dig(:search, :post_id).present?
respond_with(@post_votes) respond_with(@post_votes)
end end

View File

@@ -193,10 +193,10 @@ module ApplicationHelper
to_sentence(links, **options) to_sentence(links, **options)
end end
def link_to_user(user, text = nil) def link_to_user(user, text = nil, classes: nil, **options)
return "anonymous" if user.blank? return "anonymous" if user.blank?
user_class = "user user-#{user.level_string.downcase}" user_class = "user user-#{user.level_string.downcase} #{classes}"
user_class += " user-post-approver" if user.can_approve_posts? user_class += " user-post-approver" if user.can_approve_posts?
user_class += " user-post-uploader" if user.can_upload_free? user_class += " user-post-uploader" if user.can_upload_free?
user_class += " user-banned" if user.is_banned? user_class += " user-banned" if user.is_banned?

View File

@@ -20,6 +20,10 @@ module ComponentsHelper
render PostVotesComponent.new(post: post, **options) render PostVotesComponent.new(post: post, **options)
end end
def render_post_votes_tooltip(post, **options)
render PostVotesTooltipComponent.new(post: post, **options)
end
def render_post_navbar(post, **options) def render_post_navbar(post, **options)
render PostNavbarComponent.new(post: post, **options) render PostNavbarComponent.new(post: post, **options)
end end

View File

@@ -43,6 +43,7 @@ import PopupMenuComponent from "../../components/popup_menu_component/popup_menu
import Post from "../src/javascripts/posts.js"; import Post from "../src/javascripts/posts.js";
import PostModeMenu from "../src/javascripts/post_mode_menu.js"; import PostModeMenu from "../src/javascripts/post_mode_menu.js";
import PostTooltip from "../src/javascripts/post_tooltips.js"; import PostTooltip from "../src/javascripts/post_tooltips.js";
import PostVotesTooltipComponent from "../../components/post_votes_tooltip_component/post_votes_tooltip_component.js";
import RelatedTag from "../src/javascripts/related_tag.js"; import RelatedTag from "../src/javascripts/related_tag.js";
import Shortcuts from "../src/javascripts/shortcuts.js"; import Shortcuts from "../src/javascripts/shortcuts.js";
import TagCounter from "../src/javascripts/tag_counter.js"; import TagCounter from "../src/javascripts/tag_counter.js";
@@ -64,6 +65,7 @@ Danbooru.PopupMenuComponent = PopupMenuComponent;
Danbooru.Post = Post; Danbooru.Post = Post;
Danbooru.PostModeMenu = PostModeMenu; Danbooru.PostModeMenu = PostModeMenu;
Danbooru.PostTooltip = PostTooltip; Danbooru.PostTooltip = PostTooltip;
Danbooru.PostVotesTooltipComponent = PostVotesTooltipComponent;
Danbooru.RelatedTag = RelatedTag; Danbooru.RelatedTag = RelatedTag;
Danbooru.Shortcuts = Shortcuts; Danbooru.Shortcuts = Shortcuts;
Danbooru.TagCounter = TagCounter; Danbooru.TagCounter = TagCounter;

View File

@@ -218,6 +218,9 @@ html {
--post-artist-commentary-container-background: var(--grey-0); --post-artist-commentary-container-background: var(--grey-0);
--post-artist-commentary-container-border-color: var(--grey-1); --post-artist-commentary-container-border-color: var(--grey-1);
--post-upvote-color: var(--link-color);
--post-downvote-color: var(--red-5);
--note-body-background: #FFE; --note-body-background: #FFE;
--note-body-text-color: var(--black); --note-body-text-color: var(--black);
--note-body-border-color: var(--black); --note-body-border-color: var(--black);
@@ -418,6 +421,9 @@ body[data-current-user-theme="dark"] {
--post-artist-commentary-container-background: var(--grey-8); --post-artist-commentary-container-background: var(--grey-8);
--post-artist-commentary-container-border-color: var(--grey-7); --post-artist-commentary-container-border-color: var(--grey-7);
--post-upvote-color: var(--link-color);
--post-downvote-color: var(--red-4);
--unsaved-note-box-border-color: var(--red-5); --unsaved-note-box-border-color: var(--red-5);
--movable-note-box-border-color: var(--blue-5); --movable-note-box-border-color: var(--blue-5);
--note-preview-border-color: var(--red-5); --note-preview-border-color: var(--red-5);

View File

@@ -22,6 +22,12 @@ $spacer: 0.25rem; /* 4px */
.text-xs { font-size: var(--text-xs); } .text-xs { font-size: var(--text-xs); }
.text-sm { font-size: var(--text-sm); } .text-sm { font-size: var(--text-sm); }
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leading-none { line-height: 1; } .leading-none { line-height: 1; }
.absolute { position: absolute; } .absolute { position: absolute; }
@@ -52,6 +58,9 @@ $spacer: 0.25rem; /* 4px */
.p-0\.5 { padding: 0.5 * $spacer; } .p-0\.5 { padding: 0.5 * $spacer; }
.p-4 { padding: 4 * $spacer; } .p-4 { padding: 4 * $spacer; }
.pr-2 { padding-right: 2 * $spacer; }
.pr-4 { padding-right: 4 * $spacer; }
.w-1\/4 { width: 25%; } .w-1\/4 { width: 25%; }
.w-full { width: 100%; } .w-full { width: 100%; }
@@ -73,6 +82,60 @@ $spacer: 0.25rem; /* 4px */
.items-center { align-items: center; } .items-center { align-items: center; }
.justify-center { justify-content: center; } .justify-center { justify-content: center; }
.thin-scrollbar {
overflow-x: hidden;
overflow-y: auto;
padding-right: 2 * $spacer;
overscroll-behavior: contain; // https://caniuse.com/css-overscroll-behavior
// Firefox only
// https://caniuse.com/?search=scrollbar-width
// https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-width
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 5px;
height: 5px;
}
&::-webkit-scrollbar-button {
width: 0;
height: 0;
}
&::-webkit-scrollbar-thumb {
background: var(--post-tooltip-scrollbar-background);
border: none;
border-radius: 0;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--post-tooltip-scrollbar-thumb-color);
}
&::-webkit-scrollbar-thumb:active {
background: var(--post-tooltip-scrollbar-thumb-color);
}
&::-webkit-scrollbar-track {
background: var(--post-tooltip-scrollbar-track-background);
border: none;
border-radius: 0;
}
&::-webkit-scrollbar-track:hover {
background: var(--post-tooltip-scrollbar-track-background);
}
&::-webkit-scrollbar-track:active {
background: var(--post-tooltip-scrollbar-track-background);
}
&::-webkit-scrollbar-corner {
background: transparent;
}
}
@media screen and (min-width: 660px) { @media screen and (min-width: 660px) {
.md\:inline-block { display: inline-block; } .md\:inline-block { display: inline-block; }
.md\:flex { display: flex; } .md\:flex { display: flex; }

View File

@@ -1,50 +1,6 @@
$tooltip-line-height: 16px; $tooltip-line-height: 16px;
$tooltip-body-height: $tooltip-line-height * 4; // 4 lines high. $tooltip-body-height: $tooltip-line-height * 4; // 4 lines high.
@mixin thin-scrollbar {
&::-webkit-scrollbar {
width: 5px;
height: 5px;
}
&::-webkit-scrollbar-button {
width: 0;
height: 0;
}
&::-webkit-scrollbar-thumb {
background: var(--post-tooltip-scrollbar-background);
border: none;
border-radius: 0;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--post-tooltip-scrollbar-thumb-color);
}
&::-webkit-scrollbar-thumb:active {
background: var(--post-tooltip-scrollbar-thumb-color);
}
&::-webkit-scrollbar-track {
background: var(--post-tooltip-scrollbar-track-background);
border: none;
border-radius: 0;
}
&::-webkit-scrollbar-track:hover {
background: var(--post-tooltip-scrollbar-track-background);
}
&::-webkit-scrollbar-track:active {
background: var(--post-tooltip-scrollbar-track-background);
}
&::-webkit-scrollbar-corner {
background: transparent;
}
}
.tippy-box[data-theme~="post-tooltip"] { .tippy-box[data-theme~="post-tooltip"] {
min-width: 20em; min-width: 20em;
max-width: 40em !important; max-width: 40em !important;
@@ -59,7 +15,6 @@ $tooltip-body-height: $tooltip-line-height * 4; // 4 lines high.
} }
.post-tooltip-body { .post-tooltip-body {
@include thin-scrollbar;
max-height: $tooltip-body-height; max-height: $tooltip-body-height;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;

View File

@@ -101,6 +101,7 @@
<div id="tooltips"> <div id="tooltips">
<div id="post-tooltips"></div> <div id="post-tooltips"></div>
<div id="user-tooltips"></div> <div id="user-tooltips"></div>
<div id="post-votes-tooltips"></div>
<div id="popup-menus"></div> <div id="popup-menus"></div>
</div> </div>

View File

@@ -0,0 +1,7 @@
<%= search_form_for(post_votes_path) do |f| %>
<%= f.input :user_name, label: "Voter", input_html: { value: params[:search][:user_name], "data-autocomplete": "user" } %>
<%= f.input :post_id, label: "Post", input_html: { value: params[:search][:post_id] } %>
<%= f.input :post_tags_match, label: "Tags", input_html: { value: params[:search][:post_tags_match], "data-autocomplete": "tag-query" } %>
<%= f.input :score, collection: [["+3", "3"], ["+1", "1"], ["-1", "-1"], ["-3", "-3"]], include_blank: true, selected: params[:search][:score] %>
<%= f.submit "Search" %>
<% end %>

View File

@@ -0,0 +1,24 @@
<%= render "posts/partials/common/secondary_links" %>
<div id="c-post-votes">
<div id="a-index">
<%= render "search" %>
<%= table_for @post_votes, class: "striped autofit" do |t| %>
<% t.column "Score" do |vote| %>
<%= link_to sprintf("%+d", vote.score), post_votes_path(search: { score: vote.score }) %>
<% end %>
<% t.column "Voter" do |vote| %>
<%= link_to_user vote.user %>
<%= link_to "»", post_votes_path(search: { user_name: vote.user.name }) %>
<% end %>
<% t.column "Created" do |vote| %>
<%= time_ago_in_words_tagged(vote.created_at) %>
<% end %>
<% end %>
<%= numbered_paginator(@post_votes) %>
</div>
</div>

View File

@@ -0,0 +1 @@
<%= render_post_votes_tooltip(@post, current_user: CurrentUser.user) %>

View File

@@ -1,12 +1,6 @@
<div id="c-post-votes"> <div id="c-post-votes">
<div id="a-index"> <div id="a-index">
<%= search_form_for(post_votes_path) do |f| %> <%= render "search" %>
<%= f.input :user_name, label: "Voter", input_html: { value: params[:search][:user_name], "data-autocomplete": "user" } %>
<%= f.input :post_id, label: "Post", input_html: { value: params[:search][:post_id] } %>
<%= f.input :post_tags_match, label: "Tags", input_html: { value: params[:search][:post_tags_match], "data-autocomplete": "tag-query" } %>
<%= f.input :score, collection: [["+3", "3"], ["+1", "1"], ["-1", "-1"], ["-3", "-3"]], include_blank: true, selected: params[:search][:score] %>
<%= f.submit "Search" %>
<% end %>
<%= table_for @post_votes, class: "striped autofit" do |t| %> <%= table_for @post_votes, class: "striped autofit" do |t| %>
<% t.column "Post" do |vote| %> <% t.column "Post" do |vote| %>

View File

@@ -40,7 +40,7 @@
<% end %> <% end %>
</div> </div>
<div class="post-tooltip-body <%= "has-preview" if params[:preview].truthy? %>"> <div class="post-tooltip-body thin-scrollbar <%= "has-preview" if params[:preview].truthy? %>">
<div class="post-tooltip-body-left"> <div class="post-tooltip-body-left">
<% if params[:preview].truthy? %> <% if params[:preview].truthy? %>
<%= post_preview(@post, show_deleted: true, compact: true) %> <%= post_preview(@post, show_deleted: true, compact: true) %>

View File

@@ -10,8 +10,8 @@ class PostVotesControllerTest < ActionDispatch::IntegrationTest
context "index action" do context "index action" do
setup do setup do
@user = create(:user, enable_private_favorites: true) @user = create(:user, enable_private_favorites: true)
create(:post_vote, user: @user, score: 1) @upvote = create(:post_vote, user: @user, score: 1)
create(:post_vote, user: @user, score: -1) @downvote = create(:post_vote, user: @user, score: -1)
end end
should "render" do should "render" do
@@ -19,6 +19,16 @@ class PostVotesControllerTest < ActionDispatch::IntegrationTest
assert_response :success assert_response :success
end end
should "render for a compact view" do
get post_votes_path(variant: "compact")
assert_response :success
end
should "render for a tooltip" do
get post_votes_path(search: { post_id: @upvote.post_id }, variant: "tooltip")
assert_response :success
end
context "as a user" do context "as a user" do
should "show the user all their own votes" do should "show the user all their own votes" do
get_auth post_votes_path, @user get_auth post_votes_path, @user