bible270 0.17.0 → 0.17.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2e0821b84cb0bc7ece339c39775d9b7b814b4f274e721f112cc393aabfc5093
4
- data.tar.gz: 53f7e5d9386bfd4f9ae14ed6ac56daedab5df0139c0043e4bac6576bde257ed2
3
+ metadata.gz: 70091a35da0211de345d0a946f8fe25aea94c7656d7f9fb7b305aa63c6a1b7e6
4
+ data.tar.gz: 98e6db18fb16bdb784b7b2ad095b05860a43a81cb3111279f33b7285345bcbd0
5
5
  SHA512:
6
- metadata.gz: ef8060ffe3f6a7c99e35978886609c580e091a0bb0b6f86b809ef7bf8afa8bdda919f7e94c97f04ca5bf69e30b5b70b79b37333fb0850073864d40ed3bf8b909
7
- data.tar.gz: c17db24c8ca3ab97e950f2fa516f5366b9d98dcbaa0422a4130acab6031bdeeb1a7faefed1d28e6f4ea44f77404ea3a50a28c041b8bba48edab4936bf158313c
6
+ metadata.gz: e03f11e9596c52e59413478a3c5c615ce5bc9f4289ecbed3a037906d51d70e515d56767d18324ba331dded40de502d0d6b7c741c603836d7e351d147375187df
7
+ data.tar.gz: f2496c1342b3be1625927fdbbecd333c1792d96c8c52742359bdfc9cb66e9de7ac0f3617b20b291ee081811e4d937a92e3dbbecc9c5ee29a3c3d0788db9f473a
@@ -92,7 +92,9 @@ module Bible270
92
92
  end
93
93
 
94
94
  ids = roots.sort_by { |_root, at| -at.to_i }.first(wanted).map(&:first)
95
- threads = Comment.approved.where(id: ids).includes(:reader, replies: :reader).index_by(&:id)
95
+ threads = Comment.approved.where(id: ids)
96
+ .includes(:reader, { likes: :reader }, { replies: [:reader, { likes: :reader }] })
97
+ .index_by(&:id)
96
98
  ids.filter_map { |id| threads[id] }
97
99
  end
98
100
 
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bible270
4
+ # A heart on someone's reflection. One route, because liking and unliking are
5
+ # the same gesture — tapping the heart again takes it back.
6
+ class LikesController < ApplicationController
7
+ def toggle
8
+ return unless require_reader!
9
+
10
+ @comment = Comment.approved.find_by(id: params[:id])
11
+ head :not_found and return unless @comment
12
+
13
+ @comment.toggle_like!(current_reader)
14
+ @comment.reload
15
+
16
+ respond_to do |format|
17
+ format.turbo_stream
18
+ format.html { redirect_to day_path(@comment.day, anchor: "comment-#{@comment.id}") }
19
+ end
20
+ end
21
+ end
22
+ end
@@ -137,6 +137,18 @@ module Bible270
137
137
  end
138
138
  end
139
139
 
140
+ # Timestamps in the plan's zone rather than UTC. Dates — started_on and the
141
+ # like — need no conversion and are formatted inline.
142
+ def b270_date(time)
143
+ local = Bible270.local_time(time)
144
+ local&.strftime('%b %-d, %Y')
145
+ end
146
+
147
+ def b270_datetime(time)
148
+ local = Bible270.local_time(time)
149
+ local&.strftime('%b %-d, %Y at %-I:%M %p')
150
+ end
151
+
140
152
  # Only the writer may change their own words. An admin can remove a reflection
141
153
  # but not rewrite it: putting words in someone's mouth is worse than taking
142
154
  # them away, and taking them away is already the moderator's job.
@@ -3,6 +3,15 @@
3
3
  module Bible270
4
4
  # A publicly visible reflection attached to a plan day (optionally a track).
5
5
  class Comment < ApplicationRecord
6
+ # One heart for both states. An unliked one is the same emoji shown grey by
7
+ # CSS, so it cannot drift in size from a real like, and hovering it previews
8
+ # exactly what clicking will produce.
9
+ #
10
+ # The white heart this replaced was legible on white but nearly invisible on
11
+ # the paper background; an outline glyph like U+2661 is drawn by the font
12
+ # rather than the emoji set and needs hand-tuning to match.
13
+ HEART = '❤️'
14
+
6
15
  self.table_name = 'bible270_comments'
7
16
 
8
17
  belongs_to :reader, class_name: 'Bible270::Reader'
@@ -12,6 +21,7 @@ module Bible270
12
21
  belongs_to :parent, class_name: 'Bible270::Comment', optional: true
13
22
  has_many :replies, class_name: 'Bible270::Comment', foreign_key: :parent_id,
14
23
  dependent: :destroy, inverse_of: :parent
24
+ has_many :likes, class_name: 'Bible270::Like', dependent: :destroy, inverse_of: :comment
15
25
 
16
26
  validates :day, inclusion: { in: 1..Plan::DAYS }
17
27
  validates :body, presence: true, length: { maximum: 4000 }
@@ -37,7 +47,9 @@ module Bible270
37
47
  # Only the reflections themselves: replies are rendered under their parent, so
38
48
  # listing them again at the top level would show each twice.
39
49
  scope :for_day, ->(d) { approved.where(day: d, parent_id: nil).order(created_at: :asc) }
40
- scope :threads_for_day, ->(d) { for_day(d).includes(:reader, replies: :reader) }
50
+ scope :threads_for_day, ->(d) {
51
+ for_day(d).includes(:reader, { likes: :reader }, { replies: [:reader, { likes: :reader }] })
52
+ }
41
53
  scope :recent, -> { approved.order(created_at: :desc) }
42
54
 
43
55
  # Every reflection is visible when written; these are the moderation actions.
@@ -46,9 +58,30 @@ module Bible270
46
58
  def hidden? = !approved
47
59
  def reply? = parent_id.present?
48
60
 
61
+ def liked_by?(reader)
62
+ return false if reader.nil?
63
+
64
+ likes.any? { |like| like.reader_id == reader.id }
65
+ end
66
+
67
+ # Toggling returns the new state, so a caller can say what happened without
68
+ # asking again.
69
+ def toggle_like!(reader)
70
+ existing = likes.find { |like| like.reader_id == reader.id }
71
+ if existing
72
+ existing.destroy
73
+ likes.reload
74
+ false
75
+ else
76
+ likes.create(reader: reader)
77
+ likes.reload
78
+ true
79
+ end
80
+ end
81
+
49
82
  # A second's grace: created_at and updated_at differ by microseconds on
50
83
  # insert, which would mark every reflection as edited.
51
- def edited? = updated_at - created_at > 1
84
+ def edited? = updated_at - created_at > 1
52
85
 
53
86
  # Visible replies, oldest first: a conversation reads forwards.
54
87
  def visible_replies = replies.approved.order(created_at: :asc)
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bible270
4
+ # A reader's heart on a reflection. There is nothing to a like but who gave it
5
+ # and when — the count is the number of rows, and the hover text is the name.
6
+ class Like < ApplicationRecord
7
+ self.table_name = 'bible270_likes'
8
+
9
+ belongs_to :reader, class_name: 'Bible270::Reader'
10
+ belongs_to :comment, class_name: 'Bible270::Comment'
11
+
12
+ validates :reader_id, uniqueness: { scope: :comment_id }
13
+ end
14
+ end
@@ -8,6 +8,7 @@ module Bible270
8
8
 
9
9
  has_many :checkoffs, class_name: 'Bible270::Checkoff', dependent: :destroy
10
10
  has_many :comments, class_name: 'Bible270::Comment', dependent: :destroy
11
+ has_many :likes, class_name: 'Bible270::Like', dependent: :destroy, inverse_of: :reader
11
12
  belongs_to :owner, polymorphic: true, optional: true
12
13
 
13
14
  # Active Storage is optional: an app may have it disabled, and the engine has
@@ -18,7 +18,7 @@
18
18
  <div class="b270-cmeta">
19
19
  <strong><%= comment.reader.display_name %></strong>
20
20
  · <%= link_to "day #{comment.day}", day_path(comment.day) %>
21
- · <%= comment.created_at.strftime('%b %-d, %Y') %>
21
+ · <%= b270_date(comment.created_at) %>
22
22
  <% if comment.reply? %>· reply to <strong><%= comment.parent&.reader&.display_name || 'a deleted reflection' %></strong><% end %>
23
23
  <% if comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
24
24
  </div>
@@ -9,7 +9,7 @@
9
9
  <% if @enrollment_closed %>
10
10
  <% if @enrollment_closed_at %>
11
11
  <p class="b270-startline">Enrollment closed
12
- on <%= @enrollment_closed_at.strftime('%B %-d, %Y') %><% end %>.
12
+ on <%= b270_date(@enrollment_closed_at) %><% end %>.
13
13
  The <%= @readers.size %> reader<%= 's' unless @readers.size == 1 %> already taking part can
14
14
  still sign in and carry on.</p>
15
15
  <%= button_to 'Open to new readers', admin_enrollment_path, method: :patch,
@@ -11,9 +11,9 @@
11
11
  <div style="flex:1">
12
12
  <div class="b270-cmeta">
13
13
  <strong><%= link_to comment.reader.display_name, reader_path(comment.reader), style: "text-decoration:none" %></strong>
14
- · <%= comment.created_at.strftime("%b %-d, %Y") %>
14
+ · <%= b270_date(comment.created_at) %>
15
15
  <% if comment.track.present? %>· <%= b270_track(comment.track)[:label] %><% end %>
16
- <% if comment.edited? %>· <span class="b270-edited" title="<%= comment.updated_at.strftime('%b %-d, %Y at %-I:%M %p') %>">edited</span><% end %>
16
+ <% if comment.edited? %>· <span class="b270-edited" title="<%= b270_datetime(comment.updated_at) %>">edited</span><% end %>
17
17
  <% if b270_can_edit?(comment) %>
18
18
  <%= link_to "edit", day_path(comment.day, edit: comment.id, anchor: "comment-#{comment.id}"),
19
19
  class: "b270-cedit" %>
@@ -37,13 +37,16 @@
37
37
 
38
38
  <%# Only a top-level reflection can be replied to: one level deep, so a reply
39
39
  offers no Reply of its own. %>
40
- <% unless reply %>
41
- <% if signed_in? && comment.reader_id != current_reader.id %>
42
- <div class="b270-creply">
43
- <%= link_to "Reply", day_path(comment.day, reply_to: comment.id, anchor: "new_comment_form"),
44
- class: "b270-linkbtn" %>
45
- </div>
40
+ <div class="b270-cactions">
41
+ <%= render partial: "bible270/likes/likes", locals: { comment: comment } %>
42
+ <%# A reply may be liked but not replied to: one level deep. %>
43
+ <% if !reply && signed_in? && comment.reader_id != current_reader.id %>
44
+ <%= link_to "Reply", day_path(comment.day, reply_to: comment.id, anchor: "new_comment_form"),
45
+ class: "b270-linkbtn" %>
46
46
  <% end %>
47
+ </div>
48
+
49
+ <% unless reply %>
47
50
 
48
51
  <div class="b270-replies" id="replies-<%= comment.id %>">
49
52
  <%= render partial: "bible270/comments/comment",
@@ -2,7 +2,7 @@
2
2
  <% content_for :hide_day_index, true %><%# this page has its own, filtered grid %>
3
3
 
4
4
  <p class="b270-eyebrow">Reflections</p>
5
- <h1>What people are saying</h1>
5
+ <h1>Shared gleanings</h1>
6
6
 
7
7
  <% if @days_with_reflections.any? %>
8
8
  <div class="b270-panel">
@@ -0,0 +1,27 @@
1
+ <%# locals: (comment:) %>
2
+ <%#
3
+ One heart per like, each carrying its giver's name — so a row of hearts is both
4
+ the count and, on hover, the list of who.
5
+
6
+ Your own like is not drawn in the row: the button is your heart, filled, and
7
+ saying so. Drawing both gave a reader two hearts for one like.
8
+ %>
9
+ <%
10
+ liked = signed_in? && comment.liked_by?(current_reader)
11
+ others = signed_in? ? comment.likes.reject { |like| like.reader_id == current_reader.id } : comment.likes
12
+ %>
13
+ <span class="b270-likes" id="likes-<%= comment.id %>">
14
+ <% others.each do |like| %><span class="b270-heart"
15
+ title="<%= like.reader.display_name %>"><%= Bible270::Comment::HEART %></span><% end %>
16
+
17
+ <% if signed_in? %>
18
+ <%= button_to like_comment_path(comment), method: :post, class: "b270-likebtn#{' liked' if liked}",
19
+ form: { style: "display:inline", data: { turbo_stream: true } },
20
+ aria: { label: liked ? "Take back your like" : "Like this reflection" },
21
+ title: liked ? "You liked this — tap to take it back" : "Like this reflection" do %>
22
+ <%= Bible270::Comment::HEART %>
23
+ <% end %>
24
+ <% elsif comment.likes.any? %>
25
+ <span class="b270-likecount"><%= comment.likes.size %></span>
26
+ <% end %>
27
+ </span>
@@ -0,0 +1,3 @@
1
+ <%= turbo_stream.replace "likes-#{@comment.id}" do %>
2
+ <%= render partial: "bible270/likes/likes", locals: { comment: @comment } %>
3
+ <% end %>
@@ -11,7 +11,7 @@
11
11
  <tr><td style="padding:3px 12px 3px 0;color:#6d6558">Name</td><td><%= @reader.display_name %></td></tr>
12
12
  <tr><td style="padding:3px 12px 3px 0;color:#6d6558">Email</td><td><%= @reader.email.presence || '(none given)' %></td></tr>
13
13
  <tr><td style="padding:3px 12px 3px 0;color:#6d6558">Signed in with</td><td><%= @reader.provider.presence || 'the host application' %></td></tr>
14
- <tr><td style="padding:3px 12px 3px 0;color:#6d6558">Joined</td><td><%= @reader.created_at.strftime('%B %-d, %Y at %-I:%M %p') %></td></tr>
14
+ <tr><td style="padding:3px 12px 3px 0;color:#6d6558">Joined</td><td><%= Bible270.local_time(@reader.created_at).strftime('%B %-d, %Y at %-I:%M %p') %></td></tr>
15
15
  </table>
16
16
 
17
17
  <p style="margin:18px 0 0;font-size:14px;color:#6d6558">
@@ -3,7 +3,7 @@
3
3
  Name: <%= @reader.display_name %>
4
4
  Email: <%= @reader.email.presence || '(none given)' %>
5
5
  Signed in with: <%= @reader.provider.presence || 'the host application' %>
6
- Joined: <%= @reader.created_at.strftime('%B %-d, %Y at %-I:%M %p') %>
6
+ Joined: <%= Bible270.local_time(@reader.created_at).strftime('%B %-d, %Y at %-I:%M %p') %>
7
7
 
8
8
  That makes <%= @total %> reader<%= 's' unless @total == 1 %> taking part.
9
9
  <% if @admin_url %>
@@ -11,7 +11,7 @@
11
11
  <%= b270_avatar(r, size: 40) %>
12
12
  <div>
13
13
  <div class="name"><%= link_to r.display_name, reader_path(r), style: "text-decoration:none" %></div>
14
- <div class="sub"><%= @days_completed[r.id] %> of <%= Bible270::Plan::DAYS %> days · joined <%= r.created_at.strftime("%b %Y") %></div>
14
+ <div class="sub"><%= @days_completed[r.id] %> of <%= Bible270::Plan::DAYS %> days · joined <%= Bible270.local_time(r.created_at).strftime("%b %Y") %></div>
15
15
  </div>
16
16
  <div class="stat">
17
17
  <span class="big"><%= (@days_completed[r.id].to_f / Bible270::Plan::DAYS * 100).round %>%</span>
@@ -12,11 +12,11 @@
12
12
  <!--<%= link_to "Plan", root_path %>-->
13
13
  <%= link_to "People", community_path %>
14
14
  <%# Public, like the community page: anyone may read reflections. %>
15
- <%= link_to "Reflections", reflections_path %>
15
+ <%= link_to "Posts", reflections_path %>
16
16
  <% if signed_in? %>
17
- <% if Bible270.config.admin?(current_reader) %><%= link_to "Admin", admin_path %><% end %>
18
17
  <%= link_to "Progress", progress_path %>
19
18
  <%= link_to "Profile", profile_path %>
19
+ <% if Bible270.config.admin?(current_reader) %><%= link_to "Admin", admin_path %><% end %>
20
20
  <%= button_to "Sign out", sign_out_path, method: :delete, class: "b270-linkbtn",
21
21
  form: { style: "display:inline" } %>
22
22
  <% elsif Bible270.config.any_sign_in_method? %>
@@ -19,7 +19,7 @@
19
19
  <div style="flex:1">
20
20
  <div class="b270-cmeta">
21
21
  <%= link_to "Day #{comment.day}", day_path(comment.day) %>
22
- · <%= comment.created_at.strftime("%b %-d, %Y") %>
22
+ · <%= b270_date(comment.created_at) %>
23
23
  <% if moderate && comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
24
24
  </div>
25
25
 
@@ -161,6 +161,17 @@
161
161
  .b270-replying strong{color:var(--ink)}
162
162
  .b270-mention{color:var(--teal);font-weight:600;text-decoration:none}
163
163
  .b270-mention:hover{text-decoration:underline}
164
+ .b270-cactions{display:flex;align-items:center;gap:12px;margin-top:6px;flex-wrap:wrap}
165
+ .b270-likes{display:inline-flex;align-items:center;gap:2px}
166
+ .b270-heart{font-size:13px;line-height:1;cursor:default}
167
+ /* Same size as a heart in the row: the button is one of the hearts, not a
168
+ control sitting beside them. Unliked, it is the same emoji desaturated —
169
+ visible against the paper, and hovering shows what clicking will give. */
170
+ .b270-likebtn{background:none;border:0;padding:0 2px;font-size:13px;line-height:1;cursor:pointer;
171
+ filter:grayscale(1);opacity:.5;transition:filter .12s,opacity .12s}
172
+ .b270-likebtn:hover{filter:none;opacity:1}
173
+ .b270-likebtn.liked{filter:none;opacity:1}
174
+ .b270-likecount{font-size:11.5px;color:var(--faint);margin-left:2px}
164
175
  .b270-creply{margin-top:6px}
165
176
  .b270-cbody{margin:3px 0 0;white-space:pre-wrap}
166
177
  .b270-editform{margin-top:6px}
data/config/routes.rb CHANGED
@@ -12,6 +12,7 @@ Bible270::Engine.routes.draw do
12
12
  constraints: { day: %r{\d+} }
13
13
  post 'day/:day/comments', to: 'comments#create', as: :day_comments,
14
14
  constraints: { day: %r{\d+} }
15
+ post 'comments/:id/like', to: 'likes#toggle', as: :like_comment
15
16
  patch 'comments/:id', to: 'comments#update'
16
17
  delete 'comments/:id', to: 'comments#destroy', as: :comment
17
18
 
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateBible270Likes < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :bible270_likes do |t|
6
+ t.references :reader, null: false, index: false,
7
+ foreign_key: { to_table: :bible270_readers }
8
+ t.references :comment, null: false, index: false,
9
+ foreign_key: { to_table: :bible270_comments }
10
+ t.timestamps
11
+ end
12
+
13
+ # One like per reader per reflection, enforced by the database as well as the
14
+ # model: a double-tap should not become two hearts.
15
+ add_index :bible270_likes, %i[reader_id comment_id], unique: true,
16
+ name: 'idx_b270_likes_unique'
17
+ # Reading a thread asks for a comment's likes, so that is the index that earns
18
+ # its keep.
19
+ add_index :bible270_likes, :comment_id, name: 'idx_b270_likes_comment'
20
+ end
21
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '0.17.0'
4
+ VERSION = '0.17.1'
5
5
  end
data/lib/bible270.rb CHANGED
@@ -22,10 +22,24 @@ module_function
22
22
  # it; Date.today follows the machine. A self-hosted reading plan wants the
23
23
  # latter, so that is the default, with config.time_zone to override.
24
24
  def today
25
- zone = config.time_zone
26
- return Date.today if zone.nil? || zone.to_s.empty?
25
+ local_time(Time.now).to_date
26
+ end
27
27
 
28
- found = Time.respond_to?(:find_zone) ? Time.find_zone(zone) : nil
29
- found ? found.today : Date.today
28
+ # A stored timestamp in the plan's own zone.
29
+ #
30
+ # created_at and friends come back in Rails' Time.zone, which is UTC unless the
31
+ # host app sets it — so a reflection written at 5pm in Los Angeles was being
32
+ # shown as the next day. This is the same rule Bible270.today follows: the
33
+ # machine's zone, or config.time_zone when one is given.
34
+ def local_time(time)
35
+ return nil if time.nil?
36
+
37
+ zone = config.time_zone
38
+ if zone.to_s.empty?
39
+ time.getlocal
40
+ else
41
+ found = Time.respond_to?(:find_zone) ? Time.find_zone(zone) : nil
42
+ found ? time.in_time_zone(found) : time.getlocal
43
+ end
30
44
  end
31
45
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bible270
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.0
4
+ version: 0.17.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft
@@ -85,6 +85,7 @@ files:
85
85
  - app/controllers/bible270/checkoffs_controller.rb
86
86
  - app/controllers/bible270/comments_controller.rb
87
87
  - app/controllers/bible270/days_controller.rb
88
+ - app/controllers/bible270/likes_controller.rb
88
89
  - app/controllers/bible270/profiles_controller.rb
89
90
  - app/controllers/bible270/readers_controller.rb
90
91
  - app/controllers/bible270/sessions_controller.rb
@@ -95,6 +96,7 @@ files:
95
96
  - app/models/bible270/application_record.rb
96
97
  - app/models/bible270/checkoff.rb
97
98
  - app/models/bible270/comment.rb
99
+ - app/models/bible270/like.rb
98
100
  - app/models/bible270/reader.rb
99
101
  - app/models/bible270/setting.rb
100
102
  - app/models/bible270/sign_in_token.rb
@@ -115,6 +117,8 @@ files:
115
117
  - app/views/bible270/days/_start_date.html.erb
116
118
  - app/views/bible270/days/index.html.erb
117
119
  - app/views/bible270/days/show.html.erb
120
+ - app/views/bible270/likes/_likes.html.erb
121
+ - app/views/bible270/likes/toggle.turbo_stream.erb
118
122
  - app/views/bible270/notice_mailer/mentioned.html.erb
119
123
  - app/views/bible270/notice_mailer/mentioned.text.erb
120
124
  - app/views/bible270/notice_mailer/new_reader.html.erb
@@ -148,6 +152,7 @@ files:
148
152
  - db/migrate/20260101000010_add_remember_token_to_bible270_readers.rb
149
153
  - db/migrate/20260101000011_add_mention_notices_to_bible270_readers.rb
150
154
  - db/migrate/20260101000012_add_parent_to_bible270_comments.rb
155
+ - db/migrate/20260101000013_create_bible270_likes.rb
151
156
  - lib/bible270.rb
152
157
  - lib/bible270/avatars.rb
153
158
  - lib/bible270/configuration.rb