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,92 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reactions Stimulus Controller
|
|
5
|
+
*
|
|
6
|
+
* Handles emoji reaction interactions with optimistic updates.
|
|
7
|
+
*/
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
static targets = ["reaction", "addButton", "picker"]
|
|
10
|
+
|
|
11
|
+
static values = {
|
|
12
|
+
voteableType: String,
|
|
13
|
+
voteableId: Number,
|
|
14
|
+
allowMultiple: { type: Boolean, default: true }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
connect() {
|
|
18
|
+
this.pickerVisible = false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Toggle a reaction
|
|
23
|
+
*/
|
|
24
|
+
toggle(event) {
|
|
25
|
+
const form = event.target.closest("form")
|
|
26
|
+
const scope = form.querySelector("[name='scope']")?.value
|
|
27
|
+
const button = form.querySelector("button")
|
|
28
|
+
|
|
29
|
+
if (!button) return
|
|
30
|
+
|
|
31
|
+
const isActive = button.classList.contains("vote-fu-reaction--active")
|
|
32
|
+
|
|
33
|
+
// Optimistic update
|
|
34
|
+
if (isActive) {
|
|
35
|
+
this.removeReaction(button, scope)
|
|
36
|
+
} else {
|
|
37
|
+
this.addReaction(button, scope)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Add reaction (optimistic)
|
|
43
|
+
*/
|
|
44
|
+
addReaction(button, scope) {
|
|
45
|
+
button.classList.add("vote-fu-reaction--active")
|
|
46
|
+
button.closest(".vote-fu-reaction-wrapper")?.classList.add("vote-fu-reaction-wrapper--active")
|
|
47
|
+
|
|
48
|
+
// Update count
|
|
49
|
+
const countEl = button.querySelector(".vote-fu-reaction-count")
|
|
50
|
+
if (countEl) {
|
|
51
|
+
const count = parseInt(countEl.textContent, 10) || 0
|
|
52
|
+
countEl.textContent = count + 1
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Remove reaction (optimistic)
|
|
58
|
+
*/
|
|
59
|
+
removeReaction(button, scope) {
|
|
60
|
+
button.classList.remove("vote-fu-reaction--active")
|
|
61
|
+
button.closest(".vote-fu-reaction-wrapper")?.classList.remove("vote-fu-reaction-wrapper--active")
|
|
62
|
+
|
|
63
|
+
// Update count
|
|
64
|
+
const countEl = button.querySelector(".vote-fu-reaction-count")
|
|
65
|
+
if (countEl) {
|
|
66
|
+
const count = parseInt(countEl.textContent, 10) || 0
|
|
67
|
+
countEl.textContent = Math.max(0, count - 1)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Show reaction picker
|
|
73
|
+
*/
|
|
74
|
+
showPicker(event) {
|
|
75
|
+
// Emit custom event for apps to handle their own picker UI
|
|
76
|
+
this.dispatch("showPicker", {
|
|
77
|
+
detail: {
|
|
78
|
+
voteableType: this.voteableTypeValue,
|
|
79
|
+
voteableId: this.voteableIdValue,
|
|
80
|
+
target: event.target
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Hide reaction picker
|
|
87
|
+
*/
|
|
88
|
+
hidePicker() {
|
|
89
|
+
this.pickerVisible = false
|
|
90
|
+
this.dispatch("hidePicker")
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Star Rating Stimulus Controller
|
|
5
|
+
*
|
|
6
|
+
* Handles star rating interactions with optimistic updates.
|
|
7
|
+
*/
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
static targets = ["star"]
|
|
10
|
+
|
|
11
|
+
static values = {
|
|
12
|
+
voteableType: String,
|
|
13
|
+
voteableId: Number,
|
|
14
|
+
scope: String,
|
|
15
|
+
currentRating: Number,
|
|
16
|
+
maxStars: { type: Number, default: 5 }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
connect() {
|
|
20
|
+
this.originalRating = this.currentRatingValue
|
|
21
|
+
this.hoverRating = 0
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Handle star hover
|
|
26
|
+
*/
|
|
27
|
+
hover(event) {
|
|
28
|
+
const starValue = parseInt(event.target.dataset.starValue, 10)
|
|
29
|
+
this.hoverRating = starValue
|
|
30
|
+
this.updateStarDisplay(starValue)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Handle mouse leave
|
|
35
|
+
*/
|
|
36
|
+
leave() {
|
|
37
|
+
this.hoverRating = 0
|
|
38
|
+
this.updateStarDisplay(this.currentRatingValue)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Handle rating submission
|
|
43
|
+
*/
|
|
44
|
+
rate(event) {
|
|
45
|
+
const form = event.target.closest("form")
|
|
46
|
+
const starValue = parseInt(form.querySelector("[name='value']").value, 10)
|
|
47
|
+
|
|
48
|
+
// Optimistic update
|
|
49
|
+
this.currentRatingValue = starValue
|
|
50
|
+
this.updateStarDisplay(starValue)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Update star visual display
|
|
55
|
+
*/
|
|
56
|
+
updateStarDisplay(rating) {
|
|
57
|
+
this.starTargets.forEach((star) => {
|
|
58
|
+
const starValue = parseInt(star.dataset.starValue, 10)
|
|
59
|
+
const isFilled = starValue <= rating
|
|
60
|
+
|
|
61
|
+
star.classList.toggle("vote-fu-star-filled", isFilled)
|
|
62
|
+
|
|
63
|
+
// Update star character if using text content
|
|
64
|
+
if (star.tagName === "BUTTON") {
|
|
65
|
+
star.textContent = isFilled ? "★" : "☆"
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reset to original state (on error)
|
|
72
|
+
*/
|
|
73
|
+
reset() {
|
|
74
|
+
this.currentRatingValue = this.originalRating
|
|
75
|
+
this.updateStarDisplay(this.originalRating)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VoteFu
|
|
4
|
+
class Vote < ApplicationRecord
|
|
5
|
+
self.table_name = "vote_fu_votes"
|
|
6
|
+
|
|
7
|
+
# Associations
|
|
8
|
+
belongs_to :voter, polymorphic: true
|
|
9
|
+
belongs_to :voteable, polymorphic: true
|
|
10
|
+
|
|
11
|
+
# Validations
|
|
12
|
+
validates :value, presence: true, numericality: { only_integer: true }
|
|
13
|
+
validates :voter_id, uniqueness: {
|
|
14
|
+
scope: %i[voter_type voteable_id voteable_type scope],
|
|
15
|
+
message: "has already voted on this item"
|
|
16
|
+
}, unless: :allow_duplicate_votes?
|
|
17
|
+
|
|
18
|
+
# Scopes
|
|
19
|
+
scope :up, -> { where(value: 1..) }
|
|
20
|
+
scope :down, -> { where(value: ..0) }
|
|
21
|
+
scope :with_scope, ->(s) { s.present? ? where(scope: s) : where(scope: nil) }
|
|
22
|
+
scope :for_voter, ->(voter) { where(voter: voter) }
|
|
23
|
+
scope :for_voteable, ->(voteable) { where(voteable: voteable) }
|
|
24
|
+
scope :recent, ->(since = 2.weeks.ago) { where(created_at: since..) }
|
|
25
|
+
scope :chronological, -> { order(created_at: :desc) }
|
|
26
|
+
scope :by_value, -> { order(value: :desc) }
|
|
27
|
+
|
|
28
|
+
# Callbacks for counter cache
|
|
29
|
+
after_create :increment_voteable_counters
|
|
30
|
+
after_update :update_voteable_counters, if: :saved_change_to_value?
|
|
31
|
+
after_destroy :decrement_voteable_counters
|
|
32
|
+
|
|
33
|
+
# Callbacks for broadcasts
|
|
34
|
+
after_commit :broadcast_vote_change, if: :broadcasts_enabled?
|
|
35
|
+
|
|
36
|
+
# Instance methods
|
|
37
|
+
def up?
|
|
38
|
+
value.positive?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def down?
|
|
42
|
+
value.negative?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def direction
|
|
46
|
+
return :up if up?
|
|
47
|
+
return :down if down?
|
|
48
|
+
|
|
49
|
+
:neutral
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def allow_duplicate_votes?
|
|
55
|
+
VoteFu.configuration.allow_duplicate_votes
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def broadcasts_enabled?
|
|
59
|
+
VoteFu.configuration.turbo_broadcasts && defined?(Turbo::StreamsChannel)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def increment_voteable_counters
|
|
63
|
+
return unless VoteFu.configuration.counter_cache
|
|
64
|
+
return unless voteable.respond_to?(:increment_vote_counters)
|
|
65
|
+
|
|
66
|
+
voteable.increment_vote_counters(value)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def update_voteable_counters
|
|
70
|
+
return unless VoteFu.configuration.counter_cache
|
|
71
|
+
return unless voteable.respond_to?(:update_vote_counters)
|
|
72
|
+
|
|
73
|
+
old_value = value_before_last_save
|
|
74
|
+
voteable.update_vote_counters(old_value, value)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def decrement_voteable_counters
|
|
78
|
+
return unless VoteFu.configuration.counter_cache
|
|
79
|
+
return unless voteable.respond_to?(:decrement_vote_counters)
|
|
80
|
+
|
|
81
|
+
voteable.decrement_vote_counters(value)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def broadcast_vote_change
|
|
85
|
+
return unless voteable.respond_to?(:broadcast_vote_update)
|
|
86
|
+
|
|
87
|
+
voteable.broadcast_vote_update
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Vote Count Partial
|
|
3
|
+
|
|
4
|
+
Required locals:
|
|
5
|
+
- voteable: The voteable record
|
|
6
|
+
|
|
7
|
+
Optional locals:
|
|
8
|
+
- scope: Vote scope (default: nil)
|
|
9
|
+
- format: Display format - :plusminus, :total, :percentage (default: :plusminus)
|
|
10
|
+
%>
|
|
11
|
+
<%
|
|
12
|
+
scope ||= nil
|
|
13
|
+
format = local_assigns.fetch(:format, :plusminus)
|
|
14
|
+
scope_suffix = scope.present? ? "_#{scope}" : ""
|
|
15
|
+
dom_id_base = "vote_fu_#{voteable.model_name.singular}_#{voteable.id}#{scope_suffix}"
|
|
16
|
+
|
|
17
|
+
value = case format
|
|
18
|
+
when :total then voteable.votes_total(scope: scope)
|
|
19
|
+
when :percentage then "#{voteable.percent_for(scope: scope)}%"
|
|
20
|
+
when :split then "+#{voteable.votes_for(scope: scope)} / -#{voteable.votes_against(scope: scope)}"
|
|
21
|
+
else voteable.plusminus(scope: scope)
|
|
22
|
+
end
|
|
23
|
+
%>
|
|
24
|
+
|
|
25
|
+
<span id="<%= dom_id_base %>_count"
|
|
26
|
+
class="vote-fu-count"
|
|
27
|
+
data-vote-fu-target="count">
|
|
28
|
+
<%= value %>
|
|
29
|
+
</span>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Standalone Downvote Button Partial
|
|
3
|
+
|
|
4
|
+
Required locals:
|
|
5
|
+
- voteable: The voteable record
|
|
6
|
+
|
|
7
|
+
Optional locals:
|
|
8
|
+
- voter: The current voter
|
|
9
|
+
- scope: Vote scope (default: nil)
|
|
10
|
+
- label: Button label (default: "▼")
|
|
11
|
+
- class: Additional CSS classes
|
|
12
|
+
%>
|
|
13
|
+
<%
|
|
14
|
+
scope ||= nil
|
|
15
|
+
voter ||= nil
|
|
16
|
+
label = local_assigns.fetch(:label, "▼")
|
|
17
|
+
css_class = local_assigns.fetch(:class, "")
|
|
18
|
+
|
|
19
|
+
vote = voter ? voteable.received_votes.find_by(voter: voter, scope: scope) : nil
|
|
20
|
+
voted_down = vote&.down?
|
|
21
|
+
%>
|
|
22
|
+
|
|
23
|
+
<% if voter %>
|
|
24
|
+
<%= form_with url: vote_fu.toggle_votes_path, method: :post, data: { turbo_stream: true } do |f| %>
|
|
25
|
+
<%= f.hidden_field :voteable_type, value: voteable.class.name %>
|
|
26
|
+
<%= f.hidden_field :voteable_id, value: voteable.id %>
|
|
27
|
+
<%= f.hidden_field :scope, value: scope %>
|
|
28
|
+
<%= f.hidden_field :direction, value: :down %>
|
|
29
|
+
|
|
30
|
+
<button type="submit"
|
|
31
|
+
class="vote-fu-btn vote-fu-downvote <%= "vote-fu-active" if voted_down %> <%= css_class %>"
|
|
32
|
+
title="<%= voted_down ? "Remove downvote" : "Downvote" %>">
|
|
33
|
+
<%= label %>
|
|
34
|
+
</button>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% else %>
|
|
37
|
+
<span class="vote-fu-btn vote-fu-downvote vote-fu-disabled <%= css_class %>">
|
|
38
|
+
<%= label %>
|
|
39
|
+
</span>
|
|
40
|
+
<% end %>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Like Button Partial (Single Button Style)
|
|
3
|
+
|
|
4
|
+
For simple "like" functionality without downvotes.
|
|
5
|
+
|
|
6
|
+
Required locals:
|
|
7
|
+
- voteable: The voteable record
|
|
8
|
+
|
|
9
|
+
Optional locals:
|
|
10
|
+
- voter: The current voter
|
|
11
|
+
- scope: Vote scope (default: nil)
|
|
12
|
+
- liked_label: Button label when liked (default: "♥")
|
|
13
|
+
- unliked_label: Button label when not liked (default: "♡")
|
|
14
|
+
- show_count: Whether to show like count (default: true)
|
|
15
|
+
- class: Additional CSS classes
|
|
16
|
+
%>
|
|
17
|
+
<%
|
|
18
|
+
scope ||= nil
|
|
19
|
+
voter ||= nil
|
|
20
|
+
liked_label = local_assigns.fetch(:liked_label, "♥")
|
|
21
|
+
unliked_label = local_assigns.fetch(:unliked_label, "♡")
|
|
22
|
+
show_count = local_assigns.fetch(:show_count, true)
|
|
23
|
+
css_class = local_assigns.fetch(:class, "")
|
|
24
|
+
|
|
25
|
+
vote = voter ? voteable.received_votes.find_by(voter: voter, scope: scope) : nil
|
|
26
|
+
liked = vote&.up?
|
|
27
|
+
like_count = voteable.votes_for(scope: scope)
|
|
28
|
+
|
|
29
|
+
scope_suffix = scope.present? ? "_#{scope}" : ""
|
|
30
|
+
dom_id_base = "vote_fu_#{voteable.model_name.singular}_#{voteable.id}#{scope_suffix}"
|
|
31
|
+
%>
|
|
32
|
+
|
|
33
|
+
<div id="<%= dom_id_base %>_widget"
|
|
34
|
+
class="vote-fu-like-widget <%= "vote-fu-liked" if liked %> <%= css_class %>"
|
|
35
|
+
data-controller="vote-fu"
|
|
36
|
+
data-vote-fu-voteable-type-value="<%= voteable.class.name %>"
|
|
37
|
+
data-vote-fu-voteable-id-value="<%= voteable.id %>"
|
|
38
|
+
data-vote-fu-scope-value="<%= scope %>">
|
|
39
|
+
|
|
40
|
+
<% if voter %>
|
|
41
|
+
<%= form_with url: vote_fu.toggle_votes_path, method: :post, data: { turbo_stream: true, action: "submit->vote-fu#vote" } do |f| %>
|
|
42
|
+
<%= f.hidden_field :voteable_type, value: voteable.class.name %>
|
|
43
|
+
<%= f.hidden_field :voteable_id, value: voteable.id %>
|
|
44
|
+
<%= f.hidden_field :scope, value: scope %>
|
|
45
|
+
<%= f.hidden_field :direction, value: :up %>
|
|
46
|
+
|
|
47
|
+
<button type="submit"
|
|
48
|
+
class="vote-fu-btn vote-fu-like <%= "vote-fu-active" if liked %>"
|
|
49
|
+
data-vote-fu-target="likeBtn"
|
|
50
|
+
title="<%= liked ? "Unlike" : "Like" %>">
|
|
51
|
+
<%= liked ? liked_label : unliked_label %>
|
|
52
|
+
</button>
|
|
53
|
+
<% end %>
|
|
54
|
+
<% else %>
|
|
55
|
+
<span class="vote-fu-btn vote-fu-like vote-fu-disabled">
|
|
56
|
+
<%= unliked_label %>
|
|
57
|
+
</span>
|
|
58
|
+
<% end %>
|
|
59
|
+
|
|
60
|
+
<% if show_count %>
|
|
61
|
+
<span id="<%= dom_id_base %>_count"
|
|
62
|
+
class="vote-fu-count"
|
|
63
|
+
data-vote-fu-target="count">
|
|
64
|
+
<%= like_count %>
|
|
65
|
+
</span>
|
|
66
|
+
<% end %>
|
|
67
|
+
</div>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Standalone Upvote Button Partial
|
|
3
|
+
|
|
4
|
+
Required locals:
|
|
5
|
+
- voteable: The voteable record
|
|
6
|
+
|
|
7
|
+
Optional locals:
|
|
8
|
+
- voter: The current voter
|
|
9
|
+
- scope: Vote scope (default: nil)
|
|
10
|
+
- label: Button label (default: "▲")
|
|
11
|
+
- class: Additional CSS classes
|
|
12
|
+
%>
|
|
13
|
+
<%
|
|
14
|
+
scope ||= nil
|
|
15
|
+
voter ||= nil
|
|
16
|
+
label = local_assigns.fetch(:label, "▲")
|
|
17
|
+
css_class = local_assigns.fetch(:class, "")
|
|
18
|
+
|
|
19
|
+
vote = voter ? voteable.received_votes.find_by(voter: voter, scope: scope) : nil
|
|
20
|
+
voted_up = vote&.up?
|
|
21
|
+
%>
|
|
22
|
+
|
|
23
|
+
<% if voter %>
|
|
24
|
+
<%= form_with url: vote_fu.toggle_votes_path, method: :post, data: { turbo_stream: true } do |f| %>
|
|
25
|
+
<%= f.hidden_field :voteable_type, value: voteable.class.name %>
|
|
26
|
+
<%= f.hidden_field :voteable_id, value: voteable.id %>
|
|
27
|
+
<%= f.hidden_field :scope, value: scope %>
|
|
28
|
+
<%= f.hidden_field :direction, value: :up %>
|
|
29
|
+
|
|
30
|
+
<button type="submit"
|
|
31
|
+
class="vote-fu-btn vote-fu-upvote <%= "vote-fu-active" if voted_up %> <%= css_class %>"
|
|
32
|
+
title="<%= voted_up ? "Remove upvote" : "Upvote" %>">
|
|
33
|
+
<%= label %>
|
|
34
|
+
</button>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% else %>
|
|
37
|
+
<span class="vote-fu-btn vote-fu-upvote vote-fu-disabled <%= css_class %>">
|
|
38
|
+
<%= label %>
|
|
39
|
+
</span>
|
|
40
|
+
<% end %>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Vote Widget Partial
|
|
3
|
+
|
|
4
|
+
Required locals:
|
|
5
|
+
- voteable: The voteable record
|
|
6
|
+
- voter: The current voter (can be nil)
|
|
7
|
+
|
|
8
|
+
Optional locals:
|
|
9
|
+
- scope: Vote scope (default: nil)
|
|
10
|
+
- action: The action that just occurred (:created, :updated, :removed)
|
|
11
|
+
- show_count: Whether to show vote count (default: true)
|
|
12
|
+
- upvote_label: Custom upvote label (default: "▲")
|
|
13
|
+
- downvote_label: Custom downvote label (default: "▼")
|
|
14
|
+
%>
|
|
15
|
+
<%
|
|
16
|
+
scope ||= nil
|
|
17
|
+
action ||= nil
|
|
18
|
+
show_count = local_assigns.fetch(:show_count, true)
|
|
19
|
+
upvote_label = local_assigns.fetch(:upvote_label, "▲")
|
|
20
|
+
downvote_label = local_assigns.fetch(:downvote_label, "▼")
|
|
21
|
+
|
|
22
|
+
vote = voter ? voteable.received_votes.find_by(voter: voter, scope: scope) : nil
|
|
23
|
+
voted_up = vote&.up?
|
|
24
|
+
voted_down = vote&.down?
|
|
25
|
+
|
|
26
|
+
scope_suffix = scope.present? ? "_#{scope}" : ""
|
|
27
|
+
dom_id_base = "vote_fu_#{voteable.model_name.singular}_#{voteable.id}#{scope_suffix}"
|
|
28
|
+
%>
|
|
29
|
+
|
|
30
|
+
<div id="<%= dom_id_base %>_widget"
|
|
31
|
+
class="vote-fu-widget <%= "vote-fu-voted-up" if voted_up %> <%= "vote-fu-voted-down" if voted_down %>"
|
|
32
|
+
data-controller="vote-fu"
|
|
33
|
+
data-vote-fu-voteable-type-value="<%= voteable.class.name %>"
|
|
34
|
+
data-vote-fu-voteable-id-value="<%= voteable.id %>"
|
|
35
|
+
data-vote-fu-scope-value="<%= scope %>"
|
|
36
|
+
data-vote-fu-voted-value="<%= vote.present? %>"
|
|
37
|
+
data-vote-fu-direction-value="<%= vote&.direction %>">
|
|
38
|
+
|
|
39
|
+
<% if voter %>
|
|
40
|
+
<%= form_with url: vote_fu.toggle_votes_path, method: :post, data: { turbo_stream: true, action: "submit->vote-fu#vote" } do |f| %>
|
|
41
|
+
<%= f.hidden_field :voteable_type, value: voteable.class.name %>
|
|
42
|
+
<%= f.hidden_field :voteable_id, value: voteable.id %>
|
|
43
|
+
<%= f.hidden_field :scope, value: scope %>
|
|
44
|
+
<%= f.hidden_field :direction, value: :up %>
|
|
45
|
+
|
|
46
|
+
<button type="submit"
|
|
47
|
+
class="vote-fu-btn vote-fu-upvote <%= "vote-fu-active" if voted_up %>"
|
|
48
|
+
data-vote-fu-target="upvoteBtn"
|
|
49
|
+
title="<%= voted_up ? "Remove upvote" : "Upvote" %>">
|
|
50
|
+
<%= upvote_label %>
|
|
51
|
+
</button>
|
|
52
|
+
<% end %>
|
|
53
|
+
|
|
54
|
+
<% if show_count %>
|
|
55
|
+
<span id="<%= dom_id_base %>_count"
|
|
56
|
+
class="vote-fu-count"
|
|
57
|
+
data-vote-fu-target="count">
|
|
58
|
+
<%= voteable.plusminus(scope: scope) %>
|
|
59
|
+
</span>
|
|
60
|
+
<% end %>
|
|
61
|
+
|
|
62
|
+
<%= form_with url: vote_fu.toggle_votes_path, method: :post, data: { turbo_stream: true, action: "submit->vote-fu#vote" } do |f| %>
|
|
63
|
+
<%= f.hidden_field :voteable_type, value: voteable.class.name %>
|
|
64
|
+
<%= f.hidden_field :voteable_id, value: voteable.id %>
|
|
65
|
+
<%= f.hidden_field :scope, value: scope %>
|
|
66
|
+
<%= f.hidden_field :direction, value: :down %>
|
|
67
|
+
|
|
68
|
+
<button type="submit"
|
|
69
|
+
class="vote-fu-btn vote-fu-downvote <%= "vote-fu-active" if voted_down %>"
|
|
70
|
+
data-vote-fu-target="downvoteBtn"
|
|
71
|
+
title="<%= voted_down ? "Remove downvote" : "Downvote" %>">
|
|
72
|
+
<%= downvote_label %>
|
|
73
|
+
</button>
|
|
74
|
+
<% end %>
|
|
75
|
+
<% else %>
|
|
76
|
+
<%# Guest view - no voting buttons, just count %>
|
|
77
|
+
<span class="vote-fu-btn vote-fu-upvote vote-fu-disabled"><%= upvote_label %></span>
|
|
78
|
+
<% if show_count %>
|
|
79
|
+
<span id="<%= dom_id_base %>_count" class="vote-fu-count">
|
|
80
|
+
<%= voteable.plusminus(scope: scope) %>
|
|
81
|
+
</span>
|
|
82
|
+
<% end %>
|
|
83
|
+
<span class="vote-fu-btn vote-fu-downvote vote-fu-disabled"><%= downvote_label %></span>
|
|
84
|
+
<% end %>
|
|
85
|
+
</div>
|
data/config/importmap.rb
ADDED
data/config/routes.rb
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module VoteFu
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Install VoteFu: creates initializer and migration"
|
|
14
|
+
|
|
15
|
+
def create_initializer
|
|
16
|
+
template "initializer.rb", "config/initializers/vote_fu.rb"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create_migration
|
|
20
|
+
migration_template(
|
|
21
|
+
"migration.rb.erb",
|
|
22
|
+
"db/migrate/create_vote_fu_votes.rb"
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def show_post_install_message
|
|
27
|
+
say ""
|
|
28
|
+
say "VoteFu installed successfully!", :green
|
|
29
|
+
say ""
|
|
30
|
+
say "Next steps:"
|
|
31
|
+
say " 1. Run migrations: rails db:migrate"
|
|
32
|
+
say " 2. Add to your models:"
|
|
33
|
+
say ""
|
|
34
|
+
say " class Post < ApplicationRecord"
|
|
35
|
+
say " acts_as_voteable"
|
|
36
|
+
say " end"
|
|
37
|
+
say ""
|
|
38
|
+
say " class User < ApplicationRecord"
|
|
39
|
+
say " acts_as_voter"
|
|
40
|
+
say " end"
|
|
41
|
+
say ""
|
|
42
|
+
say " 3. Start voting:"
|
|
43
|
+
say " user.upvote(post)"
|
|
44
|
+
say " user.downvote(post)"
|
|
45
|
+
say " post.plusminus"
|
|
46
|
+
say ""
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def migration_version
|
|
52
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# VoteFu Configuration
|
|
4
|
+
#
|
|
5
|
+
# For more information, see: https://github.com/peteonrails/vote_fu
|
|
6
|
+
|
|
7
|
+
VoteFu.configure do |config|
|
|
8
|
+
# Allow voters to change their vote after casting
|
|
9
|
+
# Default: true
|
|
10
|
+
# config.allow_recast = true
|
|
11
|
+
|
|
12
|
+
# Allow multiple votes from the same voter on the same item
|
|
13
|
+
# Default: false
|
|
14
|
+
# config.allow_duplicate_votes = false
|
|
15
|
+
|
|
16
|
+
# Allow a model to vote on itself (if it's both voter and voteable)
|
|
17
|
+
# Default: false
|
|
18
|
+
# config.allow_self_vote = false
|
|
19
|
+
|
|
20
|
+
# Automatically maintain counter cache columns on voteables
|
|
21
|
+
# Requires: votes_count, votes_total, upvotes_count, downvotes_count columns
|
|
22
|
+
# Default: true
|
|
23
|
+
# config.counter_cache = true
|
|
24
|
+
|
|
25
|
+
# Broadcast vote changes via Turbo Streams
|
|
26
|
+
# Default: true
|
|
27
|
+
# config.turbo_broadcasts = true
|
|
28
|
+
|
|
29
|
+
# Use ActionCable for real-time updates
|
|
30
|
+
# Default: true
|
|
31
|
+
# config.action_cable = true
|
|
32
|
+
|
|
33
|
+
# Default ranking algorithm for voteables
|
|
34
|
+
# Options: :wilson_score, :reddit_hot, :hacker_news, :simple
|
|
35
|
+
# Default: :wilson_score
|
|
36
|
+
# config.default_ranking = :wilson_score
|
|
37
|
+
|
|
38
|
+
# Gravity parameter for Reddit Hot and Hacker News algorithms
|
|
39
|
+
# Higher values = faster decay
|
|
40
|
+
# Default: 1.8
|
|
41
|
+
# config.hot_ranking_gravity = 1.8
|
|
42
|
+
end
|