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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +84 -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 +217 -60
  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,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module VoteFu
6
+ module Concerns
7
+ module Voteable
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ # Configure a model to receive votes
12
+ #
13
+ # @example Basic usage
14
+ # class Post < ApplicationRecord
15
+ # acts_as_voteable
16
+ # end
17
+ #
18
+ # @example With options
19
+ # class Post < ApplicationRecord
20
+ # acts_as_voteable counter_cache: true, broadcasts: true
21
+ # end
22
+ #
23
+ # @param options [Hash] Configuration options
24
+ # @option options [Boolean] :counter_cache (true) Maintain vote count columns
25
+ # @option options [Boolean] :broadcasts (true) Broadcast changes via Turbo
26
+ # @option options [Array<Symbol>] :scopes (nil) Allowed voting scopes
27
+ def acts_as_voteable(**options)
28
+ class_attribute :vote_fu_voteable_options, default: {
29
+ counter_cache: VoteFu.configuration.counter_cache,
30
+ broadcasts: VoteFu.configuration.turbo_broadcasts,
31
+ scopes: nil
32
+ }.merge(options)
33
+
34
+ has_many :received_votes,
35
+ class_name: "VoteFu::Vote",
36
+ as: :voteable,
37
+ dependent: :destroy,
38
+ inverse_of: :voteable
39
+
40
+ include VoteFu::Concerns::Voteable::InstanceMethods
41
+ extend VoteFu::Concerns::Voteable::ClassMethods
42
+ end
43
+
44
+ # Alternative DSL: declare which models can vote on this one
45
+ #
46
+ # @example
47
+ # class Post < ApplicationRecord
48
+ # voteable_by :users, :admins
49
+ # end
50
+ def voteable_by(*voter_classes, **options)
51
+ acts_as_voteable(**options)
52
+
53
+ class_attribute :vote_fu_allowed_voters, default: voter_classes.map(&:to_s).map(&:classify)
54
+ end
55
+ end
56
+
57
+ module ClassMethods
58
+ # Order by total vote value (sum of all vote values)
59
+ def by_votes(direction = :desc)
60
+ left_joins(:received_votes)
61
+ .group(:id)
62
+ .order(Arel.sql("COALESCE(SUM(vote_fu_votes.value), 0) #{direction.to_s.upcase}"))
63
+ end
64
+
65
+ # Order by vote count
66
+ def by_vote_count(direction = :desc)
67
+ left_joins(:received_votes)
68
+ .group(:id)
69
+ .order(Arel.sql("COUNT(vote_fu_votes.id) #{direction.to_s.upcase}"))
70
+ end
71
+
72
+ # Items with positive net votes
73
+ def with_positive_score
74
+ left_joins(:received_votes)
75
+ .group(:id)
76
+ .having("COALESCE(SUM(vote_fu_votes.value), 0) > 0")
77
+ end
78
+
79
+ # Items with any votes
80
+ def with_votes
81
+ joins(:received_votes).distinct
82
+ end
83
+
84
+ # Items without any votes
85
+ def without_votes
86
+ left_joins(:received_votes)
87
+ .where(vote_fu_votes: { id: nil })
88
+ end
89
+
90
+ # Trending items (most votes in time period)
91
+ def trending(since: 24.hours.ago)
92
+ joins(:received_votes)
93
+ .where(vote_fu_votes: { created_at: since.. })
94
+ .group(:id)
95
+ .order(Arel.sql("COUNT(vote_fu_votes.id) DESC"))
96
+ end
97
+ end
98
+
99
+ module InstanceMethods
100
+ # Count of upvotes
101
+ # @param scope [Symbol, nil] Optional voting scope
102
+ # @return [Integer]
103
+ def votes_for(scope: nil)
104
+ received_votes.with_scope(scope).up.count
105
+ end
106
+
107
+ # Count of downvotes
108
+ # @param scope [Symbol, nil] Optional voting scope
109
+ # @return [Integer]
110
+ def votes_against(scope: nil)
111
+ received_votes.with_scope(scope).down.count
112
+ end
113
+
114
+ # Total number of votes
115
+ # @param scope [Symbol, nil] Optional voting scope
116
+ # @return [Integer]
117
+ def votes_count(scope: nil)
118
+ received_votes.with_scope(scope).count
119
+ end
120
+
121
+ # Sum of all vote values
122
+ # @param scope [Symbol, nil] Optional voting scope
123
+ # @return [Integer]
124
+ def votes_total(scope: nil)
125
+ received_votes.with_scope(scope).sum(:value)
126
+ end
127
+
128
+ # Net score (upvotes minus downvotes)
129
+ # Uses counter cache if available
130
+ # @param scope [Symbol, nil] Optional voting scope
131
+ # @return [Integer]
132
+ def plusminus(scope: nil)
133
+ if has_attribute?(:votes_total) && scope.nil?
134
+ read_attribute(:votes_total) || 0
135
+ else
136
+ votes_for(scope: scope) - votes_against(scope: scope)
137
+ end
138
+ end
139
+
140
+ # Percentage of upvotes
141
+ # @param scope [Symbol, nil] Optional voting scope
142
+ # @return [Float] 0.0 to 100.0
143
+ def percent_for(scope: nil)
144
+ total = votes_count(scope: scope)
145
+ return 0.0 if total.zero?
146
+
147
+ (votes_for(scope: scope).to_f / total * 100).round(1)
148
+ end
149
+
150
+ # Percentage of downvotes
151
+ # @param scope [Symbol, nil] Optional voting scope
152
+ # @return [Float] 0.0 to 100.0
153
+ def percent_against(scope: nil)
154
+ total = votes_count(scope: scope)
155
+ return 0.0 if total.zero?
156
+
157
+ (votes_against(scope: scope).to_f / total * 100).round(1)
158
+ end
159
+
160
+ # Check if a voter has voted on this item
161
+ # @param voter [ActiveRecord::Base] The voter to check
162
+ # @param direction [Symbol, nil] :up, :down, or nil for any
163
+ # @param scope [Symbol, nil] Optional voting scope
164
+ # @return [Boolean]
165
+ def voted_by?(voter, direction: nil, scope: nil)
166
+ vote = received_votes.find_by(voter: voter, scope: scope)
167
+ return false unless vote
168
+
169
+ case direction
170
+ when nil then true
171
+ when :up, :positive then vote.up?
172
+ when :down, :negative then vote.down?
173
+ else false
174
+ end
175
+ end
176
+
177
+ # Get all voters who voted on this item
178
+ # @param scope [Symbol, nil] Optional voting scope
179
+ # @return [Array<ActiveRecord::Base>]
180
+ def voters(scope: nil)
181
+ received_votes.with_scope(scope).includes(:voter).map(&:voter).uniq
182
+ end
183
+
184
+ # Get voters who upvoted
185
+ # @param scope [Symbol, nil] Optional voting scope
186
+ # @return [Array<ActiveRecord::Base>]
187
+ def voters_for(scope: nil)
188
+ received_votes.with_scope(scope).up.includes(:voter).map(&:voter).uniq
189
+ end
190
+
191
+ # Get voters who downvoted
192
+ # @param scope [Symbol, nil] Optional voting scope
193
+ # @return [Array<ActiveRecord::Base>]
194
+ def voters_against(scope: nil)
195
+ received_votes.with_scope(scope).down.includes(:voter).map(&:voter).uniq
196
+ end
197
+
198
+ # Wilson Score Lower Bound
199
+ # @param confidence [Float] Confidence level (0.0 to 1.0)
200
+ # @param scope [Symbol, nil] Optional voting scope
201
+ # @return [Float] Score from 0.0 to 1.0
202
+ def wilson_score(confidence: 0.95, scope: nil)
203
+ VoteFu::Algorithms::WilsonScore.call(self, confidence: confidence, scope: scope)
204
+ end
205
+
206
+ # Reddit Hot ranking score
207
+ # @param gravity [Float] Gravity parameter
208
+ # @return [Float]
209
+ def hot_score(gravity: nil)
210
+ gravity ||= VoteFu.configuration.hot_ranking_gravity
211
+ VoteFu::Algorithms::RedditHot.call(self, gravity: gravity)
212
+ end
213
+
214
+ # Counter cache methods
215
+ def increment_vote_counters(value)
216
+ return unless vote_fu_voteable_options[:counter_cache]
217
+
218
+ updates = {}
219
+ updates[:votes_count] = 1 if has_attribute?(:votes_count)
220
+ updates[:votes_total] = value if has_attribute?(:votes_total)
221
+ updates[:upvotes_count] = 1 if has_attribute?(:upvotes_count) && value.positive?
222
+ updates[:downvotes_count] = 1 if has_attribute?(:downvotes_count) && value.negative?
223
+
224
+ self.class.update_counters(id, **updates) if updates.any?
225
+ end
226
+
227
+ def decrement_vote_counters(value)
228
+ return unless vote_fu_voteable_options[:counter_cache]
229
+
230
+ updates = {}
231
+ updates[:votes_count] = -1 if has_attribute?(:votes_count)
232
+ updates[:votes_total] = -value if has_attribute?(:votes_total)
233
+ updates[:upvotes_count] = -1 if has_attribute?(:upvotes_count) && value.positive?
234
+ updates[:downvotes_count] = -1 if has_attribute?(:downvotes_count) && value.negative?
235
+
236
+ self.class.update_counters(id, **updates) if updates.any?
237
+ end
238
+
239
+ def update_vote_counters(old_value, new_value)
240
+ return unless vote_fu_voteable_options[:counter_cache]
241
+
242
+ updates = {}
243
+
244
+ if has_attribute?(:votes_total)
245
+ updates[:votes_total] = new_value - old_value
246
+ end
247
+
248
+ if has_attribute?(:upvotes_count)
249
+ old_up = old_value.positive? ? 1 : 0
250
+ new_up = new_value.positive? ? 1 : 0
251
+ updates[:upvotes_count] = new_up - old_up if new_up != old_up
252
+ end
253
+
254
+ if has_attribute?(:downvotes_count)
255
+ old_down = old_value.negative? ? 1 : 0
256
+ new_down = new_value.negative? ? 1 : 0
257
+ updates[:downvotes_count] = new_down - old_down if new_down != old_down
258
+ end
259
+
260
+ self.class.update_counters(id, **updates) if updates.any?
261
+ end
262
+
263
+ # Broadcast vote updates via Turbo Streams and ActionCable
264
+ def broadcast_vote_update(vote: nil, action: :updated)
265
+ return unless vote_fu_voteable_options[:broadcasts]
266
+
267
+ # Turbo Streams broadcast
268
+ if respond_to?(:broadcast_replace_to)
269
+ broadcast_replace_to(
270
+ [self, :votes],
271
+ target: "#{self.class.name.underscore}_#{id}_vote_widget",
272
+ partial: "vote_fu/votes/widget",
273
+ locals: { voteable: self }
274
+ )
275
+ end
276
+
277
+ # ActionCable broadcast (for custom JS handling)
278
+ if defined?(VoteFu::VotesChannel)
279
+ VoteFu::VotesChannel.broadcast_vote_update(self, vote: vote, action: action)
280
+ end
281
+ end
282
+
283
+ # Subscribe to vote updates via ActionCable
284
+ def vote_stream_name(scope: nil)
285
+ base = "vote_fu:#{self.class.name}:#{id}"
286
+ scope.present? ? "#{base}:#{scope}" : base
287
+ end
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module VoteFu
6
+ module Concerns
7
+ module Voter
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ # Configure a model to cast votes
12
+ #
13
+ # @example Basic usage
14
+ # class User < ApplicationRecord
15
+ # acts_as_voter
16
+ # end
17
+ #
18
+ # @example With options
19
+ # class User < ApplicationRecord
20
+ # acts_as_voter allow_self_vote: false
21
+ # end
22
+ #
23
+ # @param options [Hash] Configuration options
24
+ # @option options [Boolean] :allow_self_vote (false) Allow voting on self
25
+ def acts_as_voter(**options)
26
+ class_attribute :vote_fu_voter_options, default: {
27
+ allow_self_vote: VoteFu.configuration.allow_self_vote
28
+ }.merge(options)
29
+
30
+ has_many :cast_votes,
31
+ class_name: "VoteFu::Vote",
32
+ as: :voter,
33
+ dependent: :destroy,
34
+ inverse_of: :voter
35
+
36
+ include VoteFu::Concerns::Voter::InstanceMethods
37
+ extend VoteFu::Concerns::Voter::ClassMethods
38
+ end
39
+
40
+ # Declare what models this voter votes on (generates helper methods)
41
+ #
42
+ # @example
43
+ # class User < ApplicationRecord
44
+ # acts_as_voter
45
+ # votes_on :posts, :comments
46
+ # end
47
+ #
48
+ # user.upvote_post(post)
49
+ # user.downvote_comment(comment)
50
+ def votes_on(*model_names, **options)
51
+ acts_as_voter unless respond_to?(:vote_fu_voter_options)
52
+
53
+ model_names.each do |model_name|
54
+ define_voting_methods_for(model_name, **options)
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def define_voting_methods_for(model_name, scopes: nil, **)
61
+ singular = model_name.to_s.singularize
62
+
63
+ # user.upvote_post(post)
64
+ define_method(:"upvote_#{singular}") do |voteable, scope: nil|
65
+ vote_on(voteable, value: 1, scope: scope)
66
+ end
67
+
68
+ # user.downvote_post(post)
69
+ define_method(:"downvote_#{singular}") do |voteable, scope: nil|
70
+ vote_on(voteable, value: -1, scope: scope)
71
+ end
72
+
73
+ # user.unvote_post(post)
74
+ define_method(:"unvote_#{singular}") do |voteable, scope: nil|
75
+ unvote(voteable, scope: scope)
76
+ end
77
+
78
+ # user.toggle_vote_post(post)
79
+ define_method(:"toggle_vote_#{singular}") do |voteable, scope: nil|
80
+ toggle_vote(voteable, scope: scope)
81
+ end
82
+
83
+ # user.vote_on_post(post, value: 5)
84
+ define_method(:"vote_on_#{singular}") do |voteable, value:, scope: nil|
85
+ vote_on(voteable, value: value, scope: scope)
86
+ end
87
+
88
+ # user.voted_on_post?(post)
89
+ define_method(:"voted_on_#{singular}?") do |voteable, direction: nil, scope: nil|
90
+ voted_on?(voteable, direction: direction, scope: scope)
91
+ end
92
+
93
+ # user.vote_value_for_post(post)
94
+ define_method(:"vote_value_for_#{singular}") do |voteable, scope: nil|
95
+ vote_value_for(voteable, scope: scope)
96
+ end
97
+
98
+ # Generate scoped methods if scopes provided
99
+ scopes&.each do |scope_name|
100
+ define_method(:"vote_on_#{singular}_#{scope_name}") do |voteable, value:|
101
+ vote_on(voteable, value: value, scope: scope_name)
102
+ end
103
+
104
+ define_method(:"upvote_#{singular}_#{scope_name}") do |voteable|
105
+ vote_on(voteable, value: 1, scope: scope_name)
106
+ end
107
+
108
+ define_method(:"downvote_#{singular}_#{scope_name}") do |voteable|
109
+ vote_on(voteable, value: -1, scope: scope_name)
110
+ end
111
+ end
112
+ end
113
+ end
114
+
115
+ module ClassMethods
116
+ # Find voters who voted on a specific item
117
+ def voted_on(voteable, direction: nil, scope: nil)
118
+ votes = VoteFu::Vote.where(voteable: voteable).with_scope(scope)
119
+ votes = votes.up if direction == :up
120
+ votes = votes.down if direction == :down
121
+
122
+ where(id: votes.where(voter_type: name).select(:voter_id))
123
+ end
124
+ end
125
+
126
+ module InstanceMethods
127
+ # Cast a vote on a voteable
128
+ #
129
+ # @param voteable [ActiveRecord::Base] The item to vote on
130
+ # @param value [Integer] Vote value (positive for up, negative for down)
131
+ # @param scope [Symbol, nil] Optional voting scope
132
+ # @return [VoteFu::Vote] The created or updated vote
133
+ # @raise [VoteFu::SelfVoteError] If self-voting is disabled
134
+ # @raise [VoteFu::AlreadyVotedError] If already voted and recast disabled
135
+ def vote_on(voteable, value:, scope: nil)
136
+ validate_vote!(voteable, value)
137
+
138
+ existing = find_vote_for(voteable, scope: scope)
139
+
140
+ if existing
141
+ handle_existing_vote(existing, value)
142
+ else
143
+ cast_votes.create!(voteable: voteable, value: value, scope: scope)
144
+ end
145
+ end
146
+
147
+ # Upvote a voteable (+1)
148
+ #
149
+ # @param voteable [ActiveRecord::Base] The item to vote on
150
+ # @param scope [Symbol, nil] Optional voting scope
151
+ # @return [VoteFu::Vote]
152
+ def upvote(voteable, scope: nil)
153
+ vote_on(voteable, value: 1, scope: scope)
154
+ end
155
+
156
+ # Downvote a voteable (-1)
157
+ #
158
+ # @param voteable [ActiveRecord::Base] The item to vote on
159
+ # @param scope [Symbol, nil] Optional voting scope
160
+ # @return [VoteFu::Vote]
161
+ def downvote(voteable, scope: nil)
162
+ vote_on(voteable, value: -1, scope: scope)
163
+ end
164
+
165
+ # Remove a vote
166
+ #
167
+ # @param voteable [ActiveRecord::Base] The item to unvote
168
+ # @param scope [Symbol, nil] Optional voting scope
169
+ # @return [VoteFu::Vote, nil] The destroyed vote or nil
170
+ def unvote(voteable, scope: nil)
171
+ find_vote_for(voteable, scope: scope)&.destroy
172
+ end
173
+
174
+ # Toggle vote: remove if exists, upvote if not
175
+ #
176
+ # @param voteable [ActiveRecord::Base] The item to toggle
177
+ # @param scope [Symbol, nil] Optional voting scope
178
+ # @return [VoteFu::Vote, nil]
179
+ def toggle_vote(voteable, scope: nil)
180
+ existing = find_vote_for(voteable, scope: scope)
181
+ if existing
182
+ existing.destroy
183
+ nil
184
+ else
185
+ upvote(voteable, scope: scope)
186
+ end
187
+ end
188
+
189
+ # Check if voted on an item
190
+ #
191
+ # @param voteable [ActiveRecord::Base] The item to check
192
+ # @param direction [Symbol, Integer, nil] :up, :down, or specific value
193
+ # @param scope [Symbol, nil] Optional voting scope
194
+ # @return [Boolean]
195
+ def voted_on?(voteable, direction: nil, scope: nil)
196
+ vote = find_vote_for(voteable, scope: scope)
197
+ return false unless vote
198
+
199
+ case direction
200
+ when nil then true
201
+ when :up, :positive then vote.up?
202
+ when :down, :negative then vote.down?
203
+ when Integer then vote.value == direction
204
+ else false
205
+ end
206
+ end
207
+
208
+ # Get the vote value for an item
209
+ #
210
+ # @param voteable [ActiveRecord::Base] The item
211
+ # @param scope [Symbol, nil] Optional voting scope
212
+ # @return [Integer, nil]
213
+ def vote_value_for(voteable, scope: nil)
214
+ find_vote_for(voteable, scope: scope)&.value
215
+ end
216
+
217
+ # Get the vote direction for an item
218
+ #
219
+ # @param voteable [ActiveRecord::Base] The item
220
+ # @param scope [Symbol, nil] Optional voting scope
221
+ # @return [Symbol, nil] :up, :down, :neutral, or nil
222
+ def vote_direction_for(voteable, scope: nil)
223
+ find_vote_for(voteable, scope: scope)&.direction
224
+ end
225
+
226
+ # Get all items of a class that this voter voted on
227
+ #
228
+ # @param klass [Class] The voteable class
229
+ # @param scope [Symbol, nil] Optional voting scope
230
+ # @return [ActiveRecord::Relation]
231
+ def voted_items(klass, scope: nil)
232
+ klass.joins(:received_votes)
233
+ .where(vote_fu_votes: { voter: self, scope: scope })
234
+ .distinct
235
+ end
236
+
237
+ # Count of votes cast
238
+ #
239
+ # @param direction [Symbol, nil] :up, :down, or nil for all
240
+ # @return [Integer]
241
+ def vote_count(direction = nil)
242
+ votes = cast_votes
243
+ case direction
244
+ when :up, :positive then votes.up.count
245
+ when :down, :negative then votes.down.count
246
+ else votes.count
247
+ end
248
+ end
249
+
250
+ private
251
+
252
+ def find_vote_for(voteable, scope: nil)
253
+ cast_votes.find_by(voteable: voteable, scope: scope)
254
+ end
255
+
256
+ def validate_vote!(voteable, value)
257
+ if voteable == self && !vote_fu_voter_options[:allow_self_vote]
258
+ raise VoteFu::SelfVoteError
259
+ end
260
+
261
+ raise VoteFu::InvalidVoteValueError unless value.is_a?(Integer)
262
+ end
263
+
264
+ def handle_existing_vote(existing, value)
265
+ if VoteFu.configuration.allow_recast
266
+ existing.update!(value: value)
267
+ existing
268
+ else
269
+ raise VoteFu::AlreadyVotedError
270
+ end
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VoteFu
4
+ class Configuration
5
+ # Can voters change their vote after casting?
6
+ attr_accessor :allow_recast
7
+
8
+ # Can voters cast multiple votes on the same item?
9
+ attr_accessor :allow_duplicate_votes
10
+
11
+ # Can a model vote on itself?
12
+ attr_accessor :allow_self_vote
13
+
14
+ # Automatically maintain counter cache columns?
15
+ attr_accessor :counter_cache
16
+
17
+ # Broadcast vote changes via Turbo Streams?
18
+ attr_accessor :turbo_broadcasts
19
+
20
+ # Use ActionCable for real-time updates?
21
+ attr_accessor :action_cable
22
+
23
+ # Default ranking algorithm (:wilson_score, :reddit_hot, :hacker_news, :simple)
24
+ attr_accessor :default_ranking
25
+
26
+ # Gravity parameter for hot ranking algorithms
27
+ attr_accessor :hot_ranking_gravity
28
+
29
+ def initialize
30
+ @allow_recast = true
31
+ @allow_duplicate_votes = false
32
+ @allow_self_vote = false
33
+ @counter_cache = true
34
+ @turbo_broadcasts = true
35
+ @action_cable = true
36
+ @default_ranking = :wilson_score
37
+ @hot_ranking_gravity = 1.8
38
+ end
39
+
40
+ def to_h
41
+ {
42
+ allow_recast: allow_recast,
43
+ allow_duplicate_votes: allow_duplicate_votes,
44
+ allow_self_vote: allow_self_vote,
45
+ counter_cache: counter_cache,
46
+ turbo_broadcasts: turbo_broadcasts,
47
+ action_cable: action_cable,
48
+ default_ranking: default_ranking,
49
+ hot_ranking_gravity: hot_ranking_gravity
50
+ }
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Only load engine when Rails is present
4
+ if defined?(Rails::Engine)
5
+ require "turbo-rails"
6
+
7
+ module VoteFu
8
+ class Engine < ::Rails::Engine
9
+ isolate_namespace VoteFu
10
+
11
+ # Load concerns when ActiveRecord is ready
12
+ initializer "vote_fu.active_record" do
13
+ ActiveSupport.on_load(:active_record) do
14
+ require "vote_fu/concerns/voteable"
15
+ require "vote_fu/concerns/voter"
16
+ require "vote_fu/concerns/karmatic"
17
+
18
+ include VoteFu::Concerns::Voteable
19
+ include VoteFu::Concerns::Voter
20
+ include VoteFu::Concerns::Karmatic
21
+ end
22
+ end
23
+
24
+ # Include helpers in ActionView
25
+ initializer "vote_fu.helpers" do
26
+ ActiveSupport.on_load(:action_view) do
27
+ include VoteFu::VotesHelper
28
+ end
29
+ end
30
+
31
+ # Set up importmap for Stimulus controllers
32
+ initializer "vote_fu.importmap", before: "importmap" do |app|
33
+ if app.config.respond_to?(:importmap)
34
+ app.config.importmap.paths << Engine.root.join("config/importmap.rb")
35
+ app.config.importmap.cache_sweepers << Engine.root.join("app/javascript")
36
+ end
37
+ end
38
+
39
+ # Append engine assets to asset pipeline
40
+ initializer "vote_fu.assets" do |app|
41
+ if app.config.respond_to?(:assets)
42
+ app.config.assets.paths << Engine.root.join("app/assets/stylesheets")
43
+ end
44
+ end
45
+
46
+ # Configure generators
47
+ config.generators do |g|
48
+ g.test_framework :rspec
49
+ g.fixture_replacement :factory_bot
50
+ g.factory_bot dir: "spec/factories"
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VoteFu
4
+ # Base error class for all VoteFu errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when a voter attempts to vote again without recast enabled
8
+ class AlreadyVotedError < Error
9
+ def initialize(msg = "Already voted on this item")
10
+ super
11
+ end
12
+ end
13
+
14
+ # Raised when a model attempts to vote on itself
15
+ class SelfVoteError < Error
16
+ def initialize(msg = "Cannot vote on yourself")
17
+ super
18
+ end
19
+ end
20
+
21
+ # Raised when an invalid vote value is provided
22
+ class InvalidVoteValueError < Error
23
+ def initialize(msg = "Vote value must be an integer")
24
+ super
25
+ end
26
+ end
27
+
28
+ # Raised when voteable is not found
29
+ class VoteableNotFoundError < Error
30
+ def initialize(msg = "Voteable not found")
31
+ super
32
+ end
33
+ end
34
+ end