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,391 @@
1
+ /*
2
+ * VoteFu Default Styles
3
+ *
4
+ * These styles are optional and can be overridden in your application.
5
+ * Import with: @import "vote_fu/votes";
6
+ */
7
+
8
+ /* Vote Widget Container */
9
+ .vote-fu-widget {
10
+ display: inline-flex;
11
+ align-items: center;
12
+ gap: 0.25rem;
13
+ }
14
+
15
+ .vote-fu-like-widget {
16
+ display: inline-flex;
17
+ align-items: center;
18
+ gap: 0.25rem;
19
+ }
20
+
21
+ /* Vote Buttons */
22
+ .vote-fu-btn {
23
+ display: inline-flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ min-width: 2rem;
27
+ min-height: 2rem;
28
+ padding: 0.25rem 0.5rem;
29
+ border: 1px solid transparent;
30
+ border-radius: 0.25rem;
31
+ background: transparent;
32
+ color: inherit;
33
+ font-size: 1rem;
34
+ line-height: 1;
35
+ cursor: pointer;
36
+ transition: all 0.15s ease;
37
+ user-select: none;
38
+ }
39
+
40
+ .vote-fu-btn:hover:not(.vote-fu-disabled) {
41
+ background-color: rgba(0, 0, 0, 0.05);
42
+ }
43
+
44
+ .vote-fu-btn:active:not(.vote-fu-disabled) {
45
+ transform: scale(0.95);
46
+ }
47
+
48
+ /* Active States */
49
+ .vote-fu-upvote.vote-fu-active {
50
+ color: #ff6314;
51
+ background-color: rgba(255, 99, 20, 0.1);
52
+ }
53
+
54
+ .vote-fu-downvote.vote-fu-active {
55
+ color: #7193ff;
56
+ background-color: rgba(113, 147, 255, 0.1);
57
+ }
58
+
59
+ .vote-fu-like.vote-fu-active {
60
+ color: #e0245e;
61
+ }
62
+
63
+ /* Disabled State */
64
+ .vote-fu-btn.vote-fu-disabled {
65
+ opacity: 0.5;
66
+ cursor: not-allowed;
67
+ }
68
+
69
+ /* Vote Count */
70
+ .vote-fu-count {
71
+ min-width: 2rem;
72
+ padding: 0.25rem;
73
+ text-align: center;
74
+ font-weight: 600;
75
+ font-variant-numeric: tabular-nums;
76
+ }
77
+
78
+ /* Count Update Animation */
79
+ .vote-fu-count-updated {
80
+ animation: vote-fu-pulse 0.3s ease-out;
81
+ }
82
+
83
+ @keyframes vote-fu-pulse {
84
+ 0% {
85
+ transform: scale(1);
86
+ }
87
+ 50% {
88
+ transform: scale(1.2);
89
+ }
90
+ 100% {
91
+ transform: scale(1);
92
+ }
93
+ }
94
+
95
+ /* Error Display */
96
+ .vote-fu-error {
97
+ padding: 0.5rem 1rem;
98
+ margin: 0.5rem 0;
99
+ color: #dc2626;
100
+ background-color: #fef2f2;
101
+ border: 1px solid #fecaca;
102
+ border-radius: 0.25rem;
103
+ font-size: 0.875rem;
104
+ }
105
+
106
+ /* Container States */
107
+ .vote-fu-widget.vote-fu-voted-up .vote-fu-count {
108
+ color: #ff6314;
109
+ }
110
+
111
+ .vote-fu-widget.vote-fu-voted-down .vote-fu-count {
112
+ color: #7193ff;
113
+ }
114
+
115
+ .vote-fu-like-widget.vote-fu-liked .vote-fu-count {
116
+ color: #e0245e;
117
+ }
118
+
119
+ /* Dark Mode Support */
120
+ @media (prefers-color-scheme: dark) {
121
+ .vote-fu-btn:hover:not(.vote-fu-disabled) {
122
+ background-color: rgba(255, 255, 255, 0.1);
123
+ }
124
+
125
+ .vote-fu-upvote.vote-fu-active {
126
+ background-color: rgba(255, 99, 20, 0.2);
127
+ }
128
+
129
+ .vote-fu-downvote.vote-fu-active {
130
+ background-color: rgba(113, 147, 255, 0.2);
131
+ }
132
+
133
+ .vote-fu-error {
134
+ color: #fca5a5;
135
+ background-color: rgba(220, 38, 38, 0.1);
136
+ border-color: rgba(220, 38, 38, 0.3);
137
+ }
138
+ }
139
+
140
+ /* Compact Variant */
141
+ .vote-fu-widget--compact .vote-fu-btn {
142
+ min-width: 1.5rem;
143
+ min-height: 1.5rem;
144
+ padding: 0.125rem 0.25rem;
145
+ font-size: 0.875rem;
146
+ }
147
+
148
+ .vote-fu-widget--compact .vote-fu-count {
149
+ min-width: 1.5rem;
150
+ font-size: 0.875rem;
151
+ }
152
+
153
+ /* Vertical Variant (Reddit-style) */
154
+ .vote-fu-widget--vertical {
155
+ flex-direction: column;
156
+ }
157
+
158
+ /* Large Variant */
159
+ .vote-fu-widget--large .vote-fu-btn {
160
+ min-width: 3rem;
161
+ min-height: 3rem;
162
+ font-size: 1.5rem;
163
+ }
164
+
165
+ .vote-fu-widget--large .vote-fu-count {
166
+ font-size: 1.25rem;
167
+ }
168
+
169
+ /* ================================================
170
+ STAR RATING COMPONENT
171
+ ================================================ */
172
+
173
+ .vote-fu-star-rating {
174
+ display: inline-flex;
175
+ align-items: center;
176
+ gap: 0.5rem;
177
+ }
178
+
179
+ .vote-fu-stars {
180
+ display: inline-flex;
181
+ gap: 0.125rem;
182
+ }
183
+
184
+ .vote-fu-star,
185
+ .vote-fu-star-btn {
186
+ display: inline-flex;
187
+ align-items: center;
188
+ justify-content: center;
189
+ width: 1.5rem;
190
+ height: 1.5rem;
191
+ padding: 0;
192
+ border: none;
193
+ background: transparent;
194
+ color: #d1d5db;
195
+ font-size: 1.25rem;
196
+ cursor: pointer;
197
+ transition: all 0.15s ease;
198
+ }
199
+
200
+ .vote-fu-star-btn:hover {
201
+ transform: scale(1.2);
202
+ }
203
+
204
+ .vote-fu-star-filled,
205
+ .vote-fu-star-btn.vote-fu-star-filled {
206
+ color: #fbbf24;
207
+ }
208
+
209
+ .vote-fu-star-half {
210
+ color: #fbbf24;
211
+ }
212
+
213
+ .vote-fu-star-rating--readonly .vote-fu-star {
214
+ cursor: default;
215
+ }
216
+
217
+ .vote-fu-star-info {
218
+ font-size: 0.875rem;
219
+ color: #6b7280;
220
+ }
221
+
222
+ .vote-fu-star-average {
223
+ font-weight: 600;
224
+ }
225
+
226
+ .vote-fu-star-count {
227
+ color: #9ca3af;
228
+ }
229
+
230
+ /* Star Rating Sizes */
231
+ .vote-fu-star-rating--small .vote-fu-star,
232
+ .vote-fu-star-rating--small .vote-fu-star-btn {
233
+ width: 1rem;
234
+ height: 1rem;
235
+ font-size: 0.875rem;
236
+ }
237
+
238
+ .vote-fu-star-rating--large .vote-fu-star,
239
+ .vote-fu-star-rating--large .vote-fu-star-btn {
240
+ width: 2rem;
241
+ height: 2rem;
242
+ font-size: 1.75rem;
243
+ }
244
+
245
+ /* ================================================
246
+ REACTION BAR COMPONENT
247
+ ================================================ */
248
+
249
+ .vote-fu-reaction-bar {
250
+ display: inline-flex;
251
+ align-items: center;
252
+ gap: 0.5rem;
253
+ }
254
+
255
+ .vote-fu-reactions {
256
+ display: inline-flex;
257
+ flex-wrap: wrap;
258
+ gap: 0.25rem;
259
+ }
260
+
261
+ .vote-fu-reaction-wrapper {
262
+ display: inline-flex;
263
+ }
264
+
265
+ .vote-fu-reaction-wrapper--empty {
266
+ display: none;
267
+ }
268
+
269
+ .vote-fu-reaction {
270
+ display: inline-flex;
271
+ align-items: center;
272
+ gap: 0.25rem;
273
+ padding: 0.25rem 0.5rem;
274
+ border: 1px solid #e5e7eb;
275
+ border-radius: 9999px;
276
+ background: #f9fafb;
277
+ font-size: 0.875rem;
278
+ cursor: pointer;
279
+ transition: all 0.15s ease;
280
+ }
281
+
282
+ .vote-fu-reaction:hover {
283
+ background: #f3f4f6;
284
+ border-color: #d1d5db;
285
+ }
286
+
287
+ .vote-fu-reaction--active {
288
+ background: #eff6ff;
289
+ border-color: #3b82f6;
290
+ }
291
+
292
+ .vote-fu-reaction--readonly {
293
+ cursor: default;
294
+ }
295
+
296
+ .vote-fu-reaction-emoji {
297
+ font-size: 1rem;
298
+ line-height: 1;
299
+ }
300
+
301
+ .vote-fu-reaction-count {
302
+ font-weight: 500;
303
+ color: #6b7280;
304
+ }
305
+
306
+ .vote-fu-reaction--active .vote-fu-reaction-count {
307
+ color: #3b82f6;
308
+ }
309
+
310
+ .vote-fu-reaction-users {
311
+ font-size: 0.75rem;
312
+ color: #9ca3af;
313
+ max-width: 100px;
314
+ overflow: hidden;
315
+ text-overflow: ellipsis;
316
+ white-space: nowrap;
317
+ }
318
+
319
+ .vote-fu-add-reaction {
320
+ display: inline-flex;
321
+ }
322
+
323
+ .vote-fu-add-reaction-btn {
324
+ display: inline-flex;
325
+ align-items: center;
326
+ justify-content: center;
327
+ width: 2rem;
328
+ height: 2rem;
329
+ padding: 0;
330
+ border: 1px dashed #d1d5db;
331
+ border-radius: 9999px;
332
+ background: transparent;
333
+ color: #9ca3af;
334
+ font-size: 0.875rem;
335
+ cursor: pointer;
336
+ transition: all 0.15s ease;
337
+ }
338
+
339
+ .vote-fu-add-reaction-btn:hover {
340
+ border-color: #9ca3af;
341
+ color: #6b7280;
342
+ background: #f9fafb;
343
+ }
344
+
345
+ /* Dark Mode for Stars and Reactions */
346
+ @media (prefers-color-scheme: dark) {
347
+ .vote-fu-star,
348
+ .vote-fu-star-btn {
349
+ color: #4b5563;
350
+ }
351
+
352
+ .vote-fu-star-filled,
353
+ .vote-fu-star-btn.vote-fu-star-filled {
354
+ color: #fbbf24;
355
+ }
356
+
357
+ .vote-fu-star-info {
358
+ color: #9ca3af;
359
+ }
360
+
361
+ .vote-fu-reaction {
362
+ background: #1f2937;
363
+ border-color: #374151;
364
+ color: #e5e7eb;
365
+ }
366
+
367
+ .vote-fu-reaction:hover {
368
+ background: #374151;
369
+ border-color: #4b5563;
370
+ }
371
+
372
+ .vote-fu-reaction--active {
373
+ background: #1e3a5f;
374
+ border-color: #3b82f6;
375
+ }
376
+
377
+ .vote-fu-reaction-count {
378
+ color: #9ca3af;
379
+ }
380
+
381
+ .vote-fu-add-reaction-btn {
382
+ border-color: #4b5563;
383
+ color: #6b7280;
384
+ }
385
+
386
+ .vote-fu-add-reaction-btn:hover {
387
+ background: #1f2937;
388
+ border-color: #6b7280;
389
+ color: #9ca3af;
390
+ }
391
+ }
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VoteFu
4
+ module ApplicationCable
5
+ class Channel < ActionCable::Channel::Base
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VoteFu
4
+ module ApplicationCable
5
+ class Connection < ActionCable::Connection::Base
6
+ identified_by :current_voter
7
+
8
+ def connect
9
+ self.current_voter = find_verified_voter
10
+ end
11
+
12
+ private
13
+
14
+ def find_verified_voter
15
+ # Try common authentication patterns
16
+ # Applications should override this in their own connection class
17
+ if (voter = env["warden"]&.user)
18
+ voter
19
+ elsif (voter_id = cookies.encrypted[:voter_id])
20
+ find_voter_by_id(voter_id)
21
+ elsif (voter_id = request.session[:user_id] || request.session[:voter_id])
22
+ find_voter_by_id(voter_id)
23
+ else
24
+ # Allow anonymous connections for public vote viewing
25
+ nil
26
+ end
27
+ end
28
+
29
+ def find_voter_by_id(id)
30
+ # Try common user model names
31
+ if defined?(User)
32
+ User.find_by(id: id)
33
+ elsif defined?(Account)
34
+ Account.find_by(id: id)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VoteFu
4
+ class VotesChannel < ApplicationCable::Channel
5
+ # Subscribe to vote updates for a specific voteable
6
+ #
7
+ # Params:
8
+ # voteable_type: "Post"
9
+ # voteable_id: 123
10
+ # scope: "quality" (optional)
11
+ #
12
+ def subscribed
13
+ voteable = find_voteable
14
+ return reject unless voteable
15
+
16
+ stream_for voteable_stream_name
17
+ end
18
+
19
+ def unsubscribed
20
+ stop_all_streams
21
+ end
22
+
23
+ # Broadcast vote update to all subscribers
24
+ #
25
+ # Called from Vote model callbacks or controller
26
+ #
27
+ def self.broadcast_vote_update(voteable, vote: nil, action: :updated)
28
+ return unless VoteFu.configuration.turbo_broadcasts
29
+
30
+ scopes = [nil] + voteable.received_votes.distinct.pluck(:scope).compact
31
+
32
+ scopes.each do |scope|
33
+ stream_name = stream_name_for(voteable, scope)
34
+
35
+ ActionCable.server.broadcast(stream_name, {
36
+ type: "vote_update",
37
+ action: action,
38
+ voteable_type: voteable.class.name,
39
+ voteable_id: voteable.id,
40
+ scope: scope,
41
+ vote: vote&.as_json(only: %i[id value created_at]),
42
+ stats: vote_stats(voteable, scope),
43
+ html: render_widget_html(voteable, scope)
44
+ })
45
+ end
46
+ end
47
+
48
+ # Broadcast to all subscribers of a voteable (any scope)
49
+ def self.broadcast_to_voteable(voteable, message)
50
+ stream_name = "vote_fu:#{voteable.class.name}:#{voteable.id}"
51
+ ActionCable.server.broadcast(stream_name, message)
52
+ end
53
+
54
+ private
55
+
56
+ def find_voteable
57
+ type = params[:voteable_type]
58
+ id = params[:voteable_id]
59
+
60
+ return nil unless type.present? && id.present?
61
+
62
+ begin
63
+ type.constantize.find_by(id: id)
64
+ rescue NameError
65
+ nil
66
+ end
67
+ end
68
+
69
+ def voteable_stream_name
70
+ self.class.stream_name_for(
71
+ find_voteable,
72
+ params[:scope]
73
+ )
74
+ end
75
+
76
+ def self.stream_name_for(voteable, scope = nil)
77
+ base = "vote_fu:#{voteable.class.name}:#{voteable.id}"
78
+ scope.present? ? "#{base}:#{scope}" : base
79
+ end
80
+
81
+ def self.vote_stats(voteable, scope)
82
+ {
83
+ votes_for: voteable.votes_for(scope: scope),
84
+ votes_against: voteable.votes_against(scope: scope),
85
+ votes_total: voteable.votes_total(scope: scope),
86
+ votes_count: voteable.votes_count(scope: scope),
87
+ plusminus: voteable.plusminus(scope: scope),
88
+ percent_for: voteable.percent_for(scope: scope),
89
+ wilson_score: voteable.wilson_score(scope: scope)
90
+ }
91
+ end
92
+
93
+ def self.render_widget_html(voteable, scope)
94
+ # Return nil - let the client handle rendering
95
+ # This avoids needing a renderer context
96
+ nil
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VoteFu
4
+ class LikeButtonComponent < ViewComponent::Base
5
+ # Simple like button (upvote only, social media style)
6
+ #
7
+ # @param voteable [ActiveRecord::Base] The voteable record
8
+ # @param voter [ActiveRecord::Base, nil] The current voter
9
+ # @param scope [String, Symbol, nil] Vote scope
10
+ # @param liked_label [String] Label when liked
11
+ # @param unliked_label [String] Label when not liked
12
+ # @param show_count [Boolean] Show like count
13
+ # @param variant [Symbol] Button style (:default, :compact, :pill)
14
+ def initialize(
15
+ voteable:,
16
+ voter: nil,
17
+ scope: nil,
18
+ liked_label: "♥",
19
+ unliked_label: "♡",
20
+ show_count: true,
21
+ variant: :default
22
+ )
23
+ @voteable = voteable
24
+ @voter = voter
25
+ @scope = scope
26
+ @liked_label = liked_label
27
+ @unliked_label = unliked_label
28
+ @show_count = show_count
29
+ @variant = variant
30
+ end
31
+
32
+ def call
33
+ tag.div(**wrapper_attributes) do
34
+ safe_join([
35
+ like_button,
36
+ (count_display if @show_count)
37
+ ].compact)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def wrapper_attributes
44
+ {
45
+ id: dom_id(:widget),
46
+ class: wrapper_classes,
47
+ data: stimulus_data
48
+ }
49
+ end
50
+
51
+ def wrapper_classes
52
+ classes = ["vote-fu-like-widget"]
53
+ classes << "vote-fu-like-widget--#{@variant}" unless @variant == :default
54
+ classes << "vote-fu-liked" if liked?
55
+ classes.join(" ")
56
+ end
57
+
58
+ def stimulus_data
59
+ {
60
+ controller: "vote-fu",
61
+ vote_fu_voteable_type_value: @voteable.class.name,
62
+ vote_fu_voteable_id_value: @voteable.id,
63
+ vote_fu_scope_value: @scope,
64
+ vote_fu_voted_value: liked?,
65
+ vote_fu_direction_value: liked? ? "up" : nil
66
+ }
67
+ end
68
+
69
+ def like_button
70
+ if @voter
71
+ form_with(url: toggle_path, method: :post, data: turbo_form_data) do |f|
72
+ safe_join([
73
+ f.hidden_field(:voteable_type, value: @voteable.class.name),
74
+ f.hidden_field(:voteable_id, value: @voteable.id),
75
+ f.hidden_field(:scope, value: @scope),
76
+ f.hidden_field(:direction, value: :up),
77
+ tag.button(
78
+ liked? ? @liked_label : @unliked_label,
79
+ type: :submit,
80
+ class: button_classes,
81
+ title: liked? ? "Unlike" : "Like",
82
+ data: { vote_fu_target: "likeBtn" }
83
+ )
84
+ ])
85
+ end
86
+ else
87
+ tag.span(@unliked_label, class: "vote-fu-btn vote-fu-like vote-fu-disabled")
88
+ end
89
+ end
90
+
91
+ def count_display
92
+ tag.span(
93
+ like_count,
94
+ id: dom_id(:count),
95
+ class: "vote-fu-count",
96
+ data: { vote_fu_target: "count" }
97
+ )
98
+ end
99
+
100
+ def button_classes
101
+ classes = ["vote-fu-btn", "vote-fu-like"]
102
+ classes << "vote-fu-active" if liked?
103
+ classes.join(" ")
104
+ end
105
+
106
+ def turbo_form_data
107
+ { turbo_stream: true, action: "submit->vote-fu#vote" }
108
+ end
109
+
110
+ def current_vote
111
+ return @current_vote if defined?(@current_vote)
112
+
113
+ @current_vote = @voter && @voteable.received_votes.find_by(
114
+ voter: @voter,
115
+ scope: @scope
116
+ )
117
+ end
118
+
119
+ def liked?
120
+ current_vote&.up?
121
+ end
122
+
123
+ def like_count
124
+ @voteable.votes_for(scope: @scope)
125
+ end
126
+
127
+ def toggle_path
128
+ VoteFu::Engine.routes.url_helpers.toggle_votes_path
129
+ end
130
+
131
+ def dom_id(suffix)
132
+ scope_part = @scope.present? ? "_#{@scope}" : ""
133
+ "vote_fu_#{@voteable.model_name.singular}_#{@voteable.id}#{scope_part}_#{suffix}"
134
+ end
135
+ end
136
+ end