bible270 0.6.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -164
  3. data/README.md +287 -272
  4. data/Rakefile +5 -4
  5. data/app/controllers/bible270/admin_controller.rb +119 -0
  6. data/app/controllers/bible270/application_controller.rb +19 -2
  7. data/app/controllers/bible270/checkoffs_controller.rb +2 -3
  8. data/app/controllers/bible270/comments_controller.rb +6 -2
  9. data/app/controllers/bible270/days_controller.rb +5 -6
  10. data/app/controllers/bible270/readers_controller.rb +5 -4
  11. data/app/controllers/bible270/sessions_controller.rb +60 -26
  12. data/app/helpers/bible270/plan_helper.rb +11 -8
  13. data/app/mailers/bible270/application_mailer.rb +4 -1
  14. data/app/mailers/bible270/sign_in_mailer.rb +1 -0
  15. data/app/models/bible270/application_record.rb +1 -0
  16. data/app/models/bible270/checkoff.rb +4 -3
  17. data/app/models/bible270/comment.rb +26 -5
  18. data/app/models/bible270/reader.rb +78 -14
  19. data/app/models/bible270/sign_in_token.rb +5 -2
  20. data/app/views/bible270/admin/comments.html.erb +44 -0
  21. data/app/views/bible270/admin/index.html.erb +30 -0
  22. data/app/views/bible270/admin/show.html.erb +89 -0
  23. data/app/views/bible270/days/index.html.erb +10 -13
  24. data/app/views/bible270/readers/index.html.erb +2 -2
  25. data/app/views/bible270/readers/show.html.erb +5 -5
  26. data/app/views/bible270/sessions/new.html.erb +9 -3
  27. data/app/views/bible270/shared/_header.html.erb +1 -0
  28. data/app/views/bible270/shared/_styles.html.erb +10 -7
  29. data/app/views/layouts/bible270/application.html.erb +4 -3
  30. data/config/routes.rb +32 -18
  31. data/db/migrate/20260101000001_create_bible270_readers.rb +3 -2
  32. data/db/migrate/20260101000002_create_bible270_checkoffs.rb +3 -2
  33. data/db/migrate/20260101000003_create_bible270_comments.rb +4 -3
  34. data/db/migrate/20260101000004_create_bible270_sign_in_tokens.rb +4 -3
  35. data/db/migrate/20260101000005_add_names_to_bible270_readers.rb +12 -0
  36. data/db/migrate/20260101000006_add_approval_to_bible270_comments.rb +11 -0
  37. data/lib/bible270/configuration.rb +91 -19
  38. data/lib/bible270/email_sign_in.rb +7 -7
  39. data/lib/bible270/engine.rb +1 -0
  40. data/lib/bible270/plan.rb +449 -150
  41. data/lib/bible270/versification.rb +84 -67
  42. data/lib/bible270/version.rb +2 -1
  43. data/lib/bible270.rb +6 -5
  44. data/lib/generators/bible270/install/install_generator.rb +520 -39
  45. data/lib/generators/bible270/install/templates/bible270.rb.tt +8 -2
  46. data/lib/generators/bible270/install/templates/omniauth.rb.tt +6 -4
  47. metadata +17 -29
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  # A reader identity. Either self-contained (created via OmniAuth) or bridged
4
5
  # to one of the host application's users through the polymorphic :owner.
5
6
  class Reader < ApplicationRecord
6
- self.table_name = "bible270_readers"
7
+ self.table_name = 'bible270_readers'
7
8
 
8
- has_many :checkoffs, class_name: "Bible270::Checkoff", dependent: :destroy
9
- has_many :comments, class_name: "Bible270::Comment", dependent: :destroy
9
+ has_many :checkoffs, class_name: 'Bible270::Checkoff', dependent: :destroy
10
+ has_many :comments, class_name: 'Bible270::Comment', dependent: :destroy
10
11
  belongs_to :owner, polymorphic: true, optional: true
11
12
 
12
13
  validates :display_name, presence: true
@@ -20,11 +21,11 @@ module Bible270
20
21
  return nil if provider.empty? || uid.empty?
21
22
 
22
23
  info = dig_auth(auth, :info) || {}
23
- name = first_present(dig_auth(info, :name), dig_auth(info, :nickname),
24
- dig_auth(info, :first_name), dig_auth(info, :email))
24
+ name = first_present(dig_auth(info, :name), dig_auth(info, :nickname),
25
+ dig_auth(info, :first_name), dig_auth(info, :email))
25
26
 
26
27
  reader = find_or_initialize_by(provider: provider, uid: uid)
27
- reader.display_name = first_present(name, reader.display_name, "Reader")
28
+ reader.display_name = first_present(name, reader.display_name, 'Reader')
28
29
  email = dig_auth(info, :email)
29
30
  image = first_present(dig_auth(info, :image), dig_auth(info, :avatar_url))
30
31
  reader.email = email if email.present?
@@ -36,14 +37,15 @@ module Bible270
36
37
  # Find or create the reader behind a verified email address. Uses the same
37
38
  # provider/uid identity columns as OmniAuth, with provider "email", so an
38
39
  # email reader is indistinguishable from any other downstream.
39
- def self.from_email(email, display_name: nil)
40
+ def self.from_email(email, first_name: nil, last_name: nil, display_name: nil)
40
41
  address = EmailSignIn.normalize_email(email)
41
42
  return nil if address.nil?
42
43
 
43
- reader = find_or_initialize_by(provider: "email", uid: address)
44
- chosen = first_present(display_name, reader.display_name,
45
- EmailSignIn.display_name_from(address), "Reader")
46
- reader.display_name = chosen
44
+ reader = find_or_initialize_by(provider: 'email', uid: address)
45
+ reader.first_name = first_name.to_s.strip if first_name.present?
46
+ reader.last_name = last_name.to_s.strip if last_name.present?
47
+ reader.display_name = first_present(reader.full_name, display_name, reader.display_name,
48
+ EmailSignIn.display_name_from(address), 'Reader')
47
49
  reader.email = address
48
50
  reader.save
49
51
  reader
@@ -51,6 +53,7 @@ module Bible270
51
53
 
52
54
  def self.dig_auth(obj, key)
53
55
  return nil if obj.nil?
56
+
54
57
  if obj.respond_to?(:[])
55
58
  obj[key] || (obj[key.to_s] if key.is_a?(Symbol))
56
59
  elsif obj.respond_to?(key)
@@ -62,22 +65,30 @@ module Bible270
62
65
  private_class_method :dig_auth
63
66
 
64
67
  def self.first_present(*values)
65
- values.compact.find { |v| v.to_s.strip != "" }
68
+ values.compact.find { |v| v.to_s.strip != '' }
66
69
  end
67
70
  private_class_method :first_present
68
71
 
69
72
  # Find or create a reader bridged to a host user (or any model).
70
73
  def self.for_owner(owner, display_name:, email: nil, avatar_url: nil)
71
74
  reader = find_or_initialize_by(owner: owner)
72
- reader.display_name = display_name.presence || reader.display_name || "Reader"
75
+ reader.display_name = display_name.presence || reader.display_name || 'Reader'
73
76
  reader.email ||= email
74
77
  reader.avatar_url ||= avatar_url
75
78
  reader.save!
76
79
  reader
77
80
  end
78
81
 
82
+ def full_name
83
+ [first_name, last_name].map { |n| n.to_s.strip }.reject(&:empty?).join(' ').presence
84
+ end
85
+
86
+ def sort_name
87
+ [last_name, first_name].map { |n| n.to_s.strip.downcase }.join(' ').strip.presence || display_name.to_s.downcase
88
+ end
89
+
79
90
  def initials
80
- display_name.to_s.split(/\s+/).first(2).map { |w| w[0] }.join.upcase.presence || "?"
91
+ display_name.to_s.split(%r{\s+}).first(2).map { |w| w[0] }.join.upcase.presence || '?'
81
92
  end
82
93
 
83
94
  # {day => number of tracks checked off}
@@ -116,6 +127,59 @@ module Bible270
116
127
  end
117
128
 
118
129
  # Day number implied by the calendar, if a start date is set.
130
+ # ---- administrative adjustments --------------------------------------
131
+
132
+ # Tick every track that has content on this day.
133
+ def mark_day_complete!(day)
134
+ return false unless Plan.valid_day?(day)
135
+
136
+ Plan.present_tracks(day).each do |track|
137
+ checkoffs.find_or_create_by!(day: day, track: track)
138
+ end
139
+ reload_progress
140
+ true
141
+ end
142
+
143
+ def clear_day!(day)
144
+ return false unless Plan.valid_day?(day)
145
+
146
+ checkoffs.where(day: day).destroy_all
147
+ reload_progress
148
+ true
149
+ end
150
+
151
+ def toggle_day!(day)
152
+ day_complete?(day) ? clear_day!(day) : mark_day_complete!(day)
153
+ end
154
+
155
+ # Mark everything up to and including `day` complete, and clear anything after.
156
+ def mark_through!(day)
157
+ return false unless day.to_i.between?(0, Plan::DAYS)
158
+
159
+ transaction do
160
+ checkoffs.where(day: (day.to_i + 1)..).destroy_all
161
+ (1..day.to_i).each do |d|
162
+ Plan.present_tracks(d).each { |t| checkoffs.find_or_create_by!(day: d, track: t) }
163
+ end
164
+ end
165
+ reload_progress
166
+ true
167
+ end
168
+
169
+ # Put this reader on `day` as of `on` — i.e. back-date the start so that the
170
+ # given date lands on the given day of the plan.
171
+ def restart_on!(day:, on: Date.current)
172
+ day = day.to_i
173
+ return false unless Plan.valid_day?(day)
174
+
175
+ update!(started_on: Plan.to_date(on) - (day - 1))
176
+ end
177
+
178
+ def reload_progress
179
+ @checked_counts = nil
180
+ self
181
+ end
182
+
119
183
  # ---- start date / calendar ------------------------------------------
120
184
 
121
185
  # The start date that actually governs this reader. A reader's own
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  # A single-use, short-lived magic-link token for email sign-in.
4
5
  #
5
6
  # Only the SHA-256 digest of the token is stored, so the table is useless to
6
7
  # an attacker who reads it. Tokens are consumed on first successful use.
7
8
  class SignInToken < ApplicationRecord
8
- self.table_name = "bible270_sign_in_tokens"
9
+ self.table_name = 'bible270_sign_in_tokens'
9
10
 
10
11
  validates :email, presence: true
11
12
  validates :token_digest, presence: true, uniqueness: true
@@ -16,7 +17,7 @@ module Bible270
16
17
 
17
18
  # Issue a token for an email. Returns [token_record, raw_token], or
18
19
  # [nil, nil] when the address is being hammered (see rate_limited?).
19
- def self.issue!(email, display_name: nil)
20
+ def self.issue!(email, display_name: nil, first_name: nil, last_name: nil)
20
21
  address = EmailSignIn.normalize_email(email)
21
22
  return [nil, nil] if address.nil?
22
23
  return [nil, nil] if rate_limited?(address)
@@ -25,6 +26,8 @@ module Bible270
25
26
  record = create!(
26
27
  email: address,
27
28
  display_name: display_name.presence,
29
+ first_name: first_name.presence,
30
+ last_name: last_name.presence,
28
31
  token_digest: EmailSignIn.digest_token(raw),
29
32
  expires_at: Time.current + Bible270.config.email_sign_in_ttl
30
33
  )
@@ -0,0 +1,44 @@
1
+ <% content_for :title, "Reflections — Admin" %>
2
+ <p class="b270-eyebrow"><%= link_to 'Admin', admin_path %></p>
3
+ <h1>Reflections</h1>
4
+ <p class="lede">Reflections are visible as soon as they're written. Hiding one takes it off the day
5
+ and community pages without deleting it; the writer keeps their words and you can restore it.</p>
6
+
7
+ <p class="b270-startline" style="margin-bottom:14px">
8
+ <%= link_to 'All', admin_comments_path %> ·
9
+ <%= link_to 'Visible', admin_comments_path(filter: 'visible') %> ·
10
+ <%= link_to "Hidden (#{@hidden_count})", admin_comments_path(filter: 'hidden') %>
11
+ </p>
12
+
13
+ <% if @comments.any? %>
14
+ <% @comments.each do |comment| %>
15
+ <div class="b270-comment" id="comment-<%= comment.id %>">
16
+ <%= b270_avatar(comment.reader) %>
17
+ <div style="flex:1">
18
+ <div class="b270-cmeta">
19
+ <strong><%= comment.reader.display_name %></strong>
20
+ · <%= link_to "day #{comment.day}", day_path(comment.day) %>
21
+ · <%= comment.created_at.strftime('%b %-d, %Y') %>
22
+ <% if comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
23
+ </div>
24
+ <p class="b270-cbody"><%= comment.body %></p>
25
+ <div style="display:flex;gap:10px;align-items:center;margin-top:6px">
26
+ <% if comment.hidden? %>
27
+ <%= button_to 'Restore', admin_unhide_comment_path(comment), method: :patch,
28
+ params: { return_to: request.fullpath }, class: 'b270-linkbtn' %>
29
+ <% else %>
30
+ <%= button_to 'Hide', admin_hide_comment_path(comment), method: :patch,
31
+ params: { return_to: request.fullpath }, class: 'b270-linkbtn' %>
32
+ <% end %>
33
+ <%= button_to 'Delete', admin_comment_path(comment), method: :delete,
34
+ params: { return_to: request.fullpath }, class: 'b270-linkbtn',
35
+ form: { data: { turbo_confirm: 'Delete this reflection permanently?' } } %>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ <% end %>
40
+ <% else %>
41
+ <p style="color:var(--faint)">Nothing here.</p>
42
+ <% end %>
43
+
44
+ <p style="margin-top:18px"><%= link_to '← All readers', admin_path %></p>
@@ -0,0 +1,30 @@
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
+ <% if @readers.any? %>
8
+ <div class="b270-list">
9
+ <% @readers.each do |reader| %>
10
+ <div class="b270-li">
11
+ <%= b270_avatar(reader, size: 34) %>
12
+ <div>
13
+ <div class="name"><%= link_to reader.display_name, admin_reader_path(reader), style: 'text-decoration:none' %></div>
14
+ <div class="sub">
15
+ <%= reader.email.presence || reader.provider %>
16
+ · <%= @days_completed[reader.id] %>/<%= Bible270::Plan::DAYS %> days
17
+ <% if reader.dated? %>· started <%= reader.effective_start_date.strftime('%b %-d, %Y') %><% end %>
18
+ </div>
19
+ </div>
20
+ <div class="stat">
21
+ <span class="big"><%= (@days_completed[reader.id].to_f / Bible270::Plan::DAYS * 100).round %>%</span>
22
+ </div>
23
+ </div>
24
+ <% end %>
25
+ </div>
26
+ <% else %>
27
+ <p style="color:var(--faint)">No readers yet.</p>
28
+ <% end %>
29
+
30
+ <p style="margin-top:18px"><%= link_to '← Back to the plan', root_path %></p>
@@ -0,0 +1,89 @@
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">Where they are in the plan</h2>
13
+ <%= form_with url: admin_reader_start_path(@reader), method: :patch, class: "b270-startform" do %>
14
+ <label class="b270-field">
15
+ <span>Put them on day (as of today)</span>
16
+ <%= number_field_tag :day, @reader.calendar_day || 1, min: 1, max: Bible270::Plan::DAYS %>
17
+ </label>
18
+ <%= submit_tag 'Move', class: 'b270-btn' %>
19
+ <% end %>
20
+ <%= form_with url: admin_reader_start_path(@reader), method: :patch, class: "b270-startform" do %>
21
+ <label class="b270-field">
22
+ <span>…or set the start date directly</span>
23
+ <%= date_field_tag :start_date, @reader.started_on %>
24
+ </label>
25
+ <%= submit_tag 'Set date', class: 'b270-btn' %>
26
+ <% end %>
27
+ <p class="b270-hint">Moving someone re-maps the calendar only. Their check-offs are keyed to day
28
+ numbers and are not touched.</p>
29
+ </div>
30
+
31
+ <div class="b270-panel">
32
+ <h2 class="b270-subhead">Completions</h2>
33
+ <%= form_with url: admin_reader_through_path(@reader), method: :patch, class: "b270-startform" do %>
34
+ <label class="b270-field">
35
+ <span>Mark complete through day (0 clears everything)</span>
36
+ <%= number_field_tag :day, @days_completed, min: 0, max: Bible270::Plan::DAYS %>
37
+ </label>
38
+ <%= submit_tag 'Apply', class: 'b270-btn' %>
39
+ <% end %>
40
+ <p class="b270-hint">This also clears anything after that day, so it sets their progress exactly.</p>
41
+
42
+ <div class="b270-grid" style="margin-top:16px">
43
+ <% (1..Bible270::Plan::DAYS).each do |d| %>
44
+ <%= button_to d, admin_reader_day_path(@reader, d), method: :post,
45
+ class: "b270-cell #{b270_day_status_class(@reader, d)}",
46
+ form: { style: 'display:contents', id: "day-#{d}" } %>
47
+ <% end %>
48
+ </div>
49
+ <p class="b270-hint">Click a day to toggle it complete.</p>
50
+ </div>
51
+
52
+ <div class="b270-panel">
53
+ <h2 class="b270-subhead">Their reflections</h2>
54
+ <% if @comments.any? %>
55
+ <% @comments.each do |comment| %>
56
+ <div class="b270-comment">
57
+ <div style="flex:1">
58
+ <div class="b270-cmeta">
59
+ <%= link_to "day #{comment.day}", day_path(comment.day) %>
60
+ · <%= comment.created_at.strftime('%b %-d, %Y') %>
61
+ <% if comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
62
+ </div>
63
+ <p class="b270-cbody"><%= comment.body %></p>
64
+ <div style="display:flex;gap:10px;margin-top:6px">
65
+ <% if comment.hidden? %>
66
+ <%= button_to 'Restore', admin_unhide_comment_path(comment), method: :patch,
67
+ params: { return_to: request.fullpath }, class: 'b270-linkbtn' %>
68
+ <% else %>
69
+ <%= button_to 'Hide', admin_hide_comment_path(comment), method: :patch,
70
+ params: { return_to: request.fullpath }, class: 'b270-linkbtn' %>
71
+ <% end %>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ <% end %>
76
+ <% else %>
77
+ <p class="b270-hint" style="margin-top:0">None yet.</p>
78
+ <% end %>
79
+ </div>
80
+
81
+ <div class="b270-panel">
82
+ <h2 class="b270-subhead">Remove</h2>
83
+ <p class="b270-hint" style="margin-top:0">Deletes the reader together with every check-off and
84
+ reflection they have written. This cannot be undone.</p>
85
+ <%= button_to "Remove #{@reader.display_name}", admin_reader_path(@reader), method: :delete,
86
+ class: 'b270-btn', form: { data: { turbo_confirm: "Permanently remove #{@reader.display_name}?" } } %>
87
+ </div>
88
+
89
+ <p style="margin-top:18px"><%= link_to '← All readers', admin_path %></p>
@@ -1,22 +1,19 @@
1
1
  <% content_for :title, Bible270.config.app_name %>
2
2
  <p class="b270-eyebrow"><%= Bible270.config.tagline %></p>
3
- <h1><%= Bible270.config.app_name %> <em>— read the story straight through</em></h1>
4
- <p class="lede">Each day: a passage from the Old Testament, a couple of chapters of the New
5
- Testament, and a Psalm or Proverb — portions sized to take about the same time to read.
6
- Check off what you read; your place is kept for you, and you can see how the rest of the
7
- community is journeying too.</p>
3
+ <h1><%= Bible270.config.app_name %> <em>— read God's whole story</em></h1>
4
+ <p class="lede">Daily passages from the Old Testament, New Testament, and Psalms or Proverbs — portions sized to take about the same time to read. Check off what you read; your place is saved for you, and you can see how your friends are progressing as well.</p>
8
5
 
9
6
  <div class="b270-panel">
10
7
  <% if signed_in? %>
11
8
  <div class="b270-bignum">
12
9
  <span class="n"><%= current_reader.days_completed %></span>
13
- <span class="of">of <%= Plan::DAYS %> days complete</span>
10
+ <span class="of">of <%= Bible270::Plan::DAYS %> days complete</span>
14
11
  <span class="pct"><%= current_reader.completion_percent %>%</span>
15
12
  </div>
16
13
  <div class="b270-tracks">
17
- <% [["ot", current_reader.days_read_in("ot"), Plan::DAYS],
18
- ["nt", current_reader.days_read_in("nt"), Plan.nt_content_days],
19
- ["pp", current_reader.days_read_in("pp"), Plan::DAYS]].each do |track, read, total| %>
14
+ <% [["ot", current_reader.days_read_in("ot"), Bible270::Plan::DAYS],
15
+ ["nt", current_reader.days_read_in("nt"), Bible270::Plan.nt_content_days],
16
+ ["pp", current_reader.days_read_in("pp"), Bible270::Plan::DAYS]].each do |track, read, total| %>
20
17
  <% t = b270_track(track) %>
21
18
  <div class="b270-tm">
22
19
  <span class="lab"><span class="b270-rib" style="background:<%= t[:color] %>"></span><%= t[:label] %></span>
@@ -38,7 +35,7 @@
38
35
  <% if @community_start_date %>
39
36
  <p class="b270-startline">The community began this plan on
40
37
  <strong><%= @community_start_date.strftime("%B %-d, %Y") %></strong>
41
- and finishes <%= Plan.end_date_for(@community_start_date).strftime("%B %-d, %Y") %>.</p>
38
+ and finishes <%= Bible270::Plan.end_date_for(@community_start_date).strftime("%B %-d, %Y") %>.</p>
42
39
  <% end %>
43
40
  <p style="margin:14px 0 0"><%= link_to "Start at Day 1 →", day_path(1), class: "b270-btn" %></p>
44
41
  <% end %>
@@ -55,7 +52,7 @@
55
52
  <div class="name"><%= link_to r.display_name, reader_path(r), style: "text-decoration:none" %></div>
56
53
  <div class="sub"><%= @days_completed[r.id] %> days read</div>
57
54
  </div>
58
- <div class="stat"><span class="big"><%= (@days_completed[r.id].to_f / Plan::DAYS * 100).round %>%</span></div>
55
+ <div class="stat"><span class="big"><%= (@days_completed[r.id].to_f / Bible270::Plan::DAYS * 100).round %>%</span></div>
59
56
  </div>
60
57
  <% end %>
61
58
  </div>
@@ -63,9 +60,9 @@
63
60
  <% end %>
64
61
 
65
62
  <details class="b270-index">
66
- <summary>All <%= Plan::DAYS %> days</summary>
63
+ <summary>All <%= Bible270::Plan::DAYS %> days</summary>
67
64
  <div class="b270-grid">
68
- <% (1..Plan::DAYS).each do |d| %>
65
+ <% (1..Bible270::Plan::DAYS).each do |d| %>
69
66
  <%= link_to d, day_path(d), class: "b270-cell #{b270_day_status_class(current_reader, d)} #{'now' if signed_in? && d == @start_day}" %>
70
67
  <% end %>
71
68
  </div>
@@ -11,10 +11,10 @@
11
11
  <%= b270_avatar(r, size: 40) %>
12
12
  <div>
13
13
  <div class="name"><%= link_to r.display_name, reader_path(r), style: "text-decoration:none" %></div>
14
- <div class="sub"><%= @days_completed[r.id] %> of <%= Plan::DAYS %> days · joined <%= r.created_at.strftime("%b %Y") %></div>
14
+ <div class="sub"><%= @days_completed[r.id] %> of <%= Bible270::Plan::DAYS %> days · joined <%= r.created_at.strftime("%b %Y") %></div>
15
15
  </div>
16
16
  <div class="stat">
17
- <span class="big"><%= (@days_completed[r.id].to_f / Plan::DAYS * 100).round %>%</span>
17
+ <span class="big"><%= (@days_completed[r.id].to_f / Bible270::Plan::DAYS * 100).round %>%</span>
18
18
  </div>
19
19
  </div>
20
20
  <% end %>
@@ -3,15 +3,15 @@
3
3
  <%= b270_avatar(@reader, size: 56) %>
4
4
  <div>
5
5
  <h1 style="margin:0"><%= @reader.display_name %></h1>
6
- <p style="margin:2px 0 0;color:var(--faint);font-size:14px"><%= @days_completed %> of <%= Plan::DAYS %> days complete · <%= @reader.completion_percent %>%</p>
6
+ <p style="margin:2px 0 0;color:var(--faint);font-size:14px"><%= @days_completed %> of <%= Bible270::Plan::DAYS %> days complete · <%= @reader.completion_percent %>%</p>
7
7
  </div>
8
8
  </div>
9
9
 
10
10
  <div class="b270-panel" style="margin-top:18px">
11
11
  <div class="b270-tracks">
12
- <% [["ot", @reader.days_read_in("ot"), Plan::DAYS],
13
- ["nt", @reader.days_read_in("nt"), Plan.nt_content_days],
14
- ["pp", @reader.days_read_in("pp"), Plan::DAYS]].each do |track, read, total| %>
12
+ <% [["ot", @reader.days_read_in("ot"), Bible270::Plan::DAYS],
13
+ ["nt", @reader.days_read_in("nt"), Bible270::Plan.nt_content_days],
14
+ ["pp", @reader.days_read_in("pp"), Bible270::Plan::DAYS]].each do |track, read, total| %>
15
15
  <% t = b270_track(track) %>
16
16
  <div class="b270-tm">
17
17
  <span class="lab"><span class="b270-rib" style="background:<%= t[:color] %>"></span><%= t[:label] %></span>
@@ -23,7 +23,7 @@
23
23
  </div>
24
24
 
25
25
  <div class="b270-grid">
26
- <% (1..Plan::DAYS).each do |d| %>
26
+ <% (1..Bible270::Plan::DAYS).each do |d| %>
27
27
  <%= link_to d, day_path(d), class: "b270-cell #{b270_day_status_class(@reader, d)}" %>
28
28
  <% end %>
29
29
  </div>
@@ -14,13 +14,19 @@
14
14
  <%= hidden_field_tag :origin, @origin if @origin %>
15
15
  <% if Bible270.config.email_sign_in_ask_name %>
16
16
  <label class="b270-field">
17
- <span>Name shown beside your reflections</span>
18
- <%= text_field_tag :display_name, nil, placeholder: "e.g. Andrew", autocomplete: "name" %>
17
+ <span>First name</span>
18
+ <%= text_field_tag :first_name, params[:first_name], autocomplete: "given-name",
19
+ required: Bible270.config.email_sign_in_require_name %>
20
+ </label>
21
+ <label class="b270-field">
22
+ <span>Last name</span>
23
+ <%= text_field_tag :last_name, params[:last_name], autocomplete: "family-name",
24
+ required: Bible270.config.email_sign_in_require_name %>
19
25
  </label>
20
26
  <% end %>
21
27
  <label class="b270-field">
22
28
  <span>Email address</span>
23
- <%= email_field_tag :email, nil, required: true, placeholder: "you@example.com",
29
+ <%= email_field_tag :email, params[:email], required: true, placeholder: "you@example.com",
24
30
  autocomplete: "email", inputmode: "email" %>
25
31
  </label>
26
32
  <%= f.submit "Email me a sign-in link", class: "b270-btn" %>
@@ -7,6 +7,7 @@
7
7
  <%= link_to "Plan", root_path %>
8
8
  <%= link_to "Community", community_path %>
9
9
  <% if signed_in? %>
10
+ <% if Bible270.config.admin?(current_reader) %><%= link_to "Admin", admin_path %><% end %>
10
11
  <%= link_to current_reader.display_name, reader_path(current_reader), class: "b270-me" %>
11
12
  <%= button_to "Sign out", sign_out_path, method: :delete, class: "b270-linkbtn",
12
13
  form: { style: "display:inline" } %>
@@ -7,6 +7,9 @@
7
7
  color:var(--ink); font-family:"Inter",system-ui,sans-serif; line-height:1.5; -webkit-font-smoothing:antialiased;
8
8
  }
9
9
  .b270 *{box-sizing:border-box}
10
+ /* Blanket anchor colour. NB: this is (0,1,1), so any single-class rule
11
+ colouring an <a> (.b270-btn, .b270-cell, .b270-arrow ...) must be
12
+ qualified with .b270 to outrank it, or the link inherits body colour. */
10
13
  .b270 a{color:inherit}
11
14
  .b270-wrap{max-width:820px;margin:0 auto;padding:clamp(14px,3.5vw,36px) clamp(14px,4vw,40px) 64px}
12
15
 
@@ -28,10 +31,10 @@
28
31
  .b270-or{display:flex;align-items:center;gap:12px;margin:22px 0 18px;color:var(--faint);font-size:12px;letter-spacing:.1em;text-transform:uppercase}
29
32
  .b270-or::before,.b270-or::after{content:"";flex:1;height:1px;background:var(--line-soft)}
30
33
  .b270-providers{display:grid;gap:10px;max-width:340px}
31
- .b270-provider{display:block;width:100%;text-align:center;border:1px solid var(--line);background:#fff;color:var(--ink);border-radius:10px;padding:12px 16px;font:inherit;font-weight:600;font-size:14.5px;cursor:pointer;transition:border-color .15s,transform .05s}
34
+ .b270 .b270-provider{display:block;width:100%;text-align:center;border:1px solid var(--line);background:#fff;color:var(--ink);border-radius:10px;padding:12px 16px;font:inherit;font-weight:600;font-size:14.5px;cursor:pointer;transition:border-color .15s,transform .05s}
32
35
  .b270-provider:hover{border-color:var(--gold);color:var(--gold)}
33
36
  .b270-provider:active{transform:translateY(1px)}
34
- .b270-signinbtn{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:8px;padding:7px 13px;font:inherit;font-weight:600;font-size:13px;cursor:pointer}
37
+ .b270 .b270-signinbtn{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:8px;padding:7px 13px;font:inherit;font-weight:600;font-size:13px;cursor:pointer}
35
38
  .b270-signinbtn:hover{border-color:var(--gold);color:var(--gold)}
36
39
  .b270-signin-hint a{color:var(--gold);font-weight:600}
37
40
  .b270-flash{border-radius:10px;padding:11px 15px;margin-bottom:18px;font-size:14px}
@@ -41,7 +44,7 @@
41
44
  .b270-eyebrow{font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--gold);font-weight:600;margin:0 0 10px}
42
45
  .b270 h1{font-family:"Spectral",serif;font-weight:600;font-size:clamp(28px,6vw,42px);line-height:1.03;letter-spacing:-.01em;margin:0 0 6px}
43
46
  .b270 h1 em{font-style:italic;color:var(--muted);font-weight:400}
44
- .b270 .lede{color:var(--muted);max-width:54ch;margin:0 0 24px;font-size:15px}
47
+ .b270 .lede{color:var(--muted);max-width:77ch;margin:0 0 24px;font-size:15px}
45
48
 
46
49
  .b270-panel{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px 22px;box-shadow:var(--shadow);margin-bottom:24px}
47
50
  .b270-bignum{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
@@ -72,7 +75,7 @@
72
75
 
73
76
  /* day nav */
74
77
  .b270-daynav{display:flex;align-items:center;gap:10px;margin-bottom:12px}
75
- .b270-arrow{width:38px;height:38px;border-radius:10px;border:1px solid var(--line);background:var(--card);cursor:pointer;font-size:17px;color:var(--muted);display:grid;place-items:center;text-decoration:none}
78
+ .b270 .b270-arrow{width:38px;height:38px;border-radius:10px;border:1px solid var(--line);background:var(--card);cursor:pointer;font-size:17px;color:var(--muted);display:grid;place-items:center;text-decoration:none}
76
79
  .b270-arrow:hover{border-color:var(--gold);color:var(--gold)}
77
80
  .b270-arrow.disabled{opacity:.35;pointer-events:none}
78
81
  .b270-dayhead{flex:1}
@@ -101,7 +104,7 @@
101
104
  .b270-ref a:hover{border-bottom-color:var(--gold)}
102
105
  .b270-reading.done .b270-ref{color:var(--muted)}
103
106
  .b270-reading.rest .b270-ref{font-style:italic;font-size:16px;color:var(--faint)}
104
- .b270-signin-hint{font-size:12px;color:var(--faint)}
107
+ .b270 .b270-signin-hint{font-size:12px;color:var(--faint)}
105
108
 
106
109
  /* completers */
107
110
  .b270-completers{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:16px;font-size:13px;color:var(--muted)}
@@ -118,7 +121,7 @@
118
121
  .b270-cdel{font-size:12px;color:var(--oxblood);background:none;border:none;cursor:pointer;padding:0;margin-left:8px}
119
122
  .b270-form textarea{width:100%;font:inherit;font-size:15px;border:1px solid var(--line);border-radius:10px;padding:11px 13px;background:#fff;resize:vertical;min-height:76px}
120
123
  .b270-form .row{display:flex;align-items:center;gap:10px;margin-top:10px}
121
- .b270-btn{border:none;border-radius:10px;padding:10px 16px;font:inherit;font-weight:600;font-size:14px;cursor:pointer;background:var(--ink);color:var(--paper);text-decoration:none;display:inline-block}
124
+ .b270 .b270-btn{border:none;border-radius:10px;padding:10px 16px;font:inherit;font-weight:600;font-size:14px;cursor:pointer;background:var(--ink);color:var(--paper);text-decoration:none;display:inline-block}
122
125
  .b270-btn:hover{background:#3a352b}
123
126
 
124
127
  /* community */
@@ -133,7 +136,7 @@
133
136
 
134
137
  /* calendar grid */
135
138
  .b270-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(30px,1fr));gap:6px;margin-top:16px}
136
- .b270-cell{aspect-ratio:1;border-radius:6px;border:1px solid var(--line);background:var(--card);font-size:10.5px;color:var(--faint);display:grid;place-items:center;text-decoration:none;font-variant-numeric:tabular-nums}
139
+ .b270 .b270-cell{aspect-ratio:1;border-radius:6px;border:1px solid var(--line);background:var(--card);font-size:10.5px;color:var(--faint);display:grid;place-items:center;text-decoration:none;font-variant-numeric:tabular-nums}
137
140
  .b270-cell:hover{border-color:var(--gold);color:var(--gold)}
138
141
  .b270-cell.partial{background:linear-gradient(135deg,var(--card) 50%,rgba(165,129,44,.35) 50%);color:var(--muted)}
139
142
  .b270-cell.complete{background:var(--teal);border-color:var(--teal);color:#eef3f2;font-weight:600}
@@ -19,9 +19,10 @@
19
19
  <% if flash[:notice] %><div class="b270-flash notice"><%= flash[:notice] %></div><% end %>
20
20
  <%= yield %>
21
21
  <footer class="b270-footer">
22
- <p>The Old Testament is read once, cover to cover. The New Testament is read twice —
23
- the second pass begins on day <%= Bible270::Plan.nt_second_pass_start_day %>.
24
- Psalms &amp; Proverbs runs alongside twice over, whole chapters at a time.</p>
22
+ <p>The Old Testament and New Testament are each read once across the 270 days, with a
23
+ reading from all three tracks every day. Alongside them the Psalms run once and
24
+ Proverbs twice; the longest chapters are divided so the readings fill each day
25
+ evenly, and Psalm 119 comes in eleven sections.</p>
25
26
  </footer>
26
27
  </div>
27
28
  </body>
data/config/routes.rb CHANGED
@@ -1,30 +1,44 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  Bible270::Engine.routes.draw do
3
- root to: "days#index"
4
+ root to: 'days#index'
5
+
6
+ get 'day/:day', to: 'days#show', as: :day, constraints: { day: %r{\d+} }
7
+
8
+ post 'day/:day/toggle/:track', to: 'checkoffs#toggle', as: :toggle_checkoff,
9
+ constraints: { day: %r{\d+} }
10
+ post 'day/:day/comments', to: 'comments#create', as: :day_comments,
11
+ constraints: { day: %r{\d+} }
12
+ delete 'comments/:id', to: 'comments#destroy', as: :comment
4
13
 
5
- get "day/:day", to: "days#show", as: :day, constraints: { day: /\d+/ }
14
+ # Admin panel — 404s unless config.admin_emails / admin_resolver allows you.
15
+ get 'admin', to: 'admin#index', as: :admin
16
+ get 'admin/readers/:id', to: 'admin#show', as: :admin_reader
17
+ delete 'admin/readers/:id', to: 'admin#destroy'
18
+ patch 'admin/readers/:id/start', to: 'admin#update_start', as: :admin_reader_start
19
+ patch 'admin/readers/:id/through', to: 'admin#complete_through', as: :admin_reader_through
20
+ post 'admin/readers/:id/days/:day', to: 'admin#toggle_day', as: :admin_reader_day
6
21
 
7
- post "day/:day/toggle/:track", to: "checkoffs#toggle", as: :toggle_checkoff,
8
- constraints: { day: /\d+/ }
9
- post "day/:day/comments", to: "comments#create", as: :day_comments,
10
- constraints: { day: /\d+/ }
11
- delete "comments/:id", to: "comments#destroy", as: :comment
22
+ get 'admin/comments', to: 'admin#comments', as: :admin_comments
23
+ patch 'admin/comments/:id/hide', to: 'admin#hide_comment', as: :admin_hide_comment
24
+ patch 'admin/comments/:id/unhide', to: 'admin#unhide_comment', as: :admin_unhide_comment
25
+ delete 'admin/comments/:id', to: 'admin#destroy_comment', as: :admin_comment
12
26
 
13
- get "community", to: "readers#index", as: :community
14
- get "readers/:id", to: "readers#show", as: :reader
27
+ get 'community', to: 'readers#index', as: :community
28
+ get 'readers/:id', to: 'readers#show', as: :reader
15
29
 
16
- patch "start-date", to: "readers#update_start_date", as: :start_date
17
- delete "start-date", to: "readers#clear_start_date", as: :clear_start_date
30
+ patch 'start-date', to: 'readers#update_start_date', as: :start_date
31
+ delete 'start-date', to: 'readers#clear_start_date', as: :clear_start_date
18
32
 
19
33
  # Built-in OmniAuth sign-in. The request phase (POST <mount>/auth/:provider)
20
34
  # is handled by the OmniAuth middleware in the host app, not by these routes.
21
- get "sign_in", to: "sessions#new", as: :sign_in
35
+ get 'sign_in', to: 'sessions#new', as: :sign_in
22
36
 
23
37
  # Email (magic link) sign-in, for readers without a social account.
24
- post "sign_in/email", to: "sessions#email_link", as: :email_sign_in_link
25
- get "sign_in/email/:token", to: "sessions#email_callback", as: :email_sign_in
26
- get "auth/:provider/callback", to: "sessions#create"
27
- post "auth/:provider/callback", to: "sessions#create"
28
- get "auth/failure", to: "sessions#failure"
29
- delete "sign_out", to: "sessions#destroy", as: :sign_out
38
+ post 'sign_in/email', to: 'sessions#email_link', as: :email_sign_in_link
39
+ get 'sign_in/email/:token', to: 'sessions#email_callback', as: :email_sign_in
40
+ get 'auth/:provider/callback', to: 'sessions#create'
41
+ post 'auth/:provider/callback', to: 'sessions#create'
42
+ get 'auth/failure', to: 'sessions#failure'
43
+ delete 'sign_out', to: 'sessions#destroy', as: :sign_out
30
44
  end