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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +196 -237
- data/README.md +159 -79
- data/Rakefile +5 -4
- data/app/controllers/bible270/admin_controller.rb +147 -0
- data/app/controllers/bible270/application_controller.rb +24 -3
- data/app/controllers/bible270/checkoffs_controller.rb +2 -3
- data/app/controllers/bible270/comments_controller.rb +6 -2
- data/app/controllers/bible270/days_controller.rb +5 -6
- data/app/controllers/bible270/profiles_controller.rb +45 -0
- data/app/controllers/bible270/readers_controller.rb +5 -4
- data/app/controllers/bible270/sessions_controller.rb +89 -26
- data/app/helpers/bible270/plan_helper.rb +22 -12
- data/app/mailers/bible270/application_mailer.rb +4 -1
- data/app/mailers/bible270/sign_in_mailer.rb +1 -0
- data/app/models/bible270/application_record.rb +1 -0
- data/app/models/bible270/checkoff.rb +4 -3
- data/app/models/bible270/comment.rb +26 -5
- data/app/models/bible270/reader.rb +167 -17
- data/app/models/bible270/setting.rb +57 -0
- data/app/models/bible270/sign_in_token.rb +5 -2
- data/app/views/bible270/admin/comments.html.erb +44 -0
- data/app/views/bible270/admin/index.html.erb +48 -0
- data/app/views/bible270/admin/show.html.erb +104 -0
- data/app/views/bible270/days/_start_date.html.erb +1 -1
- data/app/views/bible270/days/index.html.erb +9 -21
- data/app/views/bible270/profiles/edit.html.erb +44 -0
- data/app/views/bible270/readers/index.html.erb +2 -2
- data/app/views/bible270/readers/show.html.erb +5 -5
- data/app/views/bible270/sessions/new.html.erb +17 -3
- data/app/views/bible270/shared/_day_index.html.erb +12 -0
- data/app/views/bible270/shared/_header.html.erb +2 -0
- data/app/views/bible270/shared/_styles.html.erb +17 -7
- data/app/views/layouts/bible270/application.html.erb +2 -4
- data/config/routes.rb +38 -18
- data/db/migrate/20260101000001_create_bible270_readers.rb +3 -2
- data/db/migrate/20260101000002_create_bible270_checkoffs.rb +3 -2
- data/db/migrate/20260101000003_create_bible270_comments.rb +4 -3
- data/db/migrate/20260101000004_create_bible270_sign_in_tokens.rb +4 -3
- data/db/migrate/20260101000005_add_names_to_bible270_readers.rb +12 -0
- data/db/migrate/20260101000006_add_approval_to_bible270_comments.rb +11 -0
- data/db/migrate/20260101000007_create_bible270_settings.rb +15 -0
- data/lib/bible270/avatars.rb +46 -0
- data/lib/bible270/configuration.rb +96 -24
- data/lib/bible270/email_sign_in.rb +7 -7
- data/lib/bible270/engine.rb +1 -0
- data/lib/bible270/names.rb +47 -0
- data/lib/bible270/plan.rb +356 -102
- data/lib/bible270/versification.rb +84 -67
- data/lib/bible270/version.rb +2 -1
- data/lib/bible270.rb +8 -5
- data/lib/generators/bible270/install/install_generator.rb +521 -38
- data/lib/generators/bible270/install/templates/bible270.rb.tt +2 -2
- 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 =
|
|
7
|
+
self.table_name = 'bible270_readers'
|
|
7
8
|
|
|
8
|
-
has_many :checkoffs, class_name:
|
|
9
|
-
has_many :comments, class_name:
|
|
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
|
|
24
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
reader.display_name =
|
|
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 ||
|
|
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(
|
|
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
|
-
|
|
188
|
-
|
|
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 =
|
|
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
|
|
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
|
|
4
|
-
<p class="lede">
|
|
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">
|
|
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>
|