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,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VoteFu
4
+ class VotesController < ApplicationController
5
+ before_action :require_voter!
6
+ before_action :find_voteable
7
+
8
+ # POST /vote_fu/votes
9
+ # Creates or updates a vote
10
+ def create
11
+ value = vote_value_from_params
12
+
13
+ if VoteFu.configuration.allow_recast
14
+ @vote = current_voter.vote_on(@voteable, value: value, scope: vote_scope)
15
+ else
16
+ existing = find_existing_vote
17
+ if existing
18
+ respond_with_error("You have already voted on this item")
19
+ return
20
+ end
21
+ @vote = current_voter.vote_on(@voteable, value: value, scope: vote_scope)
22
+ end
23
+
24
+ respond_with_vote(:created)
25
+ rescue StandardError => e
26
+ respond_with_error(e.message)
27
+ end
28
+
29
+ # PATCH/PUT /vote_fu/votes/:id
30
+ # Updates an existing vote's value
31
+ def update
32
+ @vote = Vote.find_by(id: params[:id], voter: current_voter)
33
+
34
+ unless @vote
35
+ respond_with_error("Vote not found")
36
+ return
37
+ end
38
+
39
+ unless VoteFu.configuration.allow_recast
40
+ respond_with_error("Vote recasting is disabled")
41
+ return
42
+ end
43
+
44
+ value = vote_value_from_params
45
+ @vote.update!(value: value)
46
+ @voteable.reload
47
+
48
+ respond_with_vote(:ok)
49
+ rescue StandardError => e
50
+ respond_with_error(e.message)
51
+ end
52
+
53
+ # DELETE /vote_fu/votes/:id
54
+ # Removes a vote
55
+ def destroy
56
+ @vote = if params[:id].present? && params[:id] != "remove"
57
+ Vote.find_by(id: params[:id], voter: current_voter)
58
+ else
59
+ find_existing_vote
60
+ end
61
+
62
+ unless @vote
63
+ respond_with_error("Vote not found")
64
+ return
65
+ end
66
+
67
+ @vote.destroy!
68
+ @voteable.reload
69
+
70
+ respond_to do |format|
71
+ format.turbo_stream { render_vote_turbo_stream(:removed) }
72
+ format.html { redirect_back fallback_location: main_app.root_path }
73
+ format.json { render json: vote_json_response(:removed), status: :ok }
74
+ end
75
+ rescue StandardError => e
76
+ respond_with_error(e.message)
77
+ end
78
+
79
+ # POST /vote_fu/votes/toggle
80
+ # Toggles between upvote/remove or downvote/remove
81
+ def toggle
82
+ direction = params[:direction]&.to_sym || :up
83
+ existing = find_existing_vote
84
+
85
+ if existing
86
+ if (direction == :up && existing.up?) || (direction == :down && existing.down?)
87
+ # Same direction - remove the vote
88
+ existing.destroy!
89
+ @voteable.reload
90
+ @vote = nil
91
+
92
+ respond_to do |format|
93
+ format.turbo_stream { render_vote_turbo_stream(:removed) }
94
+ format.html { redirect_back fallback_location: main_app.root_path }
95
+ format.json { render json: vote_json_response(:removed), status: :ok }
96
+ end
97
+ else
98
+ # Different direction - change the vote (if allowed)
99
+ if VoteFu.configuration.allow_recast
100
+ value = direction == :up ? 1 : -1
101
+ existing.update!(value: value)
102
+ @vote = existing
103
+ @voteable.reload
104
+ respond_with_vote(:ok)
105
+ else
106
+ respond_with_error("Vote recasting is disabled")
107
+ end
108
+ end
109
+ else
110
+ # No existing vote - create one
111
+ value = direction == :up ? 1 : -1
112
+ @vote = current_voter.vote_on(@voteable, value: value, scope: vote_scope)
113
+ respond_with_vote(:created)
114
+ end
115
+ rescue StandardError => e
116
+ respond_with_error(e.message)
117
+ end
118
+
119
+ private
120
+
121
+ def vote_scope
122
+ params[:scope].presence
123
+ end
124
+
125
+ def vote_value_from_params
126
+ if params[:value].present?
127
+ params[:value].to_i
128
+ elsif params[:direction].present?
129
+ case params[:direction].to_sym
130
+ when :up, :upvote then 1
131
+ when :down, :downvote then -1
132
+ else 1
133
+ end
134
+ else
135
+ 1
136
+ end
137
+ end
138
+
139
+ def find_existing_vote
140
+ Vote.find_by(
141
+ voter: current_voter,
142
+ voteable: @voteable,
143
+ scope: vote_scope
144
+ )
145
+ end
146
+
147
+ def respond_with_vote(status)
148
+ @voteable.reload
149
+
150
+ respond_to do |format|
151
+ format.turbo_stream { render_vote_turbo_stream(status == :created ? :created : :updated) }
152
+ format.html { redirect_back fallback_location: main_app.root_path }
153
+ format.json { render json: vote_json_response(:success), status: status }
154
+ end
155
+ end
156
+
157
+ def respond_with_error(message)
158
+ respond_to do |format|
159
+ format.turbo_stream do
160
+ render turbo_stream: turbo_stream.replace(
161
+ vote_dom_id(:error),
162
+ partial: "vote_fu/votes/error",
163
+ locals: { message: message }
164
+ ), status: :unprocessable_entity
165
+ end
166
+ format.html do
167
+ redirect_back fallback_location: main_app.root_path, alert: message
168
+ end
169
+ format.json do
170
+ render json: { error: message }, status: :unprocessable_entity
171
+ end
172
+ end
173
+ end
174
+
175
+ def render_vote_turbo_stream(action)
176
+ render turbo_stream: [
177
+ turbo_stream.replace(
178
+ vote_dom_id(:widget),
179
+ partial: "vote_fu/votes/widget",
180
+ locals: vote_locals.merge(action: action)
181
+ ),
182
+ turbo_stream.replace(
183
+ vote_dom_id(:count),
184
+ partial: "vote_fu/votes/count",
185
+ locals: vote_locals
186
+ )
187
+ ]
188
+ end
189
+
190
+ def vote_dom_id(suffix)
191
+ scope_part = vote_scope.present? ? "_#{vote_scope}" : ""
192
+ "vote_fu_#{@voteable.model_name.singular}_#{@voteable.id}#{scope_part}_#{suffix}"
193
+ end
194
+
195
+ def vote_locals
196
+ {
197
+ voteable: @voteable,
198
+ voter: current_voter,
199
+ vote: @vote,
200
+ scope: vote_scope
201
+ }
202
+ end
203
+
204
+ def vote_json_response(status)
205
+ {
206
+ status: status,
207
+ voteable_type: @voteable.class.name,
208
+ voteable_id: @voteable.id,
209
+ scope: vote_scope,
210
+ vote: @vote&.as_json(only: %i[id value created_at]),
211
+ stats: {
212
+ votes_for: @voteable.votes_for(scope: vote_scope),
213
+ votes_against: @voteable.votes_against(scope: vote_scope),
214
+ votes_total: @voteable.votes_total(scope: vote_scope),
215
+ votes_count: @voteable.votes_count(scope: vote_scope),
216
+ plusminus: @voteable.plusminus(scope: vote_scope),
217
+ percent_for: @voteable.percent_for(scope: vote_scope)
218
+ },
219
+ current_vote_direction: @vote&.direction
220
+ }
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VoteFu
4
+ module VotesHelper
5
+ # Renders the full vote widget with upvote/downvote buttons and count
6
+ #
7
+ # @param voteable [ActiveRecord::Base] The voteable record
8
+ # @param options [Hash] Options hash
9
+ # @option options [Object] :voter The current voter (defaults to current_user if available)
10
+ # @option options [String, Symbol] :scope Vote scope for scoped voting
11
+ # @option options [Boolean] :show_count Whether to show vote count (default: true)
12
+ # @option options [String] :upvote_label Custom upvote button label (default: "▲")
13
+ # @option options [String] :downvote_label Custom downvote button label (default: "▼")
14
+ #
15
+ # @example Basic usage
16
+ # <%= vote_widget @post %>
17
+ #
18
+ # @example With scope
19
+ # <%= vote_widget @post, scope: :quality %>
20
+ #
21
+ # @example Custom labels
22
+ # <%= vote_widget @post, upvote_label: "👍", downvote_label: "👎" %>
23
+ #
24
+ def vote_widget(voteable, options = {})
25
+ voter = options.fetch(:voter) { default_voter }
26
+
27
+ render partial: "vote_fu/votes/widget", locals: {
28
+ voteable: voteable,
29
+ voter: voter,
30
+ scope: options[:scope],
31
+ show_count: options.fetch(:show_count, true),
32
+ upvote_label: options.fetch(:upvote_label, "▲"),
33
+ downvote_label: options.fetch(:downvote_label, "▼")
34
+ }
35
+ end
36
+
37
+ # Renders the vote count for a voteable
38
+ #
39
+ # @param voteable [ActiveRecord::Base] The voteable record
40
+ # @param options [Hash] Options hash
41
+ # @option options [String, Symbol] :scope Vote scope
42
+ # @option options [Symbol] :format Display format (:plusminus, :total, :percentage, :split)
43
+ #
44
+ # @example Basic usage
45
+ # <%= vote_count @post %>
46
+ #
47
+ # @example With format
48
+ # <%= vote_count @post, format: :percentage %>
49
+ #
50
+ def vote_count(voteable, options = {})
51
+ render partial: "vote_fu/votes/count", locals: {
52
+ voteable: voteable,
53
+ scope: options[:scope],
54
+ format: options.fetch(:format, :plusminus)
55
+ }
56
+ end
57
+
58
+ # Renders a like button (upvote-only, social media style)
59
+ #
60
+ # @param voteable [ActiveRecord::Base] The voteable record
61
+ # @param options [Hash] Options hash
62
+ # @option options [Object] :voter The current voter
63
+ # @option options [String, Symbol] :scope Vote scope
64
+ # @option options [String] :liked_label Label when liked (default: "♥")
65
+ # @option options [String] :unliked_label Label when not liked (default: "♡")
66
+ # @option options [Boolean] :show_count Show like count (default: true)
67
+ # @option options [String] :class Additional CSS classes
68
+ #
69
+ # @example Basic usage
70
+ # <%= like_button @photo %>
71
+ #
72
+ # @example With custom labels
73
+ # <%= like_button @photo, liked_label: "❤️", unliked_label: "🤍" %>
74
+ #
75
+ def like_button(voteable, options = {})
76
+ voter = options.fetch(:voter) { default_voter }
77
+
78
+ render partial: "vote_fu/votes/like_button", locals: {
79
+ voteable: voteable,
80
+ voter: voter,
81
+ scope: options[:scope],
82
+ liked_label: options.fetch(:liked_label, "♥"),
83
+ unliked_label: options.fetch(:unliked_label, "♡"),
84
+ show_count: options.fetch(:show_count, true),
85
+ class: options[:class]
86
+ }
87
+ end
88
+
89
+ # Renders a standalone upvote button
90
+ #
91
+ # @param voteable [ActiveRecord::Base] The voteable record
92
+ # @param options [Hash] Options hash
93
+ #
94
+ def upvote_button(voteable, options = {})
95
+ voter = options.fetch(:voter) { default_voter }
96
+
97
+ render partial: "vote_fu/votes/upvote_button", locals: {
98
+ voteable: voteable,
99
+ voter: voter,
100
+ scope: options[:scope],
101
+ label: options.fetch(:label, "▲"),
102
+ class: options[:class]
103
+ }
104
+ end
105
+
106
+ # Renders a standalone downvote button
107
+ #
108
+ # @param voteable [ActiveRecord::Base] The voteable record
109
+ # @param options [Hash] Options hash
110
+ #
111
+ def downvote_button(voteable, options = {})
112
+ voter = options.fetch(:voter) { default_voter }
113
+
114
+ render partial: "vote_fu/votes/downvote_button", locals: {
115
+ voteable: voteable,
116
+ voter: voter,
117
+ scope: options[:scope],
118
+ label: options.fetch(:label, "▼"),
119
+ class: options[:class]
120
+ }
121
+ end
122
+
123
+ # Returns the DOM ID for a voteable's widget
124
+ #
125
+ # @param voteable [ActiveRecord::Base] The voteable record
126
+ # @param suffix [Symbol, String] ID suffix (:widget, :count, :error)
127
+ # @param scope [String, Symbol, nil] Vote scope
128
+ # @return [String] The DOM ID
129
+ #
130
+ def vote_dom_id(voteable, suffix = :widget, scope: nil)
131
+ scope_part = scope.present? ? "_#{scope}" : ""
132
+ "vote_fu_#{voteable.model_name.singular}_#{voteable.id}#{scope_part}_#{suffix}"
133
+ end
134
+
135
+ # Checks if the current voter has voted on the voteable
136
+ #
137
+ # @param voteable [ActiveRecord::Base] The voteable record
138
+ # @param options [Hash] Options hash
139
+ # @option options [Object] :voter The voter to check
140
+ # @option options [Symbol] :direction :up or :down to check specific direction
141
+ # @option options [String, Symbol] :scope Vote scope
142
+ # @return [Boolean]
143
+ #
144
+ def voted_on?(voteable, options = {})
145
+ voter = options.fetch(:voter) { default_voter }
146
+ return false unless voter
147
+
148
+ voteable.voted_by?(voter, direction: options[:direction], scope: options[:scope])
149
+ end
150
+
151
+ # Returns the current vote direction for a voter on a voteable
152
+ #
153
+ # @param voteable [ActiveRecord::Base] The voteable record
154
+ # @param options [Hash] Options hash
155
+ # @return [Symbol, nil] :up, :down, or nil if not voted
156
+ #
157
+ def current_vote_direction(voteable, options = {})
158
+ voter = options.fetch(:voter) { default_voter }
159
+ return nil unless voter
160
+
161
+ scope = options[:scope]
162
+ vote = voteable.received_votes.find_by(voter: voter, scope: scope)
163
+ vote&.direction
164
+ end
165
+
166
+ private
167
+
168
+ def default_voter
169
+ if respond_to?(:current_user, true)
170
+ send(:current_user)
171
+ elsif defined?(Current) && Current.respond_to?(:user)
172
+ Current.user
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,3 @@
1
+ import { createConsumer } from "@rails/actioncable"
2
+
3
+ export default createConsumer()
@@ -0,0 +1,2 @@
1
+ export { default as consumer } from "./consumer"
2
+ export { subscribeToVotes, autoSubscribe } from "./votes_channel"
@@ -0,0 +1,93 @@
1
+ import consumer from "./consumer"
2
+
3
+ /**
4
+ * VoteFu ActionCable Channel
5
+ *
6
+ * Subscribe to real-time vote updates for a voteable.
7
+ *
8
+ * @example
9
+ * import { subscribeToVotes } from "vote_fu/channels/votes_channel"
10
+ *
11
+ * const subscription = subscribeToVotes({
12
+ * voteableType: "Post",
13
+ * voteableId: 123,
14
+ * scope: null,
15
+ * onUpdate: (data) => {
16
+ * console.log("Vote updated:", data)
17
+ * }
18
+ * })
19
+ *
20
+ * // Later: subscription.unsubscribe()
21
+ */
22
+ export function subscribeToVotes({ voteableType, voteableId, scope = null, onUpdate }) {
23
+ return consumer.subscriptions.create(
24
+ {
25
+ channel: "VoteFu::VotesChannel",
26
+ voteable_type: voteableType,
27
+ voteable_id: voteableId,
28
+ scope: scope
29
+ },
30
+ {
31
+ connected() {
32
+ console.log(`VoteFu: Connected to ${voteableType}#${voteableId}`)
33
+ },
34
+
35
+ disconnected() {
36
+ console.log(`VoteFu: Disconnected from ${voteableType}#${voteableId}`)
37
+ },
38
+
39
+ received(data) {
40
+ if (data.type === "vote_update") {
41
+ // Update DOM elements automatically
42
+ this.updateVoteWidget(data)
43
+
44
+ // Call custom callback
45
+ if (onUpdate) {
46
+ onUpdate(data)
47
+ }
48
+ }
49
+ },
50
+
51
+ updateVoteWidget(data) {
52
+ const { voteable_type, voteable_id, scope, stats } = data
53
+ const scopeSuffix = scope ? `_${scope}` : ""
54
+ const baseId = `vote_fu_${voteable_type.toLowerCase()}_${voteable_id}${scopeSuffix}`
55
+
56
+ // Update count display
57
+ const countEl = document.getElementById(`${baseId}_count`)
58
+ if (countEl) {
59
+ countEl.textContent = stats.plusminus
60
+ countEl.classList.add("vote-fu-count-updated")
61
+ setTimeout(() => countEl.classList.remove("vote-fu-count-updated"), 300)
62
+ }
63
+
64
+ // Dispatch custom event for advanced handling
65
+ document.dispatchEvent(new CustomEvent("vote-fu:updated", {
66
+ detail: data,
67
+ bubbles: true
68
+ }))
69
+ }
70
+ }
71
+ )
72
+ }
73
+
74
+ /**
75
+ * Auto-subscribe to all vote widgets on the page
76
+ *
77
+ * Call this on page load to automatically subscribe to all voteable elements.
78
+ */
79
+ export function autoSubscribe() {
80
+ const widgets = document.querySelectorAll("[data-vote-fu-subscribe]")
81
+
82
+ widgets.forEach((widget) => {
83
+ const voteableType = widget.dataset.voteFuVoteableType
84
+ const voteableId = widget.dataset.voteFuVoteableId
85
+ const scope = widget.dataset.voteFuScope || null
86
+
87
+ if (voteableType && voteableId) {
88
+ subscribeToVotes({ voteableType, voteableId, scope })
89
+ }
90
+ })
91
+ }
92
+
93
+ export default { subscribeToVotes, autoSubscribe }
@@ -0,0 +1,9 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+
3
+ const application = Application.start()
4
+
5
+ // Configure Stimulus development experience
6
+ application.debug = false
7
+ window.Stimulus = application
8
+
9
+ export { application }
@@ -0,0 +1,9 @@
1
+ import { application } from "./application"
2
+
3
+ import VoteFuController from "./vote_fu_controller"
4
+ import VoteFuStarsController from "./vote_fu_stars_controller"
5
+ import VoteFuReactionsController from "./vote_fu_reactions_controller"
6
+
7
+ application.register("vote-fu", VoteFuController)
8
+ application.register("vote-fu-stars", VoteFuStarsController)
9
+ application.register("vote-fu-reactions", VoteFuReactionsController)
@@ -0,0 +1,148 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * VoteFu Stimulus Controller
5
+ *
6
+ * Provides optimistic UI updates for voting actions.
7
+ * Works with Turbo Streams for server reconciliation.
8
+ *
9
+ * @example
10
+ * <div data-controller="vote-fu"
11
+ * data-vote-fu-voteable-type-value="Post"
12
+ * data-vote-fu-voteable-id-value="123"
13
+ * data-vote-fu-scope-value=""
14
+ * data-vote-fu-voted-value="false"
15
+ * data-vote-fu-direction-value="">
16
+ * ...
17
+ * </div>
18
+ */
19
+ export default class extends Controller {
20
+ static targets = ["upvoteBtn", "downvoteBtn", "likeBtn", "count"]
21
+
22
+ static values = {
23
+ voteableType: String,
24
+ voteableId: Number,
25
+ scope: String,
26
+ voted: Boolean,
27
+ direction: String
28
+ }
29
+
30
+ connect() {
31
+ this.originalCount = this.countTarget?.textContent?.trim()
32
+ }
33
+
34
+ /**
35
+ * Handle vote form submission with optimistic update
36
+ */
37
+ vote(event) {
38
+ const form = event.target.closest("form")
39
+ const direction = form.querySelector("[name='direction']")?.value
40
+
41
+ // Optimistic update
42
+ this.updateUI(direction)
43
+ }
44
+
45
+ /**
46
+ * Update UI optimistically based on vote direction
47
+ */
48
+ updateUI(direction) {
49
+ const wasVoted = this.votedValue
50
+ const previousDirection = this.directionValue
51
+
52
+ // Toggle logic
53
+ if (wasVoted && previousDirection === direction) {
54
+ // Removing vote
55
+ this.votedValue = false
56
+ this.directionValue = ""
57
+ this.updateCount(-this.voteIncrement(previousDirection))
58
+ this.clearActiveStates()
59
+ } else if (wasVoted && previousDirection !== direction) {
60
+ // Changing vote direction
61
+ this.directionValue = direction
62
+ this.updateCount(
63
+ this.voteIncrement(direction) - this.voteIncrement(previousDirection)
64
+ )
65
+ this.setActiveState(direction)
66
+ } else {
67
+ // New vote
68
+ this.votedValue = true
69
+ this.directionValue = direction
70
+ this.updateCount(this.voteIncrement(direction))
71
+ this.setActiveState(direction)
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Get vote increment for a direction
77
+ */
78
+ voteIncrement(direction) {
79
+ return direction === "up" ? 1 : -1
80
+ }
81
+
82
+ /**
83
+ * Update the vote count display
84
+ */
85
+ updateCount(delta) {
86
+ if (!this.hasCountTarget) return
87
+
88
+ const currentCount = parseInt(this.countTarget.textContent, 10) || 0
89
+ const newCount = currentCount + delta
90
+ this.countTarget.textContent = newCount
91
+
92
+ // Add animation class
93
+ this.countTarget.classList.add("vote-fu-count-updated")
94
+ setTimeout(() => {
95
+ this.countTarget.classList.remove("vote-fu-count-updated")
96
+ }, 300)
97
+ }
98
+
99
+ /**
100
+ * Clear all active states from buttons
101
+ */
102
+ clearActiveStates() {
103
+ if (this.hasUpvoteBtnTarget) {
104
+ this.upvoteBtnTarget.classList.remove("vote-fu-active")
105
+ }
106
+ if (this.hasDownvoteBtnTarget) {
107
+ this.downvoteBtnTarget.classList.remove("vote-fu-active")
108
+ }
109
+ if (this.hasLikeBtnTarget) {
110
+ this.likeBtnTarget.classList.remove("vote-fu-active")
111
+ }
112
+ this.element.classList.remove("vote-fu-voted-up", "vote-fu-voted-down", "vote-fu-liked")
113
+ }
114
+
115
+ /**
116
+ * Set active state for a direction
117
+ */
118
+ setActiveState(direction) {
119
+ this.clearActiveStates()
120
+
121
+ if (direction === "up") {
122
+ if (this.hasUpvoteBtnTarget) {
123
+ this.upvoteBtnTarget.classList.add("vote-fu-active")
124
+ }
125
+ if (this.hasLikeBtnTarget) {
126
+ this.likeBtnTarget.classList.add("vote-fu-active")
127
+ }
128
+ this.element.classList.add("vote-fu-voted-up", "vote-fu-liked")
129
+ } else if (direction === "down") {
130
+ if (this.hasDownvoteBtnTarget) {
131
+ this.downvoteBtnTarget.classList.add("vote-fu-active")
132
+ }
133
+ this.element.classList.add("vote-fu-voted-down")
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Reset to server state (called on Turbo Stream failure)
139
+ */
140
+ reset() {
141
+ if (this.hasCountTarget && this.originalCount) {
142
+ this.countTarget.textContent = this.originalCount
143
+ }
144
+ this.clearActiveStates()
145
+ this.votedValue = false
146
+ this.directionValue = ""
147
+ }
148
+ }