bible270 1.9.1 → 1.10.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: 5e8a5fd512d3d6da6e59a4ecbbb112717b13eeb00b4b6fa0c4033c7c61ace730
4
- data.tar.gz: 190694fc67180501f5e991aaf7640c7577b90f636fdea9dfca742941f9577841
3
+ metadata.gz: b91b3cab89c11a366f5530e56caa8342c99352854eff8245447d96ce3a2727de
4
+ data.tar.gz: 29cf150387757a35d5b46d7ef80a0dd01080162ac57d7847efdba52174cc0e13
5
5
  SHA512:
6
- metadata.gz: 78a6b2c66b69dc0f6fb268ff1669b968567a4fdedbcf749ba34d31daec6853119a30da545c129c0f77d0571ef488a43a24b0c6f10dc7b5b7738e2c6d2e60e366
7
- data.tar.gz: a132a769bef838b993cac33603fd2d5ef6b66796a3f9d86f40d257436040732d73ebe154d92054ca5d724b88899abf6970d41a122438286afe60dedf52a128b4
6
+ metadata.gz: 4e7cf3647cecdde9ca4f5d692ea52bab1d0d711611ff05fcb0a93f5ddca161a0f9f1e3f4485b3cc94bd444181cbb5b2c04d1da71c8f53d57b28c230a71249180
7
+ data.tar.gz: d65529ce89abd4b8415e661468d898f06dfa7421fae5821038d96466326ac7018c1f6cc4bb9a60c0542ee1e8e1d64eeb3787473935eb99fec2a9f64582691ff5
data/README.md CHANGED
@@ -272,6 +272,8 @@ Admins get an extra "Admin" link in the header. The broadcast form can send an i
272
272
  message either to every reader with an email address or only to readers recognized as administrators.
273
273
  From a reader's page you can:
274
274
 
275
+ - **set their theme** — Parchment, Earth, Forest, Midnight, or System, independently of your own theme;
276
+
275
277
  - **move them to a day** — "put them on day 42 as of today" back-dates the start date; check-offs are
276
278
  keyed to day numbers, so nothing in their history moves;
277
279
  - **set their progress** — mark complete through day N (this also clears anything after, so it sets
@@ -284,7 +286,8 @@ From a reader's page you can:
284
286
  The Reflections page lists conversations by their latest approved reply, supports day and author
285
287
  filters, and paginates older threads. Signed-in readers see which conversations became active since
286
288
  their previous visit. Reflection and reply drafts are retained for up to 30 days in that browser and
287
- cleared after a successful post. On a shared device, clearing browser storage also removes saved drafts.
289
+ cleared after a successful post. If browser storage is blocked or full, the composer warns that the draft
290
+ could not be saved; posting still works. On a shared device, clearing browser storage also removes saved drafts.
288
291
  Typing `@` in the composer offers up to five unambiguous reader handles; manual mentions continue to
289
292
  work without JavaScript. A quiet **Formatting** disclosure opens a toolbar for bold, italic,
290
293
  quotations, bulleted and numbered lists, links, and YouTube videos without requiring readers to know Markdown syntax.
@@ -368,13 +371,17 @@ Psalm 35: []
368
371
  Psalm 78: [20, 39, 55]
369
372
  ```
370
373
 
371
- ## Display preference
374
+ ## Theme
372
375
 
373
- Readers can choose **Light**, **Dark**, or **System** under **Profile → Display**, then save.
374
- The preference is stored with their account and applies throughout Bible270 on every device.
375
- System is the default for existing and new readers, follows the device's current appearance,
376
+ Readers can choose **Parchment** (light), **Earth** (dark brown), **Forest** (dark green),
377
+ **Midnight** (dark blue), or **System** under **Profile → Theme**, then save. The preference is stored
378
+ with their account and applies throughout Bible270 on every device. System is the default for existing
379
+ and new readers, follows the device's current appearance using Parchment for light or Earth for dark,
376
380
  and responds to changes without reloading. Signed-out visitors also use System.
377
- The completion dove retains its bright colors in either theme.
381
+ The completion dove retains its bright colors in every theme.
382
+
383
+ Existing preferences are preserved under the new names. Forest and Midnight require no additional
384
+ migration once display preferences are installed.
378
385
 
379
386
  Upgrading hosts must install and run migration `20260101000023`:
380
387
 
@@ -420,7 +427,8 @@ Daily reminders are optional at both levels: the host enables the feature, then
420
427
  whether to receive it and at what local time from their profile. The feature and reader opt-in both
421
428
  default to off; each reader's time defaults to `08:00`. Profile times use quarter-hour choices so they
422
429
  match the recommended 15-minute scheduler interval. An existing off-quarter preference remains
423
- available until that reader chooses a new time.
430
+ available until that reader chooses a new time. The profile shows the plan's effective time zone beside
431
+ the reminder settings; this is not automatically the reader's device time zone.
424
432
 
425
433
  ```ruby
426
434
  # config/initializers/bible270.rb
@@ -22,7 +22,7 @@ module Bible270
22
22
 
23
23
  before_action :require_admin!
24
24
  before_action :load_reader,
25
- only: %i[show destroy update_start update_profile update_notifications update_completion_animation
25
+ only: %i[show destroy update_start update_profile update_theme update_notifications update_completion_animation
26
26
  remove_avatar complete_through toggle_day]
27
27
  before_action :load_comment, only: %i[hide_comment unhide_comment destroy_comment]
28
28
 
@@ -67,7 +67,7 @@ module Bible270
67
67
  @days_completed = Reader.completed_days_by_id
68
68
  @reader_sort_options = READER_SORT_OPTIONS
69
69
  @reader_sort = READER_SORT_OPTIONS.key?(params[:sort]) ? params[:sort] : 'first_name'
70
- readers = Reader.all.load
70
+ readers = Reader.with_avatar.load
71
71
  @recent_reader_activities = Checkoff.recent_activity_by_reader(readers.map(&:id))
72
72
  @reader_report = ReaderProgressReport.new(
73
73
  readers: readers,
@@ -105,7 +105,7 @@ module Bible270
105
105
  # Everything is visible by default; this lists it all so anything unsuitable
106
106
  # can be taken down.
107
107
  def comments
108
- scope = Comment.includes(:reader, parent: :reader).order(created_at: :desc)
108
+ scope = Comment.with_reader_avatars.includes(parent: :reader).order(created_at: :desc)
109
109
  @filter = params[:filter].to_s
110
110
  scope = scope.hidden if @filter == 'hidden'
111
111
  scope = scope.approved if @filter == 'visible'
@@ -248,6 +248,22 @@ module Bible270
248
248
  end
249
249
  end
250
250
 
251
+ def update_theme
252
+ unless Reader.display_mode_column?
253
+ return redirect_to admin_reader_path(@reader),
254
+ alert: 'Run the pending Bible270 database migration before changing the theme.'
255
+ end
256
+
257
+ mode = params[:display_mode].to_s
258
+ unless Reader::DISPLAY_MODES.include?(mode)
259
+ return redirect_to admin_reader_path(@reader), alert: 'Choose a theme from the list.'
260
+ end
261
+
262
+ @reader.reload unless @reader.has_attribute?(:display_mode)
263
+ @reader.update!(display_mode: mode)
264
+ redirect_to admin_reader_path(@reader), notice: 'Theme updated.'
265
+ end
266
+
251
267
  def update_notifications
252
268
  unless Bible270.config.mention_notifications
253
269
  return redirect_to admin_reader_path(@reader), alert: 'Reflection emails are disabled for this installation.'
@@ -17,39 +17,30 @@ module Bible270
17
17
  @comment_notification_level = requested_comment_notification_level
18
18
  @daily_reminders_available = daily_reminders_available?
19
19
  @display_mode = params.fetch(:display_mode, current_reader.effective_display_mode).to_s
20
- problems = [comment_notification_problem, display_mode_problem].compact
21
- problems << 'both a first and last name' unless current_reader.update_names(params[:first_name],
22
- params[:last_name])
23
- if params[:bible_version].present? && !current_reader.update_bible_version(params[:bible_version])
20
+ @names = { first_name: params[:first_name], last_name: params[:last_name] }
21
+ @daily_reminders = params[:daily_reminders] == '1'
22
+ @daily_reminder_time = daily_reminder_time_param
23
+ names = Names.normalize(params[:first_name], params[:last_name])
24
+ source = (params[:passage_source].presence || Reader::DEFAULT_PASSAGE_SOURCE).to_s
25
+ problems = [comment_notification_problem, display_mode_problem, avatar_problem].compact
26
+ problems << 'both a first and last name' unless names
27
+ if params[:bible_version].present? && !Translations.valid?(params[:bible_version])
24
28
  problems << 'a translation from the list'
25
29
  end
26
- source = params[:passage_source].presence || Reader::DEFAULT_PASSAGE_SOURCE
27
- problems << 'a reading-link source from the list' unless current_reader.update_passage_source(source)
28
- if params[:avatar].present? && !current_reader.attach_avatar(params[:avatar])
29
- problems << (current_reader.errors[:avatar].first || 'a valid image')
30
- end
31
-
32
- requested_daily_reminders = params[:daily_reminders] == '1'
33
- requested_daily_reminder_time = daily_reminder_time_param
34
- if @daily_reminders_available && !Reader.valid_daily_reminder_time?(requested_daily_reminder_time)
30
+ problems << 'a reading-link source from the list' unless Reader::PASSAGE_SOURCES.include?(source)
31
+ if @daily_reminders_available && !Reader.valid_daily_reminder_time?(@daily_reminder_time)
35
32
  problems << 'a reminder time in 24-hour HH:MM format'
36
33
  end
37
-
38
- if problems.empty?
39
- save_display_mode
40
- current_reader.update_comment_notification_level!(@comment_notification_level) if @comment_notifications_available
41
- if @daily_reminders_available
42
- current_reader.update!(daily_reminders: requested_daily_reminders,
43
- daily_reminder_time: requested_daily_reminder_time)
44
- end
45
- redirect_to profile_path, notice: 'Your profile has been updated.'
46
- else
47
- @names = { first_name: params[:first_name], last_name: params[:last_name] }
48
- @daily_reminders = requested_daily_reminders
49
- @daily_reminder_time = requested_daily_reminder_time
50
- flash.now[:alert] = "Please give #{problems.to_sentence}."
51
- render :edit, status: :unprocessable_entity
52
- end
34
+ return render_profile_error("Please give #{problems.to_sentence}.") if problems.any?
35
+
36
+ save_profile(names.merge(passage_source: source))
37
+ redirect_to profile_path, notice: 'Your profile has been updated.'
38
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
39
+ message = e.record ? e.record.errors.full_messages.to_sentence : nil
40
+ # Reload does not discard Active Storage's deferred attachment changes.
41
+ current_reader.attachment_changes.clear if Reader.avatar_uploads?
42
+ current_reader.reload
43
+ render_profile_error("Your profile could not be saved. #{message.presence || 'Please try again.'}")
53
44
  end
54
45
 
55
46
  def remove_avatar
@@ -63,11 +54,41 @@ module Bible270
63
54
  return unless Reader.display_mode_column?
64
55
  return if Reader::DISPLAY_MODES.include?(@display_mode)
65
56
 
66
- 'a display preference from the list'
57
+ 'a theme from the list'
58
+ end
59
+
60
+ def avatar_problem
61
+ upload = params[:avatar]
62
+ return if upload.blank?
63
+ return 'a valid image' unless Reader.avatar_uploads?
64
+
65
+ Avatars.problem_with(content_type: upload.content_type, byte_size: upload.size)
66
+ end
67
+
68
+ def save_profile(attributes)
69
+ attributes[:bible_version] = Translations.normalize(params[:bible_version]) if params[:bible_version].present?
70
+ attributes[:display_mode] = @display_mode if Reader.display_mode_column?
71
+ if @comment_notifications_available
72
+ attributes[:notify_on_mention] = @comment_notification_level != 'none'
73
+ attributes[:notify_on_all_comments] = @comment_notification_level == 'all'
74
+ end
75
+ if @daily_reminders_available
76
+ attributes[:daily_reminders] = @daily_reminders
77
+ attributes[:daily_reminder_time] = @daily_reminder_time
78
+ end
79
+
80
+ Reader.transaction do
81
+ current_reader.assign_attributes(attributes)
82
+ # Unlike attach, assignment never saves early. Active Storage persists the
83
+ # attachment with the reader and uploads only after a successful commit.
84
+ current_reader.avatar = params[:avatar] if params[:avatar].present?
85
+ current_reader.save!
86
+ end
67
87
  end
68
88
 
69
- def save_display_mode
70
- current_reader.update!(display_mode: @display_mode) if Reader.display_mode_column?
89
+ def render_profile_error(message)
90
+ flash.now[:alert] = message
91
+ render :edit, status: :unprocessable_entity
71
92
  end
72
93
 
73
94
  def load_comment_notification_state
@@ -5,7 +5,7 @@ module Bible270
5
5
  def index
6
6
  # Community is a directory of fellow readers, not a leaderboard. Keep the
7
7
  # order predictable without implying that progress determines standing.
8
- @readers = Reader.all.to_a.sort_by(&:sort_name)
8
+ @readers = Reader.with_avatar.to_a.sort_by(&:sort_name)
9
9
  @today_day = current_reader&.today_day
10
10
  @show_admin_progress = Bible270.config.admin?(current_reader)
11
11
  @days_completed = Reader.completed_days_by_id if @show_admin_progress
@@ -47,9 +47,7 @@ module Bible270
47
47
  private
48
48
 
49
49
  def recent_reflections(reader, limit:)
50
- comments = reader.comments.recent
51
- .includes(:reader, { likes: :reader }, { replies: [:reader, { likes: :reader }] })
52
- .limit(limit).to_a
50
+ comments = reader.comments.recent.with_thread_details.limit(limit).to_a
53
51
  ids = comments.map(&:id)
54
52
  # A reader's own replies already appear beneath any listed parent reflection.
55
53
  comments.reject { |comment| ids.include?(comment.parent_id) }
@@ -159,10 +159,11 @@ module Bible270
159
159
  reader.avatar_url.presence
160
160
  end
161
161
 
162
- def b270_avatar(reader, size: 72)
162
+ def b270_avatar(reader, size: 72, loading: 'lazy')
163
163
  src = b270_avatar_src(reader)
164
164
  if src
165
- image_tag src, class: 'b270-avatar', width: size, height: size, alt: reader.display_name
165
+ image_tag src, class: 'b270-avatar', width: size, height: size, alt: reader.display_name,
166
+ loading: loading, decoding: 'async'
166
167
  else
167
168
  content_tag :span, reader.initials, class: 'b270-avatar b270-avatar-fallback',
168
169
  style: "width:#{size}px;height:#{size}px;line-height:#{size}px"
@@ -181,12 +182,16 @@ module Bible270
181
182
  local&.strftime('%b %-d, %Y at %-I:%M %p')
182
183
  end
183
184
 
185
+ def b270_activity_datetime(time)
186
+ local = Bible270.local_time(time)
187
+ local&.strftime('%b %-d, %Y %H:%M')
188
+ end
189
+
184
190
  def b270_reader_activity_description(activity)
185
191
  day_link = link_to "Day #{activity.day}", day_path(activity.day), title: b270_day_readings(activity.day)
186
192
 
187
193
  if activity.day_completed?
188
- readings = Plan.readings_for(activity.day).values.compact.join(', ')
189
- safe_join(['completed ', day_link, " (#{readings})"])
194
+ safe_join(['completed ', day_link])
190
195
  else
191
196
  reference = Plan.parts_for(activity.day, activity.track).fetch(activity.part)
192
197
  safe_join(["checked off “#{reference}” for ", day_link])
@@ -52,9 +52,14 @@ module Bible270
52
52
  # Only the reflections themselves: replies are rendered under their parent, so
53
53
  # listing them again at the top level would show each twice.
54
54
  scope :for_day, ->(d) { approved.where(day: d, parent_id: nil).order(created_at: :asc) }
55
- scope :threads_for_day, ->(d) {
56
- for_day(d).includes(:reader, { likes: :reader }, { replies: [:reader, { likes: :reader }] })
55
+ scope :with_reader_avatars, -> {
56
+ Reader.avatar_uploads? ? includes(reader: { avatar_attachment: :blob }) : includes(:reader)
57
57
  }
58
+ scope :with_thread_details, -> {
59
+ reply_reader = Reader.avatar_uploads? ? { reader: { avatar_attachment: :blob } } : :reader
60
+ with_reader_avatars.includes({ likes: :reader }, { replies: [reply_reader, { likes: :reader }] })
61
+ }
62
+ scope :threads_for_day, ->(d) { for_day(d).with_thread_details }
58
63
  scope :recent, -> { approved.order(created_at: :desc) }
59
64
 
60
65
  def self.body_format_column?
@@ -95,9 +100,7 @@ module Bible270
95
100
  page = page.to_i.clamp(1, pages)
96
101
  page_rows = ordered.slice((page - 1) * per_page, per_page) || []
97
102
  ids = page_rows.map(&:first)
98
- loaded = approved.where(id: ids)
99
- .includes(:reader, { likes: :reader }, { replies: [:reader, { likes: :reader }] })
100
- .index_by(&:id)
103
+ loaded = approved.where(id: ids).with_thread_details.index_by(&:id)
101
104
 
102
105
  ThreadPage.new(
103
106
  threads: ids.filter_map { |id| loaded[id] },
@@ -19,6 +19,8 @@ module Bible270
19
19
 
20
20
  def self.avatar_uploads? = AVATAR_UPLOADS
21
21
 
22
+ scope :with_avatar, -> { avatar_uploads? ? includes(avatar_attachment: :blob) : all }
23
+
22
24
  # Fires for every way a reader comes into existence — email sign-in, OmniAuth,
23
25
  # or a bridged host user — rather than in each controller, so no path can
24
26
  # quietly skip it. after_create_commit, so nothing is sent for a reader whose
@@ -27,7 +29,7 @@ module Bible270
27
29
 
28
30
  PASSAGE_SOURCES = %w[bible_com bible_gateway blue_letter_bible].freeze
29
31
  DEFAULT_PASSAGE_SOURCE = 'bible_com'
30
- DISPLAY_MODES = %w[light dark system].freeze
32
+ DISPLAY_MODES = %w[light dark dark_green dark_blue system].freeze
31
33
  DEFAULT_DISPLAY_MODE = 'system'
32
34
  DAILY_REMINDER_TIME_FORMAT = %r{\A(?:[01]\d|2[0-3]):[0-5]\d\z}
33
35
  DAILY_REMINDER_MINUTES = %w[00 15 30 45].freeze
@@ -87,7 +89,7 @@ module Bible270
87
89
  .having('COUNT(*) >= ?', Plan.total_parts(day))
88
90
  .order(Checkoff.arel_table[:created_at].maximum.asc, :reader_id)
89
91
  .pluck(:reader_id)
90
- readers_by_id = where(id: reader_ids).index_by(&:id)
92
+ readers_by_id = with_avatar.where(id: reader_ids).index_by(&:id)
91
93
  reader_ids.filter_map { |reader_id| readers_by_id[reader_id] }
92
94
  end
93
95
 
@@ -96,7 +96,7 @@
96
96
  </div>
97
97
  <div class="sub b270-reader-activity">
98
98
  <% if (activity = @recent_reader_activities[reader.id]) %>
99
- <%= b270_date(activity.occurred_at) %>: <%= b270_reader_activity_description(activity) %>
99
+ <%= b270_activity_datetime(activity.occurred_at) %>: <%= b270_reader_activity_description(activity) %>
100
100
  <% else %>
101
101
  No reading activity yet
102
102
  <% end %>
@@ -3,7 +3,7 @@
3
3
  <p class="b270-eyebrow"><%= link_to 'Admin', admin_path %></p>
4
4
  <h1><%= @reader.display_name %></h1>
5
5
  <p class="lede">
6
- <%= @reader.email.presence || @reader.provider %> ·
6
+ <%= @reader.email.present? ? mail_to(@reader.email) : @reader.provider %> ·
7
7
  <%= @days_completed %> of <%= Bible270::Plan::DAYS %> days ·
8
8
  <%= @reader.completion_percent %>%
9
9
  <% if @reader.today_day %>· day <%= @reader.today_day %> today
@@ -16,15 +16,21 @@
16
16
  <%= form_with url: admin_reader_profile_path(@reader), method: :patch, class: "b270-emailform",
17
17
  html: { multipart: true } do %>
18
18
  <div class="b270-avatarrow">
19
- <%= b270_avatar(@reader, size: 72) %>
19
+ <%= b270_avatar(@reader, size: 72, loading: 'eager') %>
20
20
  <% if Bible270::Reader.avatar_uploads? %>
21
- <label class="b270-field" style="flex:1">
22
- <span>Picture</span>
23
- <%= file_field_tag :avatar, accept: Bible270::Avatars.content_types.join(',') %>
24
- <span class="b270-hint" style="margin:0">
25
- PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes) %>.
26
- </span>
27
- </label>
21
+ <div class="b270-avatarcontrols" style="flex:1;min-width:0">
22
+ <label class="b270-field">
23
+ <span>Picture</span>
24
+ <%= file_field_tag :avatar, accept: Bible270::Avatars.content_types.join(',') %>
25
+ <span class="b270-hint" style="margin:0">
26
+ PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes) %>.
27
+ </span>
28
+ </label>
29
+ <% if @reader.avatar_uploaded? %>
30
+ <%= button_tag "Remove picture", type: "submit", class: "b270-linkbtn",
31
+ form: "b270-remove-picture" %>
32
+ <% end %>
33
+ </div>
28
34
  <% end %>
29
35
  </div>
30
36
 
@@ -40,9 +46,54 @@
40
46
  <% end %>
41
47
 
42
48
  <% if @reader.avatar_uploaded? %>
43
- <%= button_to "Remove picture", admin_reader_avatar_path(@reader), method: :delete,
44
- class: 'b270-linkbtn',
45
- form: { data: { turbo_confirm: "Remove #{@reader.display_name}'s picture?" } } %>
49
+ <%# Keep picture removal separate from unsaved name and upload changes. %>
50
+ <%= form_with url: admin_reader_avatar_path(@reader), method: :delete,
51
+ html: { id: "b270-remove-picture", hidden: true },
52
+ data: { turbo_confirm: "Remove #{@reader.display_name}'s picture?" } do %>
53
+ <% end %>
54
+ <% end %>
55
+ </div>
56
+
57
+ <div class="b270-panel">
58
+ <h2 class="b270-subhead">Which translation they read</h2>
59
+ <p class="b270-hint" style="margin-top:0">
60
+ Reading links open in this translation.
61
+ <% if @reader.bible_version.blank? %>
62
+ They have expressed no preference, so they follow the site default
63
+ (<%= Bible270.config.bible_version %>).
64
+ <% end %>
65
+ </p>
66
+
67
+ <%= form_with url: admin_reader_version_path(@reader), method: :patch, class: "b270-form" do %>
68
+ <div class="row">
69
+ <%= select_tag :bible_version,
70
+ options_for_select([["Site default (#{Bible270.config.bible_version})", ""]] +
71
+ Bible270::Translations.options,
72
+ @reader.bible_version.to_s),
73
+ class: "b270-select", style: "width:auto" %>
74
+ </div>
75
+ <%= render "bible270/shared/passage_source", reader: @reader %>
76
+ <%= submit_tag "Save", class: "b270-btn" %>
77
+ <% end %>
78
+ </div>
79
+
80
+ <div class="b270-panel">
81
+ <h2 class="b270-subhead" id="b270-theme-heading">Theme</h2>
82
+ <% if Bible270::Reader.display_mode_column? %>
83
+ <%= form_with url: admin_reader_theme_path(@reader), method: :patch, class: "b270-emailform" do %>
84
+ <%= select_tag :display_mode,
85
+ options_for_select([["Parchment", "light"], ["Earth", "dark"], ["Forest", "dark_green"],
86
+ ["Midnight", "dark_blue"], ["System", "system"]], @reader.effective_display_mode),
87
+ class: "b270-select", aria: { labelledby: "b270-theme-heading", describedby: "b270-theme-hint" } %>
88
+ <p id="b270-theme-hint" class="b270-hint" style="margin:0">
89
+ System follows the reader's device setting: Parchment for light, Earth for dark.
90
+ </p>
91
+ <%= submit_tag "Save", class: "b270-btn" %>
92
+ <% end %>
93
+ <% else %>
94
+ <p class="b270-flash alert" style="margin:0">
95
+ Run the pending Bible270 database migration before changing the theme.
96
+ </p>
46
97
  <% end %>
47
98
  </div>
48
99
 
@@ -119,28 +170,6 @@
119
170
  <%= render "bible270/shared/reflections", comments: @comments, moderate: true, editable: true, actions: true %>
120
171
  </div>
121
172
 
122
- <div class="b270-panel">
123
- <h2 class="b270-subhead">Which translation they read</h2>
124
- <p class="b270-hint" style="margin-top:0">
125
- Reading links open in this translation.
126
- <% if @reader.bible_version.blank? %>
127
- They have expressed no preference, so they follow the site default
128
- (<%= Bible270.config.bible_version %>).
129
- <% end %>
130
- </p>
131
-
132
- <%= form_with url: admin_reader_version_path(@reader), method: :patch, class: "b270-form" do %>
133
- <div class="row">
134
- <%= select_tag :bible_version,
135
- options_for_select([["Site default (#{Bible270.config.bible_version})", ""]] +
136
- Bible270::Translations.options,
137
- @reader.bible_version.to_s),
138
- class: "b270-select", style: "width:auto" %>
139
- </div>
140
- <%= render "bible270/shared/passage_source", reader: @reader %>
141
- <%= submit_tag "Save", class: "b270-btn" %>
142
- <% end %>
143
- </div>
144
173
 
145
174
  <div class="b270-panel">
146
175
  <h2 class="b270-subhead">Completions</h2>
@@ -20,7 +20,7 @@
20
20
  <%= f.select :track,
21
21
  options_for_select([["The whole day", ""]] + Bible270::Plan::TRACKS.map { |k, v| [v[:label], k] },
22
22
  comment.track.to_s),
23
- {}, class: "b270-select", style: "width:auto" %>
23
+ {}, class: "b270-select", style: "width:auto", aria: { label: "Reading this reflection relates to" } %>
24
24
  <% end %>
25
25
  <%= f.submit "Save", class: "b270-btn" %>
26
26
  <%= link_to "Cancel", day_path(comment.day, anchor: "comment-#{comment.id}"), class: "b270-linkbtn" %>
@@ -1,6 +1,6 @@
1
1
  <%# locals: (comment:) %>
2
2
  <div class="b270-comment b270-editing" id="comment-<%= comment.id %>">
3
- <%= b270_avatar(comment.reader) %>
3
+ <%= b270_avatar(comment.reader, loading: 'eager') %>
4
4
  <div style="flex:1">
5
5
  <%= render partial: "bible270/comments/edit_fields", locals: { comment: comment } %>
6
6
  </div>
@@ -25,7 +25,8 @@
25
25
  <div class="row">
26
26
  <%= f.select :track,
27
27
  options_for_select([["The whole day", ""]] + Bible270::Plan::TRACKS.map { |k, v| [v[:label], k] }),
28
- {}, style: "font:inherit;padding:8px;border:1px solid var(--line);border-radius:8px;background:#fff" %>
28
+ {}, aria: { label: "Reading this reflection relates to" },
29
+ style: "font:inherit;padding:8px;border:1px solid var(--line);border-radius:8px;background:#fff" %>
29
30
  <%= f.submit "Post reflection", class: "b270-btn" %>
30
31
  </div>
31
32
  <% end %>
@@ -8,15 +8,21 @@
8
8
  <%= form_with url: profile_path, method: :patch, class: "b270-emailform",
9
9
  html: { multipart: true } do %>
10
10
  <div class="b270-avatarrow">
11
- <%= b270_avatar(current_reader, size: 72) %>
11
+ <%= b270_avatar(current_reader, size: 72, loading: 'eager') %>
12
12
  <% if Bible270::Reader.avatar_uploads? %>
13
- <label class="b270-field" style="flex:1">
14
- <span>Picture</span>
15
- <%= file_field_tag :avatar, accept: Bible270::Avatars.content_types.join(',') %>
16
- <span class="b270-hint" style="margin:0">
17
- PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes) %>.
18
- </span>
19
- </label>
13
+ <div class="b270-avatarcontrols" style="flex:1;min-width:0">
14
+ <label class="b270-field">
15
+ <span>Picture</span>
16
+ <%= file_field_tag :avatar, accept: Bible270::Avatars.content_types.join(',') %>
17
+ <span class="b270-hint" style="margin:0">
18
+ PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes) %>.
19
+ </span>
20
+ </label>
21
+ <% if current_reader.avatar_uploaded? %>
22
+ <%= button_tag "Remove picture", type: "submit", class: "b270-linkbtn",
23
+ form: "b270-remove-picture" %>
24
+ <% end %>
25
+ </div>
20
26
  <% end %>
21
27
  </div>
22
28
 
@@ -40,15 +46,16 @@
40
46
 
41
47
  <% if Bible270::Reader.display_mode_column? %>
42
48
  <label class="b270-field">
43
- <span>Display</span>
49
+ <span>Theme</span>
44
50
  <%= select_tag :display_mode,
45
- options_for_select([["Light", "light"], ["Dark", "dark"], ["System", "system"]], @display_mode),
51
+ options_for_select([["Parchment", "light"], ["Earth", "dark"], ["Forest", "dark_green"],
52
+ ["Midnight", "dark_blue"], ["System", "system"]], @display_mode),
46
53
  class: "b270-select", aria: { describedby: "b270-display-hint" } %>
47
- <span id="b270-display-hint" class="b270-hint" style="margin:0">System follows your device's light or dark setting.</span>
54
+ <span id="b270-display-hint" class="b270-hint" style="margin:0">System follows your device's setting: Parchment for light, Earth for dark.</span>
48
55
  </label>
49
56
  <% else %>
50
57
  <p class="b270-flash alert" style="margin:0">
51
- Display settings are temporarily unavailable while this installation finishes a database update.
58
+ Theme settings are temporarily unavailable while this installation finishes a database update.
52
59
  </p>
53
60
  <% end %>
54
61
 
@@ -63,6 +70,13 @@
63
70
  <% end %>
64
71
 
65
72
  <% if @daily_reminders_available %>
73
+ <% reminder_local_time = Bible270.local_time(Time.now) %>
74
+ <% reminder_zone = if reminder_local_time.respond_to?(:time_zone)
75
+ reminder_local_time.time_zone.tzinfo.name
76
+ else
77
+ ["Server time zone", reminder_local_time.zone.presence].compact.join(" — ")
78
+ end %>
79
+ <% reminder_tooltip = "Uses the plan's time zone: #{reminder_zone}, not your device's time zone. You can turn reminders off here at any time." %>
66
80
  <% reminder_hour, reminder_minute = @daily_reminder_time.to_s.split(":", 2) %>
67
81
  <% reminder_minutes = (Bible270::Reader::DAILY_REMINDER_MINUTES + [reminder_minute]).compact.uniq.sort %>
68
82
  <div class="b270-field">
@@ -73,7 +87,7 @@
73
87
  aria: { describedby: "daily-reminder-optin-hint" } %>
74
88
  <span>Email me each day's readings</span>
75
89
  <span class="b270-reminder-tooltip" id="daily-reminder-optin-hint" role="tooltip">
76
- Uses the plan's configured time zone. You can turn reminders off here at any time.
90
+ <%= reminder_tooltip %>
77
91
  </span>
78
92
  </label>
79
93
  <div class="b270-reminder-time">
@@ -88,11 +102,14 @@
88
102
  options_for_select(reminder_minutes, reminder_minute),
89
103
  class: "b270-time-select", required: true, aria: { label: "Minute" } %>
90
104
  <span class="b270-reminder-tooltip" id="daily-reminder-time-hint" role="tooltip">
91
- Uses the plan's configured time zone. You can turn reminders off here at any time.
105
+ <%= reminder_tooltip %>
92
106
  </span>
93
107
  </span>
94
108
  </div>
95
109
  </div>
110
+ <span id="daily-reminder-zone" class="b270-hint" style="margin:0;overflow-wrap:anywhere">
111
+ Plan time zone: <%= reminder_zone %>
112
+ </span>
96
113
  </div>
97
114
  <% elsif Bible270.config.daily_reminders? %>
98
115
  <p class="b270-flash alert" style="margin:0">
@@ -104,8 +121,11 @@
104
121
  <% end %>
105
122
 
106
123
  <% if current_reader.avatar_uploaded? %>
107
- <%= button_to "Remove picture", profile_avatar_path, method: :delete, class: "b270-linkbtn",
108
- form: { data: { turbo_confirm: "Remove your picture?" } } %>
124
+ <%# The picture button targets this separate form so it never submits profile edits. %>
125
+ <%= form_with url: profile_avatar_path, method: :delete,
126
+ html: { id: "b270-remove-picture", hidden: true },
127
+ data: { turbo_confirm: "Remove your picture?" } do %>
128
+ <% end %>
109
129
  <% end %>
110
130
 
111
131
  <% unless Bible270::Reader.avatar_uploads? %>
@@ -1,7 +1,7 @@
1
1
  <% content_for :title, "#{@reader.display_name} — #{Bible270.config.app_name}" %>
2
2
  <% content_for :hide_day_index, true %><%# this page has its own grid %>
3
3
  <div style="display:flex;align-items:center;gap:16px;margin-bottom:8px">
4
- <%= b270_avatar(@reader, size: 72) %>
4
+ <%= b270_avatar(@reader, size: 72, loading: 'eager') %>
5
5
  <div>
6
6
  <h1 style="margin:0"><%= @reader.display_name %></h1>
7
7
  <% if signed_in? && current_reader.id == @reader.id %>
@@ -20,8 +20,13 @@
20
20
  try { return area.getItem(key); } catch (_error) { return null; }
21
21
  },
22
22
  set(area, key, value) {
23
- if (!area) return;
24
- try { area.setItem(key, value); } catch (_error) { /* Drafts degrade safely. */ }
23
+ if (!area) return false;
24
+ try {
25
+ area.setItem(key, value);
26
+ return true;
27
+ } catch (_error) {
28
+ return false;
29
+ }
25
30
  },
26
31
  remove(area, key) {
27
32
  if (!area) return;
@@ -49,7 +54,7 @@
49
54
 
50
55
  function setStatus(form, message) {
51
56
  const status = form.querySelector("[data-b270-draft-status]");
52
- if (status) status.textContent = message;
57
+ if (status && status.textContent !== message) status.textContent = message;
53
58
  }
54
59
 
55
60
  function save(form) {
@@ -81,8 +86,9 @@
81
86
  savedAt: Date.now(),
82
87
  revision: `${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}`
83
88
  };
84
- storage.set(local, key, JSON.stringify(draft));
85
- setStatus(form, "Draft saved in this browser.");
89
+ const saved = storage.set(local, key, JSON.stringify(draft));
90
+ setStatus(form, saved ? "Draft saved in this browser." :
91
+ "Draft couldn’t be saved. Keep this page open until posting.");
86
92
  }
87
93
 
88
94
  function settlePending(key = null) {
@@ -29,18 +29,38 @@
29
29
  (() => {
30
30
  if (window.Bible270NavigationMenu) return;
31
31
 
32
+ function setExpanded(menu, expanded) {
33
+ const toggle = menu && menu.querySelector("button.b270-navtoggle");
34
+ const links = menu && menu.querySelector(".b270-navlinks");
35
+ if (!toggle || !links) return;
36
+
37
+ toggle.setAttribute("aria-expanded", String(expanded));
38
+ links.hidden = !expanded;
39
+ }
40
+
32
41
  document.addEventListener("click", (event) => {
33
42
  const target = event.target;
43
+ document.querySelectorAll(".b270-navmenu").forEach((menu) => {
44
+ if (!menu.contains(target)) setExpanded(menu, false);
45
+ });
46
+
34
47
  const toggle = target.closest && target.closest("button.b270-navtoggle");
35
48
  if (!toggle) return;
36
49
 
37
- const menu = toggle.closest(".b270-navmenu");
38
- const links = menu && menu.querySelector(".b270-navlinks");
39
- if (!links) return;
50
+ setExpanded(toggle.closest(".b270-navmenu"), toggle.getAttribute("aria-expanded") !== "true");
51
+ });
52
+
53
+ document.addEventListener("keydown", (event) => {
54
+ if (event.key !== "Escape" || event.defaultPrevented) return;
55
+
56
+ const menu = event.target.closest && event.target.closest(".b270-navmenu");
57
+ const toggle = menu && menu.querySelector("button.b270-navtoggle");
58
+ if (!toggle || toggle.getAttribute("aria-expanded") !== "true") return;
40
59
 
41
- const opening = toggle.getAttribute("aria-expanded") !== "true";
42
- toggle.setAttribute("aria-expanded", String(opening));
43
- links.hidden = !opening;
60
+ const restoreFocus = menu.contains(document.activeElement);
61
+ setExpanded(menu, false);
62
+ event.preventDefault();
63
+ if (restoreFocus) toggle.focus();
44
64
  });
45
65
 
46
66
  window.Bible270NavigationMenu = true;
@@ -32,6 +32,36 @@
32
32
  --alert-surface:#42282a; --alert-ink:#ffc0b8; --alert-line:#a66d68;
33
33
  --notice-surface:#25382a; --notice-ink:#b6dfb6; --notice-line:#6d956e;
34
34
  }
35
+ .b270[data-display-mode="dark_green"]{
36
+ color-scheme:dark;
37
+ background-image:none;
38
+ --control-line:#7c9280;
39
+ --paper:#1c3027; --card:#17291f; --ink:#d9decb; --muted:#bdc9b5; --faint:#a6b69e;
40
+ --line:#3c5045; --line-soft:#304638; --teal:#83c4b3; --oxblood:#df9aa6; --gold:#d8c184;
41
+ --gold-lite:#62501e; --shadow:0 1px 0 rgba(0,0,0,.1);
42
+ --control:#101c17; --task-surface:rgba(255,255,255,.025); --button-hover:#1e3829;
43
+ --on-accent:#e3e7d5; --complete-ink:#e3e7d5; --complete-dot:#e3e7d5;
44
+ --button-surface:#102219; --button-ink:#e3e7d5;
45
+ --teal-fill:#28554b; --oxblood-fill:#6a2c3c; --gold-fill:#62501e;
46
+ --teal-mark:#4d9d89; --oxblood-mark:#c46c7f; --gold-mark:#b79b45;
47
+ --alert-surface:#3e282c; --alert-ink:#ffc1c2; --alert-line:#a87479;
48
+ --notice-surface:#243b2b; --notice-ink:#c1e2b9; --notice-line:#779c70;
49
+ }
50
+ .b270[data-display-mode="dark_blue"]{
51
+ color-scheme:dark;
52
+ background-image:none;
53
+ --control-line:#7c8da7;
54
+ --paper:#182537; --card:#121d2e; --ink:#d1dbe8; --muted:#b6c3d5; --faint:#9dadc3;
55
+ --line:#36465d; --line-soft:#29394e; --teal:#81bfca; --oxblood:#df9caf; --gold:#d5bd82;
56
+ --gold-lite:#5d4c25; --shadow:0 1px 0 rgba(0,0,0,.1);
57
+ --control:#0b1422; --task-surface:rgba(255,255,255,.025); --button-hover:#20334d;
58
+ --on-accent:#e0e7ef; --complete-ink:#e0e7ef; --complete-dot:#e0e7ef;
59
+ --button-surface:#0c1829; --button-ink:#e0e7ef;
60
+ --teal-fill:#245363; --oxblood-fill:#622d49; --gold-fill:#5d4c25;
61
+ --teal-mark:#4c9eae; --oxblood-mark:#b86382; --gold-mark:#b59a54;
62
+ --alert-surface:#3b2432; --alert-ink:#ffc0cd; --alert-line:#a66c80;
63
+ --notice-surface:#1e3430; --notice-ink:#b7decc; --notice-line:#6b9787;
64
+ }
35
65
  @media (prefers-color-scheme:dark){
36
66
  .b270[data-display-mode="system"]{
37
67
  color-scheme:dark;
data/config/routes.rb CHANGED
@@ -23,6 +23,7 @@ Bible270::Engine.routes.draw do
23
23
  get 'admin/readers/:id', to: 'admin#show', as: :admin_reader
24
24
  delete 'admin/readers/:id', to: 'admin#destroy'
25
25
  patch 'admin/readers/:id/profile', to: 'admin#update_profile', as: :admin_reader_profile
26
+ patch 'admin/readers/:id/theme', to: 'admin#update_theme', as: :admin_reader_theme
26
27
  patch 'admin/readers/:id/notifications', to: 'admin#update_notifications', as: :admin_reader_notifications
27
28
  patch 'admin/readers/:id/completion-animation', to: 'admin#update_completion_animation',
28
29
  as: :admin_reader_completion_animation
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '1.9.1'
4
+ VERSION = '1.10.1'
5
5
  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: 1.9.1
4
+ version: 1.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft