bible270 0.9.0 → 0.11.0

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +196 -237
  3. data/README.md +159 -79
  4. data/Rakefile +5 -4
  5. data/app/controllers/bible270/admin_controller.rb +147 -0
  6. data/app/controllers/bible270/application_controller.rb +24 -3
  7. data/app/controllers/bible270/checkoffs_controller.rb +2 -3
  8. data/app/controllers/bible270/comments_controller.rb +6 -2
  9. data/app/controllers/bible270/days_controller.rb +5 -6
  10. data/app/controllers/bible270/profiles_controller.rb +45 -0
  11. data/app/controllers/bible270/readers_controller.rb +5 -4
  12. data/app/controllers/bible270/sessions_controller.rb +89 -26
  13. data/app/helpers/bible270/plan_helper.rb +22 -12
  14. data/app/mailers/bible270/application_mailer.rb +4 -1
  15. data/app/mailers/bible270/sign_in_mailer.rb +1 -0
  16. data/app/models/bible270/application_record.rb +1 -0
  17. data/app/models/bible270/checkoff.rb +4 -3
  18. data/app/models/bible270/comment.rb +26 -5
  19. data/app/models/bible270/reader.rb +167 -17
  20. data/app/models/bible270/setting.rb +57 -0
  21. data/app/models/bible270/sign_in_token.rb +5 -2
  22. data/app/views/bible270/admin/comments.html.erb +44 -0
  23. data/app/views/bible270/admin/index.html.erb +48 -0
  24. data/app/views/bible270/admin/show.html.erb +104 -0
  25. data/app/views/bible270/days/_start_date.html.erb +1 -1
  26. data/app/views/bible270/days/index.html.erb +9 -21
  27. data/app/views/bible270/profiles/edit.html.erb +44 -0
  28. data/app/views/bible270/readers/index.html.erb +2 -2
  29. data/app/views/bible270/readers/show.html.erb +5 -5
  30. data/app/views/bible270/sessions/new.html.erb +17 -3
  31. data/app/views/bible270/shared/_day_index.html.erb +12 -0
  32. data/app/views/bible270/shared/_header.html.erb +2 -0
  33. data/app/views/bible270/shared/_styles.html.erb +17 -7
  34. data/app/views/layouts/bible270/application.html.erb +2 -4
  35. data/config/routes.rb +38 -18
  36. data/db/migrate/20260101000001_create_bible270_readers.rb +3 -2
  37. data/db/migrate/20260101000002_create_bible270_checkoffs.rb +3 -2
  38. data/db/migrate/20260101000003_create_bible270_comments.rb +4 -3
  39. data/db/migrate/20260101000004_create_bible270_sign_in_tokens.rb +4 -3
  40. data/db/migrate/20260101000005_add_names_to_bible270_readers.rb +12 -0
  41. data/db/migrate/20260101000006_add_approval_to_bible270_comments.rb +11 -0
  42. data/db/migrate/20260101000007_create_bible270_settings.rb +15 -0
  43. data/lib/bible270/avatars.rb +46 -0
  44. data/lib/bible270/configuration.rb +96 -24
  45. data/lib/bible270/email_sign_in.rb +7 -7
  46. data/lib/bible270/engine.rb +1 -0
  47. data/lib/bible270/names.rb +47 -0
  48. data/lib/bible270/plan.rb +356 -102
  49. data/lib/bible270/versification.rb +84 -67
  50. data/lib/bible270/version.rb +2 -1
  51. data/lib/bible270.rb +8 -5
  52. data/lib/generators/bible270/install/install_generator.rb +521 -38
  53. data/lib/generators/bible270/install/templates/bible270.rb.tt +2 -2
  54. metadata +15 -44
@@ -1,14 +1,23 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  # A reader identity. Either self-contained (created via OmniAuth) or bridged
4
5
  # to one of the host application's users through the polymorphic :owner.
5
6
  class Reader < ApplicationRecord
6
- self.table_name = "bible270_readers"
7
+ self.table_name = 'bible270_readers'
7
8
 
8
- has_many :checkoffs, class_name: "Bible270::Checkoff", dependent: :destroy
9
- has_many :comments, class_name: "Bible270::Comment", dependent: :destroy
9
+ has_many :checkoffs, class_name: 'Bible270::Checkoff', dependent: :destroy
10
+ has_many :comments, class_name: 'Bible270::Comment', dependent: :destroy
10
11
  belongs_to :owner, polymorphic: true, optional: true
11
12
 
13
+ # Active Storage is optional: an app may have it disabled, and the engine has
14
+ # to keep working there — readers simply can't upload, and any avatar from
15
+ # their sign-in provider is used instead.
16
+ AVATAR_UPLOADS = respond_to?(:has_one_attached)
17
+ has_one_attached :avatar if AVATAR_UPLOADS
18
+
19
+ def self.avatar_uploads? = AVATAR_UPLOADS
20
+
12
21
  validates :display_name, presence: true
13
22
  validates :uid, uniqueness: { scope: :provider }, allow_nil: true
14
23
 
@@ -20,11 +29,11 @@ module Bible270
20
29
  return nil if provider.empty? || uid.empty?
21
30
 
22
31
  info = dig_auth(auth, :info) || {}
23
- name = first_present(dig_auth(info, :name), dig_auth(info, :nickname),
24
- dig_auth(info, :first_name), dig_auth(info, :email))
32
+ name = first_present(dig_auth(info, :name), dig_auth(info, :nickname),
33
+ dig_auth(info, :first_name), dig_auth(info, :email))
25
34
 
26
35
  reader = find_or_initialize_by(provider: provider, uid: uid)
27
- reader.display_name = first_present(name, reader.display_name, "Reader")
36
+ reader.display_name = first_present(name, reader.display_name, 'Reader')
28
37
  email = dig_auth(info, :email)
29
38
  image = first_present(dig_auth(info, :image), dig_auth(info, :avatar_url))
30
39
  reader.email = email if email.present?
@@ -36,14 +45,30 @@ module Bible270
36
45
  # Find or create the reader behind a verified email address. Uses the same
37
46
  # provider/uid identity columns as OmniAuth, with provider "email", so an
38
47
  # email reader is indistinguishable from any other downstream.
39
- def self.from_email(email, display_name: nil)
48
+ # Used when enrolment is closed: an existing reader may still sign in, a new
49
+ # one may not be created.
50
+ def self.email_reader_exists?(email)
51
+ address = EmailSignIn.normalize_email(email)
52
+ return false if address.nil?
53
+
54
+ exists?(provider: 'email', uid: address)
55
+ end
56
+
57
+ def self.omniauth_reader_exists?(provider, uid)
58
+ return false if provider.blank? || uid.blank?
59
+
60
+ exists?(provider: provider.to_s, uid: uid.to_s)
61
+ end
62
+
63
+ def self.from_email(email, first_name: nil, last_name: nil, display_name: nil)
40
64
  address = EmailSignIn.normalize_email(email)
41
65
  return nil if address.nil?
42
66
 
43
- reader = find_or_initialize_by(provider: "email", uid: address)
44
- chosen = first_present(display_name, reader.display_name,
45
- EmailSignIn.display_name_from(address), "Reader")
46
- reader.display_name = chosen
67
+ reader = find_or_initialize_by(provider: 'email', uid: address)
68
+ reader.first_name = first_name.to_s.strip if first_name.present?
69
+ reader.last_name = last_name.to_s.strip if last_name.present?
70
+ reader.display_name = first_present(reader.full_name, display_name, reader.display_name,
71
+ EmailSignIn.display_name_from(address), 'Reader')
47
72
  reader.email = address
48
73
  reader.save
49
74
  reader
@@ -51,6 +76,7 @@ module Bible270
51
76
 
52
77
  def self.dig_auth(obj, key)
53
78
  return nil if obj.nil?
79
+
54
80
  if obj.respond_to?(:[])
55
81
  obj[key] || (obj[key.to_s] if key.is_a?(Symbol))
56
82
  elsif obj.respond_to?(key)
@@ -62,22 +88,53 @@ module Bible270
62
88
  private_class_method :dig_auth
63
89
 
64
90
  def self.first_present(*values)
65
- values.compact.find { |v| v.to_s.strip != "" }
91
+ values.compact.find { |v| v.to_s.strip != '' }
66
92
  end
67
93
  private_class_method :first_present
68
94
 
69
95
  # Find or create a reader bridged to a host user (or any model).
70
96
  def self.for_owner(owner, display_name:, email: nil, avatar_url: nil)
71
97
  reader = find_or_initialize_by(owner: owner)
72
- reader.display_name = display_name.presence || reader.display_name || "Reader"
98
+ reader.display_name = display_name.presence || reader.display_name || 'Reader'
73
99
  reader.email ||= email
74
100
  reader.avatar_url ||= avatar_url
75
101
  reader.save!
76
102
  reader
77
103
  end
78
104
 
105
+ def full_name
106
+ [first_name, last_name].map { |n| n.to_s.strip }.reject(&:empty?).join(' ').presence
107
+ end
108
+
109
+ def sort_name
110
+ [last_name, first_name].map { |n| n.to_s.strip.downcase }.join(' ').strip.presence || display_name.to_s.downcase
111
+ end
112
+
113
+ def avatar_uploaded?
114
+ self.class.avatar_uploads? && avatar.attached?
115
+ end
116
+
117
+ # Returns false and sets an error when the upload isn't acceptable.
118
+ def attach_avatar(upload)
119
+ return false unless self.class.avatar_uploads?
120
+
121
+ problem = Avatars.problem_with(content_type: upload.content_type, byte_size: upload.size)
122
+ if problem
123
+ errors.add(:avatar, problem)
124
+ return false
125
+ end
126
+
127
+ avatar.attach(upload)
128
+ true
129
+ end
130
+
131
+ def remove_avatar!
132
+ avatar.purge if avatar_uploaded?
133
+ true
134
+ end
135
+
79
136
  def initials
80
- display_name.to_s.split(/\s+/).first(2).map { |w| w[0] }.join.upcase.presence || "?"
137
+ display_name.to_s.split(%r{\s+}).first(2).map { |w| w[0] }.join.upcase.presence || '?'
81
138
  end
82
139
 
83
140
  # {day => number of tracks checked off}
@@ -85,6 +142,20 @@ module Bible270
85
142
  @checked_counts ||= checkoffs.group(:day).count
86
143
  end
87
144
 
145
+ # Tracks ticked on a day, counted from the single grouped query in
146
+ # checked_counts. Use this rather than read_tracks_for when rendering a grid:
147
+ # read_tracks_for costs a query per day, which is 270 of them per page.
148
+ def checked_count(day)
149
+ checked_counts[day].to_i
150
+ end
151
+
152
+ def day_status(day)
153
+ done = checked_count(day)
154
+ return :none if done.zero?
155
+
156
+ done >= Plan.required_track_count(day) ? :complete : :partial
157
+ end
158
+
88
159
  def read_tracks_for(day)
89
160
  checkoffs.where(day: day).pluck(:track)
90
161
  end
@@ -116,6 +187,76 @@ module Bible270
116
187
  end
117
188
 
118
189
  # Day number implied by the calendar, if a start date is set.
190
+ # Set the name shown beside this reader's reflections. Returns false when
191
+ # either half is missing, so callers can re-render with a message.
192
+ def update_names(first, last)
193
+ names = Names.normalize(first, last)
194
+ return false if names.nil?
195
+
196
+ update(**names)
197
+ end
198
+
199
+ # Fill first/last from the display name where we only have one string, e.g.
200
+ # a reader who arrived through OmniAuth.
201
+ def suggested_names
202
+ return { first_name: first_name, last_name: last_name } if full_name
203
+
204
+ Names.split(display_name) || { first_name: display_name, last_name: nil }
205
+ end
206
+
207
+ # ---- administrative adjustments --------------------------------------
208
+
209
+ # Tick every track that has content on this day.
210
+ def mark_day_complete!(day)
211
+ return false unless Plan.valid_day?(day)
212
+
213
+ Plan.present_tracks(day).each do |track|
214
+ checkoffs.find_or_create_by!(day: day, track: track)
215
+ end
216
+ reload_progress
217
+ true
218
+ end
219
+
220
+ def clear_day!(day)
221
+ return false unless Plan.valid_day?(day)
222
+
223
+ checkoffs.where(day: day).destroy_all
224
+ reload_progress
225
+ true
226
+ end
227
+
228
+ def toggle_day!(day)
229
+ day_complete?(day) ? clear_day!(day) : mark_day_complete!(day)
230
+ end
231
+
232
+ # Mark everything up to and including `day` complete, and clear anything after.
233
+ def mark_through!(day)
234
+ return false unless day.to_i.between?(0, Plan::DAYS)
235
+
236
+ transaction do
237
+ checkoffs.where(day: (day.to_i + 1)..).destroy_all
238
+ (1..day.to_i).each do |d|
239
+ Plan.present_tracks(d).each { |t| checkoffs.find_or_create_by!(day: d, track: t) }
240
+ end
241
+ end
242
+ reload_progress
243
+ true
244
+ end
245
+
246
+ # Put this reader on `day` as of `on` — i.e. back-date the start so that the
247
+ # given date lands on the given day of the plan.
248
+ def restart_on!(day:, on: Date.current)
249
+ day = day.to_i
250
+ return false unless Plan.valid_day?(day)
251
+
252
+ update!(started_on: Plan.to_date(on) - (day - 1))
253
+ end
254
+
255
+ def reload_progress
256
+ @checked_counts = nil
257
+ self
258
+ end
259
+
119
260
  # ---- start date / calendar ------------------------------------------
120
261
 
121
262
  # The start date that actually governs this reader. A reader's own
@@ -184,15 +325,24 @@ module Bible270
184
325
  started_on
185
326
  end
186
327
 
187
- def update_start_date!(value)
188
- return false unless Bible270.config.allow_reader_start_date
189
-
328
+ # Set the start date regardless of whether readers are allowed to set their
329
+ # own. For administrative use: config.allow_reader_start_date governs what a
330
+ # reader may do to themselves, not what an admin may do on their behalf.
331
+ # Returns false only when the value isn't a date.
332
+ def set_start_date!(value)
190
333
  date = Plan.to_date(value)
191
334
  return false if date.nil?
192
335
 
193
336
  update!(started_on: date)
194
337
  end
195
338
 
339
+ # The reader-facing version, which does respect that permission.
340
+ def update_start_date!(value)
341
+ return false unless Bible270.config.allow_reader_start_date
342
+
343
+ set_start_date!(value)
344
+ end
345
+
196
346
  def clear_start_date!
197
347
  update!(started_on: nil)
198
348
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bible270
4
+ # Runtime state an admin can change without a deploy. Currently just whether
5
+ # this run of the plan is open to new readers.
6
+ class Setting < ApplicationRecord
7
+ self.table_name = 'bible270_settings'
8
+
9
+ ENROLLMENT_CLOSED_AT = 'enrollment_closed_at'
10
+
11
+ validates :key, presence: true, uniqueness: true
12
+
13
+ def self.read(key)
14
+ find_by(key: key.to_s)&.value
15
+ end
16
+
17
+ def self.write(key, value)
18
+ record = find_or_initialize_by(key: key.to_s)
19
+ record.value = value
20
+ record.save!
21
+ value
22
+ end
23
+
24
+ def self.delete_key(key)
25
+ where(key: key.to_s).delete_all
26
+ end
27
+
28
+ # ---- enrolment --------------------------------------------------------
29
+
30
+ # Closed either because an admin closed it, or because the app was configured
31
+ # to launch closed.
32
+ def self.enrollment_closed?
33
+ return true if read(ENROLLMENT_CLOSED_AT).present?
34
+
35
+ Bible270.config.respond_to?(:enrollment_open) && Bible270.config.enrollment_open == false
36
+ end
37
+
38
+ def self.enrollment_closed_at
39
+ raw = read(ENROLLMENT_CLOSED_AT)
40
+ return nil if raw.blank?
41
+
42
+ Time.zone ? Time.zone.parse(raw) : Time.parse(raw)
43
+ rescue ArgumentError
44
+ nil
45
+ end
46
+
47
+ def self.close_enrollment!(at: Time.current)
48
+ write(ENROLLMENT_CLOSED_AT, at.iso8601)
49
+ true
50
+ end
51
+
52
+ def self.open_enrollment!
53
+ delete_key(ENROLLMENT_CLOSED_AT)
54
+ true
55
+ end
56
+ end
57
+ end
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  # A single-use, short-lived magic-link token for email sign-in.
4
5
  #
5
6
  # Only the SHA-256 digest of the token is stored, so the table is useless to
6
7
  # an attacker who reads it. Tokens are consumed on first successful use.
7
8
  class SignInToken < ApplicationRecord
8
- self.table_name = "bible270_sign_in_tokens"
9
+ self.table_name = 'bible270_sign_in_tokens'
9
10
 
10
11
  validates :email, presence: true
11
12
  validates :token_digest, presence: true, uniqueness: true
@@ -16,7 +17,7 @@ module Bible270
16
17
 
17
18
  # Issue a token for an email. Returns [token_record, raw_token], or
18
19
  # [nil, nil] when the address is being hammered (see rate_limited?).
19
- def self.issue!(email, display_name: nil)
20
+ def self.issue!(email, display_name: nil, first_name: nil, last_name: nil)
20
21
  address = EmailSignIn.normalize_email(email)
21
22
  return [nil, nil] if address.nil?
22
23
  return [nil, nil] if rate_limited?(address)
@@ -25,6 +26,8 @@ module Bible270
25
26
  record = create!(
26
27
  email: address,
27
28
  display_name: display_name.presence,
29
+ first_name: first_name.presence,
30
+ last_name: last_name.presence,
28
31
  token_digest: EmailSignIn.digest_token(raw),
29
32
  expires_at: Time.current + Bible270.config.email_sign_in_ttl
30
33
  )
@@ -0,0 +1,44 @@
1
+ <% content_for :title, "Reflections — Admin" %>
2
+ <p class="b270-eyebrow"><%= link_to 'Admin', admin_path %></p>
3
+ <h1>Reflections</h1>
4
+ <p class="lede">Reflections are visible as soon as they're written. Hiding one takes it off the day
5
+ and community pages without deleting it; the writer keeps their words and you can restore it.</p>
6
+
7
+ <p class="b270-startline" style="margin-bottom:14px">
8
+ <%= link_to 'All', admin_comments_path %> ·
9
+ <%= link_to 'Visible', admin_comments_path(filter: 'visible') %> ·
10
+ <%= link_to "Hidden (#{@hidden_count})", admin_comments_path(filter: 'hidden') %>
11
+ </p>
12
+
13
+ <% if @comments.any? %>
14
+ <% @comments.each do |comment| %>
15
+ <div class="b270-comment" id="comment-<%= comment.id %>">
16
+ <%= b270_avatar(comment.reader) %>
17
+ <div style="flex:1">
18
+ <div class="b270-cmeta">
19
+ <strong><%= comment.reader.display_name %></strong>
20
+ · <%= link_to "day #{comment.day}", day_path(comment.day) %>
21
+ · <%= comment.created_at.strftime('%b %-d, %Y') %>
22
+ <% if comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
23
+ </div>
24
+ <p class="b270-cbody"><%= comment.body %></p>
25
+ <div style="display:flex;gap:10px;align-items:center;margin-top:6px">
26
+ <% if comment.hidden? %>
27
+ <%= button_to 'Restore', admin_unhide_comment_path(comment), method: :patch,
28
+ params: { return_to: request.fullpath }, class: 'b270-linkbtn' %>
29
+ <% else %>
30
+ <%= button_to 'Hide', admin_hide_comment_path(comment), method: :patch,
31
+ params: { return_to: request.fullpath }, class: 'b270-linkbtn' %>
32
+ <% end %>
33
+ <%= button_to 'Delete', admin_comment_path(comment), method: :delete,
34
+ params: { return_to: request.fullpath }, class: 'b270-linkbtn',
35
+ form: { data: { turbo_confirm: 'Delete this reflection permanently?' } } %>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ <% end %>
40
+ <% else %>
41
+ <p style="color:var(--faint)">Nothing here.</p>
42
+ <% end %>
43
+
44
+ <p style="margin-top:18px"><%= link_to '← All readers', admin_path %></p>
@@ -0,0 +1,48 @@
1
+ <% content_for :title, "Admin — #{Bible270.config.app_name}" %>
2
+ <p class="b270-eyebrow">Admin</p>
3
+ <h1>Readers</h1>
4
+ <p class="lede"><%= @readers.size %> reader<%= 's' unless @readers.size == 1 %> taking part.
5
+ <%= link_to 'Moderate reflections →', admin_comments_path %></p>
6
+
7
+ <div class="b270-panel">
8
+ <h2 class="b270-subhead">This run of the plan</h2>
9
+ <% if @enrollment_closed %>
10
+ <p class="b270-startline"><strong>Closed to new readers</strong><% if @enrollment_closed_at %>
11
+ since <%= @enrollment_closed_at.strftime('%B %-d, %Y') %><% end %>.
12
+ The <%= @readers.size %> reader<%= 's' unless @readers.size == 1 %> already taking part can
13
+ still sign in and carry on.</p>
14
+ <%= button_to 'Open to new readers', admin_enrollment_path, method: :patch,
15
+ params: { state: 'open' }, class: 'b270-btn' %>
16
+ <% else %>
17
+ <p class="b270-startline"><strong>Open to new readers.</strong> Anyone signing in for the first
18
+ time joins this run.</p>
19
+ <%= button_to 'Close to new readers', admin_enrollment_path, method: :patch,
20
+ params: { state: 'closed' }, class: 'b270-btn',
21
+ form: { data: { turbo_confirm: 'Close this run? Existing readers keep their access.' } } %>
22
+ <% end %>
23
+ </div>
24
+
25
+ <% if @readers.any? %>
26
+ <div class="b270-list">
27
+ <% @readers.each do |reader| %>
28
+ <div class="b270-li">
29
+ <%= b270_avatar(reader, size: 34) %>
30
+ <div>
31
+ <div class="name"><%= link_to reader.display_name, admin_reader_path(reader), style: 'text-decoration:none' %></div>
32
+ <div class="sub">
33
+ <%= reader.email.presence || reader.provider %>
34
+ · <%= @days_completed[reader.id] %>/<%= Bible270::Plan::DAYS %> days
35
+ <% if reader.dated? %>· started <%= reader.effective_start_date.strftime('%b %-d, %Y') %><% end %>
36
+ </div>
37
+ </div>
38
+ <div class="stat">
39
+ <span class="big"><%= (@days_completed[reader.id].to_f / Bible270::Plan::DAYS * 100).round %>%</span>
40
+ </div>
41
+ </div>
42
+ <% end %>
43
+ </div>
44
+ <% else %>
45
+ <p style="color:var(--faint)">No readers yet.</p>
46
+ <% end %>
47
+
48
+ <p style="margin-top:18px"><%= link_to '← Back to the plan', root_path %></p>
@@ -0,0 +1,104 @@
1
+ <% content_for :title, "#{@reader.display_name} — Admin" %>
2
+ <p class="b270-eyebrow"><%= link_to 'Admin', admin_path %></p>
3
+ <h1><%= @reader.display_name %></h1>
4
+ <p class="lede">
5
+ <%= @reader.email.presence || @reader.provider %> ·
6
+ <%= @days_completed %> of <%= Bible270::Plan::DAYS %> days ·
7
+ <%= @reader.completion_percent %>%
8
+ <% if @reader.dated? %>· day <%= @reader.calendar_day %> today<% end %>
9
+ </p>
10
+
11
+ <div class="b270-panel">
12
+ <h2 class="b270-subhead">Name</h2>
13
+ <%= form_with url: admin_reader_name_path(@reader), method: :patch, class: "b270-emailform" do %>
14
+ <label class="b270-field">
15
+ <span>First name</span>
16
+ <%= text_field_tag :first_name, @reader.suggested_names[:first_name], required: true %>
17
+ </label>
18
+ <label class="b270-field">
19
+ <span>Last name</span>
20
+ <%= text_field_tag :last_name, @reader.suggested_names[:last_name], required: true %>
21
+ </label>
22
+ <%= submit_tag 'Save name', class: 'b270-btn' %>
23
+ <% end %>
24
+ </div>
25
+
26
+ <div class="b270-panel">
27
+ <h2 class="b270-subhead">Where they are in the plan</h2>
28
+ <%= form_with url: admin_reader_start_path(@reader), method: :patch, class: "b270-startform" do %>
29
+ <label class="b270-field">
30
+ <span>Put them on day (as of today)</span>
31
+ <%= number_field_tag :day, @reader.calendar_day || 1, min: 1, max: Bible270::Plan::DAYS %>
32
+ </label>
33
+ <%= submit_tag 'Move', class: 'b270-btn' %>
34
+ <% end %>
35
+ <%= form_with url: admin_reader_start_path(@reader), method: :patch, class: "b270-startform" do %>
36
+ <label class="b270-field">
37
+ <span>…or set the start date directly</span>
38
+ <%= date_field_tag :start_date, @reader.started_on %>
39
+ </label>
40
+ <%= submit_tag 'Set date', class: 'b270-btn' %>
41
+ <% end %>
42
+ <p class="b270-hint">Moving someone re-maps the calendar only. Their check-offs are keyed to day
43
+ numbers and are not touched.</p>
44
+ </div>
45
+
46
+ <div class="b270-panel">
47
+ <h2 class="b270-subhead">Completions</h2>
48
+ <%= form_with url: admin_reader_through_path(@reader), method: :patch, class: "b270-startform" do %>
49
+ <label class="b270-field">
50
+ <span>Mark complete through day (0 clears everything)</span>
51
+ <%= number_field_tag :day, @days_completed, min: 0, max: Bible270::Plan::DAYS %>
52
+ </label>
53
+ <%= submit_tag 'Apply', class: 'b270-btn' %>
54
+ <% end %>
55
+ <p class="b270-hint">This also clears anything after that day, so it sets their progress exactly.</p>
56
+
57
+ <div class="b270-grid" style="margin-top:16px">
58
+ <% (1..Bible270::Plan::DAYS).each do |d| %>
59
+ <%= button_to d, admin_reader_day_path(@reader, d), method: :post,
60
+ class: "b270-cell #{b270_day_status_class(@reader, d)}",
61
+ form: { style: 'display:contents', id: "day-#{d}" } %>
62
+ <% end %>
63
+ </div>
64
+ <p class="b270-hint">Click a day to toggle it complete.</p>
65
+ </div>
66
+
67
+ <div class="b270-panel">
68
+ <h2 class="b270-subhead">Their reflections</h2>
69
+ <% if @comments.any? %>
70
+ <% @comments.each do |comment| %>
71
+ <div class="b270-comment">
72
+ <div style="flex:1">
73
+ <div class="b270-cmeta">
74
+ <%= link_to "day #{comment.day}", day_path(comment.day) %>
75
+ · <%= comment.created_at.strftime('%b %-d, %Y') %>
76
+ <% if comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
77
+ </div>
78
+ <p class="b270-cbody"><%= comment.body %></p>
79
+ <div style="display:flex;gap:10px;margin-top:6px">
80
+ <% if comment.hidden? %>
81
+ <%= button_to 'Restore', admin_unhide_comment_path(comment), method: :patch,
82
+ params: { return_to: request.fullpath }, class: 'b270-linkbtn' %>
83
+ <% else %>
84
+ <%= button_to 'Hide', admin_hide_comment_path(comment), method: :patch,
85
+ params: { return_to: request.fullpath }, class: 'b270-linkbtn' %>
86
+ <% end %>
87
+ </div>
88
+ </div>
89
+ </div>
90
+ <% end %>
91
+ <% else %>
92
+ <p class="b270-hint" style="margin-top:0">None yet.</p>
93
+ <% end %>
94
+ </div>
95
+
96
+ <div class="b270-panel">
97
+ <h2 class="b270-subhead">Remove</h2>
98
+ <p class="b270-hint" style="margin-top:0">Deletes the reader together with every check-off and
99
+ reflection they have written. This cannot be undone.</p>
100
+ <%= button_to "Remove #{@reader.display_name}", admin_reader_path(@reader), method: :delete,
101
+ class: 'b270-btn', form: { data: { turbo_confirm: "Permanently remove #{@reader.display_name}?" } } %>
102
+ </div>
103
+
104
+ <p style="margin-top:18px"><%= link_to '← All readers', admin_path %></p>
@@ -4,7 +4,7 @@
4
4
  <% if reader.own_start_date? %>
5
5
  Started <strong><%= reader.start_date.strftime("%B %-d, %Y") %></strong>
6
6
  <% else %>
7
- Community plan began <strong><%= Bible270.config.start_date.strftime("%B %-d, %Y") %></strong>
7
+ Community plan start date: <strong><%= Bible270.config.start_date.strftime("%B %-d, %Y") %></strong>
8
8
  <% end %>
9
9
  · finishes <%= reader.plan_end_date.strftime("%B %-d, %Y") %>
10
10
  <% if reader.not_started_yet? %>
@@ -1,22 +1,19 @@
1
1
  <% content_for :title, Bible270.config.app_name %>
2
2
  <p class="b270-eyebrow"><%= Bible270.config.tagline %></p>
3
- <h1><%= Bible270.config.app_name %> <em>— read the story straight through</em></h1>
4
- <p class="lede">Each day: a passage from the Old Testament, a couple of chapters of the New
5
- Testament, and a Psalm or Proverb — portions sized to take about the same time to read.
6
- Check off what you read; your place is kept for you, and you can see how the rest of the
7
- community is journeying too.</p>
3
+ <h1><%= Bible270.config.app_name %> <em>— read God's whole story</em></h1>
4
+ <p class="lede">Daily passages from the Old Testament, New Testament, and Psalms or Proverbs — portions sized to take about the same time to read. Check off what you read; your place is saved for you, and you can see how your friends are progressing as well.</p>
8
5
 
9
6
  <div class="b270-panel">
10
7
  <% if signed_in? %>
11
8
  <div class="b270-bignum">
12
9
  <span class="n"><%= current_reader.days_completed %></span>
13
- <span class="of">of <%= Plan::DAYS %> days complete</span>
10
+ <span class="of">of <%= Bible270::Plan::DAYS %> days complete</span>
14
11
  <span class="pct"><%= current_reader.completion_percent %>%</span>
15
12
  </div>
16
13
  <div class="b270-tracks">
17
- <% [["ot", current_reader.days_read_in("ot"), Plan::DAYS],
18
- ["nt", current_reader.days_read_in("nt"), Plan.nt_content_days],
19
- ["pp", current_reader.days_read_in("pp"), Plan::DAYS]].each do |track, read, total| %>
14
+ <% [["ot", current_reader.days_read_in("ot"), Bible270::Plan::DAYS],
15
+ ["nt", current_reader.days_read_in("nt"), Bible270::Plan.nt_content_days],
16
+ ["pp", current_reader.days_read_in("pp"), Bible270::Plan::DAYS]].each do |track, read, total| %>
20
17
  <% t = b270_track(track) %>
21
18
  <div class="b270-tm">
22
19
  <span class="lab"><span class="b270-rib" style="background:<%= t[:color] %>"></span><%= t[:label] %></span>
@@ -36,9 +33,8 @@
36
33
  <p class="b270-signedout"><%= link_to "Sign in", sign_in_path %> to track your progress and join
37
34
  the conversation. You can browse the whole plan and read others' reflections without an account.</p>
38
35
  <% if @community_start_date %>
39
- <p class="b270-startline">The community began this plan on
40
- <strong><%= @community_start_date.strftime("%B %-d, %Y") %></strong>
41
- and finishes <%= Plan.end_date_for(@community_start_date).strftime("%B %-d, %Y") %>.</p>
36
+ <p class="b270-startline">This community plan runs from <strong><%= @community_start_date.strftime("%B %-d, %Y") %></strong> to
37
+ <strong><%= Bible270::Plan.end_date_for(@community_start_date).strftime("%B %-d, %Y") %></strong>.</p>
42
38
  <% end %>
43
39
  <p style="margin:14px 0 0"><%= link_to "Start at Day 1 →", day_path(1), class: "b270-btn" %></p>
44
40
  <% end %>
@@ -55,18 +51,10 @@
55
51
  <div class="name"><%= link_to r.display_name, reader_path(r), style: "text-decoration:none" %></div>
56
52
  <div class="sub"><%= @days_completed[r.id] %> days read</div>
57
53
  </div>
58
- <div class="stat"><span class="big"><%= (@days_completed[r.id].to_f / Plan::DAYS * 100).round %>%</span></div>
54
+ <div class="stat"><span class="big"><%= (@days_completed[r.id].to_f / Bible270::Plan::DAYS * 100).round %>%</span></div>
59
55
  </div>
60
56
  <% end %>
61
57
  </div>
62
58
  <p style="margin:12px 0 0"><%= link_to "See everyone →", community_path %></p>
63
59
  <% end %>
64
60
 
65
- <details class="b270-index">
66
- <summary>All <%= Plan::DAYS %> days</summary>
67
- <div class="b270-grid">
68
- <% (1..Plan::DAYS).each do |d| %>
69
- <%= link_to d, day_path(d), class: "b270-cell #{b270_day_status_class(current_reader, d)} #{'now' if signed_in? && d == @start_day}" %>
70
- <% end %>
71
- </div>
72
- </details>
@@ -0,0 +1,44 @@
1
+ <% content_for :title, "Your profile — #{Bible270.config.app_name}" %>
2
+ <p class="b270-eyebrow">Your details</p>
3
+ <h1>Your profile</h1>
4
+ <p class="lede">This is how you appear beside your reflections and on the community page.</p>
5
+
6
+ <div class="b270-panel">
7
+ <%= form_with url: profile_path, method: :patch, class: "b270-emailform",
8
+ html: { multipart: true } do %>
9
+ <div class="b270-avatarrow">
10
+ <%= b270_avatar(current_reader, size: 64) %>
11
+ <% if Bible270::Reader.avatar_uploads? %>
12
+ <label class="b270-field" style="flex:1">
13
+ <span>Picture</span>
14
+ <%= file_field_tag :avatar, accept: Bible270::Avatars.content_types.join(',') %>
15
+ <span class="b270-hint" style="margin:0">
16
+ PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes) %>.
17
+ </span>
18
+ </label>
19
+ <% end %>
20
+ </div>
21
+
22
+ <label class="b270-field">
23
+ <span>First name</span>
24
+ <%= text_field_tag :first_name, @names[:first_name], required: true, autocomplete: "given-name" %>
25
+ </label>
26
+ <label class="b270-field">
27
+ <span>Last name</span>
28
+ <%= text_field_tag :last_name, @names[:last_name], required: true, autocomplete: "family-name" %>
29
+ </label>
30
+ <%= submit_tag "Save", class: "b270-btn" %>
31
+ <% end %>
32
+
33
+ <% if current_reader.avatar_uploaded? %>
34
+ <%= button_to "Remove picture", profile_avatar_path, method: :delete, class: "b270-linkbtn",
35
+ form: { data: { turbo_confirm: "Remove your picture?" } } %>
36
+ <% end %>
37
+
38
+ <% unless Bible270::Reader.avatar_uploads? %>
39
+ <p class="b270-hint">Picture uploads need Active Storage in this application
40
+ (<code>bin/rails active_storage:install</code>). Until then, avatars come from your sign-in provider.</p>
41
+ <% end %>
42
+ </div>
43
+
44
+ <p style="margin-top:18px"><%= link_to "← Back to your progress", reader_path(current_reader) %></p>