vote_fu 0.0.11 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +84 -0
- data/README.md +265 -0
- data/app/assets/stylesheets/vote_fu/votes.css +391 -0
- data/app/channels/vote_fu/application_cable/channel.rb +8 -0
- data/app/channels/vote_fu/application_cable/connection.rb +39 -0
- data/app/channels/vote_fu/votes_channel.rb +99 -0
- data/app/components/vote_fu/like_button_component.rb +136 -0
- data/app/components/vote_fu/reaction_bar_component.rb +208 -0
- data/app/components/vote_fu/star_rating_component.rb +199 -0
- data/app/components/vote_fu/vote_widget_component.rb +181 -0
- data/app/controllers/vote_fu/application_controller.rb +49 -0
- data/app/controllers/vote_fu/votes_controller.rb +223 -0
- data/app/helpers/vote_fu/votes_helper.rb +176 -0
- data/app/javascript/vote_fu/channels/consumer.js +3 -0
- data/app/javascript/vote_fu/channels/index.js +2 -0
- data/app/javascript/vote_fu/channels/votes_channel.js +93 -0
- data/app/javascript/vote_fu/controllers/application.js +9 -0
- data/app/javascript/vote_fu/controllers/index.js +9 -0
- data/app/javascript/vote_fu/controllers/vote_fu_controller.js +148 -0
- data/app/javascript/vote_fu/controllers/vote_fu_reactions_controller.js +92 -0
- data/app/javascript/vote_fu/controllers/vote_fu_stars_controller.js +77 -0
- data/app/models/vote_fu/application_record.rb +7 -0
- data/app/models/vote_fu/vote.rb +90 -0
- data/app/views/vote_fu/votes/_count.html.erb +29 -0
- data/app/views/vote_fu/votes/_downvote_button.html.erb +40 -0
- data/app/views/vote_fu/votes/_error.html.erb +9 -0
- data/app/views/vote_fu/votes/_like_button.html.erb +67 -0
- data/app/views/vote_fu/votes/_upvote_button.html.erb +40 -0
- data/app/views/vote_fu/votes/_widget.html.erb +85 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +9 -0
- data/lib/generators/vote_fu/install/install_generator.rb +56 -0
- data/lib/generators/vote_fu/install/templates/initializer.rb +42 -0
- data/lib/generators/vote_fu/install/templates/migration.rb.erb +41 -0
- data/lib/generators/vote_fu/migration/migration_generator.rb +29 -0
- data/lib/generators/vote_fu/migration/templates/create_vote_fu_votes.rb.erb +40 -0
- data/lib/vote_fu/algorithms/hacker_news.rb +54 -0
- data/lib/vote_fu/algorithms/reddit_hot.rb +55 -0
- data/lib/vote_fu/algorithms/wilson_score.rb +69 -0
- data/lib/vote_fu/concerns/karmatic.rb +320 -0
- data/lib/vote_fu/concerns/voteable.rb +291 -0
- data/lib/vote_fu/concerns/voter.rb +275 -0
- data/lib/vote_fu/configuration.rb +53 -0
- data/lib/vote_fu/engine.rb +54 -0
- data/lib/vote_fu/errors.rb +34 -0
- data/lib/vote_fu/version.rb +5 -0
- data/lib/vote_fu.rb +22 -9
- metadata +217 -60
- data/CHANGELOG.markdown +0 -31
- data/README.markdown +0 -220
- data/examples/routes.rb +0 -7
- data/examples/users_controller.rb +0 -76
- data/examples/voteable.html.erb +0 -8
- data/examples/voteable.rb +0 -10
- data/examples/voteables_controller.rb +0 -117
- data/examples/votes/_voteable_vote.html.erb +0 -23
- data/examples/votes/create.rjs +0 -1
- data/examples/votes_controller.rb +0 -110
- data/generators/vote_fu/templates/migration.rb +0 -21
- data/generators/vote_fu/vote_fu_generator.rb +0 -8
- data/init.rb +0 -1
- data/lib/acts_as_voteable.rb +0 -114
- data/lib/acts_as_voter.rb +0 -75
- data/lib/controllers/votes_controller.rb +0 -96
- data/lib/has_karma.rb +0 -68
- data/lib/models/vote.rb +0 -17
- data/rails/init.rb +0 -10
- data/test/vote_fu_test.rb +0 -8
|
@@ -0,0 +1,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,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 "./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
|
+
}
|