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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +150 -164
- data/README.md +287 -272
- data/Rakefile +5 -4
- data/app/controllers/bible270/admin_controller.rb +119 -0
- data/app/controllers/bible270/application_controller.rb +19 -2
- 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/readers_controller.rb +5 -4
- data/app/controllers/bible270/sessions_controller.rb +60 -26
- data/app/helpers/bible270/plan_helper.rb +11 -8
- 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 +78 -14
- 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 +30 -0
- data/app/views/bible270/admin/show.html.erb +89 -0
- data/app/views/bible270/days/index.html.erb +10 -13
- 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 +9 -3
- data/app/views/bible270/shared/_header.html.erb +1 -0
- data/app/views/bible270/shared/_styles.html.erb +10 -7
- data/app/views/layouts/bible270/application.html.erb +4 -3
- data/config/routes.rb +32 -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/lib/bible270/configuration.rb +91 -19
- data/lib/bible270/email_sign_in.rb +7 -7
- data/lib/bible270/engine.rb +1 -0
- data/lib/bible270/plan.rb +449 -150
- data/lib/bible270/versification.rb +84 -67
- data/lib/bible270/version.rb +2 -1
- data/lib/bible270.rb +6 -5
- data/lib/generators/bible270/install/install_generator.rb +520 -39
- data/lib/generators/bible270/install/templates/bible270.rb.tt +8 -2
- data/lib/generators/bible270/install/templates/omniauth.rb.tt +6 -4
- 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 =
|
|
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
|
|
|
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
|
|
24
|
-
|
|
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,
|
|
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:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
reader.display_name =
|
|
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 ||
|
|
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(
|
|
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 =
|
|
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
|
|
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>
|
|
@@ -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>
|
|
18
|
-
<%= text_field_tag :
|
|
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,
|
|
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:
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
14
|
-
get
|
|
27
|
+
get 'community', to: 'readers#index', as: :community
|
|
28
|
+
get 'readers/:id', to: 'readers#show', as: :reader
|
|
15
29
|
|
|
16
|
-
patch
|
|
17
|
-
delete
|
|
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
|
|
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
|
|
25
|
-
get
|
|
26
|
-
get
|
|
27
|
-
post
|
|
28
|
-
get
|
|
29
|
-
delete
|
|
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
|