vote_fu 0.0.11 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +84 -0
- data/README.md +265 -0
- data/app/assets/stylesheets/vote_fu/votes.css +391 -0
- data/app/channels/vote_fu/application_cable/channel.rb +8 -0
- data/app/channels/vote_fu/application_cable/connection.rb +39 -0
- data/app/channels/vote_fu/votes_channel.rb +99 -0
- data/app/components/vote_fu/like_button_component.rb +136 -0
- data/app/components/vote_fu/reaction_bar_component.rb +208 -0
- data/app/components/vote_fu/star_rating_component.rb +199 -0
- data/app/components/vote_fu/vote_widget_component.rb +181 -0
- data/app/controllers/vote_fu/application_controller.rb +49 -0
- data/app/controllers/vote_fu/votes_controller.rb +223 -0
- data/app/helpers/vote_fu/votes_helper.rb +176 -0
- data/app/javascript/vote_fu/channels/consumer.js +3 -0
- data/app/javascript/vote_fu/channels/index.js +2 -0
- data/app/javascript/vote_fu/channels/votes_channel.js +93 -0
- data/app/javascript/vote_fu/controllers/application.js +9 -0
- data/app/javascript/vote_fu/controllers/index.js +9 -0
- data/app/javascript/vote_fu/controllers/vote_fu_controller.js +148 -0
- data/app/javascript/vote_fu/controllers/vote_fu_reactions_controller.js +92 -0
- data/app/javascript/vote_fu/controllers/vote_fu_stars_controller.js +77 -0
- data/app/models/vote_fu/application_record.rb +7 -0
- data/app/models/vote_fu/vote.rb +90 -0
- data/app/views/vote_fu/votes/_count.html.erb +29 -0
- data/app/views/vote_fu/votes/_downvote_button.html.erb +40 -0
- data/app/views/vote_fu/votes/_error.html.erb +9 -0
- data/app/views/vote_fu/votes/_like_button.html.erb +67 -0
- data/app/views/vote_fu/votes/_upvote_button.html.erb +40 -0
- data/app/views/vote_fu/votes/_widget.html.erb +85 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +9 -0
- data/lib/generators/vote_fu/install/install_generator.rb +56 -0
- data/lib/generators/vote_fu/install/templates/initializer.rb +42 -0
- data/lib/generators/vote_fu/install/templates/migration.rb.erb +41 -0
- data/lib/generators/vote_fu/migration/migration_generator.rb +29 -0
- data/lib/generators/vote_fu/migration/templates/create_vote_fu_votes.rb.erb +40 -0
- data/lib/vote_fu/algorithms/hacker_news.rb +54 -0
- data/lib/vote_fu/algorithms/reddit_hot.rb +55 -0
- data/lib/vote_fu/algorithms/wilson_score.rb +69 -0
- data/lib/vote_fu/concerns/karmatic.rb +320 -0
- data/lib/vote_fu/concerns/voteable.rb +291 -0
- data/lib/vote_fu/concerns/voter.rb +275 -0
- data/lib/vote_fu/configuration.rb +53 -0
- data/lib/vote_fu/engine.rb +54 -0
- data/lib/vote_fu/errors.rb +34 -0
- data/lib/vote_fu/version.rb +5 -0
- data/lib/vote_fu.rb +22 -9
- metadata +217 -60
- data/CHANGELOG.markdown +0 -31
- data/README.markdown +0 -220
- data/examples/routes.rb +0 -7
- data/examples/users_controller.rb +0 -76
- data/examples/voteable.html.erb +0 -8
- data/examples/voteable.rb +0 -10
- data/examples/voteables_controller.rb +0 -117
- data/examples/votes/_voteable_vote.html.erb +0 -23
- data/examples/votes/create.rjs +0 -1
- data/examples/votes_controller.rb +0 -110
- data/generators/vote_fu/templates/migration.rb +0 -21
- data/generators/vote_fu/vote_fu_generator.rb +0 -8
- data/init.rb +0 -1
- data/lib/acts_as_voteable.rb +0 -114
- data/lib/acts_as_voter.rb +0 -75
- data/lib/controllers/votes_controller.rb +0 -96
- data/lib/has_karma.rb +0 -68
- data/lib/models/vote.rb +0 -17
- data/rails/init.rb +0 -10
- data/test/vote_fu_test.rb +0 -8
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VoteFu
|
|
4
|
+
class ReactionBarComponent < ViewComponent::Base
|
|
5
|
+
# Reaction bar component for emoji reactions (like Slack, GitHub, etc.)
|
|
6
|
+
#
|
|
7
|
+
# Uses scoped voting where each reaction type is a scope.
|
|
8
|
+
# Vote values indicate the reaction emoji index.
|
|
9
|
+
#
|
|
10
|
+
# @param voteable [ActiveRecord::Base] The voteable record
|
|
11
|
+
# @param voter [ActiveRecord::Base, nil] The current voter
|
|
12
|
+
# @param reactions [Array<Hash>] Array of reaction configs: { emoji:, label:, scope: }
|
|
13
|
+
# @param show_counts [Boolean] Show reaction counts
|
|
14
|
+
# @param show_users [Boolean] Show who reacted (requires extra query)
|
|
15
|
+
# @param max_users [Integer] Max user names to show per reaction
|
|
16
|
+
# @param allow_multiple [Boolean] Allow user to add multiple reactions
|
|
17
|
+
def initialize(
|
|
18
|
+
voteable:,
|
|
19
|
+
voter: nil,
|
|
20
|
+
reactions: default_reactions,
|
|
21
|
+
show_counts: true,
|
|
22
|
+
show_users: false,
|
|
23
|
+
max_users: 3,
|
|
24
|
+
allow_multiple: true
|
|
25
|
+
)
|
|
26
|
+
@voteable = voteable
|
|
27
|
+
@voter = voter
|
|
28
|
+
@reactions = reactions
|
|
29
|
+
@show_counts = show_counts
|
|
30
|
+
@show_users = show_users
|
|
31
|
+
@max_users = max_users
|
|
32
|
+
@allow_multiple = allow_multiple
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call
|
|
36
|
+
tag.div(**wrapper_attributes) do
|
|
37
|
+
safe_join([
|
|
38
|
+
reactions_container,
|
|
39
|
+
(add_reaction_button if @voter && @allow_multiple)
|
|
40
|
+
].compact)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def default_reactions
|
|
47
|
+
[
|
|
48
|
+
{ emoji: "👍", label: "Like", scope: "like" },
|
|
49
|
+
{ emoji: "❤️", label: "Love", scope: "love" },
|
|
50
|
+
{ emoji: "😂", label: "Laugh", scope: "laugh" },
|
|
51
|
+
{ emoji: "😮", label: "Wow", scope: "wow" },
|
|
52
|
+
{ emoji: "😢", label: "Sad", scope: "sad" },
|
|
53
|
+
{ emoji: "😡", label: "Angry", scope: "angry" }
|
|
54
|
+
]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def wrapper_attributes
|
|
58
|
+
{
|
|
59
|
+
id: dom_id(:reactions),
|
|
60
|
+
class: "vote-fu-reaction-bar",
|
|
61
|
+
data: stimulus_data
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def stimulus_data
|
|
66
|
+
{
|
|
67
|
+
controller: "vote-fu-reactions",
|
|
68
|
+
vote_fu_reactions_voteable_type_value: @voteable.class.name,
|
|
69
|
+
vote_fu_reactions_voteable_id_value: @voteable.id,
|
|
70
|
+
vote_fu_reactions_allow_multiple_value: @allow_multiple
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reactions_container
|
|
75
|
+
tag.div(class: "vote-fu-reactions") do
|
|
76
|
+
safe_join(@reactions.map { |reaction| reaction_button(reaction) })
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def reaction_button(reaction)
|
|
81
|
+
scope = reaction[:scope]
|
|
82
|
+
count = reaction_count(scope)
|
|
83
|
+
user_reacted = user_reacted?(scope)
|
|
84
|
+
|
|
85
|
+
return nil if count.zero? && !@voter
|
|
86
|
+
|
|
87
|
+
tag.div(
|
|
88
|
+
class: reaction_wrapper_classes(user_reacted, count),
|
|
89
|
+
data: { scope: scope }
|
|
90
|
+
) do
|
|
91
|
+
if @voter
|
|
92
|
+
interactive_reaction(reaction, count, user_reacted)
|
|
93
|
+
else
|
|
94
|
+
readonly_reaction(reaction, count)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def interactive_reaction(reaction, count, user_reacted)
|
|
100
|
+
form_with(url: toggle_path, method: :post, data: turbo_form_data) do |f|
|
|
101
|
+
safe_join([
|
|
102
|
+
f.hidden_field(:voteable_type, value: @voteable.class.name),
|
|
103
|
+
f.hidden_field(:voteable_id, value: @voteable.id),
|
|
104
|
+
f.hidden_field(:scope, value: reaction[:scope]),
|
|
105
|
+
f.hidden_field(:direction, value: :up),
|
|
106
|
+
tag.button(
|
|
107
|
+
reaction_content(reaction, count),
|
|
108
|
+
type: :submit,
|
|
109
|
+
class: reaction_button_classes(user_reacted),
|
|
110
|
+
title: reaction_title(reaction, user_reacted),
|
|
111
|
+
data: { vote_fu_reactions_target: "reaction" }
|
|
112
|
+
)
|
|
113
|
+
])
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def readonly_reaction(reaction, count)
|
|
118
|
+
tag.span(
|
|
119
|
+
reaction_content(reaction, count),
|
|
120
|
+
class: "vote-fu-reaction vote-fu-reaction--readonly",
|
|
121
|
+
title: reaction[:label]
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def reaction_content(reaction, count)
|
|
126
|
+
parts = [tag.span(reaction[:emoji], class: "vote-fu-reaction-emoji")]
|
|
127
|
+
parts << tag.span(count.to_s, class: "vote-fu-reaction-count") if @show_counts && count.positive?
|
|
128
|
+
|
|
129
|
+
if @show_users && count.positive?
|
|
130
|
+
parts << tag.span(
|
|
131
|
+
reaction_users(reaction[:scope]),
|
|
132
|
+
class: "vote-fu-reaction-users"
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
safe_join(parts)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def reaction_wrapper_classes(user_reacted, count)
|
|
140
|
+
classes = ["vote-fu-reaction-wrapper"]
|
|
141
|
+
classes << "vote-fu-reaction-wrapper--active" if user_reacted
|
|
142
|
+
classes << "vote-fu-reaction-wrapper--empty" if count.zero?
|
|
143
|
+
classes.join(" ")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def reaction_button_classes(user_reacted)
|
|
147
|
+
classes = ["vote-fu-reaction"]
|
|
148
|
+
classes << "vote-fu-reaction--active" if user_reacted
|
|
149
|
+
classes.join(" ")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def reaction_title(reaction, user_reacted)
|
|
153
|
+
if user_reacted
|
|
154
|
+
"Remove #{reaction[:label]} reaction"
|
|
155
|
+
else
|
|
156
|
+
"React with #{reaction[:label]}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def add_reaction_button
|
|
161
|
+
tag.div(class: "vote-fu-add-reaction") do
|
|
162
|
+
tag.button(
|
|
163
|
+
"➕",
|
|
164
|
+
class: "vote-fu-add-reaction-btn",
|
|
165
|
+
title: "Add reaction",
|
|
166
|
+
data: {
|
|
167
|
+
action: "click->vote-fu-reactions#showPicker",
|
|
168
|
+
vote_fu_reactions_target: "addButton"
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def turbo_form_data
|
|
175
|
+
{ turbo_stream: true, action: "submit->vote-fu-reactions#toggle" }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def reaction_count(scope)
|
|
179
|
+
@reaction_counts ||= {}
|
|
180
|
+
@reaction_counts[scope] ||= @voteable.votes_for(scope: scope)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def user_reacted?(scope)
|
|
184
|
+
return false unless @voter
|
|
185
|
+
|
|
186
|
+
@user_reactions ||= {}
|
|
187
|
+
@user_reactions[scope] ||= @voteable.voted_by?(@voter, scope: scope)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def reaction_users(scope)
|
|
191
|
+
voters = @voteable.voters_for(scope: scope).limit(@max_users)
|
|
192
|
+
names = voters.map { |v| v.try(:name) || v.try(:username) || "Someone" }
|
|
193
|
+
|
|
194
|
+
remaining = reaction_count(scope) - names.size
|
|
195
|
+
names << "#{remaining} more" if remaining.positive?
|
|
196
|
+
|
|
197
|
+
names.join(", ")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def toggle_path
|
|
201
|
+
VoteFu::Engine.routes.url_helpers.toggle_votes_path
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def dom_id(suffix)
|
|
205
|
+
"vote_fu_#{@voteable.model_name.singular}_#{@voteable.id}_#{suffix}"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VoteFu
|
|
4
|
+
class StarRatingComponent < ViewComponent::Base
|
|
5
|
+
# Star rating component for 1-5 star ratings
|
|
6
|
+
#
|
|
7
|
+
# Uses vote values 1-5 to represent star ratings.
|
|
8
|
+
#
|
|
9
|
+
# @param voteable [ActiveRecord::Base] The voteable record
|
|
10
|
+
# @param voter [ActiveRecord::Base, nil] The current voter
|
|
11
|
+
# @param scope [String, Symbol, nil] Vote scope
|
|
12
|
+
# @param max_stars [Integer] Maximum number of stars (default: 5)
|
|
13
|
+
# @param filled_star [String] Character for filled star
|
|
14
|
+
# @param empty_star [String] Character for empty star
|
|
15
|
+
# @param half_star [String] Character for half star (used in average display)
|
|
16
|
+
# @param show_average [Boolean] Show average rating
|
|
17
|
+
# @param show_count [Boolean] Show vote count
|
|
18
|
+
# @param readonly [Boolean] Disable voting (show average only)
|
|
19
|
+
def initialize(
|
|
20
|
+
voteable:,
|
|
21
|
+
voter: nil,
|
|
22
|
+
scope: nil,
|
|
23
|
+
max_stars: 5,
|
|
24
|
+
filled_star: "★",
|
|
25
|
+
empty_star: "☆",
|
|
26
|
+
half_star: "⯨",
|
|
27
|
+
show_average: true,
|
|
28
|
+
show_count: true,
|
|
29
|
+
readonly: false
|
|
30
|
+
)
|
|
31
|
+
@voteable = voteable
|
|
32
|
+
@voter = voter
|
|
33
|
+
@scope = scope
|
|
34
|
+
@max_stars = max_stars
|
|
35
|
+
@filled_star = filled_star
|
|
36
|
+
@empty_star = empty_star
|
|
37
|
+
@half_star = half_star
|
|
38
|
+
@show_average = show_average
|
|
39
|
+
@show_count = show_count
|
|
40
|
+
@readonly = readonly
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call
|
|
44
|
+
tag.div(**wrapper_attributes) do
|
|
45
|
+
safe_join([
|
|
46
|
+
star_container,
|
|
47
|
+
(rating_info if @show_average || @show_count)
|
|
48
|
+
].compact)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def wrapper_attributes
|
|
55
|
+
{
|
|
56
|
+
id: dom_id(:widget),
|
|
57
|
+
class: wrapper_classes,
|
|
58
|
+
data: stimulus_data
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def wrapper_classes
|
|
63
|
+
classes = ["vote-fu-star-rating"]
|
|
64
|
+
classes << "vote-fu-star-rating--readonly" if @readonly || @voter.nil?
|
|
65
|
+
classes << "vote-fu-star-rating--voted" if current_vote.present?
|
|
66
|
+
classes.join(" ")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def stimulus_data
|
|
70
|
+
{
|
|
71
|
+
controller: "vote-fu-stars",
|
|
72
|
+
vote_fu_stars_voteable_type_value: @voteable.class.name,
|
|
73
|
+
vote_fu_stars_voteable_id_value: @voteable.id,
|
|
74
|
+
vote_fu_stars_scope_value: @scope,
|
|
75
|
+
vote_fu_stars_current_rating_value: current_rating,
|
|
76
|
+
vote_fu_stars_max_stars_value: @max_stars
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def star_container
|
|
81
|
+
tag.div(class: "vote-fu-stars") do
|
|
82
|
+
if interactive?
|
|
83
|
+
interactive_stars
|
|
84
|
+
else
|
|
85
|
+
readonly_stars
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def interactive_stars
|
|
91
|
+
safe_join(
|
|
92
|
+
(1..@max_stars).map { |value| interactive_star(value) }
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def interactive_star(value)
|
|
97
|
+
form_with(url: vote_path, method: :post, data: turbo_form_data) do |f|
|
|
98
|
+
safe_join([
|
|
99
|
+
f.hidden_field(:voteable_type, value: @voteable.class.name),
|
|
100
|
+
f.hidden_field(:voteable_id, value: @voteable.id),
|
|
101
|
+
f.hidden_field(:scope, value: @scope),
|
|
102
|
+
f.hidden_field(:value, value: value),
|
|
103
|
+
tag.button(
|
|
104
|
+
value <= current_rating ? @filled_star : @empty_star,
|
|
105
|
+
type: :submit,
|
|
106
|
+
class: star_button_classes(value),
|
|
107
|
+
title: "Rate #{value} star#{value == 1 ? "" : "s"}",
|
|
108
|
+
data: {
|
|
109
|
+
vote_fu_stars_target: "star",
|
|
110
|
+
star_value: value
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
])
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def readonly_stars
|
|
118
|
+
safe_join(
|
|
119
|
+
(1..@max_stars).map { |value| readonly_star(value) }
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def readonly_star(value)
|
|
124
|
+
avg = average_rating
|
|
125
|
+
star_char = if value <= avg.floor
|
|
126
|
+
@filled_star
|
|
127
|
+
elsif value - 0.5 <= avg
|
|
128
|
+
@half_star
|
|
129
|
+
else
|
|
130
|
+
@empty_star
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
tag.span(star_char, class: star_classes(value, avg))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def star_button_classes(value)
|
|
137
|
+
classes = ["vote-fu-star-btn"]
|
|
138
|
+
classes << "vote-fu-star-filled" if value <= current_rating
|
|
139
|
+
classes.join(" ")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def star_classes(value, avg)
|
|
143
|
+
classes = ["vote-fu-star"]
|
|
144
|
+
classes << "vote-fu-star-filled" if value <= avg.floor
|
|
145
|
+
classes << "vote-fu-star-half" if value > avg.floor && value - 0.5 <= avg
|
|
146
|
+
classes.join(" ")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def rating_info
|
|
150
|
+
tag.div(class: "vote-fu-star-info") do
|
|
151
|
+
parts = []
|
|
152
|
+
parts << tag.span("#{average_rating.round(1)}", class: "vote-fu-star-average") if @show_average
|
|
153
|
+
parts << tag.span("(#{vote_count})", class: "vote-fu-star-count") if @show_count
|
|
154
|
+
safe_join(parts, " ")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def turbo_form_data
|
|
159
|
+
{ turbo_stream: true, action: "submit->vote-fu-stars#rate" }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def interactive?
|
|
163
|
+
!@readonly && @voter.present?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def current_vote
|
|
167
|
+
return @current_vote if defined?(@current_vote)
|
|
168
|
+
|
|
169
|
+
@current_vote = @voter && @voteable.received_votes.find_by(
|
|
170
|
+
voter: @voter,
|
|
171
|
+
scope: @scope
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def current_rating
|
|
176
|
+
current_vote&.value.to_i
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def average_rating
|
|
180
|
+
return @average_rating if defined?(@average_rating)
|
|
181
|
+
|
|
182
|
+
votes = @voteable.received_votes.with_scope(@scope)
|
|
183
|
+
@average_rating = votes.any? ? votes.average(:value).to_f : 0.0
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def vote_count
|
|
187
|
+
@voteable.received_votes.with_scope(@scope).count
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def vote_path
|
|
191
|
+
VoteFu::Engine.routes.url_helpers.votes_path
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def dom_id(suffix)
|
|
195
|
+
scope_part = @scope.present? ? "_#{@scope}" : ""
|
|
196
|
+
"vote_fu_#{@voteable.model_name.singular}_#{@voteable.id}#{scope_part}_#{suffix}"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VoteFu
|
|
4
|
+
class VoteWidgetComponent < ViewComponent::Base
|
|
5
|
+
# The voteable record
|
|
6
|
+
attr_reader :voteable
|
|
7
|
+
|
|
8
|
+
# @param voteable [ActiveRecord::Base] The voteable record
|
|
9
|
+
# @param voter [ActiveRecord::Base, nil] The current voter
|
|
10
|
+
# @param scope [String, Symbol, nil] Vote scope for scoped voting
|
|
11
|
+
# @param variant [Symbol] Widget style (:default, :compact, :vertical, :large)
|
|
12
|
+
# @param upvote_label [String] Upvote button label
|
|
13
|
+
# @param downvote_label [String] Downvote button label
|
|
14
|
+
# @param show_count [Boolean] Whether to show vote count
|
|
15
|
+
# @param count_format [Symbol] Count format (:plusminus, :total, :split)
|
|
16
|
+
def initialize(
|
|
17
|
+
voteable:,
|
|
18
|
+
voter: nil,
|
|
19
|
+
scope: nil,
|
|
20
|
+
variant: :default,
|
|
21
|
+
upvote_label: "▲",
|
|
22
|
+
downvote_label: "▼",
|
|
23
|
+
show_count: true,
|
|
24
|
+
count_format: :plusminus
|
|
25
|
+
)
|
|
26
|
+
@voteable = voteable
|
|
27
|
+
@voter = voter
|
|
28
|
+
@scope = scope
|
|
29
|
+
@variant = variant
|
|
30
|
+
@upvote_label = upvote_label
|
|
31
|
+
@downvote_label = downvote_label
|
|
32
|
+
@show_count = show_count
|
|
33
|
+
@count_format = count_format
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call
|
|
37
|
+
tag.div(**wrapper_attributes) do
|
|
38
|
+
safe_join([
|
|
39
|
+
upvote_button,
|
|
40
|
+
(count_display if @show_count),
|
|
41
|
+
downvote_button
|
|
42
|
+
].compact)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def wrapper_attributes
|
|
49
|
+
{
|
|
50
|
+
id: dom_id(:widget),
|
|
51
|
+
class: wrapper_classes,
|
|
52
|
+
data: stimulus_data
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def wrapper_classes
|
|
57
|
+
classes = ["vote-fu-widget"]
|
|
58
|
+
classes << "vote-fu-widget--#{@variant}" unless @variant == :default
|
|
59
|
+
classes << "vote-fu-voted-up" if voted_up?
|
|
60
|
+
classes << "vote-fu-voted-down" if voted_down?
|
|
61
|
+
classes.join(" ")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def stimulus_data
|
|
65
|
+
{
|
|
66
|
+
controller: "vote-fu",
|
|
67
|
+
vote_fu_voteable_type_value: @voteable.class.name,
|
|
68
|
+
vote_fu_voteable_id_value: @voteable.id,
|
|
69
|
+
vote_fu_scope_value: @scope,
|
|
70
|
+
vote_fu_voted_value: current_vote.present?,
|
|
71
|
+
vote_fu_direction_value: current_vote&.direction
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def upvote_button
|
|
76
|
+
if @voter
|
|
77
|
+
form_with(url: toggle_path, method: :post, data: turbo_form_data) do |f|
|
|
78
|
+
safe_join([
|
|
79
|
+
f.hidden_field(:voteable_type, value: @voteable.class.name),
|
|
80
|
+
f.hidden_field(:voteable_id, value: @voteable.id),
|
|
81
|
+
f.hidden_field(:scope, value: @scope),
|
|
82
|
+
f.hidden_field(:direction, value: :up),
|
|
83
|
+
tag.button(
|
|
84
|
+
@upvote_label,
|
|
85
|
+
type: :submit,
|
|
86
|
+
class: upvote_button_classes,
|
|
87
|
+
title: voted_up? ? "Remove upvote" : "Upvote",
|
|
88
|
+
data: { vote_fu_target: "upvoteBtn" }
|
|
89
|
+
)
|
|
90
|
+
])
|
|
91
|
+
end
|
|
92
|
+
else
|
|
93
|
+
tag.span(@upvote_label, class: "vote-fu-btn vote-fu-upvote vote-fu-disabled")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def downvote_button
|
|
98
|
+
if @voter
|
|
99
|
+
form_with(url: toggle_path, method: :post, data: turbo_form_data) do |f|
|
|
100
|
+
safe_join([
|
|
101
|
+
f.hidden_field(:voteable_type, value: @voteable.class.name),
|
|
102
|
+
f.hidden_field(:voteable_id, value: @voteable.id),
|
|
103
|
+
f.hidden_field(:scope, value: @scope),
|
|
104
|
+
f.hidden_field(:direction, value: :down),
|
|
105
|
+
tag.button(
|
|
106
|
+
@downvote_label,
|
|
107
|
+
type: :submit,
|
|
108
|
+
class: downvote_button_classes,
|
|
109
|
+
title: voted_down? ? "Remove downvote" : "Downvote",
|
|
110
|
+
data: { vote_fu_target: "downvoteBtn" }
|
|
111
|
+
)
|
|
112
|
+
])
|
|
113
|
+
end
|
|
114
|
+
else
|
|
115
|
+
tag.span(@downvote_label, class: "vote-fu-btn vote-fu-downvote vote-fu-disabled")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def count_display
|
|
120
|
+
tag.span(
|
|
121
|
+
formatted_count,
|
|
122
|
+
id: dom_id(:count),
|
|
123
|
+
class: "vote-fu-count",
|
|
124
|
+
data: { vote_fu_target: "count" }
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def formatted_count
|
|
129
|
+
case @count_format
|
|
130
|
+
when :total
|
|
131
|
+
@voteable.votes_total(scope: @scope)
|
|
132
|
+
when :split
|
|
133
|
+
"+#{@voteable.votes_for(scope: @scope)} / -#{@voteable.votes_against(scope: @scope)}"
|
|
134
|
+
else
|
|
135
|
+
@voteable.plusminus(scope: @scope)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def upvote_button_classes
|
|
140
|
+
classes = ["vote-fu-btn", "vote-fu-upvote"]
|
|
141
|
+
classes << "vote-fu-active" if voted_up?
|
|
142
|
+
classes.join(" ")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def downvote_button_classes
|
|
146
|
+
classes = ["vote-fu-btn", "vote-fu-downvote"]
|
|
147
|
+
classes << "vote-fu-active" if voted_down?
|
|
148
|
+
classes.join(" ")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def turbo_form_data
|
|
152
|
+
{ turbo_stream: true, action: "submit->vote-fu#vote" }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def current_vote
|
|
156
|
+
return @current_vote if defined?(@current_vote)
|
|
157
|
+
|
|
158
|
+
@current_vote = @voter && @voteable.received_votes.find_by(
|
|
159
|
+
voter: @voter,
|
|
160
|
+
scope: @scope
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def voted_up?
|
|
165
|
+
current_vote&.up?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def voted_down?
|
|
169
|
+
current_vote&.down?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def toggle_path
|
|
173
|
+
VoteFu::Engine.routes.url_helpers.toggle_votes_path
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def dom_id(suffix)
|
|
177
|
+
scope_part = @scope.present? ? "_#{@scope}" : ""
|
|
178
|
+
"vote_fu_#{@voteable.model_name.singular}_#{@voteable.id}#{scope_part}_#{suffix}"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VoteFu
|
|
4
|
+
class ApplicationController < ::ApplicationController
|
|
5
|
+
protect_from_forgery with: :exception
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def find_voteable
|
|
10
|
+
@voteable_type = params[:voteable_type]
|
|
11
|
+
@voteable_id = params[:voteable_id]
|
|
12
|
+
|
|
13
|
+
unless @voteable_type.present? && @voteable_id.present?
|
|
14
|
+
raise VoteFu::Errors::VoteableNotFound, "voteable_type and voteable_id are required"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
begin
|
|
18
|
+
@voteable_class = @voteable_type.constantize
|
|
19
|
+
rescue NameError
|
|
20
|
+
raise VoteFu::Errors::VoteableNotFound, "Unknown voteable type: #{@voteable_type}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@voteable = @voteable_class.find_by(id: @voteable_id)
|
|
24
|
+
raise VoteFu::Errors::VoteableNotFound, "Voteable not found" unless @voteable
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def current_voter
|
|
28
|
+
# Override this in your ApplicationController to return the current user
|
|
29
|
+
# Default implementation tries common patterns
|
|
30
|
+
return @current_voter if defined?(@current_voter)
|
|
31
|
+
|
|
32
|
+
@current_voter = if respond_to?(:current_user, true)
|
|
33
|
+
send(:current_user)
|
|
34
|
+
elsif defined?(Current) && Current.respond_to?(:user)
|
|
35
|
+
Current.user
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def require_voter!
|
|
40
|
+
return if current_voter.present?
|
|
41
|
+
|
|
42
|
+
respond_to do |format|
|
|
43
|
+
format.turbo_stream { head :unauthorized }
|
|
44
|
+
format.html { redirect_to main_app.root_path, alert: "You must be signed in to vote" }
|
|
45
|
+
format.json { render json: { error: "Unauthorized" }, status: :unauthorized }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|