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,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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VoteFu
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -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,9 @@
1
+ <%#
2
+ Vote Error Partial
3
+
4
+ Required locals:
5
+ - message: The error message
6
+ %>
7
+ <div class="vote-fu-error" role="alert">
8
+ <%= message %>
9
+ </div>
@@ -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>
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # VoteFu JavaScript imports
4
+ pin_all_from VoteFu::Engine.root.join("app/javascript/vote_fu/controllers"),
5
+ under: "vote_fu/controllers",
6
+ to: "vote_fu/controllers"
data/config/routes.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ VoteFu::Engine.routes.draw do
4
+ resources :votes, only: %i[create update destroy] do
5
+ collection do
6
+ post :toggle
7
+ end
8
+ end
9
+ end
@@ -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