bible270 0.17.0 → 0.17.2

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +3 -0
  3. data/app/controllers/bible270/admin_controller.rb +74 -0
  4. data/app/controllers/bible270/comments_controller.rb +3 -1
  5. data/app/controllers/bible270/likes_controller.rb +22 -0
  6. data/app/controllers/bible270/sessions_controller.rb +3 -1
  7. data/app/helpers/bible270/plan_helper.rb +12 -0
  8. data/app/mailers/bible270/broadcast.html.erb +17 -0
  9. data/app/mailers/bible270/broadcast.text.erb +7 -0
  10. data/app/mailers/bible270/notice_mailer.rb +20 -0
  11. data/app/models/bible270/comment.rb +35 -2
  12. data/app/models/bible270/like.rb +14 -0
  13. data/app/models/bible270/reader.rb +28 -0
  14. data/app/views/bible270/admin/comments.html.erb +1 -1
  15. data/app/views/bible270/admin/index.html.erb +28 -1
  16. data/app/views/bible270/admin/show.html.erb +22 -0
  17. data/app/views/bible270/comments/_comment.html.erb +11 -8
  18. data/app/views/bible270/comments/index.html.erb +1 -1
  19. data/app/views/bible270/likes/_likes.html.erb +27 -0
  20. data/app/views/bible270/likes/toggle.turbo_stream.erb +3 -0
  21. data/app/views/bible270/notice_mailer/broadcast.html.erb +17 -0
  22. data/app/views/bible270/notice_mailer/broadcast.text.erb +7 -0
  23. data/app/views/bible270/notice_mailer/new_reader.html.erb +1 -1
  24. data/app/views/bible270/notice_mailer/new_reader.text.erb +1 -1
  25. data/app/views/bible270/readers/index.html.erb +1 -1
  26. data/app/views/bible270/shared/_header.html.erb +2 -2
  27. data/app/views/bible270/shared/_reflections.html.erb +1 -1
  28. data/app/views/bible270/shared/_styles.html.erb +12 -0
  29. data/config/routes.rb +3 -0
  30. data/db/migrate/20260101000013_create_bible270_likes.rb +21 -0
  31. data/lib/bible270/version.rb +1 -1
  32. data/lib/bible270.rb +18 -4
  33. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2e0821b84cb0bc7ece339c39775d9b7b814b4f274e721f112cc393aabfc5093
4
- data.tar.gz: 53f7e5d9386bfd4f9ae14ed6ac56daedab5df0139c0043e4bac6576bde257ed2
3
+ metadata.gz: 9e3c27f2336f3e2a809205242bbc42c46d241e49b9ea2b1bf49ca288523d7c88
4
+ data.tar.gz: 8e8c25b59eafde481a17546ac14b563527dd591ca64d9cf7541d95a01dd1f36c
5
5
  SHA512:
6
- metadata.gz: ef8060ffe3f6a7c99e35978886609c580e091a0bb0b6f86b809ef7bf8afa8bdda919f7e94c97f04ca5bf69e30b5b70b79b37333fb0850073864d40ed3bf8b909
7
- data.tar.gz: c17db24c8ca3ab97e950f2fa516f5366b9d98dcbaa0422a4130acab6031bdeeb1a7faefed1d28e6f4ea44f77404ea3a50a28c041b8bba48edab4936bf158313c
6
+ metadata.gz: 06af092550c30ded960e3611301d941e10fdea25e79fcfcab8aa97c4d68fee7b3780961f22bc4f81be4ba3f065ebe57642386ba318c69f80e2f1bd1e07ce0dec
7
+ data.tar.gz: ed69c75af0e88e8dfeb347b756c6c10692f46b6e90196e96851aecc026a664752ce6f2fbb3de3b324734eff316555c229d19aa15270d26061b370b4db643f3ae
data/Rakefile CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'bundler/gem_tasks' # provides build / install / release
4
4
  require 'rake/testtask'
5
+ require 'coveralls/rake/task'
5
6
 
6
7
  Rake::TestTask.new(:test) do |t|
7
8
  t.libs << 'test' << 'lib'
@@ -9,4 +10,6 @@ Rake::TestTask.new(:test) do |t|
9
10
  t.warning = false
10
11
  end
11
12
 
13
+ Coveralls::RakeTask.new
14
+
12
15
  task default: :test
@@ -8,6 +8,9 @@ module Bible270
8
8
  # neither set the panel is unreachable, and every action 404s rather than 403s
9
9
  # so its existence isn't advertised.
10
10
  class AdminController < ApplicationController
11
+ LAST_BROADCAST_AT = 'last_broadcast_at'
12
+ LAST_BROADCAST_SUBJECT = 'last_broadcast_subject'
13
+
11
14
  before_action :require_admin!
12
15
  before_action :load_reader,
13
16
  only: %i[show destroy update_start update_profile remove_avatar complete_through toggle_day]
@@ -32,6 +35,9 @@ module Bible270
32
35
  counts = Checkoff.group(:reader_id, :day).count
33
36
  @days_completed = Hash.new(0)
34
37
  counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.total_parts(day) }
38
+ @reachable = Reader.where.not(email: [nil, '']).count
39
+ @last_broadcast_at = parsed_time(Setting.read(LAST_BROADCAST_AT))
40
+ @last_broadcast_subject = Setting.read(LAST_BROADCAST_SUBJECT)
35
41
  end
36
42
 
37
43
  def show
@@ -75,6 +81,47 @@ module Bible270
75
81
  end
76
82
 
77
83
  # Put the reader on a given day as of today, or set an explicit start date.
84
+ def broadcast
85
+ subject = params[:subject].to_s.strip
86
+ body = params[:body].to_s.strip
87
+
88
+ if subject.empty? || body.empty?
89
+ return redirect_to(admin_path, alert: 'A message needs both a subject and something to say.')
90
+ end
91
+
92
+ recipients = Reader.where.not(email: [nil, '']).order(:id)
93
+ return redirect_to(admin_path, alert: 'Nobody has an email address.') if recipients.empty?
94
+
95
+ sent = deliver_broadcast(recipients, subject, body)
96
+ Setting.write(LAST_BROADCAST_AT, Time.current.iso8601)
97
+ Setting.write(LAST_BROADCAST_SUBJECT, subject)
98
+
99
+ redirect_to admin_path,
100
+ notice: "Sent to #{sent} #{'reader'.pluralize(sent)}."
101
+ end
102
+
103
+ def update_bible_version
104
+ reader = Reader.find_by(id: params[:id])
105
+ return redirect_to(admin_path, alert: 'No such reader.') if reader.nil?
106
+
107
+ code = params[:bible_version].to_s
108
+ # Blank means "no preference": the reader follows the site default, which is
109
+ # not the same as choosing whichever translation the site happens to use now.
110
+ if code.empty?
111
+ reader.update(bible_version: nil)
112
+ return redirect_to admin_reader_path(reader),
113
+ notice: "#{reader.display_name} now follows the site default " \
114
+ "(#{Bible270.config.bible_version})."
115
+ end
116
+
117
+ if reader.update_bible_version(code)
118
+ redirect_to admin_reader_path(reader),
119
+ notice: "#{reader.display_name} now reads the #{reader.bible_version_label}."
120
+ else
121
+ redirect_to admin_reader_path(reader), alert: 'That is not a translation on the list.'
122
+ end
123
+ end
124
+
78
125
  def update_start
79
126
  if params[:start_date].present?
80
127
  # set_start_date!, not update_start_date!: an admin is not subject to
@@ -138,6 +185,33 @@ module Bible270
138
185
  end
139
186
  end
140
187
 
188
+ # Failures are counted rather than raised: one bad address must not stop the
189
+ # rest of the message going out, and the admin is told how many were sent.
190
+ # A stored timestamp that cannot be read is treated as absent rather than
191
+ # blowing up the admin page.
192
+ def parsed_time(value)
193
+ return nil if value.blank?
194
+
195
+ Time.parse(value.to_s)
196
+ rescue ArgumentError, TypeError
197
+ nil
198
+ end
199
+
200
+ def deliver_broadcast(recipients, subject, body)
201
+ later = Bible270.config.registration_notice_deliver_later
202
+ sent = 0
203
+
204
+ recipients.find_each do |reader|
205
+ mail = NoticeMailer.broadcast(reader_id: reader.id, subject: subject, body: body)
206
+ later ? mail.deliver_later : mail.deliver_now
207
+ sent += 1
208
+ rescue StandardError => e
209
+ Rails.logger.error("[bible270] broadcast to reader #{reader.id} failed: #{e.class}: #{e.message}")
210
+ end
211
+
212
+ sent
213
+ end
214
+
141
215
  private
142
216
 
143
217
  def require_admin!
@@ -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
@@ -36,7 +36,9 @@ module Bible270
36
36
  remember_reader!(reader)
37
37
  @current_reader = reader
38
38
 
39
- redirect_to destination, notice: "Welcome #{reader.first_name}."
39
+ # first_name is filled in for every route now, but a bridged host user may
40
+ # still arrive without one.
41
+ redirect_to destination, notice: "Welcome #{reader.first_name.presence || reader.display_name}."
40
42
  end
41
43
 
42
44
  def destroy
@@ -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.
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body style="margin:0;padding:24px;background:#ece6da;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#231f18">
4
+ <div style="max-width:520px;margin:0 auto;background:#f4efe6;border:1px solid #d7cfbf;border-radius:14px;padding:28px">
5
+ <p style="margin:0 0 16px;font-size:15px">
6
+ <%= @reader.first_name.presence || @reader.display_name %>,
7
+ </p>
8
+
9
+ <div style="font-size:15px;line-height:1.6"><%= simple_format(@body) %></div>
10
+
11
+ <p style="margin:24px 0 0;padding-top:16px;border-top:1px solid #d7cfbf;font-size:12px;color:#9a9082">
12
+ <%= @app_name %><% if @plan_url %> ·
13
+ <a href="<%= @plan_url %>" style="color:#a5812c"><%= @plan_url %></a><% end %>
14
+ </p>
15
+ </div>
16
+ </body>
17
+ </html>
@@ -0,0 +1,7 @@
1
+ <%= @reader.first_name.presence || @reader.display_name %>,
2
+
3
+ <%= @body %>
4
+
5
+ --
6
+ <%= @app_name %><% if @plan_url %>
7
+ <%= @plan_url %><% end %>
@@ -31,6 +31,22 @@ module Bible270
31
31
  subject: "#{@app_name}: #{@author.display_name} mentioned you on day #{@comment.day}"
32
32
  end
33
33
 
34
+ # A message from whoever runs the plan to one reader. Sent individually rather
35
+ # than as one email with everyone in bcc: a bcc list of a hundred addresses is
36
+ # a spam signal, and it means nobody can be greeted by name or unsubscribe
37
+ # meaningfully.
38
+ def broadcast(reader_id:, subject:, body:)
39
+ @reader = Reader.find_by(id: reader_id)
40
+ return message.perform_deliveries = false if @reader.nil? || @reader.email.blank?
41
+ return message.perform_deliveries = false if subject.to_s.strip.empty? || body.to_s.strip.empty?
42
+
43
+ @body = body.to_s
44
+ @app_name = Bible270.config.app_name
45
+ @plan_url = plan_url
46
+
47
+ mail to: @reader.email, subject: subject.to_s.strip
48
+ end
49
+
34
50
  private
35
51
 
36
52
  # A mailer has no request, so the host is config.mailer_host or nothing. No
@@ -41,6 +57,10 @@ module Bible270
41
57
  # config.mount_at was worse than useless — ignored when blank, and overriding
42
58
  # the true prefix when not, so a config that disagreed with routes.rb would
43
59
  # have produced links to nowhere.
60
+ def plan_url
61
+ engine_url(:root_url)
62
+ end
63
+
44
64
  def day_url_for(day)
45
65
  engine_url(:day_url, day)
46
66
  end
@@ -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
@@ -44,10 +45,37 @@ module Bible270
44
45
  image = first_present(dig_auth(info, :image), dig_auth(info, :avatar_url))
45
46
  reader.email = email if email.present?
46
47
  reader.avatar_url = image if image.present?
48
+ assign_names_from(reader, info)
47
49
  reader.save
48
50
  reader
49
51
  end
50
52
 
53
+ # Providers hand back one display name far more often than two fields, so the
54
+ # name is split when they do not. Without this an OmniAuth reader had no
55
+ # first_name — which made the greeting read "Welcome ." and, more seriously,
56
+ # made them unmentionable: Reader.mentioned_in only considers readers who have
57
+ # one.
58
+ #
59
+ # Only filled in when empty, so a reader who has since corrected their name on
60
+ # their profile does not have it overwritten at every sign-in.
61
+ def self.assign_names_from(reader, info)
62
+ return if reader.first_name.present?
63
+
64
+ given = dig_auth(info, :first_name)
65
+ family = dig_auth(info, :last_name)
66
+
67
+ if given.present?
68
+ reader.first_name = given
69
+ reader.last_name = family
70
+ return
71
+ end
72
+
73
+ split = Names.split_display_name(reader.display_name)
74
+ reader.first_name = split ? split[:first_name] : reader.display_name.to_s.split.first
75
+ reader.last_name = split ? split[:last_name] : nil
76
+ end
77
+ private_class_method :assign_names_from
78
+
51
79
  # Find or create the reader behind a verified email address. Uses the same
52
80
  # provider/uid identity columns as OmniAuth, with provider "email", so an
53
81
  # email reader is indistinguishable from any other downstream.
@@ -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,
@@ -47,4 +47,31 @@
47
47
  <p style="color:var(--faint)">No readers yet.</p>
48
48
  <% end %>
49
49
 
50
+ <div class="b270-panel">
51
+ <h2 class="b270-subhead">Write to everyone</h2>
52
+
53
+ <% if @reachable.zero? %>
54
+ <p class="b270-hint" style="margin-top:0">No reader has an email address yet.</p>
55
+ <% else %>
56
+ <p class="b270-hint" style="margin-top:0">
57
+ Goes to <strong><%= @reachable %></strong>
58
+ <%= @reachable == 1 ? 'reader' : 'readers' %> individually, not as one message with
59
+ everyone in bcc — so each is greeted by name.
60
+ <% if @last_broadcast_at.present? %>
61
+ <br>Last sent <%= b270_datetime(@last_broadcast_at) %><%
62
+ %><% if @last_broadcast_subject.present? %>: <em><%= @last_broadcast_subject %></em><% end %>.
63
+ <% end %>
64
+ </p>
65
+
66
+ <%= form_with url: admin_broadcast_path, method: :post, class: "b270-form",
67
+ data: { turbo_confirm: "Send this to #{@reachable} #{@reachable == 1 ? 'reader' : 'readers'}?" } do %>
68
+ <%= text_field_tag :subject, params[:subject], placeholder: "Subject", class: "b270-input" %>
69
+ <%= text_area_tag :body, params[:body], placeholder: "What would you like to say?", rows: 6 %>
70
+ <div class="row">
71
+ <%= submit_tag "Send", class: "b270-btn" %>
72
+ </div>
73
+ <% end %>
74
+ <% end %>
75
+ </div>
76
+
50
77
  <p style="margin-top:18px"><%= link_to '← Back to the plan', root_path %></p>
@@ -92,6 +92,28 @@
92
92
  <%= render "bible270/shared/reflections", comments: @comments, moderate: true %>
93
93
  </div>
94
94
 
95
+ <div class="b270-panel">
96
+ <h2 class="b270-subhead">Which translation they read</h2>
97
+ <p class="b270-hint" style="margin-top:0">
98
+ Reading links open in this translation.
99
+ <% if @reader.bible_version.blank? %>
100
+ They have expressed no preference, so they follow the site default
101
+ (<%= Bible270.config.bible_version %>).
102
+ <% end %>
103
+ </p>
104
+
105
+ <%= form_with url: admin_reader_version_path(@reader), method: :patch, class: "b270-form" do %>
106
+ <div class="row">
107
+ <%= select_tag :bible_version,
108
+ options_for_select([["Site default (#{Bible270.config.bible_version})", ""]] +
109
+ Bible270::Translations.options,
110
+ @reader.bible_version.to_s),
111
+ class: "b270-select", style: "width:auto" %>
112
+ <%= submit_tag "Save", class: "b270-btn" %>
113
+ </div>
114
+ <% end %>
115
+ </div>
116
+
95
117
  <div class="b270-panel">
96
118
  <h2 class="b270-subhead">Remove</h2>
97
119
  <p class="b270-hint" style="margin-top:0">Deletes the reader together with every check-off and
@@ -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 %>
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body style="margin:0;padding:24px;background:#ece6da;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#231f18">
4
+ <div style="max-width:520px;margin:0 auto;background:#f4efe6;border:1px solid #d7cfbf;border-radius:14px;padding:28px">
5
+ <p style="margin:0 0 16px;font-size:15px">
6
+ <%= @reader.first_name.presence || @reader.display_name %>,
7
+ </p>
8
+
9
+ <div style="font-size:15px;line-height:1.6"><%= simple_format(@body) %></div>
10
+
11
+ <p style="margin:24px 0 0;padding-top:16px;border-top:1px solid #d7cfbf;font-size:12px;color:#9a9082">
12
+ <%= @app_name %><% if @plan_url %> ·
13
+ <a href="<%= @plan_url %>" style="color:#a5812c"><%= @plan_url %></a><% end %>
14
+ </p>
15
+ </div>
16
+ </body>
17
+ </html>
@@ -0,0 +1,7 @@
1
+ <%= @reader.first_name.presence || @reader.display_name %>,
2
+
3
+ <%= @body %>
4
+
5
+ --
6
+ <%= @app_name %><% if @plan_url %>
7
+ <%= @plan_url %><% 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}
@@ -172,6 +183,7 @@
172
183
  .b270-comment.b270-editing{background:rgba(165,129,44,.05);border-radius:10px;padding:12px}
173
184
  .b270-editing textarea{min-height:64px}
174
185
  .b270-cdel{font-size:12px;color:var(--oxblood);background:none;border:none;cursor:pointer;padding:0;margin-left:8px}
186
+ .b270-input{width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:9px;background:#fff;font:inherit;font-size:14px;margin-bottom:8px}
175
187
  .b270-form textarea{width:100%;font:inherit;font-size:15px;border:1px solid var(--line);border-radius:10px;padding:11px 13px;background:#fff;resize:vertical;min-height:76px}
176
188
  .b270-form .row{display:flex;align-items:center;gap:10px;margin-top:10px}
177
189
  .b270 .b270-btn{border:none;border-radius:10px;padding:8px 16px;margin-top:4px;font:inherit;font-weight:600;font-size:14px;cursor:pointer;background:var(--ink);color:var(--paper);text-decoration:none;display:inline-block}
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
 
@@ -21,10 +22,12 @@ Bible270::Engine.routes.draw do
21
22
  delete 'admin/readers/:id', to: 'admin#destroy'
22
23
  patch 'admin/readers/:id/profile', to: 'admin#update_profile', as: :admin_reader_profile
23
24
  delete 'admin/readers/:id/avatar', to: 'admin#remove_avatar', as: :admin_reader_avatar
25
+ patch 'admin/readers/:id/version', to: 'admin#update_bible_version', as: :admin_reader_version
24
26
  patch 'admin/readers/:id/start', to: 'admin#update_start', as: :admin_reader_start
25
27
  patch 'admin/readers/:id/through', to: 'admin#complete_through', as: :admin_reader_through
26
28
  post 'admin/readers/:id/days/:day', to: 'admin#toggle_day', as: :admin_reader_day
27
29
 
30
+ post 'admin/broadcast', to: 'admin#broadcast', as: :admin_broadcast
28
31
  patch 'admin/enrollment', to: 'admin#update_enrollment', as: :admin_enrollment
29
32
  get 'admin/comments', to: 'admin#comments', as: :admin_comments
30
33
  patch 'admin/comments/:id/hide', to: 'admin#hide_comment', as: :admin_hide_comment
@@ -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.2'
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft
@@ -85,16 +85,20 @@ 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
91
92
  - app/helpers/bible270/plan_helper.rb
92
93
  - app/mailers/bible270/application_mailer.rb
94
+ - app/mailers/bible270/broadcast.html.erb
95
+ - app/mailers/bible270/broadcast.text.erb
93
96
  - app/mailers/bible270/notice_mailer.rb
94
97
  - app/mailers/bible270/sign_in_mailer.rb
95
98
  - app/models/bible270/application_record.rb
96
99
  - app/models/bible270/checkoff.rb
97
100
  - app/models/bible270/comment.rb
101
+ - app/models/bible270/like.rb
98
102
  - app/models/bible270/reader.rb
99
103
  - app/models/bible270/setting.rb
100
104
  - app/models/bible270/sign_in_token.rb
@@ -115,6 +119,10 @@ files:
115
119
  - app/views/bible270/days/_start_date.html.erb
116
120
  - app/views/bible270/days/index.html.erb
117
121
  - app/views/bible270/days/show.html.erb
122
+ - app/views/bible270/likes/_likes.html.erb
123
+ - app/views/bible270/likes/toggle.turbo_stream.erb
124
+ - app/views/bible270/notice_mailer/broadcast.html.erb
125
+ - app/views/bible270/notice_mailer/broadcast.text.erb
118
126
  - app/views/bible270/notice_mailer/mentioned.html.erb
119
127
  - app/views/bible270/notice_mailer/mentioned.text.erb
120
128
  - app/views/bible270/notice_mailer/new_reader.html.erb
@@ -148,6 +156,7 @@ files:
148
156
  - db/migrate/20260101000010_add_remember_token_to_bible270_readers.rb
149
157
  - db/migrate/20260101000011_add_mention_notices_to_bible270_readers.rb
150
158
  - db/migrate/20260101000012_add_parent_to_bible270_comments.rb
159
+ - db/migrate/20260101000013_create_bible270_likes.rb
151
160
  - lib/bible270.rb
152
161
  - lib/bible270/avatars.rb
153
162
  - lib/bible270/configuration.rb