From cb61b36715ac21fb06c544b1c1409d3c6044241a Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 23 Jan 2026 13:25:55 -0500 Subject: [PATCH] Allow boosts on cards (#2411) * Add reactions association to Card model Adds `has_many :reactions` to Card, matching the implementation in Comment. This allows cards to be reacted to directly with emoji reactions. The association: - Orders reactions chronologically - Uses polymorphic `:reactable` interface - Deletes reactions when card is destroyed Includes test coverage for the new association. Co-Authored-By: Claude Opus 4.5 * Add reactions UI to card detail view - Create Cards::ReactionsController with turbo stream responses - Add card reactions views (reactions list, new form, menu partial) - Position reaction button flush-right when no reactions exist - Show reactions on bottom-left when present, with button inline - Handle narrow viewports by flowing button below meta section - Add controller tests for card reactions Co-Authored-By: Claude Opus 4.5 * Add boost count to card preview Display reaction count alongside comment count on card tiles in board columns. Introduces a .card__counts wrapper to group both counts with proper positioning on all viewport sizes. Co-Authored-By: Claude Opus 4.5 * Consolidate reaction views for cards and comments Extract shared views to app/views/reactions/ using polymorphic routing. Both card and comment reactions now render the same partials, with a helper to compute the correct path prefix for nested routes. Co-Authored-By: Claude Opus 4.5 * Fix emoji picker width in card reactions Override .card .popup { inline-size: 260px } for the reaction popup so the emoji grid displays without horizontal scrollbar. Co-Authored-By: Claude Opus 4.5 * Hide boost count where comment count is hidden Add .card__boosts alongside .card__comments in: - Tray view (display: none) - Cards with background images (opacity toggle on hover) Co-Authored-By: Claude Opus 4.5 * Fix reactions button overlap with zoom button in card footer When a card has a background image, the footer contains both a reactions button and a zoom button. Previously they would overlap because the reactions button was absolutely positioned to the bottom-right. Co-Authored-By: Claude Opus 4.5 * Tighten up previews * Move card reactions from meta partial to container Fixes two issues: 1. Reactions were duplicated each time a card was assigned because the turbo stream replaced the meta div with the full perma/meta partial, which included reactions outside the replaced element. 2. Draft cards incorrectly showed the boost button because the meta partial was shared between published and draft containers. Moving reactions to _container.html.erb (published cards only) fixes both issues and keeps the meta partial focused on meta content. Fixes https://app.fizzy.do/5986089/cards/3837 Fixes https://app.fizzy.do/5986089/cards/3835 Co-Authored-By: Claude Opus 4.5 * WIP adjust perma page * Position and style the footer * Render the stamp in the card body instead of the footer * Fix undefined local variable in public cards view Changed `card` to `@card` when rendering the stamp partial. Co-Authored-By: Claude Opus 4.5 * Preload card reactions to avoid N+1 queries Include reactions and their reacters in the preloaded scope, matching what we already do for comments. Co-Authored-By: Claude Opus 4.5 * Add model tests for card and comment reaction cleanup Test that: - Creating a card reaction touches the card's last_active_at - Reactions are deleted when their parent comment is destroyed - Reactions are deleted when their parent card is destroyed Co-Authored-By: Claude Opus 4.5 * Minor improvements to reactions code - Use size instead of count in boosts partial to avoid extra SQL query on already-loaded collection - Add else clause to reaction_path_prefix_for to raise ArgumentError for unknown reactable types instead of silently returning nil Co-Authored-By: Claude Opus 4.5 * Bump specificity of reaction popup in the card perma --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Andy Smith --- app/assets/stylesheets/card-columns.css | 35 +- app/assets/stylesheets/card-perma.css | 106 ++- app/assets/stylesheets/cards.css | 6 +- app/assets/stylesheets/comments.css | 5 + app/assets/stylesheets/reactions.css | 7 +- app/assets/stylesheets/trays.css | 1 + .../cards/comments/reactions_controller.rb | 26 +- app/controllers/cards/reactions_controller.rb | 49 ++ app/helpers/reactions_helper.rb | 10 + app/models/card.rb | 3 +- app/views/cards/_container.html.erb | 5 +- app/views/cards/comments/_comment.html.erb | 2 +- .../comments/reactions/_reactions.html.erb | 16 - .../reactions/create.turbo_stream.erb | 3 - .../cards/comments/reactions/index.html.erb | 1 - .../comments/reactions/index.json.jbuilder | 1 - app/views/cards/display/_preview.html.erb | 6 +- .../cards/display/_public_preview.html.erb | 1 + .../cards/display/common/_background.html.erb | 16 - .../cards/display/common/_stamp.html.erb | 15 + .../cards/display/perma/_background.html.erb | 2 +- .../cards/display/preview/_boosts.html.erb | 7 + .../cards/display/preview/_comments.html.erb | 4 +- app/views/cards/drafts/_container.html.erb | 2 +- app/views/public/cards/show.html.erb | 3 +- .../comments => }/reactions/_menu.html.erb | 0 .../reactions/_reaction.html.erb | 2 +- .../reactions/_reaction.json.jbuilder | 2 +- app/views/reactions/_reactions.html.erb | 16 + app/views/reactions/create.turbo_stream.erb | 3 + .../reactions/destroy.turbo_stream.erb | 0 app/views/reactions/index.html.erb | 1 + app/views/reactions/index.json.jbuilder | 1 + .../comments => }/reactions/new.html.erb | 12 +- config/routes.rb | 2 + docs/plans/2026-01-21-card-boosts-design.md | 741 ++++++++++++++++++ .../cards/reactions_controller_test.rb | 67 ++ test/fixtures/reactions.yml | 14 + .../card_preview_boost_count_test.rb | 31 + test/models/card_test.rb | 14 + test/models/reaction_test.rb | 33 +- 41 files changed, 1177 insertions(+), 94 deletions(-) create mode 100644 app/controllers/cards/reactions_controller.rb create mode 100644 app/helpers/reactions_helper.rb delete mode 100644 app/views/cards/comments/reactions/_reactions.html.erb delete mode 100644 app/views/cards/comments/reactions/create.turbo_stream.erb delete mode 100644 app/views/cards/comments/reactions/index.html.erb delete mode 100644 app/views/cards/comments/reactions/index.json.jbuilder create mode 100644 app/views/cards/display/common/_stamp.html.erb create mode 100644 app/views/cards/display/preview/_boosts.html.erb rename app/views/{cards/comments => }/reactions/_menu.html.erb (100%) rename app/views/{cards/comments => }/reactions/_reaction.html.erb (93%) rename app/views/{cards/comments => }/reactions/_reaction.json.jbuilder (57%) create mode 100644 app/views/reactions/_reactions.html.erb create mode 100644 app/views/reactions/create.turbo_stream.erb rename app/views/{cards/comments => }/reactions/destroy.turbo_stream.erb (100%) create mode 100644 app/views/reactions/index.html.erb create mode 100644 app/views/reactions/index.json.jbuilder rename app/views/{cards/comments => }/reactions/new.html.erb (65%) create mode 100644 docs/plans/2026-01-21-card-boosts-design.md create mode 100644 test/controllers/cards/reactions_controller_test.rb create mode 100644 test/integration/card_preview_boost_count_test.rb diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 3409b83de..1083c2ffc 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -536,16 +536,33 @@ /* Set lower limit for font size */ font-size: clamp(0.6rem, 0.85cqi, 100px); - .card__comments { - --column-gap: 0.8ch; + .card__counts { + --gap: 0.5ch; + align-items: flex-end; display: flex; - margin-block-end: calc(var(--block-space) * -1.7); - margin-inline-end: calc(var(--card-padding-inline) * -0.5); + flex-shrink: 0; + gap: calc(2 * var(--gap)); + margin-inline: auto calc(var(--card-padding-inline) * -0.5); + padding-inline-start: var(--gap); + } - .icon { - --icon-size: 1.6em; + .card__boosts, + .card__comments { + --icon-size: 1.6em; + align-items: center; + display: flex; + flex-shrink: 0; + font-weight: 600; + gap: var(--gap); + + img { + block-size: var(--icon-size); + inline-size: var(--icon-size); + } + + .icon--comment { color: var(--card-color); } } @@ -584,11 +601,16 @@ .local-time-value { font-weight: inherit; } + + @media (max-width: 639px) { + inline-size: auto; + } } &:has(.card__background img:not([src=""])) { .card__content, .card__meta, + .card__boosts, .card__comments, .card__column-name:not(.card__column-name--current) { opacity: 0; @@ -599,6 +621,7 @@ &:hover { .card__content, .card__footer, + .card__boosts, .card__comments, .card__column-name:not(.card__column-name--current) { opacity: 1; diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index e68706765..b3d37d68a 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -34,6 +34,23 @@ } } + @media (max-width: 799px) { + --half-btn-height: 1.25rem; + --padding-inline: 1.5ch; + + column-gap: 0; + grid-template-areas: + "notch-top notch-top notch-top" + "card card card" + "actions-left notch-bottom actions-right" + "closure-message closure-message closure-message"; + grid-template-columns: 1fr auto 1fr; + inline-size: calc(100% + 2 * var(--padding-inline)); + margin-inline: calc(-1 * var(--padding-inline)); + max-inline-size: none; + position: relative; + } + .card { --card-aspect-ratio: 2 / 0.95; --lexxy-bg-color: var(--card-bg-color); @@ -47,6 +64,8 @@ } .card__body { + position: relative; + @media (max-width: 639px) { flex-direction: column; padding-block-end: calc(var(--card-padding-block) * 1.5); @@ -86,6 +105,11 @@ } } + .card__meta { + grid-area: meta; + margin-inline-end: auto; + } + .card__stages { max-inline-size: 32ch; @@ -119,6 +143,71 @@ } } + .card__footer { + --btn-size: 2.5rem; + + display: flex; + gap: 0.5ch; + inline-size: 100%; + text-align: start; + + /* Switch to grid layout so that the bg zoom button can stay next to the + * meta element, and the reactions can sit below */ + &:has(.reaction) { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: + "meta bg-zoom" + "reactions reactions"; + } + + @media (max-width: 639px) { + font-size: var(--text-x-small); + } + } + + .reactions { + --reaction-size: var(--btn-size); + + align-self: flex-end; + display: flex; + gap: 0.5ch; + grid-area: reactions; + margin-inline-start: auto; + + &:has(.reaction) { + --padding: calc(var(--card-padding-block) / 2); + --reaction-size: 1.6875rem; + + margin-block: var(--padding) calc(-1 * var(--padding)); + padding-block-start: var(--padding); + position: relative; + + &:before { + border-block-start: 1px dashed color-mix(in srgb, transparent, var(--card-color) 33%); + content: ""; + inset: 0 calc(-1 * var(--card-padding-inline)) auto; + position: absolute; + } + } + + &:not(:has(.reaction)) { + position: static; + + .reactions__trigger { + --btn-border-color: var(--color-ink-light); + } + } + } + + .reaction__popup.popup { + inline-size: max-content; + } + + .card__zoom-bg-btn { + grid-area: bg-zoom; + } + .bubble { --bubble-number-max: 42px; --bubble-size: 6rem; @@ -132,23 +221,6 @@ inset: calc(var(--bubble-size) / 1.5) 0 auto auto; } } - - @media (max-width: 799px) { - --half-btn-height: 1.5rem; - --padding-inline: 1.5ch; - - column-gap: 0; - grid-template-areas: - "notch-top notch-top notch-top" - "card card card" - "actions-left notch-bottom actions-right" - "closure-message closure-message closure-message"; - grid-template-columns: 1fr auto 1fr; - inline-size: calc(100% + 2 * var(--padding-inline)); - margin-inline: calc(-1 * var(--padding-inline)); - max-inline-size: none; - position: relative; - } } /* Child items diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index 3aec6e3b0..0baec71e6 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -252,9 +252,10 @@ padding-block: var(--block-space-half); } - /* Meta + /* Footer /* ------------------------------------------------------------------------ */ + /* Card metadata */ .card__meta { --meta-spacer-block: 0.5ch; --meta-spacer-inline: 0.75ch; @@ -376,11 +377,12 @@ display: flex; flex-direction: column; font-weight: bold; - inset: auto 1ch 1ch auto; + inset: auto 0 -1lh auto; justify-content: center; max-inline-size: 25ch; min-inline-size: 16ch; padding: 1ch; + pointer-events: none; position: absolute; rotate: 5deg; transform-origin: top right; diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 006cbb985..a8605503b 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -80,6 +80,11 @@ display: none !important; } } + + .reactions { + margin-block-start: var(--block-space-half); + margin-inline: calc(var(--column-gap) / -1); + } } .comment__author { diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css index d1cd0a03f..64f2a5164 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -10,8 +10,6 @@ flex-wrap: wrap; gap: var(--inline-space-half); inline-size: 100%; - margin-block-start: var(--block-space-half); - margin-inline: calc(var(--column-gap) / -1); &:has([open]) { z-index: var(--z-popup); @@ -34,11 +32,8 @@ .reactions__trigger { --btn-border-color: var(--color-ink-lightest); - background-color: var(--color-ink-lightest); - &:not(:hover) { - opacity: 0.66; - } + background-color: var(--color-ink-lightest); } } } diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/trays.css index 7793b6f2d..0e47857ab 100644 --- a/app/assets/stylesheets/trays.css +++ b/app/assets/stylesheets/trays.css @@ -440,6 +440,7 @@ .card__meta-text:not(.card__meta-text--updated), .card__stages, .card__steps, + .card__boosts, .card__comments, .card__closed { display: none; diff --git a/app/controllers/cards/comments/reactions_controller.rb b/app/controllers/cards/comments/reactions_controller.rb index 0f1277a3c..0c822360e 100644 --- a/app/controllers/cards/comments/reactions_controller.rb +++ b/app/controllers/cards/comments/reactions_controller.rb @@ -2,20 +2,26 @@ class Cards::Comments::ReactionsController < ApplicationController include CardScoped before_action :set_comment - before_action :set_reaction, only: %i[ destroy ] - before_action :ensure_permision_to_administer_reaction, only: %i[ destroy ] + before_action :set_reactable + + with_options only: :destroy do + before_action :set_reaction + before_action :ensure_permission_to_administer_reaction + end def index + render "reactions/index" end def new + render "reactions/new" end def create - @reaction = @comment.reactions.create!(params.expect(reaction: :content)) + @reaction = @reactable.reactions.create!(params.expect(reaction: :content)) respond_to do |format| - format.turbo_stream + format.turbo_stream { render "reactions/create" } format.json { head :created } end end @@ -24,7 +30,7 @@ class Cards::Comments::ReactionsController < ApplicationController @reaction.destroy respond_to do |format| - format.turbo_stream + format.turbo_stream { render "reactions/destroy" } format.json { head :no_content } end end @@ -34,11 +40,15 @@ class Cards::Comments::ReactionsController < ApplicationController @comment = @card.comments.find(params[:comment_id]) end - def set_reaction - @reaction = @comment.reactions.find(params[:id]) + def set_reactable + @reactable = @comment end - def ensure_permision_to_administer_reaction + def set_reaction + @reaction = @reactable.reactions.find(params[:id]) + end + + def ensure_permission_to_administer_reaction head :forbidden if Current.user != @reaction.reacter end end diff --git a/app/controllers/cards/reactions_controller.rb b/app/controllers/cards/reactions_controller.rb new file mode 100644 index 000000000..4105a4121 --- /dev/null +++ b/app/controllers/cards/reactions_controller.rb @@ -0,0 +1,49 @@ +class Cards::ReactionsController < ApplicationController + include CardScoped + + before_action :set_reactable + + with_options only: :destroy do + before_action :set_reaction + before_action :ensure_permission_to_administer_reaction + end + + def index + render "reactions/index" + end + + def new + render "reactions/new" + end + + def create + @reaction = @reactable.reactions.create!(params.expect(reaction: :content)) + + respond_to do |format| + format.turbo_stream { render "reactions/create" } + format.json { head :created } + end + end + + def destroy + @reaction.destroy + + respond_to do |format| + format.turbo_stream { render "reactions/destroy" } + format.json { head :no_content } + end + end + + private + def set_reactable + @reactable = @card + end + + def set_reaction + @reaction = @reactable.reactions.find(params[:id]) + end + + def ensure_permission_to_administer_reaction + head :forbidden if Current.user != @reaction.reacter + end +end diff --git a/app/helpers/reactions_helper.rb b/app/helpers/reactions_helper.rb new file mode 100644 index 000000000..5c47781f6 --- /dev/null +++ b/app/helpers/reactions_helper.rb @@ -0,0 +1,10 @@ +module ReactionsHelper + def reaction_path_prefix_for(reactable) + case reactable + when Card then [ reactable ] + when Comment then [ reactable.card, reactable ] + else + raise ArgumentError, "Unknown reactable type: #{reactable.class}" + end + end +end diff --git a/app/models/card.rb b/app/models/card.rb index 43e2d9361..d422afa85 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -7,6 +7,7 @@ class Card < ApplicationRecord belongs_to :board belongs_to :creator, class_name: "User", default: -> { Current.user } + has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all has_one_attached :image, dependent: :purge_later has_rich_text :description @@ -22,7 +23,7 @@ class Card < ApplicationRecord scope :chronologically, -> { order created_at: :asc, id: :asc } scope :latest, -> { order last_active_at: :desc, id: :desc } scope :with_users, -> { preload(creator: [ :avatar_attachment, :account ], assignees: [ :avatar_attachment, :account ]) } - scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds } + scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, reactions: :reacter, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds } scope :indexed_by, ->(index) do case index diff --git a/app/views/cards/_container.html.erb b/app/views/cards/_container.html.erb index de3bc4b27..b6823bd76 100644 --- a/app/views/cards/_container.html.erb +++ b/app/views/cards/_container.html.erb @@ -21,11 +21,14 @@ <% if card.published? %> <%= render "cards/triage/columns", card: card %> <% end %> + + <%= render "cards/display/common/stamp", card: card %> -