bible270 1.4.0 → 1.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -3
- data/app/controllers/bible270/admin_controller.rb +79 -29
- data/app/controllers/bible270/checkoffs_controller.rb +20 -16
- data/app/controllers/bible270/days_controller.rb +2 -9
- data/app/models/bible270/reader.rb +29 -0
- data/app/views/bible270/admin/show.html.erb +23 -0
- data/app/views/bible270/checkoffs/toggle.turbo_stream.erb +1 -1
- data/app/views/bible270/days/_completers.html.erb +2 -3
- data/app/views/bible270/days/_day_progress.html.erb +4 -2
- data/app/views/bible270/days/show.html.erb +3 -4
- data/app/views/bible270/shared/_day_completion_dove.html.erb +7 -5
- data/app/views/bible270/shared/_interaction_ui.html.erb +64 -0
- data/app/views/bible270/shared/_styles.html.erb +13 -9
- data/config/routes.rb +3 -0
- data/db/migrate/20260101000020_add_completion_dove_disabled_to_bible270_readers.rb +7 -0
- data/lib/bible270/migration_reconciler.rb +5 -0
- data/lib/bible270/version.rb +1 -1
- data/lib/generators/bible270/install/install_generator.rb +130 -42
- metadata +2 -2
- data/app/views/bible270/shared/_day_completion_effect.html.erb +0 -67
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8792007d09956f4b53cbe9842007baea209a5eb0010758ef35e7d6f1977928f0
|
|
4
|
+
data.tar.gz: 0c5d16299136af62a6e1466210e962ef6e8d48065d759d9b5b57450215471c31
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 89cbba3ff6adfc107efacddb9b2d80903452488384983b488ecf733aa6e16d55451a0137626cda5a446591b2c28585201f49b612927a6ba80ad0b54a5889c377
|
|
7
|
+
data.tar.gz: d2d501c3f9935405bf0b850124f2329b3c5355ccfdf5d7403688329a65f1bfdc4a779fc3d65d957eba242363393e6a057519ef509b337986f6426bb2a3c6ffdc
|
data/CHANGELOG.md
CHANGED
|
@@ -6,15 +6,50 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
|
|
|
6
6
|
|
|
7
7
|
### Added
|
|
8
8
|
|
|
9
|
+
- let administrators disable the day-completion dove animation for individual readers
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- simplify day completion to one continuous server-confirmed dove flight from the lower center, eliminating the stagger between a pre-submit animation and the Turbo or HTML response
|
|
14
|
+
- keep the dove bright in dark mode, grow it to an 80%-of-viewport fit, and finish with a quick fade instead of veering offscreen
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- show the dove only once for each persisted day-completion event, even when Turbo replays a response or retries a stale-session request
|
|
19
|
+
|
|
20
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.0...main)**
|
|
21
|
+
|
|
22
|
+
## [1.4.0] - 2026-09-02
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
9
26
|
- mark a newly completed day with a brief, translucent dove animation that begins immediately on the final checkoff in JavaScript-capable browsers and remains available after Turbo or HTML-fallback responses; it respects reduced-motion preferences, replaces the final generic success message, and never blocks interaction
|
|
10
27
|
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- refine the completion animation to 1.6 seconds with smoother easing, limit checkoff transitions to relevant visual properties, and make reduced-motion transitions effectively instantaneous
|
|
31
|
+
|
|
11
32
|
### Fixed
|
|
12
33
|
|
|
13
|
-
- correct Day 27's New Testament reading to include Matthew 26:75
|
|
14
|
-
- silently recover all Turbo interactions from stale sessions, retry once with a fresh CSRF token, and reload rather than submit with a known-stale token if refreshing fails
|
|
15
34
|
- keep repeated or concurrent explicit checkoffs idempotent instead of raising a duplicate-validation Rails 422 error
|
|
16
35
|
|
|
17
|
-
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.3.0
|
|
36
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.3.2...v1.4.0)**
|
|
37
|
+
|
|
38
|
+
## [1.3.2] - 2026-09-02
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- silently recover all Turbo interactions from stale sessions, retry once with a fresh CSRF token, and reload rather than submit with a known-stale token if refreshing fails
|
|
43
|
+
|
|
44
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.3.1...v1.3.2)**
|
|
45
|
+
|
|
46
|
+
## [1.3.1] - 2026-09-01
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- correct Day 27's New Testament reading to include Matthew 26:75
|
|
51
|
+
|
|
52
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.3.0...v1.3.1)**
|
|
18
53
|
|
|
19
54
|
## [1.3.0] - 2026-08-29
|
|
20
55
|
|
|
@@ -596,6 +631,9 @@ This is the initial public release.
|
|
|
596
631
|
|
|
597
632
|
* Email sign-in requires working Action Mailer delivery in the host application. Set `config.mailer_from`; delivery is inline by default, or set `email_sign_in_deliver_later` when a queue backend is available.
|
|
598
633
|
|
|
634
|
+
[1.4.0]: https://github.com/avonderluft/bible270/releases/tag/v1.4.0
|
|
635
|
+
[1.3.2]: https://github.com/avonderluft/bible270/releases/tag/v1.3.2
|
|
636
|
+
[1.3.1]: https://github.com/avonderluft/bible270/releases/tag/v1.3.1
|
|
599
637
|
[1.3.0]: https://github.com/avonderluft/bible270/releases/tag/v1.3.0
|
|
600
638
|
[1.2.0]: https://github.com/avonderluft/bible270/releases/tag/v1.2.0
|
|
601
639
|
[1.1.7]: https://github.com/avonderluft/bible270/releases/tag/v1.1.7
|
|
@@ -13,19 +13,22 @@ module Bible270
|
|
|
13
13
|
|
|
14
14
|
before_action :require_admin!
|
|
15
15
|
before_action :load_reader,
|
|
16
|
-
only: %i[show destroy update_start update_profile update_notifications
|
|
17
|
-
toggle_day]
|
|
16
|
+
only: %i[show destroy update_start update_profile update_notifications update_completion_animation
|
|
17
|
+
remove_avatar complete_through toggle_day]
|
|
18
18
|
before_action :load_comment, only: %i[hide_comment unhide_comment destroy_comment]
|
|
19
19
|
|
|
20
20
|
# ---- enrolment --------------------------------------------------------
|
|
21
21
|
|
|
22
22
|
def update_enrollment
|
|
23
|
-
|
|
23
|
+
case params[:state]
|
|
24
|
+
when 'closed'
|
|
24
25
|
Setting.close_enrollment!
|
|
25
26
|
redirect_to admin_path, notice: 'Closed to new readers. Existing readers can still sign in.'
|
|
26
|
-
|
|
27
|
+
when 'open'
|
|
27
28
|
Setting.open_enrollment!
|
|
28
29
|
redirect_to admin_path, notice: 'Open to new readers.'
|
|
30
|
+
else
|
|
31
|
+
redirect_to admin_path, alert: 'Choose whether enrollment is open or closed. Nothing was changed.'
|
|
29
32
|
end
|
|
30
33
|
end
|
|
31
34
|
|
|
@@ -71,6 +74,7 @@ module Bible270
|
|
|
71
74
|
|
|
72
75
|
def show
|
|
73
76
|
@comment_notifications_available = comment_notifications_available_for?(@reader)
|
|
77
|
+
@completion_dove_preference_available = completion_dove_available_for(@reader)
|
|
74
78
|
@days_completed = @reader.days_completed
|
|
75
79
|
@comments = @reader.comments.order(created_at: :desc).limit(50)
|
|
76
80
|
end
|
|
@@ -122,12 +126,23 @@ module Bible270
|
|
|
122
126
|
recipients = Reader.where.not(email: [nil, '']).order(:id)
|
|
123
127
|
return redirect_to(admin_path, alert: 'Nobody has an email address.') if recipients.empty?
|
|
124
128
|
|
|
129
|
+
recipient_count = recipients.count
|
|
125
130
|
sent = deliver_broadcast(recipients, subject, body)
|
|
131
|
+
later = Bible270.config.registration_notice_deliver_later
|
|
132
|
+
|
|
133
|
+
if sent.zero?
|
|
134
|
+
action = later ? 'queued' : 'sent'
|
|
135
|
+
return redirect_to admin_path, alert: "The message could not be #{action} to any readers."
|
|
136
|
+
end
|
|
137
|
+
|
|
126
138
|
Setting.write(LAST_BROADCAST_AT, Time.current.iso8601)
|
|
127
139
|
Setting.write(LAST_BROADCAST_SUBJECT, subject)
|
|
128
140
|
|
|
129
|
-
|
|
130
|
-
|
|
141
|
+
action = later ? 'Queued for' : 'Sent to'
|
|
142
|
+
failures = recipient_count - sent
|
|
143
|
+
notice = "#{action} #{sent} #{'reader'.pluralize(sent)}."
|
|
144
|
+
notice += " #{failures} #{'reader'.pluralize(failures)} could not be #{later ? 'queued' : 'sent'}." if failures.positive?
|
|
145
|
+
redirect_to admin_path, notice: notice
|
|
131
146
|
end
|
|
132
147
|
|
|
133
148
|
def update_bible_version
|
|
@@ -169,9 +184,14 @@ module Bible270
|
|
|
169
184
|
redirect_to admin_reader_path(@reader),
|
|
170
185
|
alert: "Couldn't read #{params[:start_date].inspect} as a date."
|
|
171
186
|
end
|
|
172
|
-
elsif params[:day].present?
|
|
173
|
-
|
|
174
|
-
|
|
187
|
+
elsif params[:day].present?
|
|
188
|
+
day = strict_integer(params[:day], 1..Plan::DAYS)
|
|
189
|
+
if day && @reader.restart_on!(day: day)
|
|
190
|
+
redirect_to admin_reader_path(@reader),
|
|
191
|
+
notice: "#{@reader.display_name} is now on day #{day}."
|
|
192
|
+
else
|
|
193
|
+
redirect_to admin_reader_path(@reader), alert: 'Give a start date or a day between 1 and 270.'
|
|
194
|
+
end
|
|
175
195
|
else
|
|
176
196
|
redirect_to admin_reader_path(@reader), alert: 'Give a start date or a day between 1 and 270.'
|
|
177
197
|
end
|
|
@@ -181,12 +201,15 @@ module Bible270
|
|
|
181
201
|
# themselves on their own profile.
|
|
182
202
|
def update_profile
|
|
183
203
|
problems = []
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
problems
|
|
204
|
+
@reader.transaction do
|
|
205
|
+
if (params[:first_name].present? || params[:last_name].present?) && !@reader.update_names(params[:first_name],
|
|
206
|
+
params[:last_name])
|
|
207
|
+
problems << 'both a first and last name'
|
|
208
|
+
end
|
|
209
|
+
if problems.empty? && params[:avatar].present? && !@reader.attach_avatar(params[:avatar])
|
|
210
|
+
problems << (@reader.errors[:avatar].first || 'a valid image')
|
|
211
|
+
end
|
|
212
|
+
raise ActiveRecord::Rollback if problems.any?
|
|
190
213
|
end
|
|
191
214
|
|
|
192
215
|
if problems.empty?
|
|
@@ -214,28 +237,40 @@ module Bible270
|
|
|
214
237
|
redirect_to admin_reader_path(@reader), notice: 'Reflection email setting updated.'
|
|
215
238
|
end
|
|
216
239
|
|
|
240
|
+
def update_completion_animation
|
|
241
|
+
unless completion_dove_available_for(@reader)
|
|
242
|
+
alert = 'Run the pending Bible270 database migration before changing the completion animation.'
|
|
243
|
+
return redirect_to(admin_reader_path(@reader), alert: alert)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
@reader.update!(completion_dove_disabled: params[:completion_dove_disabled] == '1')
|
|
247
|
+
redirect_to admin_reader_path(@reader), notice: 'Completion animation setting updated.'
|
|
248
|
+
end
|
|
249
|
+
|
|
217
250
|
def remove_avatar
|
|
218
251
|
@reader.remove_avatar!
|
|
219
252
|
redirect_to admin_reader_path(@reader), notice: "Removed #{@reader.display_name}'s picture."
|
|
220
253
|
end
|
|
221
254
|
|
|
222
255
|
def complete_through
|
|
223
|
-
day = params[:day]
|
|
224
|
-
|
|
225
|
-
redirect_to admin_reader_path(@reader),
|
|
226
|
-
notice: day.zero? ? 'All check-offs cleared.' : "Marked days 1-#{day} complete."
|
|
227
|
-
else
|
|
228
|
-
redirect_to admin_reader_path(@reader), alert: 'Give a day between 0 and 270.'
|
|
256
|
+
day = strict_integer(params[:day], 0..Plan::DAYS)
|
|
257
|
+
unless day
|
|
258
|
+
return redirect_to admin_reader_path(@reader), alert: 'Give a day between 0 and 270.'
|
|
229
259
|
end
|
|
260
|
+
|
|
261
|
+
@reader.mark_through!(day)
|
|
262
|
+
redirect_to admin_reader_path(@reader),
|
|
263
|
+
notice: day.zero? ? 'All check-offs cleared.' : "Marked days 1-#{day} complete."
|
|
230
264
|
end
|
|
231
265
|
|
|
232
266
|
def toggle_day
|
|
233
|
-
day = params[:day]
|
|
234
|
-
|
|
235
|
-
redirect_to admin_reader_path(@reader,
|
|
236
|
-
else
|
|
237
|
-
redirect_to admin_reader_path(@reader), alert: 'That day is outside the plan.'
|
|
267
|
+
day = strict_integer(params[:day], 1..Plan::DAYS)
|
|
268
|
+
unless day
|
|
269
|
+
return redirect_to admin_reader_path(@reader), alert: 'That day is outside the plan.'
|
|
238
270
|
end
|
|
271
|
+
|
|
272
|
+
@reader.toggle_day!(day)
|
|
273
|
+
redirect_to admin_reader_path(@reader, anchor: "day-#{day}")
|
|
239
274
|
end
|
|
240
275
|
|
|
241
276
|
# Failures are counted rather than raised: one bad address must not stop the
|
|
@@ -274,6 +309,12 @@ module Bible270
|
|
|
274
309
|
true
|
|
275
310
|
end
|
|
276
311
|
|
|
312
|
+
def completion_dove_available_for(reader)
|
|
313
|
+
available = Reader.completion_dove_column?
|
|
314
|
+
reader.reload if available && !reader.has_attribute?('completion_dove_disabled')
|
|
315
|
+
available
|
|
316
|
+
end
|
|
317
|
+
|
|
277
318
|
def require_admin!
|
|
278
319
|
return if Bible270.config.admin_configured? && Bible270.config.admin?(current_reader)
|
|
279
320
|
|
|
@@ -282,16 +323,25 @@ module Bible270
|
|
|
282
323
|
|
|
283
324
|
def load_comment
|
|
284
325
|
@comment = Comment.find_by(id: params[:id])
|
|
285
|
-
|
|
326
|
+
return if @comment
|
|
327
|
+
|
|
328
|
+
redirect_to admin_comments_path, alert: 'Reflection not found.'
|
|
286
329
|
end
|
|
287
330
|
|
|
288
331
|
def redirect_back_to_comments(notice)
|
|
289
|
-
redirect_to(params[:return_to]
|
|
332
|
+
redirect_to(url_from(params[:return_to]) || admin_comments_path, notice: notice)
|
|
290
333
|
end
|
|
291
334
|
|
|
292
335
|
def load_reader
|
|
293
336
|
@reader = Reader.find_by(id: params[:id])
|
|
294
|
-
|
|
337
|
+
return if @reader
|
|
338
|
+
|
|
339
|
+
redirect_to admin_path, alert: 'Reader not found.'
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def strict_integer(value, range)
|
|
343
|
+
integer = Integer(value.to_s, 10, exception: false)
|
|
344
|
+
integer if range.cover?(integer)
|
|
295
345
|
end
|
|
296
346
|
end
|
|
297
347
|
end
|
|
@@ -18,25 +18,27 @@ module Bible270
|
|
|
18
18
|
requested_state = params[:checked] if params.key?(:checked)
|
|
19
19
|
head :bad_request and return if params.key?(:checked) && !%w[0 1].include?(requested_state)
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
# Serialize this reader's transitions so two different final portions
|
|
22
|
+
# submitted concurrently cannot both announce the same day completion.
|
|
23
|
+
marked_read = nil
|
|
24
|
+
current_reader.with_lock do
|
|
25
|
+
marked_read, @completion_event_id = apply_checkoff(requested_state)
|
|
26
|
+
@reader = current_reader.reload.reload_progress
|
|
27
|
+
@day_just_completed = marked_read && @completion_event_id && @reader.day_complete?(@day)
|
|
28
|
+
end
|
|
25
29
|
@reader_tracks = @reader.read_tracks_for(@day)
|
|
26
30
|
@readings = Plan.readings_for(@day)
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
completer_ids = Checkoff.where(day: @day)
|
|
30
|
-
.group(:reader_id)
|
|
31
|
-
.having('COUNT(*) >= ?', required)
|
|
32
|
-
.pluck(:reader_id)
|
|
33
|
-
@completers = Reader.where(id: completer_ids).order(:display_name)
|
|
32
|
+
@completers = Reader.completers_for(@day)
|
|
34
33
|
|
|
35
34
|
respond_to do |format|
|
|
36
35
|
format.turbo_stream
|
|
37
36
|
format.html do
|
|
38
37
|
if @day_just_completed
|
|
39
|
-
redirect_to day_path(@day), flash: {
|
|
38
|
+
redirect_to day_path(@day), flash: {
|
|
39
|
+
b270_day_just_completed: @day,
|
|
40
|
+
b270_completion_event_id: @completion_event_id
|
|
41
|
+
}
|
|
40
42
|
else
|
|
41
43
|
message = marked_read ? 'Reading marked read.' : 'Reading marked unread.'
|
|
42
44
|
redirect_to day_path(@day), flash: { b270_interaction_status: message }
|
|
@@ -51,9 +53,10 @@ module Bible270
|
|
|
51
53
|
existing = find_checkoff
|
|
52
54
|
if requested_state == '1'
|
|
53
55
|
current_reader.ensure_started!
|
|
54
|
-
[true, existing.nil?
|
|
56
|
+
[true, existing.nil? ? create_checkoff : nil]
|
|
55
57
|
elsif requested_state == '0' || existing
|
|
56
|
-
|
|
58
|
+
existing&.destroy
|
|
59
|
+
[false, nil]
|
|
57
60
|
else
|
|
58
61
|
current_reader.ensure_started!
|
|
59
62
|
[true, create_checkoff]
|
|
@@ -66,15 +69,16 @@ module Bible270
|
|
|
66
69
|
end
|
|
67
70
|
end
|
|
68
71
|
|
|
72
|
+
# The created id is stable if Turbo replays this response, while a later
|
|
73
|
+
# re-completion creates a new id and is correctly treated as a new event.
|
|
69
74
|
def create_checkoff
|
|
70
|
-
Checkoff.create!(reader: current_reader, day: @day, track: @track, part: @part)
|
|
71
|
-
true
|
|
75
|
+
Checkoff.create!(reader: current_reader, day: @day, track: @track, part: @part).id
|
|
72
76
|
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
|
|
73
77
|
# A repeated or concurrent desired-state request is already successful. Do
|
|
74
78
|
# not hide any other validation failure under the same recovery.
|
|
75
79
|
raise unless find_checkoff
|
|
76
80
|
|
|
77
|
-
|
|
81
|
+
nil
|
|
78
82
|
end
|
|
79
83
|
end
|
|
80
84
|
end
|
|
@@ -21,16 +21,9 @@ module Bible270
|
|
|
21
21
|
@new_comment = Comment.new(day: @day, parent_id: reply_parent&.id)
|
|
22
22
|
@reader_tracks = current_reader ? current_reader.read_tracks_for(@day) : []
|
|
23
23
|
@day_just_completed = flash[:b270_day_just_completed].to_i == @day
|
|
24
|
+
@completion_event_id = flash[:b270_completion_event_id] if @day_just_completed
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
# chapters is three rows, so counting tracks would call someone finished
|
|
27
|
-
# who had only read the Old Testament.
|
|
28
|
-
required = Plan.total_parts(@day)
|
|
29
|
-
completer_ids = Checkoff.where(day: @day)
|
|
30
|
-
.group(:reader_id)
|
|
31
|
-
.having('COUNT(*) >= ?', required)
|
|
32
|
-
.pluck(:reader_id)
|
|
33
|
-
@completers = Reader.where(id: completer_ids).order(:display_name)
|
|
26
|
+
@completers = Reader.completers_for(@day)
|
|
34
27
|
end
|
|
35
28
|
|
|
36
29
|
private
|
|
@@ -33,6 +33,7 @@ module Bible270
|
|
|
33
33
|
REFLECTIONS_SEEN_COLUMN = 'reflections_seen_at'
|
|
34
34
|
COMMENT_NOTIFICATION_LEVELS = %w[all personal none].freeze
|
|
35
35
|
COMMENT_NOTIFICATION_COLUMNS = %w[notify_on_mention notify_on_all_comments].freeze
|
|
36
|
+
COMPLETION_DOVE_COLUMN = 'completion_dove_disabled'
|
|
36
37
|
MENTION_SUGGESTION_LIMIT = 5
|
|
37
38
|
|
|
38
39
|
validates :display_name, presence: true
|
|
@@ -64,6 +65,18 @@ module Bible270
|
|
|
64
65
|
end
|
|
65
66
|
end
|
|
66
67
|
|
|
68
|
+
# A day is completed when its final required checkoff is created, so MAX(created_at)
|
|
69
|
+
# is the completion moment. Keep reader_id as a deterministic tie-breaker.
|
|
70
|
+
def self.completers_for(day)
|
|
71
|
+
reader_ids = Checkoff.where(day: day)
|
|
72
|
+
.group(:reader_id)
|
|
73
|
+
.having('COUNT(*) >= ?', Plan.total_parts(day))
|
|
74
|
+
.order(Checkoff.arel_table[:created_at].maximum.asc, :reader_id)
|
|
75
|
+
.pluck(:reader_id)
|
|
76
|
+
readers_by_id = where(id: reader_ids).index_by(&:id)
|
|
77
|
+
reader_ids.filter_map { |reader_id| readers_by_id[reader_id] }
|
|
78
|
+
end
|
|
79
|
+
|
|
67
80
|
def self.reflections_seen_column?
|
|
68
81
|
column_names.include?(REFLECTIONS_SEEN_COLUMN)
|
|
69
82
|
rescue ActiveRecord::StatementInvalid
|
|
@@ -289,6 +302,22 @@ module Bible270
|
|
|
289
302
|
where(notify_on_all_comments: true).where.not(email: [nil, ''])
|
|
290
303
|
end
|
|
291
304
|
|
|
305
|
+
def self.completion_dove_column?
|
|
306
|
+
return true if column_names.include?(COMPLETION_DOVE_COLUMN)
|
|
307
|
+
|
|
308
|
+
reset_column_information
|
|
309
|
+
column_names.include?(COMPLETION_DOVE_COLUMN)
|
|
310
|
+
rescue ActiveRecord::StatementInvalid
|
|
311
|
+
false
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Keep day completion usable while an upgraded host is waiting to run the
|
|
315
|
+
# preference migration. Historically the dove was enabled, so a missing
|
|
316
|
+
# column preserves that behavior.
|
|
317
|
+
def completion_dove_disabled?
|
|
318
|
+
has_attribute?(COMPLETION_DOVE_COLUMN) && self[COMPLETION_DOVE_COLUMN] == true
|
|
319
|
+
end
|
|
320
|
+
|
|
292
321
|
# Small, deterministic suggestions for the reflection composer. Full-handle
|
|
293
322
|
# collisions are omitted because the server must never guess which reader a
|
|
294
323
|
# mention intended.
|
|
@@ -70,6 +70,29 @@
|
|
|
70
70
|
<% end %>
|
|
71
71
|
</div>
|
|
72
72
|
|
|
73
|
+
<div class="b270-panel">
|
|
74
|
+
<h2 class="b270-subhead">Completion animation</h2>
|
|
75
|
+
<% unless @completion_dove_preference_available %>
|
|
76
|
+
<p class="b270-flash alert" style="margin:0">
|
|
77
|
+
Run the pending Bible270 database migration before changing the completion animation.
|
|
78
|
+
</p>
|
|
79
|
+
<% else %>
|
|
80
|
+
<%= form_with url: admin_reader_completion_animation_path(@reader), method: :patch,
|
|
81
|
+
class: "b270-emailform" do %>
|
|
82
|
+
<div class="b270-field">
|
|
83
|
+
<label class="b270-reminder-optin">
|
|
84
|
+
<%= check_box_tag :completion_dove_disabled, "1", @reader.completion_dove_disabled? %>
|
|
85
|
+
<span>Turn off the dove animation for this reader</span>
|
|
86
|
+
</label>
|
|
87
|
+
</div>
|
|
88
|
+
<p class="b270-hint" style="margin:0">
|
|
89
|
+
Day completion and progress tracking still work normally when the animation is off.
|
|
90
|
+
</p>
|
|
91
|
+
<%= submit_tag 'Save', class: 'b270-btn' %>
|
|
92
|
+
<% end %>
|
|
93
|
+
<% end %>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
73
96
|
<div class="b270-panel">
|
|
74
97
|
<h2 class="b270-subhead">Where they are in the plan</h2>
|
|
75
98
|
<%= form_with url: admin_reader_start_path(@reader), method: :patch, class: "b270-startform" do %>
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
<%= turbo_stream.replace "day_progress_#{@day}" do %>
|
|
7
7
|
<%= render "bible270/days/day_progress", day: @day, reader: @reader,
|
|
8
|
-
just_completed: @day_just_completed %>
|
|
8
|
+
just_completed: @day_just_completed, completion_event_id: @completion_event_id %>
|
|
9
9
|
<% end %>
|
|
10
10
|
|
|
11
11
|
<%= turbo_stream.replace "completers_#{@day}" do %>
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
<div class="b270-completers" id="completers_<%= day %>">
|
|
3
3
|
<% if completers.any? %>
|
|
4
4
|
<span>Finished this day:</span>
|
|
5
|
-
<% completers.
|
|
6
|
-
<%= link_to b270_avatar(reader), reader_path(reader), title: reader.display_name %>
|
|
5
|
+
<% completers.each do |reader| %>
|
|
6
|
+
<%= link_to b270_avatar(reader, size: 56), reader_path(reader), title: reader.display_name %>
|
|
7
7
|
<% end %>
|
|
8
|
-
<% if completers.size > 12 %><span><%= completers.size - 12 %> more</span><% end %>
|
|
9
8
|
<% end %>
|
|
10
9
|
</div>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# locals: (day:, reader:, just_completed: false) %>
|
|
1
|
+
<%# locals: (day:, reader:, just_completed: false, completion_event_id: nil) %>
|
|
2
2
|
<%
|
|
3
3
|
previous_day = day > 1 ? day - 1 : nil
|
|
4
4
|
next_day = day < Bible270::Plan::DAYS ? day + 1 : nil
|
|
@@ -33,7 +33,9 @@
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
<% if complete %>
|
|
36
|
-
|
|
36
|
+
<% if just_completed && completion_event_id && !reader&.completion_dove_disabled? %>
|
|
37
|
+
<%= render "bible270/shared/day_completion_dove", day: day, completion_event_id: completion_event_id %>
|
|
38
|
+
<% end %>
|
|
37
39
|
<div class="b270-day-complete" role="status" aria-live="polite"
|
|
38
40
|
<%= tag.attributes(data: completion_data) %>>
|
|
39
41
|
<div>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<div class="b270-day-page">
|
|
4
4
|
<%= render "bible270/days/day_progress", day: @day, reader: current_reader,
|
|
5
|
-
just_completed: @day_just_completed %>
|
|
5
|
+
just_completed: @day_just_completed, completion_event_id: @completion_event_id %>
|
|
6
6
|
|
|
7
7
|
<%= render "bible270/shared/interaction_status" %>
|
|
8
8
|
|
|
@@ -16,8 +16,6 @@
|
|
|
16
16
|
<% end %>
|
|
17
17
|
|
|
18
18
|
<div class="b270-day-community">
|
|
19
|
-
<%= render "bible270/days/completers", day: @day, completers: @completers %>
|
|
20
|
-
|
|
21
19
|
<h2 class="b270-section-h">Reflections</h2>
|
|
22
20
|
<div id="comments">
|
|
23
21
|
<%= render partial: "bible270/comments/comment", collection: @comments, as: :comment %>
|
|
@@ -30,8 +28,9 @@
|
|
|
30
28
|
<%= render "bible270/shared/comment_drafts" %>
|
|
31
29
|
<%= render "bible270/shared/mention_typeahead" %>
|
|
32
30
|
<% end %>
|
|
31
|
+
|
|
32
|
+
<%= render "bible270/days/completers", day: @day, completers: @completers %>
|
|
33
33
|
</div>
|
|
34
34
|
</div>
|
|
35
35
|
|
|
36
36
|
<%= render "bible270/shared/interaction_ui" %>
|
|
37
|
-
<%= render "bible270/shared/day_completion_effect", day: @day %>
|
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
<%# locals: (day:) %>
|
|
1
|
+
<%# locals: (day:, completion_event_id:) %>
|
|
2
2
|
<svg class="b270-completion-dove" data-b270-completion-dove data-b270-day="<%= day %>"
|
|
3
|
+
data-b270-completion-event="<%= completion_event_id %>" data-turbo-temporary
|
|
3
4
|
viewBox="0 0 260 200" aria-hidden="true" focusable="false">
|
|
4
5
|
<defs>
|
|
5
6
|
<radialGradient id="b270-dove-light" cx="50%" cy="43%" r="68%">
|
|
6
|
-
<stop offset="0%" stop-color="#fff" />
|
|
7
|
-
<stop offset="58%" stop-color="#fffef8" />
|
|
8
|
-
<stop offset="100%" stop-color="#fff9e2" />
|
|
7
|
+
<stop class="b270-dove-light-center" offset="0%" stop-color="#fff" />
|
|
8
|
+
<stop class="b270-dove-light-middle" offset="58%" stop-color="#fffef8" />
|
|
9
|
+
<stop class="b270-dove-light-edge" offset="100%" stop-color="#fff9e2" />
|
|
9
10
|
</radialGradient>
|
|
10
11
|
</defs>
|
|
11
12
|
<g class="b270-dove-silhouette">
|
|
12
13
|
<path d="M122 94C103 82 87 69 72 53L39 17l11 28-23-23 15 32-23-20 19 30-24-16 22 26-25-12 27 23-24-7 30 19-22-3 31 17c20 11 42 16 59 8 5-9 8-18 10-25Z" />
|
|
13
14
|
<path d="M138 94c19-12 35-25 50-41l33-36-11 28 23-23-15 32 23-20-19 30 24-16-22 26 25-12-27 23 24-7-30 19 22-3-31 17c-20 11-42 16-59 8-5-9-8-18-10-25Z" />
|
|
14
15
|
<path d="M148 140c6 7 16 14 24 19 2 2 1 5-2 6-5 2-10-1-14-5 4 8 7 17 4 22-2 3-5 1-7-2l-6-11c3 10 2 19-3 24-2 3-5 1-6-3l-4-15c0 11-3 21-8 24-3 2-5-1-6-5l-1-19c-3 10-6 17-11 20-3 2-5-1-5-5l2-19c-4 8-9 13-14 14-3 1-5-2-4-6l6-18c-5 5-11 7-16 4-3-1-3-4 0-7 7-7 17-13 26-19Z" />
|
|
15
|
-
<path
|
|
16
|
+
<path class="b270-dove-beak" d="M140 80C144 80 148 80.8 151 82 152 82.4 152 83.2 151 83.8 148 85.1 144 85.8 140 85 138.5 83.5 138.5 81.2 140 80Z" />
|
|
17
|
+
<path d="M108 116c5-10 9-17 14-17-1-11-2-23 4-29 5-4 12-3 15 1 2 3 2 6 1 9 1 1 1 3 0 4-1 5-3 10-5 15 6 3 11 9 15 17-1 15-6 28-16 39-3 4-7 7-10 9-4-2-8-5-11-9-9-11-14-25-11-37 1-1 2-2 4-2Z" />
|
|
16
18
|
</g>
|
|
17
19
|
</svg>
|
|
@@ -18,6 +18,53 @@
|
|
|
18
18
|
region.dataset.state = state;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
const shownCompletionDoves = new Set();
|
|
22
|
+
|
|
23
|
+
function claimCompletionDove(dove) {
|
|
24
|
+
const eventId = dove.dataset.b270CompletionEvent;
|
|
25
|
+
if (!eventId) return false;
|
|
26
|
+
|
|
27
|
+
const key = `b270-completion-dove:${eventId}`;
|
|
28
|
+
if (shownCompletionDoves.has(key)) return false;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
if (window.sessionStorage.getItem(key) === "shown") return false;
|
|
32
|
+
window.sessionStorage.setItem(key, "shown");
|
|
33
|
+
} catch (_error) {
|
|
34
|
+
// Some privacy modes disable session storage. The in-memory set still
|
|
35
|
+
// prevents duplicate Turbo rendering within the current page.
|
|
36
|
+
}
|
|
37
|
+
shownCompletionDoves.add(key);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function initializeCompletionDove(dove) {
|
|
42
|
+
if (dove.dataset.b270DoveInitialized === "true") return;
|
|
43
|
+
dove.dataset.b270DoveInitialized = "true";
|
|
44
|
+
|
|
45
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
46
|
+
dove.remove();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!claimCompletionDove(dove)) {
|
|
51
|
+
dove.remove();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Keep an active flight outside the Turbo-replaced progress panel so a
|
|
56
|
+
// replayed response cannot restart or cut short the same completion event.
|
|
57
|
+
if (dove.parentElement !== document.body) document.body.appendChild(dove);
|
|
58
|
+
requestAnimationFrame(() => {
|
|
59
|
+
if (dove.isConnected) dove.classList.add("b270-completion-dove-active");
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function initializeCompletionDoves(root = document) {
|
|
64
|
+
if (root.matches && root.matches("[data-b270-completion-dove]")) initializeCompletionDove(root);
|
|
65
|
+
root.querySelectorAll?.("[data-b270-completion-dove]").forEach(initializeCompletionDove);
|
|
66
|
+
}
|
|
67
|
+
|
|
21
68
|
function resetForm(form) {
|
|
22
69
|
form.removeAttribute("aria-busy");
|
|
23
70
|
form.dataset.b270Submitting = "false";
|
|
@@ -29,8 +76,18 @@
|
|
|
29
76
|
|
|
30
77
|
function reset() {
|
|
31
78
|
document.querySelectorAll("form[data-b270-submit='true']").forEach(resetForm);
|
|
79
|
+
initializeCompletionDoves();
|
|
32
80
|
}
|
|
33
81
|
|
|
82
|
+
const completionDoveObserver = new MutationObserver((mutations) => {
|
|
83
|
+
mutations.forEach((mutation) => {
|
|
84
|
+
mutation.addedNodes.forEach((node) => {
|
|
85
|
+
if (node.nodeType === Node.ELEMENT_NODE) initializeCompletionDoves(node);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
completionDoveObserver.observe(document.documentElement, { childList: true, subtree: true });
|
|
90
|
+
|
|
34
91
|
const SESSION_REFRESH_AFTER = 5 * 60 * 1000;
|
|
35
92
|
let sessionRefreshedAt = Date.now();
|
|
36
93
|
let sessionRefresh = null;
|
|
@@ -201,6 +258,13 @@
|
|
|
201
258
|
}
|
|
202
259
|
});
|
|
203
260
|
document.addEventListener("turbo:load", reset);
|
|
261
|
+
document.addEventListener("turbo:before-cache", () => {
|
|
262
|
+
document.querySelectorAll("[data-b270-completion-dove]").forEach((dove) => dove.remove());
|
|
263
|
+
});
|
|
264
|
+
document.addEventListener("animationend", (event) => {
|
|
265
|
+
if (event.animationName === "b270-dove-flight" &&
|
|
266
|
+
event.target.matches("[data-b270-completion-dove]")) event.target.remove();
|
|
267
|
+
});
|
|
204
268
|
|
|
205
269
|
window.Bible270InteractionUI = { reset };
|
|
206
270
|
reset();
|
|
@@ -147,17 +147,21 @@
|
|
|
147
147
|
.b270-day-complete strong{font-family:"Spectral",serif;font-size:16px}
|
|
148
148
|
.b270-day-complete span{font-size:12px;color:var(--muted)}
|
|
149
149
|
.b270 .b270-day-complete .b270-btn{flex:none;margin:0 0 0 auto}
|
|
150
|
-
.b270-completion-dove{position:fixed;z-index:1000;top:
|
|
151
|
-
width:min(
|
|
150
|
+
.b270-completion-dove{position:fixed;z-index:1000;top:72vh;left:50vw;
|
|
151
|
+
width:min(80vw,104vh);height:auto;overflow:visible;pointer-events:none;opacity:0;
|
|
152
|
+
color-scheme:only light;forced-color-adjust:none;
|
|
152
153
|
filter:drop-shadow(0 0 8px rgba(255,255,255,1)) drop-shadow(0 0 24px rgba(255,252,220,1)) drop-shadow(0 0 52px rgba(225,191,92,.82));
|
|
153
|
-
will-change:top,left,transform,opacity
|
|
154
|
-
.b270-dove-
|
|
154
|
+
will-change:top,left,transform,opacity}
|
|
155
|
+
.b270-completion-dove-active{animation:b270-dove-flight 1.7s cubic-bezier(.18,.72,.28,1) both}
|
|
156
|
+
.b270-dove-silhouette{fill:url(#b270-dove-light)!important;stroke:none}
|
|
157
|
+
.b270-dove-light-center{stop-color:#fff!important}
|
|
158
|
+
.b270-dove-light-middle{stop-color:#fffef8!important}
|
|
159
|
+
.b270-dove-light-edge{stop-color:#fff9e2!important}
|
|
155
160
|
@keyframes b270-dove-flight{
|
|
156
|
-
0%{top:
|
|
157
|
-
8%{top:calc(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
100%{top:-25vh;left:115vw;opacity:0;visibility:hidden;transform:translate(-50%,-50%) scale(1.55) rotate(12deg)}
|
|
161
|
+
0%{top:72vh;left:50vw;opacity:.22;visibility:visible;transform:translate(-50%,-50%) scale(.02) rotate(-8deg)}
|
|
162
|
+
8%{top:calc(72vh - 10px);left:50vw;opacity:.78;transform:translate(-50%,-50%) scale(.08) rotate(-5deg)}
|
|
163
|
+
33%{top:50vh;left:50vw;opacity:1;visibility:visible;transform:translate(-50%,-50%) scale(1) rotate(4deg)}
|
|
164
|
+
100%{top:50vh;left:50vw;opacity:0;visibility:hidden;transform:translate(-50%,-50%) scale(1) rotate(4deg)}
|
|
161
165
|
}
|
|
162
166
|
|
|
163
167
|
/* readings */
|
data/config/routes.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Rails routes are intentionally kept in one cohesive DSL block.
|
|
3
4
|
Bible270::Engine.routes.draw do
|
|
4
5
|
root to: 'days#index'
|
|
5
6
|
|
|
@@ -22,6 +23,8 @@ Bible270::Engine.routes.draw do
|
|
|
22
23
|
delete 'admin/readers/:id', to: 'admin#destroy'
|
|
23
24
|
patch 'admin/readers/:id/profile', to: 'admin#update_profile', as: :admin_reader_profile
|
|
24
25
|
patch 'admin/readers/:id/notifications', to: 'admin#update_notifications', as: :admin_reader_notifications
|
|
26
|
+
patch 'admin/readers/:id/completion-animation', to: 'admin#update_completion_animation',
|
|
27
|
+
as: :admin_reader_completion_animation
|
|
25
28
|
delete 'admin/readers/:id/avatar', to: 'admin#remove_avatar', as: :admin_reader_avatar
|
|
26
29
|
patch 'admin/readers/:id/version', to: 'admin#update_bible_version', as: :admin_reader_version
|
|
27
30
|
patch 'admin/readers/:id/start', to: 'admin#update_start', as: :admin_reader_start
|
|
@@ -29,6 +29,7 @@ module Bible270
|
|
|
29
29
|
'AddReflectionsSeenAtToBible270Readers' => :check_reflections_seen_at,
|
|
30
30
|
'AddAllCommentNoticesToBible270Readers' => :check_all_comment_notices,
|
|
31
31
|
'ChangePassageSourceDefaultToBibleCom' => :check_passage_source_default,
|
|
32
|
+
'AddCompletionDoveDisabledToBible270Readers' => :check_completion_dove_disabled,
|
|
32
33
|
'CreateActiveStorageTables' => :check_active_storage
|
|
33
34
|
}.freeze
|
|
34
35
|
|
|
@@ -204,6 +205,10 @@ module Bible270
|
|
|
204
205
|
column&.default == 'bible_com' ? [] : ['column bible270_readers.passage_source default bible_com']
|
|
205
206
|
end
|
|
206
207
|
|
|
208
|
+
def check_completion_dove_disabled
|
|
209
|
+
table_missing(:bible270_readers, columns: %i[completion_dove_disabled])
|
|
210
|
+
end
|
|
211
|
+
|
|
207
212
|
# The Bible270 installer can install Active Storage for reader avatars. A
|
|
208
213
|
# copied database can therefore have the same timestamp mismatch for Rails'
|
|
209
214
|
# generated Active Storage migration as it has for the engine migrations.
|
data/lib/bible270/version.rb
CHANGED
|
@@ -18,10 +18,12 @@ module Bible270
|
|
|
18
18
|
# --mailer-from=no-reply@example.org
|
|
19
19
|
#
|
|
20
20
|
class InstallGenerator < Rails::Generators::Base
|
|
21
|
-
MOUNT_LINE
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
MOUNT_LINE = 'mount Bible270::Engine, at: Bible270.config.mount_at'
|
|
22
|
+
MOUNT_LINE_PATTERN = %r{^\s*mount\s*\(?\s*Bible270::Engine\b}
|
|
23
|
+
PROVIDER_IDENTIFIER = %r{\A[a-z][a-z0-9_]*\z}
|
|
24
|
+
DEFAULT_MOUNT = '/daily-bread'
|
|
25
|
+
DEFAULT_PROVIDERS = 'github'
|
|
26
|
+
KNOWN_PROVIDERS = {
|
|
25
27
|
'github' => 'omniauth-github',
|
|
26
28
|
'google_oauth2' => 'omniauth-google-oauth2',
|
|
27
29
|
'facebook' => 'omniauth-facebook',
|
|
@@ -97,18 +99,23 @@ module Bible270
|
|
|
97
99
|
def add_strategy_gems
|
|
98
100
|
return if @providers.empty?
|
|
99
101
|
|
|
100
|
-
missing = @providers.filter_map { |
|
|
101
|
-
|
|
102
|
+
missing = @providers.filter_map { |provider| KNOWN_PROVIDERS[provider] }
|
|
103
|
+
.reject { |gem_name| gemfile_has?(gem_name) }
|
|
104
|
+
@unknown_providers = @providers.reject { |provider| KNOWN_PROVIDERS.key?(provider) }
|
|
105
|
+
@strategy_gems_added = missing.any?
|
|
102
106
|
|
|
103
107
|
missing.each { |name| gem name }
|
|
104
108
|
|
|
105
|
-
return if
|
|
109
|
+
return if @unknown_providers.empty?
|
|
106
110
|
|
|
107
|
-
|
|
111
|
+
identifiers = @unknown_providers.map { |provider| provider.to_sym.inspect }.join(', ')
|
|
112
|
+
say " Unrecognised provider(s): #{@unknown_providers.join(', ')}.", :yellow
|
|
113
|
+
say " Add an OmniAuth strategy gem that registers #{identifiers} to the Gemfile, then run bundle install.",
|
|
114
|
+
:yellow
|
|
108
115
|
end
|
|
109
116
|
|
|
110
117
|
def install_bundle
|
|
111
|
-
return
|
|
118
|
+
return unless @strategy_gems_added
|
|
112
119
|
|
|
113
120
|
@bundled = decide(:bundle, 'Run bundle install now (needed before the app can boot)?', true)
|
|
114
121
|
run 'bundle install' if @bundled
|
|
@@ -121,7 +128,16 @@ module Bible270
|
|
|
121
128
|
# rather than assuming a fresh app has it.
|
|
122
129
|
|
|
123
130
|
def ensure_active_storage
|
|
124
|
-
|
|
131
|
+
if skip_rails_commands?
|
|
132
|
+
@active_storage = if !active_storage_enabled?
|
|
133
|
+
:unavailable
|
|
134
|
+
elsif active_storage_installed?
|
|
135
|
+
:present
|
|
136
|
+
else
|
|
137
|
+
:pending
|
|
138
|
+
end
|
|
139
|
+
return
|
|
140
|
+
end
|
|
125
141
|
|
|
126
142
|
unless active_storage_enabled?
|
|
127
143
|
@active_storage = :unavailable
|
|
@@ -153,11 +169,15 @@ module Bible270
|
|
|
153
169
|
# Migrations run before the initializers are written, so a problem in the
|
|
154
170
|
# generated config can never block the database work.
|
|
155
171
|
def copy_migrations
|
|
156
|
-
|
|
172
|
+
if skip_rails_commands?
|
|
173
|
+
@migrations_copied = false
|
|
174
|
+
return
|
|
175
|
+
end
|
|
157
176
|
|
|
158
177
|
say ''
|
|
159
178
|
say 'Copying migrations into db/migrate', :yellow
|
|
160
179
|
rails_command 'bible270:install:migrations'
|
|
180
|
+
@migrations_copied = true
|
|
161
181
|
end
|
|
162
182
|
|
|
163
183
|
def run_migrations
|
|
@@ -191,7 +211,11 @@ module Bible270
|
|
|
191
211
|
|
|
192
212
|
def add_route
|
|
193
213
|
if routes_mounted?
|
|
194
|
-
|
|
214
|
+
if move_mount_above_catch_all
|
|
215
|
+
say ' config/routes.rb already mounted Bible270::Engine; moved it above the catch-all route.', :yellow
|
|
216
|
+
else
|
|
217
|
+
say " config/routes.rb already mounts Bible270::Engine — left as is.#{cms_order_hint}", :yellow
|
|
218
|
+
end
|
|
195
219
|
return
|
|
196
220
|
end
|
|
197
221
|
|
|
@@ -202,7 +226,9 @@ module Bible270
|
|
|
202
226
|
# across files, an unusual layout, a missing file). Verify rather than
|
|
203
227
|
# assume, since a silent miss leaves the plan unreachable.
|
|
204
228
|
if routes_mounted?
|
|
205
|
-
|
|
229
|
+
moved = move_mount_above_catch_all
|
|
230
|
+
detail = moved ? ' Mounted above the catch-all route.' : cms_order_hint
|
|
231
|
+
say " Mounted in config/routes.rb.#{detail}", :green
|
|
206
232
|
else
|
|
207
233
|
@route_failed = true
|
|
208
234
|
say ' Could not edit config/routes.rb automatically.', :red
|
|
@@ -220,7 +246,7 @@ module Bible270
|
|
|
220
246
|
say " Plan mounted at #{@mount_at}"
|
|
221
247
|
say " Sign-in #{auth_summary}"
|
|
222
248
|
say " Initializers #{initializer_summary}"
|
|
223
|
-
say " Migrations #{
|
|
249
|
+
say " Migrations #{migration_summary}"
|
|
224
250
|
say " Picture uploads #{active_storage_summary}"
|
|
225
251
|
say ''
|
|
226
252
|
|
|
@@ -241,16 +267,13 @@ module Bible270
|
|
|
241
267
|
# bin/rails call fail ("Could not find ... in locally installed gems"), so
|
|
242
268
|
# don't even try.
|
|
243
269
|
def skip_rails_commands?
|
|
244
|
-
return false unless @
|
|
270
|
+
return false unless @strategy_gems_added && @bundled == false
|
|
245
271
|
return true if @warned_unbundled
|
|
246
272
|
|
|
247
273
|
@warned_unbundled = true
|
|
248
274
|
say ''
|
|
249
275
|
say ' Gemfile changed but not bundled, so bin/rails cannot run yet.', :yellow
|
|
250
|
-
say ' Skipping
|
|
251
|
-
say ' bundle install'
|
|
252
|
-
say ' bin/rails bible270:install:migrations'
|
|
253
|
-
say ' bin/rails db:migrate'
|
|
276
|
+
say ' Skipping Active Storage and migration commands until bundle install succeeds.', :yellow
|
|
254
277
|
true
|
|
255
278
|
end
|
|
256
279
|
|
|
@@ -320,10 +343,10 @@ module Bible270
|
|
|
320
343
|
|
|
321
344
|
def ask_mailer_from
|
|
322
345
|
default = "no-reply@#{app_domain}"
|
|
323
|
-
address = prompt('From: address for sign-in emails?', default)
|
|
346
|
+
address = options[:mailer_from] || prompt('From: address for sign-in emails?', default)
|
|
324
347
|
|
|
325
348
|
until valid_email?(address)
|
|
326
|
-
say "
|
|
349
|
+
say " #{address.inspect} doesn't look like an email address.", :red
|
|
327
350
|
address = prompt('From: address for sign-in emails?', default)
|
|
328
351
|
end
|
|
329
352
|
address
|
|
@@ -352,7 +375,15 @@ module Bible270
|
|
|
352
375
|
end
|
|
353
376
|
|
|
354
377
|
def parse_providers(value)
|
|
355
|
-
value.to_s.split(',').map { |
|
|
378
|
+
providers = value.to_s.split(',').map { |provider| provider.strip.downcase.tr('-', '_') }
|
|
379
|
+
.reject(&:empty?).uniq
|
|
380
|
+
invalid = providers.grep_v(PROVIDER_IDENTIFIER)
|
|
381
|
+
if invalid.any?
|
|
382
|
+
say " Ignoring unsafe provider identifier(s): #{invalid.join(', ')}.", :red
|
|
383
|
+
say ' Provider names must start with a letter and contain only lowercase letters, numbers, and underscores.',
|
|
384
|
+
:red
|
|
385
|
+
end
|
|
386
|
+
providers - invalid
|
|
356
387
|
end
|
|
357
388
|
|
|
358
389
|
def app_domain
|
|
@@ -363,7 +394,11 @@ module Bible270
|
|
|
363
394
|
end
|
|
364
395
|
|
|
365
396
|
def providers_literal
|
|
366
|
-
@providers.map { |
|
|
397
|
+
@providers.map { |provider| provider.to_sym.inspect }.join(', ')
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def ruby_literal(value)
|
|
401
|
+
value.to_s.dump
|
|
367
402
|
end
|
|
368
403
|
|
|
369
404
|
def auth_path_prefix
|
|
@@ -382,15 +417,40 @@ module Bible270
|
|
|
382
417
|
path = File.join(destination_root, 'config', 'routes.rb')
|
|
383
418
|
return '' unless File.exist?(path)
|
|
384
419
|
|
|
385
|
-
|
|
386
|
-
|
|
420
|
+
lines = File.readlines(path)
|
|
421
|
+
mount_index = lines.index { |line| line.match?(MOUNT_LINE_PATTERN) }
|
|
422
|
+
catch_all_index = catch_all_route_index(lines)
|
|
423
|
+
return '' unless catch_all_index && (!mount_index || mount_index > catch_all_index)
|
|
387
424
|
|
|
388
|
-
'
|
|
425
|
+
' Move the mount line ABOVE the catch-all route.'
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def move_mount_above_catch_all
|
|
429
|
+
path = File.join(destination_root, 'config', 'routes.rb')
|
|
430
|
+
lines = File.readlines(path)
|
|
431
|
+
mount_index = lines.index { |line| line.match?(MOUNT_LINE_PATTERN) }
|
|
432
|
+
catch_all_index = catch_all_route_index(lines)
|
|
433
|
+
return false unless mount_index && catch_all_index && mount_index > catch_all_index
|
|
434
|
+
|
|
435
|
+
mount_line = lines.delete_at(mount_index)
|
|
436
|
+
lines.insert(catch_all_index, mount_line)
|
|
437
|
+
File.write(path, lines.join)
|
|
438
|
+
true
|
|
439
|
+
rescue StandardError
|
|
440
|
+
false
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def catch_all_route_index(lines)
|
|
444
|
+
lines.index do |line|
|
|
445
|
+
code = line.sub(%r{#.*\z}, '')
|
|
446
|
+
code.match?(%r{\bcomfy_route\s+:cms\b.*\bpath:\s*['"]/['"]}) ||
|
|
447
|
+
code.match?(%r{\b(?:get|match)\s*\(?\s*['"]\*})
|
|
448
|
+
end
|
|
389
449
|
end
|
|
390
450
|
|
|
391
451
|
def routes_mounted?
|
|
392
452
|
path = File.join(destination_root, 'config', 'routes.rb')
|
|
393
|
-
File.exist?(path) && File.
|
|
453
|
+
File.exist?(path) && File.foreach(path).any? { |line| line.match?(MOUNT_LINE_PATTERN) }
|
|
394
454
|
end
|
|
395
455
|
|
|
396
456
|
def report_auth_choice
|
|
@@ -434,17 +494,30 @@ module Bible270
|
|
|
434
494
|
body.include?('path_prefix') && !body.include?('Bible270.config.auth_path_prefix')
|
|
435
495
|
end
|
|
436
496
|
|
|
497
|
+
def migration_summary
|
|
498
|
+
if @migrations_copied
|
|
499
|
+
@migrated ? 'copied and run' : 'copied (not yet run)'
|
|
500
|
+
else
|
|
501
|
+
'not copied (not run)'
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
437
505
|
def active_storage_summary
|
|
438
506
|
case @active_storage
|
|
439
507
|
when :installed then 'Active Storage installed'
|
|
440
508
|
when :present then 'Active Storage already present'
|
|
441
509
|
when :declined then 'off — Active Storage not installed'
|
|
442
510
|
when :unavailable then 'off — Active Storage not enabled in this app'
|
|
511
|
+
when :pending then 'not installed — waiting for bundle install'
|
|
443
512
|
else 'unchanged'
|
|
444
513
|
end
|
|
445
514
|
end
|
|
446
515
|
|
|
447
516
|
def outstanding_steps
|
|
517
|
+
route_and_initializer_steps + installation_steps + provider_setup_steps + mailer_steps
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def route_and_initializer_steps
|
|
448
521
|
steps = []
|
|
449
522
|
steps << "Add to config/routes.rb: #{MOUNT_LINE}" if @route_failed
|
|
450
523
|
if @providers.any? && !initializer_present?('omniauth.rb')
|
|
@@ -453,22 +526,36 @@ module Bible270
|
|
|
453
526
|
if stale_omniauth_prefix?
|
|
454
527
|
steps << "config/initializers/omniauth.rb sets its own path_prefix — make it 'Bible270.config.auth_path_prefix'"
|
|
455
528
|
end
|
|
456
|
-
steps
|
|
457
|
-
|
|
529
|
+
steps
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def installation_steps
|
|
533
|
+
steps = []
|
|
534
|
+
steps << 'bundle install' if @strategy_gems_added && !@bundled
|
|
535
|
+
steps << unknown_provider_gem_step if @unknown_providers&.any?
|
|
536
|
+
steps << 'bin/rails active_storage:install' if @active_storage == :pending
|
|
537
|
+
steps << 'bin/rails bible270:install:migrations' unless @migrations_copied
|
|
458
538
|
steps << 'bin/rails db:migrate' unless @migrated
|
|
459
539
|
if @active_storage == :declined
|
|
460
540
|
steps << 'bin/rails active_storage:install && bin/rails db:migrate — for picture uploads'
|
|
461
541
|
end
|
|
542
|
+
steps
|
|
543
|
+
end
|
|
462
544
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
545
|
+
def unknown_provider_gem_step
|
|
546
|
+
providers = @unknown_providers.map { |provider| provider.to_sym.inspect }.join(', ')
|
|
547
|
+
"Add an OmniAuth strategy gem registering #{providers} to Gemfile, then run bundle install"
|
|
548
|
+
end
|
|
467
549
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
550
|
+
def provider_setup_steps
|
|
551
|
+
return [] if @providers.empty?
|
|
552
|
+
|
|
553
|
+
['Set each provider\'s client id/secret in credentials or ENV'] +
|
|
554
|
+
@providers.map { |provider| "Register callback URL: https://YOUR-HOST#{auth_path_prefix}/#{provider}/callback" }
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def mailer_steps
|
|
558
|
+
@email ? ['Confirm Action Mailer can deliver mail in this environment'] : []
|
|
472
559
|
end
|
|
473
560
|
|
|
474
561
|
# ---- file bodies ------------------------------------------------------
|
|
@@ -489,7 +576,7 @@ module Bible270
|
|
|
489
576
|
# Change this one value to move the plan. config/routes.rb and
|
|
490
577
|
# config/initializers/omniauth.rb both read it, so nothing else needs editing
|
|
491
578
|
# (but do update the callback URLs registered with each OAuth provider).
|
|
492
|
-
config.mount_at =
|
|
579
|
+
config.mount_at = #{ruby_literal(@mount_at)}
|
|
493
580
|
|
|
494
581
|
config.app_name = 'Daily Bread'
|
|
495
582
|
config.tagline = 'A 270-day journey through Scripture'
|
|
@@ -532,7 +619,7 @@ module Bible270
|
|
|
532
619
|
# Passwordless email sign-in: a one-time link, no password, no third-party
|
|
533
620
|
# account. This is what lets anyone take part. Needs Action Mailer delivery.
|
|
534
621
|
config.email_sign_in = true
|
|
535
|
-
config.mailer_from =
|
|
622
|
+
config.mailer_from = #{ruby_literal(@mailer_from)}
|
|
536
623
|
config.email_sign_in_ask_name = true
|
|
537
624
|
# config.email_sign_in_ttl = 20 * 60 # link lifetime, seconds
|
|
538
625
|
# config.email_sign_in_max_per_window = 5 # per address, per window
|
|
@@ -607,11 +694,12 @@ module Bible270
|
|
|
607
694
|
end
|
|
608
695
|
|
|
609
696
|
def provider_line(provider)
|
|
697
|
+
provider_symbol = provider.to_sym.inspect
|
|
610
698
|
env = provider.upcase
|
|
611
699
|
<<~RUBY.chomp
|
|
612
|
-
provider
|
|
613
|
-
Rails.application.credentials.dig(
|
|
614
|
-
Rails.application.credentials.dig(
|
|
700
|
+
provider #{provider_symbol},
|
|
701
|
+
Rails.application.credentials.dig(#{provider_symbol}, :client_id) || ENV.fetch(#{ruby_literal("#{env}_CLIENT_ID")}, nil),
|
|
702
|
+
Rails.application.credentials.dig(#{provider_symbol}, :client_secret) || ENV.fetch(#{ruby_literal("#{env}_CLIENT_SECRET")}, nil)
|
|
615
703
|
RUBY
|
|
616
704
|
end
|
|
617
705
|
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.4.
|
|
4
|
+
version: 1.4.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew vonderLuft
|
|
@@ -139,7 +139,6 @@ files:
|
|
|
139
139
|
- app/views/bible270/shared/_comment_drafts.html.erb
|
|
140
140
|
- app/views/bible270/shared/_comment_notification_choices.html.erb
|
|
141
141
|
- app/views/bible270/shared/_day_completion_dove.html.erb
|
|
142
|
-
- app/views/bible270/shared/_day_completion_effect.html.erb
|
|
143
142
|
- app/views/bible270/shared/_day_index.html.erb
|
|
144
143
|
- app/views/bible270/shared/_footer.html.erb
|
|
145
144
|
- app/views/bible270/shared/_grid_legend.html.erb
|
|
@@ -176,6 +175,7 @@ files:
|
|
|
176
175
|
- db/migrate/20260101000017_add_reflections_seen_at_to_bible270_readers.rb
|
|
177
176
|
- db/migrate/20260101000018_add_all_comment_notices_to_bible270_readers.rb
|
|
178
177
|
- db/migrate/20260101000019_change_passage_source_default_to_bible_com.rb
|
|
178
|
+
- db/migrate/20260101000020_add_completion_dove_disabled_to_bible270_readers.rb
|
|
179
179
|
- lib/bible270.rb
|
|
180
180
|
- lib/bible270/avatars.rb
|
|
181
181
|
- lib/bible270/configuration.rb
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
<%# locals: (day:) %>
|
|
2
|
-
<template data-b270-completion-dove-template data-b270-day="<%= day %>">
|
|
3
|
-
<%= render "bible270/shared/day_completion_dove", day: day %>
|
|
4
|
-
</template>
|
|
5
|
-
<script nonce="<%= content_security_policy_nonce %>">
|
|
6
|
-
(() => {
|
|
7
|
-
if (window.Bible270DayCompletionEffect) return;
|
|
8
|
-
|
|
9
|
-
function willCompleteDay(form) {
|
|
10
|
-
const desiredState = form.querySelector("input[name='checked']");
|
|
11
|
-
if (!desiredState || desiredState.value !== "1") return false;
|
|
12
|
-
|
|
13
|
-
const unfinished = Array.from(
|
|
14
|
-
document.querySelectorAll("form[data-b270-checkoff='true'] input[name='checked'][value='1']")
|
|
15
|
-
);
|
|
16
|
-
return unfinished.length === 1 && unfinished[0].form === form;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function startDove(day) {
|
|
20
|
-
if (document.querySelector("[data-b270-completion-dove][data-b270-immediate]")) return;
|
|
21
|
-
|
|
22
|
-
const template = document.querySelector("[data-b270-completion-dove-template]");
|
|
23
|
-
if (!template || template.dataset.b270Day !== day) return;
|
|
24
|
-
|
|
25
|
-
const dove = template.content.querySelector("[data-b270-completion-dove]").cloneNode(true);
|
|
26
|
-
dove.dataset.b270Immediate = "true";
|
|
27
|
-
document.body.append(dove);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function removeServerDuplicate() {
|
|
31
|
-
const immediate = document.querySelector("[data-b270-completion-dove][data-b270-immediate]");
|
|
32
|
-
if (!immediate) return;
|
|
33
|
-
|
|
34
|
-
document.querySelectorAll("[data-b270-completion-dove]").forEach((dove) => {
|
|
35
|
-
if (dove !== immediate) dove.remove();
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
document.addEventListener("submit", (event) => {
|
|
40
|
-
const form = event.target.closest("form[data-b270-checkoff='true']");
|
|
41
|
-
const check = form && form.querySelector(".b270-check");
|
|
42
|
-
if (!check) return;
|
|
43
|
-
|
|
44
|
-
const bounds = check.getBoundingClientRect();
|
|
45
|
-
document.documentElement.style.setProperty(
|
|
46
|
-
"--b270-dove-origin-x",
|
|
47
|
-
`${bounds.left + bounds.width / 2}px`
|
|
48
|
-
);
|
|
49
|
-
document.documentElement.style.setProperty(
|
|
50
|
-
"--b270-dove-origin-y",
|
|
51
|
-
`${bounds.top + bounds.height / 2}px`
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
if (willCompleteDay(form)) startDove(form.action.match(/\/day\/(\d+)/)?.[1] || "");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
document.addEventListener("animationend", (event) => {
|
|
58
|
-
if (event.target.matches("[data-b270-completion-dove]")) event.target.remove();
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
new MutationObserver(removeServerDuplicate).observe(document.documentElement, {
|
|
62
|
-
childList: true,
|
|
63
|
-
subtree: true
|
|
64
|
-
});
|
|
65
|
-
window.Bible270DayCompletionEffect = true;
|
|
66
|
-
})();
|
|
67
|
-
</script>
|