vote_fu 0.0.11 → 2.0.0

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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +78 -0
  3. data/README.md +265 -0
  4. data/app/assets/stylesheets/vote_fu/votes.css +391 -0
  5. data/app/channels/vote_fu/application_cable/channel.rb +8 -0
  6. data/app/channels/vote_fu/application_cable/connection.rb +39 -0
  7. data/app/channels/vote_fu/votes_channel.rb +99 -0
  8. data/app/components/vote_fu/like_button_component.rb +136 -0
  9. data/app/components/vote_fu/reaction_bar_component.rb +208 -0
  10. data/app/components/vote_fu/star_rating_component.rb +199 -0
  11. data/app/components/vote_fu/vote_widget_component.rb +181 -0
  12. data/app/controllers/vote_fu/application_controller.rb +49 -0
  13. data/app/controllers/vote_fu/votes_controller.rb +223 -0
  14. data/app/helpers/vote_fu/votes_helper.rb +176 -0
  15. data/app/javascript/vote_fu/channels/consumer.js +3 -0
  16. data/app/javascript/vote_fu/channels/index.js +2 -0
  17. data/app/javascript/vote_fu/channels/votes_channel.js +93 -0
  18. data/app/javascript/vote_fu/controllers/application.js +9 -0
  19. data/app/javascript/vote_fu/controllers/index.js +9 -0
  20. data/app/javascript/vote_fu/controllers/vote_fu_controller.js +148 -0
  21. data/app/javascript/vote_fu/controllers/vote_fu_reactions_controller.js +92 -0
  22. data/app/javascript/vote_fu/controllers/vote_fu_stars_controller.js +77 -0
  23. data/app/models/vote_fu/application_record.rb +7 -0
  24. data/app/models/vote_fu/vote.rb +90 -0
  25. data/app/views/vote_fu/votes/_count.html.erb +29 -0
  26. data/app/views/vote_fu/votes/_downvote_button.html.erb +40 -0
  27. data/app/views/vote_fu/votes/_error.html.erb +9 -0
  28. data/app/views/vote_fu/votes/_like_button.html.erb +67 -0
  29. data/app/views/vote_fu/votes/_upvote_button.html.erb +40 -0
  30. data/app/views/vote_fu/votes/_widget.html.erb +85 -0
  31. data/config/importmap.rb +6 -0
  32. data/config/routes.rb +9 -0
  33. data/lib/generators/vote_fu/install/install_generator.rb +56 -0
  34. data/lib/generators/vote_fu/install/templates/initializer.rb +42 -0
  35. data/lib/generators/vote_fu/install/templates/migration.rb.erb +41 -0
  36. data/lib/generators/vote_fu/migration/migration_generator.rb +29 -0
  37. data/lib/generators/vote_fu/migration/templates/create_vote_fu_votes.rb.erb +40 -0
  38. data/lib/vote_fu/algorithms/hacker_news.rb +54 -0
  39. data/lib/vote_fu/algorithms/reddit_hot.rb +55 -0
  40. data/lib/vote_fu/algorithms/wilson_score.rb +69 -0
  41. data/lib/vote_fu/concerns/karmatic.rb +320 -0
  42. data/lib/vote_fu/concerns/voteable.rb +291 -0
  43. data/lib/vote_fu/concerns/voter.rb +275 -0
  44. data/lib/vote_fu/configuration.rb +53 -0
  45. data/lib/vote_fu/engine.rb +54 -0
  46. data/lib/vote_fu/errors.rb +34 -0
  47. data/lib/vote_fu/version.rb +5 -0
  48. data/lib/vote_fu.rb +22 -9
  49. metadata +215 -63
  50. data/CHANGELOG.markdown +0 -31
  51. data/README.markdown +0 -220
  52. data/examples/routes.rb +0 -7
  53. data/examples/users_controller.rb +0 -76
  54. data/examples/voteable.html.erb +0 -8
  55. data/examples/voteable.rb +0 -10
  56. data/examples/voteables_controller.rb +0 -117
  57. data/examples/votes/_voteable_vote.html.erb +0 -23
  58. data/examples/votes/create.rjs +0 -1
  59. data/examples/votes_controller.rb +0 -110
  60. data/generators/vote_fu/templates/migration.rb +0 -21
  61. data/generators/vote_fu/vote_fu_generator.rb +0 -8
  62. data/init.rb +0 -1
  63. data/lib/acts_as_voteable.rb +0 -114
  64. data/lib/acts_as_voter.rb +0 -75
  65. data/lib/controllers/votes_controller.rb +0 -96
  66. data/lib/has_karma.rb +0 -68
  67. data/lib/models/vote.rb +0 -17
  68. data/rails/init.rb +0 -10
  69. 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