diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 7930cf76c..bb6f5bb98 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -27,7 +27,7 @@ class UsersController < ApplicationController def index if params[:name].present? @user = User.find_by_name!(params[:name]) - redirect_to user_path(@user) + redirect_to user_path(@user, variant: params[:variant]) return end @@ -42,7 +42,9 @@ class UsersController < ApplicationController def show @user = authorize User.find(params[:id]) - respond_with(@user, methods: @user.full_attributes) + respond_with(@user, methods: @user.full_attributes) do |format| + format.html.tooltip { render layout: false } + end end def profile diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b1fdc0816..b33c763d1 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -121,6 +121,10 @@ module ApplicationHelper raw content_tag(:time, duration, datetime: datetime, title: title) end + def humanized_number(number) + number_to_human number, units: { thousand: "k", million: "m" }, format: "%n%u" + end + def time_ago_in_words_tagged(time, compact: false) if time.nil? tag.em(tag.time("unknown")) @@ -162,8 +166,10 @@ module ApplicationHelper end end - def link_to_ip(ip) - link_to ip, ip_addresses_path(search: { ip_addr: ip, group_by: "user" }) + def link_to_ip(ip, shorten: false, **options) + ip_addr = IPAddr.new(ip.to_s) + ip_addr.prefix = 64 if ip_addr.ipv6? && shorten + link_to ip_addr.to_s, ip_addresses_path(search: { ip_addr: ip, group_by: "user" }), **options end def link_to_search(search) @@ -186,11 +192,13 @@ module ApplicationHelper def link_to_user(user) return "anonymous" if user.blank? - user_class = "user-#{user.level_string.downcase}" + user_class = "user user-#{user.level_string.downcase}" user_class += " user-post-approver" if user.can_approve_posts? user_class += " user-post-uploader" if user.can_upload_free? user_class += " user-banned" if user.is_banned? - link_to(user.pretty_name, user_path(user), :class => user_class) + + data = { "user-id": user.id, "user-name": user.name, "user-level": user.level } + link_to(user.pretty_name, user_path(user), class: user_class, data: data) end def mod_link_to_user(user, positive_or_negative) diff --git a/app/javascript/src/javascripts/post_tooltips.js b/app/javascript/src/javascripts/post_tooltips.js index dca379fd8..7854a4fe6 100644 --- a/app/javascript/src/javascripts/post_tooltips.js +++ b/app/javascript/src/javascripts/post_tooltips.js @@ -24,7 +24,7 @@ PostTooltip.initialize = function () { interactive: true, maxWidth: PostTooltip.MAX_WIDTH, target: PostTooltip.POST_SELECTOR, - theme: "post-tooltip", + theme: "common-tooltip post-tooltip", touch: false, onCreate: PostTooltip.on_create, @@ -63,13 +63,13 @@ PostTooltip.on_show = async function (instance) { } try { - $tooltip.addClass("post-tooltip-loading"); + $tooltip.addClass("tooltip-loading"); instance._request = $.get(`/posts/${post_id}`, { variant: "tooltip", preview: preview }); let html = await instance._request; instance.setContent(html); - $tooltip.removeClass("post-tooltip-loading"); + $tooltip.removeClass("tooltip-loading"); } catch (error) { if (error.status !== 0 && error.statusText !== "abort") { Utility.error(`Error displaying tooltip for post #${post_id} (error: ${error.status} ${error.statusText})`); diff --git a/app/javascript/src/javascripts/user_tooltips.js b/app/javascript/src/javascripts/user_tooltips.js new file mode 100644 index 000000000..091ff9b51 --- /dev/null +++ b/app/javascript/src/javascripts/user_tooltips.js @@ -0,0 +1,72 @@ +import Utility from './utility'; +import { delegate } from 'tippy.js'; +import 'tippy.js/dist/tippy.css'; + +let UserTooltip = {}; + +UserTooltip.SELECTOR = "*:not(.user-tooltip-name) > a.user, a.dtext-user-id-link, a.dtext-user-mention-link"; +UserTooltip.SHOW_DELAY = 500; +UserTooltip.HIDE_DELAY = 125; +UserTooltip.DURATION = 250; +UserTooltip.MAX_WIDTH = 600; + +UserTooltip.initialize = function () { + delegate("body", { + allowHTML: true, + appendTo: document.body, + delay: [UserTooltip.SHOW_DELAY, UserTooltip.HIDE_DELAY], + duration: UserTooltip.DURATION, + interactive: true, + maxWidth: UserTooltip.MAX_WIDTH, + target: UserTooltip.SELECTOR, + theme: "common-tooltip user-tooltip", + touch: false, + + onShow: UserTooltip.on_show, + onHide: UserTooltip.on_hide, + }); +}; + +UserTooltip.on_show = async function (instance) { + let $target = $(instance.reference); + let $tooltip = $(instance.popper); + + // skip if tooltip has already been rendered. + if ($tooltip.has(".user-tooltip-body").length) { + return; + } + + try { + $tooltip.addClass("tooltip-loading"); + + if ($target.is("a.dtext-user-id-link")) { + let user_id = /\/users\/(\d+)/.exec($target.attr("href"))[1]; + instance._request = $.get(`/users/${user_id}`, { variant: "tooltip" }); + } else if ($target.is("a.user")) { + let user_id = $target.attr("data-user-id"); + instance._request = $.get(`/users/${user_id}`, { variant: "tooltip" }); + } else if ($target.is("a.dtext-user-mention-link")) { + let user_name = $target.attr("data-user-name"); + instance._request = $.get(`/users`, { name: user_name, 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 tooltip (error: ${error.status} ${error.statusText})`); + } + } +}; + +UserTooltip.on_hide = function (instance) { + if (instance._request?.state() === "pending") { + instance._request.abort(); + } +} + +$(document).ready(UserTooltip.initialize); + +export default UserTooltip diff --git a/app/javascript/src/styles/base/020_base.scss b/app/javascript/src/styles/base/020_base.scss index 4dbfee03a..352f46349 100644 --- a/app/javascript/src/styles/base/020_base.scss +++ b/app/javascript/src/styles/base/020_base.scss @@ -129,6 +129,14 @@ table tfoot { font-size: 0.9em; } +a.link-plain { + color: unset; + + &:hover { + text-decoration: underline; + } +} + .fixed-width-container { max-width: 70em; } diff --git a/app/javascript/src/styles/base/040_colors.css b/app/javascript/src/styles/base/040_colors.css index 561f86e60..211b835fc 100644 --- a/app/javascript/src/styles/base/040_colors.css +++ b/app/javascript/src/styles/base/040_colors.css @@ -2,6 +2,7 @@ --body-background-color: white; --text-color: hsl(0, 0%, 15%); + --inverse-text-color: white; --muted-text-color: hsl(0, 0%, 55%); --header-color: hsl(0, 0%, 15%); @@ -82,6 +83,9 @@ --post-tooltip-scrollbar-track-background: #EEEEEE; --post-tooltip-scrollbar-track-border: 0 none white; + --user-tooltip-positive-feedback-color: orange; + --user-tooltip-negative-feedback-color: red; + --preview-current-post-background: rgba(0, 0, 0, 0.1); --autocomplete-selected-background-color: var(--subnav-menu-background-color); @@ -201,6 +205,7 @@ --user-platinum-color: gray; --user-gold-color: #00F; --user-member-color: var(--link-color); + --user-banned-color: black; --news-updates-background: #EEE; --news-updates-border: 2px solid #666; @@ -261,6 +266,7 @@ body[data-current-user-theme="dark"] { /* main text colors */ --text-color: var(--grey-5); + --inverse-text-color: white; --muted-text-color: var(--grey-4); --header-color: var(--grey-6); @@ -283,6 +289,7 @@ body[data-current-user-theme="dark"] { --collection-pool-color: var(--general-tag-color); --collection-pool-hover-color: var(--general-tag-hover-color); + --user-banned-color: var(--grey-1); --user-member-color: var(--blue-1); --user-gold-color: var(--yellow-1); --user-platinum-color: var(--grey-4); @@ -395,6 +402,9 @@ body[data-current-user-theme="dark"] { --post-tooltip-scrollbar-track-background: var(--grey-1); --post-tooltip-scrollbar-track-border: none; + --user-tooltip-positive-feedback-color: var(--yellow-1); + --user-tooltip-negative-feedback-color: var(--red-1); + --preview-pending-color: var(--blue-1); --preview-flagged-color: var(--red-1); --preview-deleted-color: var(--grey-5); diff --git a/app/javascript/src/styles/common/messages.scss b/app/javascript/src/styles/common/messages.scss index c25f27085..4d70c0103 100644 --- a/app/javascript/src/styles/common/messages.scss +++ b/app/javascript/src/styles/common/messages.scss @@ -17,7 +17,8 @@ div.list-of-messages { a.message-timestamp { font-style: italic; - color: var(--text-color); + font-size: 0.90em; + color: var(--muted-text-color); &:hover { text-decoration: underline; } } } diff --git a/app/javascript/src/styles/specific/common_tooltips.scss b/app/javascript/src/styles/specific/common_tooltips.scss new file mode 100644 index 000000000..f4a9dca65 --- /dev/null +++ b/app/javascript/src/styles/specific/common_tooltips.scss @@ -0,0 +1,51 @@ +div[data-tippy-root].tooltip-loading { + visibility: hidden !important; +} + +.tippy-box[data-theme~="common-tooltip"] { + box-sizing: border-box; + + border: 1px solid var(--post-tooltip-border-color); + border-radius: 4px; + + color: var(--text-color); + background-color: var(--post-tooltip-background-color); + background-clip: padding-box; + box-shadow: var(--post-tooltip-box-shadow); + + /* bordered arrow styling; see https://github.com/atomiks/tippyjs/blob/master/src/scss/themes/light-border.scss */ + &[data-placement^=bottom] { + > .tippy-arrow:before { + border-bottom-color: var(--post-tooltip-background-color); + bottom: 16px; + } + + > .tippy-arrow:after { + border-bottom-color: var(--post-tooltip-border-color); + border-width: 0 7px 7px; + top: -8px; + left: 1px; + } + } + + &[data-placement^=top] { + > .tippy-arrow:before { + border-top-color: var(--post-tooltip-background-color); + } + + > .tippy-arrow:after { + border-top-color: var(--post-tooltip-border-color); + border-width: 7px 7px 0; + top: 17px; + left: 1px; + } + } + + > .tippy-arrow:after { + border-color: transparent; + border-style: solid; + content: ""; + position: absolute; + z-index: -1; + } +} diff --git a/app/javascript/src/styles/specific/post_tooltips.scss b/app/javascript/src/styles/specific/post_tooltips.scss index fa843763a..e50b23fa4 100644 --- a/app/javascript/src/styles/specific/post_tooltips.scss +++ b/app/javascript/src/styles/specific/post_tooltips.scss @@ -47,17 +47,9 @@ $tooltip-body-height: $tooltip-line-height * 4; // 4 lines high. .tippy-box[data-theme~="post-tooltip"] { min-width: 200px; - box-sizing: border-box; font-size: 11px; line-height: $tooltip-line-height; - border: 1px solid var(--post-tooltip-border-color); - border-radius: 4px; - - background-color: var(--post-tooltip-background-color); - background-clip: padding-box; - box-shadow: var(--post-tooltip-box-shadow); - .tippy-content { padding: 0; @@ -118,44 +110,4 @@ $tooltip-body-height: $tooltip-line-height * 4; // 4 lines high. font-size: 10px; } } - - /* bordered arrow styling; see https://github.com/atomiks/tippyjs/blob/master/src/scss/themes/light-border.scss */ - &[data-placement^=bottom] { - > .tippy-arrow:before { - border-bottom-color: var(--post-tooltip-background-color); - bottom: 16px; - } - - > .tippy-arrow:after { - border-bottom-color: var(--post-tooltip-border-color); - border-width: 0 7px 7px; - top: -8px; - left: 1px; - } - } - - &[data-placement^=top] { - > .tippy-arrow:before { - border-top-color: var(--post-tooltip-background-color); - } - - > .tippy-arrow:after { - border-top-color: var(--post-tooltip-border-color); - border-width: 7px 7px 0; - top: 17px; - left: 1px; - } - } - - > .tippy-arrow:after { - border-color: transparent; - border-style: solid; - content: ""; - position: absolute; - z-index: -1; - } -} - -div[data-tippy-root].post-tooltip-loading { - visibility: hidden !important; } diff --git a/app/javascript/src/styles/specific/user_tooltips.scss b/app/javascript/src/styles/specific/user_tooltips.scss new file mode 100644 index 000000000..2ff6289c4 --- /dev/null +++ b/app/javascript/src/styles/specific/user_tooltips.scss @@ -0,0 +1,78 @@ +.tippy-box[data-theme~="user-tooltip"] { + line-height: 1.25em; + min-width: 350px; + + .user-tooltip-header { + margin-bottom: 1em; + display: grid; + grid: + "avatar header-top" + "avatar header-bottom" / + 32px 1fr; + column-gap: 0.25em; + + .user-tooltip-avatar { + font-size: 32px; + grid-area: avatar; + align-self: center; + } + + .user-tooltip-header-top { + grid-area: header-top; + + .user-tooltip-badge { + color: var(--inverse-text-color); + font-size: 0.70em; + padding: 2px 4px; + margin-right: 0.25em; + border-radius: 3px; + + &.user-tooltip-badge-admin { background-color: var(--user-admin-color); } + &.user-tooltip-badge-moderator { background-color: var(--user-moderator-color); } + &.user-tooltip-badge-approver { background-color: var(--user-builder-color); } + &.user-tooltip-badge-contributor { background-color: var(--user-builder-color); } + &.user-tooltip-badge-builder { background-color: var(--user-builder-color); } + &.user-tooltip-badge-platinum { background-color: var(--user-platinum-color); } + &.user-tooltip-badge-gold { background-color: var(--user-gold-color); } + &.user-tooltip-badge-member { background-color: var(--user-member-color); } + &.user-tooltip-badge-banned { background-color: var(--user-banned-color); } + + &.user-tooltip-badge-positive-feedback { + color: var(--user-tooltip-positive-feedback-color); + border: 1px solid; + } + + &.user-tooltip-badge-negative-feedback { + color: var(--user-tooltip-negative-feedback-color); + border: 1px solid; + } + } + } + + .user-tooltip-header-bottom { + grid-area: header-bottom; + color: var(--muted-text-color); + font-size: 0.75em; + } + } + + .user-tooltip-stats { + display: grid; + grid: auto / repeat(3, 1fr); + column-gap: 1em; + row-gap: 0.5em; + + .user-tooltip-stat-item { + text-align: center; + + .user-tooltip-stat-value { + font-weight: bold; + } + + .user-tooltip-stat-name { + font-size: 0.90em; + color: var(--muted-text-color); + } + } + } +} diff --git a/app/views/users/show.html+tooltip.erb b/app/views/users/show.html+tooltip.erb new file mode 100644 index 000000000..4b54baf30 --- /dev/null +++ b/app/views/users/show.html+tooltip.erb @@ -0,0 +1,86 @@ +