bible270 0.10.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 +60 -1
- data/README.md +9 -7
- data/app/controllers/bible270/admin_controller.rb +32 -4
- data/app/controllers/bible270/application_controller.rb +5 -1
- data/app/controllers/bible270/profiles_controller.rb +45 -0
- data/app/controllers/bible270/sessions_controller.rb +39 -10
- data/app/helpers/bible270/plan_helper.rb +15 -8
- data/app/models/bible270/reader.rb +89 -3
- data/app/models/bible270/setting.rb +57 -0
- data/app/views/bible270/admin/index.html.erb +18 -0
- data/app/views/bible270/admin/show.html.erb +15 -0
- data/app/views/bible270/days/_start_date.html.erb +1 -1
- data/app/views/bible270/days/index.html.erb +2 -11
- data/app/views/bible270/profiles/edit.html.erb +44 -0
- data/app/views/bible270/sessions/new.html.erb +14 -6
- data/app/views/bible270/shared/_day_index.html.erb +12 -0
- data/app/views/bible270/shared/_header.html.erb +1 -0
- data/app/views/bible270/shared/_styles.html.erb +7 -0
- data/app/views/layouts/bible270/application.html.erb +2 -4
- data/config/routes.rb +6 -0
- data/db/migrate/20260101000007_create_bible270_settings.rb +15 -0
- data/lib/bible270/avatars.rb +46 -0
- data/lib/bible270/configuration.rb +31 -2
- data/lib/bible270/names.rb +47 -0
- data/lib/bible270/plan.rb +4 -4
- data/lib/bible270/version.rb +1 -1
- data/lib/bible270.rb +2 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 84d3ca14b2813767c7f837ec1844feaaff31b1ac12e37d691c95b8975313a9e9
|
|
4
|
+
data.tar.gz: 69374a41cfaed437a5bb0367ac66b7500949cda2654e9f80e5ab46f9f8822547
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 71c2ef21eb61bb68359ed3cd202ecc0278082cc97a18e4f9886093799d065409fd0529775edeb1a42f33d25a6ceaac702f44d7c53b59fda82e2c91d894cb704f
|
|
7
|
+
data.tar.gz: 1cbd38d2f361892bb1cab9f981986598d3d03bc2c04ce63506d60907aca58b57ffd96f2dc9f607ead5dc1c6966086954898e60b7826955b9960c769aa8b8c26e
|
data/CHANGELOG.md
CHANGED
|
@@ -7,11 +7,66 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html), tr
|
|
|
7
7
|
|
|
8
8
|
See the changes since the last release:
|
|
9
9
|
|
|
10
|
-
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.
|
|
10
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.11.0...main)**
|
|
11
|
+
|
|
12
|
+
## [0.11.0] — 2026-07-28
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **Readers can edit their own name and picture** at `<mount>/profile`, linked from the nav. An admin
|
|
17
|
+
can edit anyone's from that reader's admin page. Both go through one rule (`Bible270::Names`):
|
|
18
|
+
first and last required, whitespace squished, display name derived — so internal capitals and
|
|
19
|
+
particles survive (`vonderLuft`, `von der Luft`). A reader who arrived via OmniAuth with only a
|
|
20
|
+
display name gets the form pre-filled by splitting it.
|
|
21
|
+
- **Avatar uploads** — PNG, JPEG, GIF or WebP up to `config.avatar_max_bytes` (2MB), with a Remove
|
|
22
|
+
button. SVG is refused, since it can carry script. Needs Active Storage in the host app; without it
|
|
23
|
+
the field is hidden, the engine still loads, and avatars come from the sign-in provider as before.
|
|
24
|
+
No resizing: the original is served at CSS dimensions.
|
|
25
|
+
- **A run can be closed to new readers** from the admin panel. Existing readers carry on signing in;
|
|
26
|
+
nobody new can join. Enforced at all three points a reader can be created — the OmniAuth callback,
|
|
27
|
+
issuing an email link, and consuming one. Runtime state in a new `bible270_settings` table, so it
|
|
28
|
+
needs no deploy; `config.enrollment_open = false` launches closed.
|
|
29
|
+
- The **All 270 days** index now appears on every page, rendered from the layout rather than only on
|
|
30
|
+
the overview.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **A returning reader needs only their email to sign in.** Names are optional on the sign-in form; a
|
|
35
|
+
new reader is asked for theirs on their profile straight after clicking the link. Requiring them
|
|
36
|
+
only for unknown addresses would have made the response differ between a known and an unknown one,
|
|
37
|
+
which is an account-enumeration oracle — and the closed-run banner exists for the same reason.
|
|
38
|
+
- Closing a run says so on the sign-in page before anyone types, since the "check your inbox"
|
|
39
|
+
response deliberately cannot distinguish a known address from an unknown one.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- Setting a reader's start date from the admin panel failed with "That doesn't look like a date"
|
|
44
|
+
whenever `config.allow_reader_start_date` was off. That setting governs what a *reader* may change
|
|
45
|
+
about their own plan and was wrongly gating the admin action, which now uses an ungated setter.
|
|
46
|
+
- The day grid ran a query per day (`read_tracks_for`), so its 270 cells cost up to 270 queries. It
|
|
47
|
+
now reads the reader's single grouped count — which matters more now the grid is on every page.
|
|
48
|
+
- The nav's "Sign out" sat below the other items: `button_to` renders a form, and the shared
|
|
49
|
+
`.b270-linkbtn` class carries a top margin, smaller font and underline meant for use under a form.
|
|
50
|
+
- `Bible270::Configuration` referenced `Avatars` without requiring it, so instantiating it raised
|
|
51
|
+
`NameError` unless something else had already loaded avatars — which the app did by luck of require
|
|
52
|
+
order and the test suite did not.
|
|
53
|
+
|
|
54
|
+
### Internal
|
|
55
|
+
|
|
56
|
+
- Tests now guard the classes of bug that bit during this run: every file under `lib/` must load on
|
|
57
|
+
its own *and* survive instantiating the configuration; every setting the code calls must exist on
|
|
58
|
+
`Configuration`; templates must not reference engine constants unqualified; a coloured link class
|
|
59
|
+
must outrank `.b270 a`; admin actions must not use reader-gated methods; and closure must be
|
|
60
|
+
enforced at all three creation points.
|
|
61
|
+
- Migrations: `20260101000007` (settings). Active Storage tables are the host app's
|
|
62
|
+
(`bin/rails active_storage:install`).
|
|
11
63
|
|
|
12
64
|
## [0.10.0] — 2026-07-26
|
|
13
65
|
|
|
66
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v0.9.0...v0.10.0)**
|
|
67
|
+
|
|
14
68
|
### Reading plan
|
|
69
|
+
|
|
15
70
|
- **Proverbs is read once**, not twice, and its longer chapters are divided — at the discourse
|
|
16
71
|
boundaries in chapters 1–9 and 30–31, evenly elsewhere, since 10–29 are collections of
|
|
17
72
|
independent sayings.
|
|
@@ -31,6 +86,7 @@ See the changes since the last release:
|
|
|
31
86
|
changes. Precedence: constant < config < file.
|
|
32
87
|
|
|
33
88
|
### Features
|
|
89
|
+
|
|
34
90
|
- **Interactive installer** — `bin/rails generate bible270:install` asks where to mount, which
|
|
35
91
|
sign-in methods to enable, writes the initializers, adds the `mount` line, copies the migrations
|
|
36
92
|
and runs them. Scriptable with `--defaults` and friends.
|
|
@@ -49,6 +105,7 @@ See the changes since the last release:
|
|
|
49
105
|
delete it outright, from a list filterable by visible/hidden or from the writer's own page.
|
|
50
106
|
|
|
51
107
|
### Fixed
|
|
108
|
+
|
|
52
109
|
- Sign-in was broken: OmniAuth 2.0+ rejects GET on its request phase, so the controls are now POST
|
|
53
110
|
forms with CSRF tokens. Open-redirect hardening on the return-to path, and `reset_session` on
|
|
54
111
|
sign-in and sign-out.
|
|
@@ -77,6 +134,7 @@ See the changes since the last release:
|
|
|
77
134
|
accessor fails the suite instead of raising `NoMethodError` on whichever request touches it.
|
|
78
135
|
|
|
79
136
|
### Notes
|
|
137
|
+
|
|
80
138
|
- Table names changed from `bible_reading_plan_*` in the 0.9.0 line; this is a fresh install.
|
|
81
139
|
|
|
82
140
|
## [0.9.0] — 2026-07-25
|
|
@@ -176,6 +234,7 @@ This is the initial public release.
|
|
|
176
234
|
|
|
177
235
|
* Email sign-in requires working Action Mailer delivery in the host application. Set `config.mailer_from`; delivery is inline by default, or set `email_sign_in_deliver_later` when a queue backend is available.
|
|
178
236
|
|
|
237
|
+
[0.11.0]: https://github.com/avonderluft/bible270/releases/tag/v0.10.0
|
|
179
238
|
[0.10.0]: https://github.com/avonderluft/bible270/releases/tag/v0.10.0
|
|
180
239
|
[0.9.0]: https://github.com/avonderluft/bible270/releases/tag/v0.9.0
|
|
181
240
|
[0.6.2]: https://github.com/avonderluft/bible270/releases/tag/v0.6.2
|
data/README.md
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
# Bible 270
|
|
2
2
|
|
|
3
|
-
[](https://badge.fury.io/rb/bible270) [](http://rubygems.org/gems/bible270) [](https://github.com/avonderluft/bible270/releases)
|
|
3
|
+
[](https://badge.fury.io/rb/bible270) [](http://rubygems.org/gems/bible270) [](https://github.com/avonderluft/bible270/releases)
|
|
4
4
|
|
|
5
5
|
A mountable **Rails engine** that drops a 270-day (9 month) interactive Bible reading plan into any Rails app, built with [ComfortableMediaSurfer](https://github.com/shakacode/comfortable-media-surfer) CMS in mind. Readers tick off each day's readings, post reflections, and see how everyone else is getting on, much like the social plans on Bible.com.
|
|
6
6
|
|
|
7
|
-
Initially created for use by students and faculty of [Kingdom Movement School of Ministry](https://www.kmsm.life/), to read through all of Scripture together
|
|
7
|
+
Initially created for use by students and faculty of [Kingdom Movement School of Ministry](https://www.kmsm.life/), to read through all of Scripture together during the school year. Thus it is potentially useful for any Bible School, Seminary, or Discipleship school working on a 9 month schedule.
|
|
8
8
|
|
|
9
9
|
## The plan
|
|
10
10
|
|
|
11
11
|
Three readings a day, sized by **verse count** so each day takes roughly the same time.
|
|
12
12
|
|
|
13
|
-
- **Old Testament** — once through, with Psalms and Proverbs
|
|
14
|
-
- **New Testament** — once through, a reading every day. 260 chapters over 270 days,
|
|
15
|
-
- **Psalms & Proverbs** — once each, interleaved in one track. 181 chapters have to cover 270 days, so
|
|
13
|
+
- **Old Testament** — once through, with Psalms and Proverbs in their own track.
|
|
14
|
+
- **New Testament** — once through, a reading every day. 260 chapters over 270 days, long chapters are divided to even out the daily reading
|
|
15
|
+
- **Psalms & Proverbs** — once each, interleaved in one track. 181 chapters have to cover 270 days, so some are divided, shared between the two books by verse load. **A psalm of 25 verses or fewer is never divided**, keeping 131 of the 150 whole; longer psalms and Proverbs chapters break at their turns of thought (see `CHAPTER_BREAKS`). **Psalm 119 is 11 sections of 16 verses** — two of its eight-verse acrostic stanzas each. Proverbs lands on 89 days, near its proportional share. **A divided chapter is always read on consecutive days**, so nothing is inserted between the parts of a split psalm.
|
|
16
16
|
|
|
17
17
|
Genesis → Malachi and Revelation 22 both land on day 270, as does the second Proverbs 31 — the first falls on day 135, the midpoint. Psalm 150 comes in on day 269. Every day carries all three tracks. A typical day is ~119 verses (range 76–175).
|
|
18
18
|
|
|
@@ -20,7 +20,7 @@ The schedule is pure deterministic Ruby (`Bible270::Plan`) — none of it is sto
|
|
|
20
20
|
|
|
21
21
|
## Requirements
|
|
22
22
|
|
|
23
|
-
- **Ruby** >= 3.
|
|
23
|
+
- **Ruby** >= 3.2. Plain Ruby, no C extensions. Runs on 4.0.6 — it avoids everything 4.0 dropped or unbundled (notably `cgi`; URL escaping uses `URI.encode_www_form_component`).
|
|
24
24
|
- **Rails** >= 7.0. On Ruby 4.0 your host app is the real constraint, not this engine — check your lockfile with RailsBump first.
|
|
25
25
|
- A host app, and Turbo if you want check-offs without a page reload.
|
|
26
26
|
|
|
@@ -295,8 +295,10 @@ Bible270.configure do |config|
|
|
|
295
295
|
config.mount_at = "/daily-bread" # single source of truth for the path
|
|
296
296
|
config.app_name = "Daily Bread"
|
|
297
297
|
config.tagline = "A 270-day journey through Scripture"
|
|
298
|
+
config.admin_emails = %w[andrew@example.org]
|
|
298
299
|
|
|
299
|
-
config.start_date = nil
|
|
300
|
+
# config.start_date = nil # community start date, or nil for undated
|
|
301
|
+
config.start_date = Date.new(2026, 9, 6)
|
|
300
302
|
config.allow_reader_start_date = true
|
|
301
303
|
|
|
302
304
|
config.parent_controller = "ActionController::Base" # or "::ApplicationController"
|
|
@@ -9,10 +9,25 @@ module Bible270
|
|
|
9
9
|
# so its existence isn't advertised.
|
|
10
10
|
class AdminController < ApplicationController
|
|
11
11
|
before_action :require_admin!
|
|
12
|
-
before_action :load_reader,
|
|
12
|
+
before_action :load_reader,
|
|
13
|
+
only: %i[show destroy update_start update_name complete_through toggle_day]
|
|
13
14
|
before_action :load_comment, only: %i[hide_comment unhide_comment destroy_comment]
|
|
14
15
|
|
|
16
|
+
# ---- enrolment --------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
def update_enrollment
|
|
19
|
+
if params[:state] == 'closed'
|
|
20
|
+
Setting.close_enrollment!
|
|
21
|
+
redirect_to admin_path, notice: 'Closed to new readers. Existing readers can still sign in.'
|
|
22
|
+
else
|
|
23
|
+
Setting.open_enrollment!
|
|
24
|
+
redirect_to admin_path, notice: 'Open to new readers.'
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
15
28
|
def index
|
|
29
|
+
@enrollment_closed = Setting.enrollment_closed?
|
|
30
|
+
@enrollment_closed_at = Setting.enrollment_closed_at
|
|
16
31
|
@readers = Reader.all.to_a.sort_by(&:sort_name)
|
|
17
32
|
counts = Checkoff.group(:reader_id, :day).count
|
|
18
33
|
@days_completed = Hash.new(0)
|
|
@@ -62,10 +77,15 @@ module Bible270
|
|
|
62
77
|
# Put the reader on a given day as of today, or set an explicit start date.
|
|
63
78
|
def update_start
|
|
64
79
|
if params[:start_date].present?
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
# set_start_date!, not update_start_date!: an admin is not subject to
|
|
81
|
+
# config.allow_reader_start_date, which governs what readers may do to
|
|
82
|
+
# their own dates.
|
|
83
|
+
if @reader.set_start_date!(params[:start_date])
|
|
84
|
+
redirect_to admin_reader_path(@reader),
|
|
85
|
+
notice: "Start date set to #{@reader.started_on.strftime('%B %-d, %Y')}."
|
|
67
86
|
else
|
|
68
|
-
redirect_to admin_reader_path(@reader),
|
|
87
|
+
redirect_to admin_reader_path(@reader),
|
|
88
|
+
alert: "Couldn't read #{params[:start_date].inspect} as a date."
|
|
69
89
|
end
|
|
70
90
|
elsif params[:day].present? && @reader.restart_on!(day: params[:day])
|
|
71
91
|
redirect_to admin_reader_path(@reader),
|
|
@@ -75,6 +95,14 @@ module Bible270
|
|
|
75
95
|
end
|
|
76
96
|
end
|
|
77
97
|
|
|
98
|
+
def update_name
|
|
99
|
+
if @reader.update_names(params[:first_name], params[:last_name])
|
|
100
|
+
redirect_to admin_reader_path(@reader), notice: 'Name updated.'
|
|
101
|
+
else
|
|
102
|
+
redirect_to admin_reader_path(@reader), alert: 'Both a first and last name are needed.'
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
78
106
|
def complete_through
|
|
79
107
|
day = params[:day].to_i
|
|
80
108
|
if @reader.mark_through!(day)
|
|
@@ -20,7 +20,7 @@ module Bible270
|
|
|
20
20
|
|
|
21
21
|
layout :bible270_layout
|
|
22
22
|
|
|
23
|
-
helper_method :current_reader, :signed_in?, :b270_config
|
|
23
|
+
helper_method :current_reader, :signed_in?, :b270_config, :enrollment_closed?
|
|
24
24
|
|
|
25
25
|
private
|
|
26
26
|
|
|
@@ -28,6 +28,10 @@ module Bible270
|
|
|
28
28
|
Bible270.config.layout
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
def enrollment_closed?
|
|
32
|
+
Setting.enrollment_closed?
|
|
33
|
+
end
|
|
34
|
+
|
|
31
35
|
def b270_config
|
|
32
36
|
Bible270.config
|
|
33
37
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bible270
|
|
4
|
+
# A reader editing their own name — the one thing about themselves they can
|
|
5
|
+
# change. Everything else about a reader comes from how they signed in.
|
|
6
|
+
class ProfilesController < ApplicationController
|
|
7
|
+
before_action :require_signed_in_reader
|
|
8
|
+
|
|
9
|
+
def edit
|
|
10
|
+
@names = current_reader.suggested_names
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def update
|
|
14
|
+
problems = []
|
|
15
|
+
problems << 'both a first and last name' unless current_reader.update_names(params[:first_name],
|
|
16
|
+
params[:last_name])
|
|
17
|
+
if params[:avatar].present? && !current_reader.attach_avatar(params[:avatar])
|
|
18
|
+
problems << (current_reader.errors[:avatar].first || 'a valid image')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
if problems.empty?
|
|
22
|
+
redirect_to reader_path(current_reader), notice: 'Your profile has been updated.'
|
|
23
|
+
else
|
|
24
|
+
@names = { first_name: params[:first_name], last_name: params[:last_name] }
|
|
25
|
+
flash.now[:alert] = "Please give #{problems.to_sentence}."
|
|
26
|
+
render :edit, status: :unprocessable_entity
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def remove_avatar
|
|
31
|
+
current_reader.remove_avatar!
|
|
32
|
+
redirect_to profile_path, notice: 'Your picture has been removed.'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Unlike checking off a reading, there is nothing sensible to do here for a
|
|
38
|
+
# visitor who isn't signed in, so send them to sign in rather than 404.
|
|
39
|
+
def require_signed_in_reader
|
|
40
|
+
return if signed_in?
|
|
41
|
+
|
|
42
|
+
redirect_to sign_in_path(origin: request.fullpath), alert: 'Please sign in first.'
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -21,6 +21,10 @@ module Bible270
|
|
|
21
21
|
auth = request.env['omniauth.auth']
|
|
22
22
|
redirect_to(sign_in_path, alert: "Sign in didn't complete. Please try again.") and return unless auth
|
|
23
23
|
|
|
24
|
+
if enrollment_closed? && !Reader.omniauth_reader_exists?(auth['provider'], auth['uid'])
|
|
25
|
+
redirect_to(sign_in_path, alert: enrollment_closed_message) and return
|
|
26
|
+
end
|
|
27
|
+
|
|
24
28
|
reader = Reader.from_omniauth(auth)
|
|
25
29
|
redirect_to(sign_in_path, alert: "We couldn't set up your reader profile.") and return unless reader&.persisted?
|
|
26
30
|
|
|
@@ -60,17 +64,18 @@ module Bible270
|
|
|
60
64
|
alert: "That doesn't look like an email address — please check and try again.") and return
|
|
61
65
|
end
|
|
62
66
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
alert: 'Please give both your first and last name.') and return
|
|
69
|
-
end
|
|
67
|
+
# Names are optional here. Requiring them only for unknown addresses would
|
|
68
|
+
# make the response differ, which tells a stranger whether an address
|
|
69
|
+
# already has an account. A new reader is asked for their name after they
|
|
70
|
+
# click the link instead (see email_callback).
|
|
71
|
+
names = Names.normalize(params[:first_name], params[:last_name])
|
|
70
72
|
|
|
71
|
-
_record, raw = SignInToken.issue!(address, first_name: first_name,
|
|
73
|
+
_record, raw = SignInToken.issue!(address, first_name: names&.dig(:first_name),
|
|
74
|
+
last_name: names&.dig(:last_name))
|
|
72
75
|
|
|
73
|
-
if raw
|
|
76
|
+
if raw && enrollment_closed? && !Reader.email_reader_exists?(address)
|
|
77
|
+
Rails.logger.info("[bible270] enrolment closed; no link sent to #{address}")
|
|
78
|
+
elsif raw
|
|
74
79
|
deliver_sign_in_link(address, email_sign_in_url(token: raw, origin: origin))
|
|
75
80
|
else
|
|
76
81
|
# Silent to the reader by design; say so in the log or this is undebuggable.
|
|
@@ -92,8 +97,12 @@ module Bible270
|
|
|
92
97
|
alert: 'That link has expired or was already used. Please request a new one.') and return
|
|
93
98
|
end
|
|
94
99
|
|
|
100
|
+
if enrollment_closed? && !Reader.email_reader_exists?(token.email)
|
|
101
|
+
redirect_to(sign_in_path, alert: enrollment_closed_message) and return
|
|
102
|
+
end
|
|
103
|
+
|
|
95
104
|
reader = Reader.from_email(token.email, first_name: token.first_name,
|
|
96
|
-
|
|
105
|
+
last_name: token.last_name, display_name: token.display_name)
|
|
97
106
|
redirect_to(sign_in_path, alert: "We couldn't set up your reader profile.") and return unless reader&.persisted?
|
|
98
107
|
|
|
99
108
|
destination = safe_origin(params[:origin]) || after_sign_in_path
|
|
@@ -102,6 +111,12 @@ module Bible270
|
|
|
102
111
|
session[:bible270_reader_id] = reader.id
|
|
103
112
|
@current_reader = reader
|
|
104
113
|
|
|
114
|
+
if name_needed?(reader)
|
|
115
|
+
redirect_to(profile_path,
|
|
116
|
+
notice: 'Welcome. Please add your name, so others know who they are reading with.')
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
|
|
105
120
|
redirect_to destination, notice: "Welcome, #{reader.display_name}."
|
|
106
121
|
end
|
|
107
122
|
|
|
@@ -137,6 +152,20 @@ module Bible270
|
|
|
137
152
|
Rails.env.development? || Rails.env.test?
|
|
138
153
|
end
|
|
139
154
|
|
|
155
|
+
# A reader who signed in by email and still has no first/last name. Applies
|
|
156
|
+
# only to email sign-in: an OmniAuth reader carries whatever name their
|
|
157
|
+
# provider gave.
|
|
158
|
+
def name_needed?(reader)
|
|
159
|
+
Bible270.config.email_sign_in_require_name &&
|
|
160
|
+
reader.provider == 'email' &&
|
|
161
|
+
reader.full_name.nil?
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def enrollment_closed_message
|
|
165
|
+
'This run of the plan is closed to new readers. If you have taken part before, ' \
|
|
166
|
+
'sign in with the same email or account.'
|
|
167
|
+
end
|
|
168
|
+
|
|
140
169
|
# Only ever redirect within this app, and never back into an auth route.
|
|
141
170
|
def safe_origin(value)
|
|
142
171
|
value = value.to_s
|
|
@@ -24,9 +24,18 @@ module Bible270
|
|
|
24
24
|
Bible270.config.passage_url_builder.call(reference, Bible270.config.bible_version)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# An uploaded avatar wins over whatever the sign-in provider gave us.
|
|
28
|
+
# Active Storage routes live in the host app, hence main_app.
|
|
29
|
+
def b270_avatar_src(reader)
|
|
30
|
+
return main_app.rails_blob_path(reader.avatar, only_path: true) if reader.avatar_uploaded?
|
|
31
|
+
|
|
32
|
+
reader.avatar_url.presence
|
|
33
|
+
end
|
|
34
|
+
|
|
27
35
|
def b270_avatar(reader, size: 34)
|
|
28
|
-
|
|
29
|
-
|
|
36
|
+
src = b270_avatar_src(reader)
|
|
37
|
+
if src
|
|
38
|
+
image_tag src, class: 'b270-avatar', width: size, height: size, alt: reader.display_name
|
|
30
39
|
else
|
|
31
40
|
content_tag :span, reader.initials, class: 'b270-avatar b270-avatar-fallback',
|
|
32
41
|
style: "width:#{size}px;height:#{size}px;line-height:#{size}px"
|
|
@@ -36,12 +45,10 @@ module Bible270
|
|
|
36
45
|
def b270_day_status_class(reader, day)
|
|
37
46
|
return '' unless reader
|
|
38
47
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
else
|
|
44
|
-
''
|
|
48
|
+
case reader.day_status(day)
|
|
49
|
+
when :complete then 'complete'
|
|
50
|
+
when :partial then 'partial'
|
|
51
|
+
else ''
|
|
45
52
|
end
|
|
46
53
|
end
|
|
47
54
|
end
|
|
@@ -10,6 +10,14 @@ module Bible270
|
|
|
10
10
|
has_many :comments, class_name: 'Bible270::Comment', dependent: :destroy
|
|
11
11
|
belongs_to :owner, polymorphic: true, optional: true
|
|
12
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
|
+
|
|
13
21
|
validates :display_name, presence: true
|
|
14
22
|
validates :uid, uniqueness: { scope: :provider }, allow_nil: true
|
|
15
23
|
|
|
@@ -37,6 +45,21 @@ module Bible270
|
|
|
37
45
|
# Find or create the reader behind a verified email address. Uses the same
|
|
38
46
|
# provider/uid identity columns as OmniAuth, with provider "email", so an
|
|
39
47
|
# email reader is indistinguishable from any other downstream.
|
|
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
|
+
|
|
40
63
|
def self.from_email(email, first_name: nil, last_name: nil, display_name: nil)
|
|
41
64
|
address = EmailSignIn.normalize_email(email)
|
|
42
65
|
return nil if address.nil?
|
|
@@ -87,6 +110,29 @@ module Bible270
|
|
|
87
110
|
[last_name, first_name].map { |n| n.to_s.strip.downcase }.join(' ').strip.presence || display_name.to_s.downcase
|
|
88
111
|
end
|
|
89
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
|
+
|
|
90
136
|
def initials
|
|
91
137
|
display_name.to_s.split(%r{\s+}).first(2).map { |w| w[0] }.join.upcase.presence || '?'
|
|
92
138
|
end
|
|
@@ -96,6 +142,20 @@ module Bible270
|
|
|
96
142
|
@checked_counts ||= checkoffs.group(:day).count
|
|
97
143
|
end
|
|
98
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
|
+
|
|
99
159
|
def read_tracks_for(day)
|
|
100
160
|
checkoffs.where(day: day).pluck(:track)
|
|
101
161
|
end
|
|
@@ -127,6 +187,23 @@ module Bible270
|
|
|
127
187
|
end
|
|
128
188
|
|
|
129
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
|
+
|
|
130
207
|
# ---- administrative adjustments --------------------------------------
|
|
131
208
|
|
|
132
209
|
# Tick every track that has content on this day.
|
|
@@ -248,15 +325,24 @@ module Bible270
|
|
|
248
325
|
started_on
|
|
249
326
|
end
|
|
250
327
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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)
|
|
254
333
|
date = Plan.to_date(value)
|
|
255
334
|
return false if date.nil?
|
|
256
335
|
|
|
257
336
|
update!(started_on: date)
|
|
258
337
|
end
|
|
259
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
|
+
|
|
260
346
|
def clear_start_date!
|
|
261
347
|
update!(started_on: nil)
|
|
262
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
|
|
@@ -4,6 +4,24 @@
|
|
|
4
4
|
<p class="lede"><%= @readers.size %> reader<%= 's' unless @readers.size == 1 %> taking part.
|
|
5
5
|
<%= link_to 'Moderate reflections →', admin_comments_path %></p>
|
|
6
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
|
+
|
|
7
25
|
<% if @readers.any? %>
|
|
8
26
|
<div class="b270-list">
|
|
9
27
|
<% @readers.each do |reader| %>
|
|
@@ -8,6 +8,21 @@
|
|
|
8
8
|
<% if @reader.dated? %>· day <%= @reader.calendar_day %> today<% end %>
|
|
9
9
|
</p>
|
|
10
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
|
+
|
|
11
26
|
<div class="b270-panel">
|
|
12
27
|
<h2 class="b270-subhead">Where they are in the plan</h2>
|
|
13
28
|
<%= form_with url: admin_reader_start_path(@reader), method: :patch, class: "b270-startform" do %>
|
|
@@ -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? %>
|
|
@@ -33,9 +33,8 @@
|
|
|
33
33
|
<p class="b270-signedout"><%= link_to "Sign in", sign_in_path %> to track your progress and join
|
|
34
34
|
the conversation. You can browse the whole plan and read others' reflections without an account.</p>
|
|
35
35
|
<% if @community_start_date %>
|
|
36
|
-
<p class="b270-startline">
|
|
37
|
-
<strong><%= @community_start_date.strftime("%B %-d, %Y") %></strong>
|
|
38
|
-
and finishes <%= Bible270::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>
|
|
39
38
|
<% end %>
|
|
40
39
|
<p style="margin:14px 0 0"><%= link_to "Start at Day 1 →", day_path(1), class: "b270-btn" %></p>
|
|
41
40
|
<% end %>
|
|
@@ -59,11 +58,3 @@
|
|
|
59
58
|
<p style="margin:12px 0 0"><%= link_to "See everyone →", community_path %></p>
|
|
60
59
|
<% end %>
|
|
61
60
|
|
|
62
|
-
<details class="b270-index">
|
|
63
|
-
<summary>All <%= Bible270::Plan::DAYS %> days</summary>
|
|
64
|
-
<div class="b270-grid">
|
|
65
|
-
<% (1..Bible270::Plan::DAYS).each do |d| %>
|
|
66
|
-
<%= link_to d, day_path(d), class: "b270-cell #{b270_day_status_class(current_reader, d)} #{'now' if signed_in? && d == @start_day}" %>
|
|
67
|
-
<% end %>
|
|
68
|
-
</div>
|
|
69
|
-
</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>
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
leave reflections alongside everyone else. Browsing the plan and reading others' reflections
|
|
6
6
|
never requires an account.</p>
|
|
7
7
|
|
|
8
|
+
<% if enrollment_closed? %>
|
|
9
|
+
<div class="b270-flash alert" style="margin-bottom:18px">
|
|
10
|
+
This run of the plan is closed to new readers. If you have taken part before, sign in with the
|
|
11
|
+
same email or account and you'll pick up where you left off.
|
|
12
|
+
</div>
|
|
13
|
+
<% end %>
|
|
14
|
+
|
|
8
15
|
<div class="b270-panel">
|
|
9
16
|
<% if Bible270.config.email_sign_in? %>
|
|
10
17
|
<h2 class="b270-subhead">Sign in with your email</h2>
|
|
@@ -13,15 +20,16 @@
|
|
|
13
20
|
<%= form_with url: email_sign_in_link_path, method: :post, class: "b270-emailform" do |f| %>
|
|
14
21
|
<%= hidden_field_tag :origin, @origin if @origin %>
|
|
15
22
|
<% if Bible270.config.email_sign_in_ask_name %>
|
|
23
|
+
<p class="b270-hint" style="margin:0">
|
|
24
|
+
If you have read with us before, just your email is enough — we already know your name.
|
|
25
|
+
</p>
|
|
16
26
|
<label class="b270-field">
|
|
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 %>
|
|
27
|
+
<span>First name <em>(new readers)</em></span>
|
|
28
|
+
<%= text_field_tag :first_name, params[:first_name], autocomplete: "given-name" %>
|
|
20
29
|
</label>
|
|
21
30
|
<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 %>
|
|
31
|
+
<span>Last name <em>(new readers)</em></span>
|
|
32
|
+
<%= text_field_tag :last_name, params[:last_name], autocomplete: "family-name" %>
|
|
25
33
|
</label>
|
|
26
34
|
<% end %>
|
|
27
35
|
<label class="b270-field">
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<% if Bible270::Plan::DAYS.positive? %>
|
|
2
|
+
<details class="b270-index">
|
|
3
|
+
<summary>All <%= Bible270::Plan::DAYS %> days</summary>
|
|
4
|
+
<div class="b270-grid">
|
|
5
|
+
<% today = signed_in? ? current_reader.current_day : nil %>
|
|
6
|
+
<% (1..Bible270::Plan::DAYS).each do |d| %>
|
|
7
|
+
<%= link_to d, day_path(d),
|
|
8
|
+
class: "b270-cell #{b270_day_status_class(current_reader, d)} #{'now' if d == today}" %>
|
|
9
|
+
<% end %>
|
|
10
|
+
</div>
|
|
11
|
+
</details>
|
|
12
|
+
<% end %>
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
<% if signed_in? %>
|
|
10
10
|
<% if Bible270.config.admin?(current_reader) %><%= link_to "Admin", admin_path %><% end %>
|
|
11
11
|
<%= link_to current_reader.display_name, reader_path(current_reader), class: "b270-me" %>
|
|
12
|
+
<%= link_to "Profile", profile_path %>
|
|
12
13
|
<%= button_to "Sign out", sign_out_path, method: :delete, class: "b270-linkbtn",
|
|
13
14
|
form: { style: "display:inline" } %>
|
|
14
15
|
<% elsif Bible270.config.any_sign_in_method? %>
|
|
@@ -20,9 +20,16 @@
|
|
|
20
20
|
.b270-nav{display:flex;align-items:center;gap:16px;font-size:14px}
|
|
21
21
|
.b270-nav a{color:var(--muted);text-decoration:none}
|
|
22
22
|
.b270-nav a:hover{color:var(--gold)}
|
|
23
|
+
/* button_to renders a form; make it a flex item so the button sits on the same
|
|
24
|
+
line as the links, and drop the margin/underline the shared class carries. */
|
|
25
|
+
.b270-nav form{display:flex;align-items:center;margin:0}
|
|
26
|
+
.b270-nav .b270-linkbtn{margin:0;padding:0;font-size:14px;color:var(--muted);text-decoration:none;line-height:1}
|
|
27
|
+
.b270-nav .b270-linkbtn:hover{color:var(--gold)}
|
|
23
28
|
.b270-signin,.b270-me{color:var(--ink)!important;font-weight:600}
|
|
24
29
|
|
|
25
30
|
.b270-subhead{font-family:"Spectral",serif;font-size:17px;font-weight:600;margin:0 0 4px}
|
|
31
|
+
.b270-avatarrow{display:flex;align-items:center;gap:14px}
|
|
32
|
+
.b270-avatarrow input[type=file]{font:inherit;font-size:13px;max-width:100%}
|
|
26
33
|
.b270-emailform{display:grid;gap:12px;max-width:340px;margin:12px 0 0}
|
|
27
34
|
.b270-field{display:grid;gap:5px;font-size:12.5px;color:var(--muted)}
|
|
28
35
|
.b270-field input{font:inherit;font-size:15px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);width:100%}
|
|
@@ -18,11 +18,9 @@
|
|
|
18
18
|
<% if flash[:alert] %><div class="b270-flash alert"><%= flash[:alert] %></div><% end %>
|
|
19
19
|
<% if flash[:notice] %><div class="b270-flash notice"><%= flash[:notice] %></div><% end %>
|
|
20
20
|
<%= yield %>
|
|
21
|
+
<%= render 'bible270/shared/day_index' %>
|
|
21
22
|
<footer class="b270-footer">
|
|
22
|
-
<p>The Old Testament
|
|
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>
|
|
23
|
+
<p>The Old Testament, New Testament and Psalms/Proverbs are each read once across the 270 days. Long chapters are divided so the readings fill each day evenly, e.g. Psalm 119 comes in eleven sections.</p>
|
|
26
24
|
</footer>
|
|
27
25
|
</div>
|
|
28
26
|
</body>
|
data/config/routes.rb
CHANGED
|
@@ -15,15 +15,21 @@ Bible270::Engine.routes.draw do
|
|
|
15
15
|
get 'admin', to: 'admin#index', as: :admin
|
|
16
16
|
get 'admin/readers/:id', to: 'admin#show', as: :admin_reader
|
|
17
17
|
delete 'admin/readers/:id', to: 'admin#destroy'
|
|
18
|
+
patch 'admin/readers/:id/name', to: 'admin#update_name', as: :admin_reader_name
|
|
18
19
|
patch 'admin/readers/:id/start', to: 'admin#update_start', as: :admin_reader_start
|
|
19
20
|
patch 'admin/readers/:id/through', to: 'admin#complete_through', as: :admin_reader_through
|
|
20
21
|
post 'admin/readers/:id/days/:day', to: 'admin#toggle_day', as: :admin_reader_day
|
|
21
22
|
|
|
23
|
+
patch 'admin/enrollment', to: 'admin#update_enrollment', as: :admin_enrollment
|
|
22
24
|
get 'admin/comments', to: 'admin#comments', as: :admin_comments
|
|
23
25
|
patch 'admin/comments/:id/hide', to: 'admin#hide_comment', as: :admin_hide_comment
|
|
24
26
|
patch 'admin/comments/:id/unhide', to: 'admin#unhide_comment', as: :admin_unhide_comment
|
|
25
27
|
delete 'admin/comments/:id', to: 'admin#destroy_comment', as: :admin_comment
|
|
26
28
|
|
|
29
|
+
get 'profile', to: 'profiles#edit', as: :profile
|
|
30
|
+
patch 'profile', to: 'profiles#update'
|
|
31
|
+
delete 'profile/avatar', to: 'profiles#remove_avatar', as: :profile_avatar
|
|
32
|
+
|
|
27
33
|
get 'community', to: 'readers#index', as: :community
|
|
28
34
|
get 'readers/:id', to: 'readers#show', as: :reader
|
|
29
35
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateBible270Settings < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
# A tiny key/value store for state an admin can change at runtime. Closing a
|
|
6
|
+
# run to new readers can't live in config, since that would need a deploy.
|
|
7
|
+
create_table :bible270_settings do |t|
|
|
8
|
+
t.string :key, null: false
|
|
9
|
+
t.string :value
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :bible270_settings, :key, unique: true, name: 'idx_b270_settings_key'
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bible270
|
|
4
|
+
# What counts as an acceptable avatar. Kept free of Rails and of Active Storage
|
|
5
|
+
# so the rules can be unit tested, and so the engine still loads in an app that
|
|
6
|
+
# has Active Storage disabled.
|
|
7
|
+
module Avatars
|
|
8
|
+
CONTENT_TYPES = %w[image/png image/jpeg image/gif image/webp].freeze
|
|
9
|
+
|
|
10
|
+
# Two megabytes. Large enough for a photo off a phone, small enough that
|
|
11
|
+
# nobody's profile page crawls.
|
|
12
|
+
DEFAULT_MAX_BYTES = 2 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def content_types = CONTENT_TYPES
|
|
17
|
+
|
|
18
|
+
def max_bytes
|
|
19
|
+
configured = Bible270.config.avatar_max_bytes if Bible270.config.respond_to?(:avatar_max_bytes)
|
|
20
|
+
configured.to_i.positive? ? configured.to_i : DEFAULT_MAX_BYTES
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def acceptable_type?(content_type)
|
|
24
|
+
CONTENT_TYPES.include?(content_type.to_s.downcase.split(';').first.to_s.strip)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def acceptable_size?(byte_size)
|
|
28
|
+
size = byte_size.to_i
|
|
29
|
+
size.positive? && size <= max_bytes
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# nil when the upload is fine, otherwise the reason — phrased for a reader.
|
|
33
|
+
def problem_with(content_type:, byte_size:)
|
|
34
|
+
return 'must be a PNG, JPEG, GIF or WebP image' unless acceptable_type?(content_type)
|
|
35
|
+
return 'looks empty' unless byte_size.to_i.positive?
|
|
36
|
+
return "must be under #{human_size(max_bytes)}" unless acceptable_size?(byte_size)
|
|
37
|
+
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def human_size(bytes)
|
|
42
|
+
mb = bytes.to_f / (1024 * 1024)
|
|
43
|
+
mb >= 1 ? "#{format('%g', mb.round(1))}MB" : "#{(bytes.to_f / 1024).round}KB"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
# URI.encode_www_form_component produces byte-identical output to CGI.escape.
|
|
7
7
|
require 'uri'
|
|
8
8
|
require 'bible270/plan'
|
|
9
|
+
require 'bible270/avatars'
|
|
9
10
|
|
|
10
11
|
module Bible270
|
|
11
12
|
class Configuration
|
|
@@ -108,7 +109,6 @@ module Bible270
|
|
|
108
109
|
|
|
109
110
|
# Simple abuse guard: at most N links per address per window (seconds).
|
|
110
111
|
attr_accessor :email_sign_in_window
|
|
111
|
-
attr_accessor :after_sign_out_path, :email_sign_in_max_per_window, :passage_url_builder, :tagline, :admin_emails, :admin_resolver
|
|
112
112
|
|
|
113
113
|
# Whether a reader may type the display name shown beside their reflections
|
|
114
114
|
# when signing in by email (otherwise it's derived from the address).
|
|
@@ -133,7 +133,12 @@ module Bible270
|
|
|
133
133
|
# With neither set the panel is unreachable and its routes 404.
|
|
134
134
|
# Break points set from the host app, overriding Plan::CHAPTER_BREAKS.
|
|
135
135
|
# Keys may be ['Psalm', 18] or 'Psalm 18'; [] keeps a chapter whole.
|
|
136
|
-
|
|
136
|
+
# Largest avatar a reader may upload, in bytes. Uploading needs Active
|
|
137
|
+
# Storage in the host app; without it the field is hidden and readers keep
|
|
138
|
+
# whatever avatar their sign-in provider gave them.
|
|
139
|
+
# Whether new readers may join this run of the plan. An admin can close it
|
|
140
|
+
# from the panel at runtime; setting this to false launches closed.
|
|
141
|
+
attr_accessor :enrollment_open
|
|
137
142
|
|
|
138
143
|
# Optional YAML file holding the same thing, re-read whenever it changes so
|
|
139
144
|
# breaks can be tuned without restarting:
|
|
@@ -142,6 +147,7 @@ module Bible270
|
|
|
142
147
|
# Psalm 35: []
|
|
143
148
|
# Psalm 78: [20, 39, 55]
|
|
144
149
|
attr_accessor :chapter_breaks_path
|
|
150
|
+
attr_accessor :after_sign_out_path, :email_sign_in_max_per_window, :passage_url_builder, :tagline, :avatar_max_bytes, :chapter_breaks, :admin_emails, :admin_resolver
|
|
145
151
|
|
|
146
152
|
def admin?(reader)
|
|
147
153
|
return false if reader.nil?
|
|
@@ -160,6 +166,27 @@ module Bible270
|
|
|
160
166
|
|
|
161
167
|
def email_sign_in? = !!@email_sign_in
|
|
162
168
|
|
|
169
|
+
# Reserved domains from RFC 2606 — a From: address at one of these will fail
|
|
170
|
+
# SPF for your real domain, and receiving servers routinely drop it without
|
|
171
|
+
# a bounce. The default value is one of them deliberately, so it has to be
|
|
172
|
+
# changed, but nothing was checking that it had been.
|
|
173
|
+
PLACEHOLDER_DOMAINS = %w[example.com example.org example.net example.edu invalid localhost].freeze
|
|
174
|
+
|
|
175
|
+
# Why the sign-in mail is likely to vanish, or nil if it looks fine.
|
|
176
|
+
def mailer_from_problem
|
|
177
|
+
return nil unless email_sign_in?
|
|
178
|
+
|
|
179
|
+
address = mailer_from.to_s.strip
|
|
180
|
+
return 'config.mailer_from is blank' if address.empty?
|
|
181
|
+
return "config.mailer_from (#{address}) is not a valid address" unless EmailSignIn.valid_email?(address)
|
|
182
|
+
|
|
183
|
+
domain = address.split('@').last.to_s.downcase
|
|
184
|
+
return nil unless PLACEHOLDER_DOMAINS.include?(domain)
|
|
185
|
+
|
|
186
|
+
"config.mailer_from is still the placeholder #{address} — mail from a reserved domain " \
|
|
187
|
+
'fails SPF and is usually dropped without a bounce'
|
|
188
|
+
end
|
|
189
|
+
|
|
163
190
|
# Any way at all for a new person to sign in?
|
|
164
191
|
def any_sign_in_method?
|
|
165
192
|
email_sign_in? || omniauth_providers.any?
|
|
@@ -224,6 +251,8 @@ module Bible270
|
|
|
224
251
|
@email_sign_in_ask_name = true
|
|
225
252
|
@email_sign_in_require_name = true
|
|
226
253
|
@email_sign_in_log_link = nil
|
|
254
|
+
@enrollment_open = true
|
|
255
|
+
@avatar_max_bytes = Avatars::DEFAULT_MAX_BYTES
|
|
227
256
|
@chapter_breaks = {}
|
|
228
257
|
@chapter_breaks_path = nil
|
|
229
258
|
@admin_emails = []
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bible270
|
|
4
|
+
# Readers are known to each other by name beside their reflections, so a first
|
|
5
|
+
# and last name are both required wherever a name is set: at email sign-in, when
|
|
6
|
+
# a reader edits their own, and when an admin edits someone else's.
|
|
7
|
+
#
|
|
8
|
+
# Kept free of Rails so the rules can be tested on their own.
|
|
9
|
+
module Names
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Collapse runs of whitespace, including the non-breaking space that arrives
|
|
13
|
+
# from copy-paste, and trim.
|
|
14
|
+
def squish(value)
|
|
15
|
+
value.to_s.tr("\u00A0", ' ').gsub(%r{\s+}, ' ').strip
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A first and last name, or nil when either is missing. Returns the pieces
|
|
19
|
+
# plus the display name built from them, so every caller derives it the same
|
|
20
|
+
# way rather than assembling it locally.
|
|
21
|
+
def normalize(first, last)
|
|
22
|
+
first_name = squish(first)
|
|
23
|
+
last_name = squish(last)
|
|
24
|
+
return nil if first_name.empty? || last_name.empty?
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
first_name: first_name,
|
|
28
|
+
last_name: last_name,
|
|
29
|
+
display_name: "#{first_name} #{last_name}"
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def valid?(first, last)
|
|
34
|
+
!normalize(first, last).nil?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Best effort at splitting a single name, for readers who arrived through
|
|
38
|
+
# OmniAuth with only a display name. Everything after the first word is the
|
|
39
|
+
# surname, so "Andrew von der Luft" keeps its particles together.
|
|
40
|
+
def split(display_name)
|
|
41
|
+
parts = squish(display_name).split
|
|
42
|
+
return nil if parts.size < 2
|
|
43
|
+
|
|
44
|
+
{ first_name: parts.first, last_name: parts[1..].join(' ') }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/bible270/plan.rb
CHANGED
|
@@ -15,7 +15,7 @@ module Bible270
|
|
|
15
15
|
# grouped so each day is ~equal in verses (~73/day)
|
|
16
16
|
# * New Testament – read once, one reading every day; the 10 longest chapters
|
|
17
17
|
# (Luke 1 and friends) are divided in two so 260 chapters fill 270 days
|
|
18
|
-
# * Psalms/Proverbs – Psalms
|
|
18
|
+
# * Psalms/Proverbs – Psalms and Proverbs once, interleaved in one track.
|
|
19
19
|
# Longer psalms are divided so the two fill exactly 270 days;
|
|
20
20
|
# Psalm 119 is pinned to 11 sections of 16 verses.
|
|
21
21
|
#
|
|
@@ -292,7 +292,7 @@ module Bible270
|
|
|
292
292
|
end
|
|
293
293
|
|
|
294
294
|
def parse_breaks_file(path)
|
|
295
|
-
raw = YAML.
|
|
295
|
+
raw = YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
|
|
296
296
|
raise ArgumentError, "#{path} must map \"Book Chapter\" to a list of verses" unless raw.is_a?(Hash)
|
|
297
297
|
|
|
298
298
|
normalize_breaks(raw)
|
|
@@ -308,7 +308,7 @@ module Bible270
|
|
|
308
308
|
def break_key(key)
|
|
309
309
|
return [key[0].to_s, key[1].to_i] if key.is_a?(Array)
|
|
310
310
|
|
|
311
|
-
match = key.to_s.strip.match(
|
|
311
|
+
match = key.to_s.strip.match(%r{\A(.+?)\s+(\d+)\z})
|
|
312
312
|
raise ArgumentError, "chapter break key #{key.inspect} must look like 'Psalm 18'" if match.nil?
|
|
313
313
|
|
|
314
314
|
[match[1], match[2].to_i]
|
|
@@ -406,7 +406,7 @@ module Bible270
|
|
|
406
406
|
['Psalm', 44] => [], ['Psalm', 49] => [], ['Psalm', 50] => [], ['Psalm', 51] => [], # Stay whole
|
|
407
407
|
['Psalm', 55] => [], ['Psalm', 59] => [], ['Psalm', 65] => [], ['Psalm', 66] => [], # Stay whole
|
|
408
408
|
['Psalm', 68] => [18], # => Psalm 68:1–18, 68:19–35
|
|
409
|
-
['Psalm', 69] => [18
|
|
409
|
+
['Psalm', 69] => [18], # => Psalm 69:1–18, 69:19–36
|
|
410
410
|
['Psalm', 78] => [20, 39, 55], # => 78:1–20, 21–39, 40–55, 56–72
|
|
411
411
|
['Psalm', 37] => [20], # => Psalm 37:1–20, 37:21–40
|
|
412
412
|
['Matthew', 26] => [35], # => Matthew 26:1–35, 26:36–75
|
data/lib/bible270/version.rb
CHANGED
data/lib/bible270.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bible270
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew vonderLuft
|
|
@@ -71,6 +71,7 @@ files:
|
|
|
71
71
|
- app/controllers/bible270/checkoffs_controller.rb
|
|
72
72
|
- app/controllers/bible270/comments_controller.rb
|
|
73
73
|
- app/controllers/bible270/days_controller.rb
|
|
74
|
+
- app/controllers/bible270/profiles_controller.rb
|
|
74
75
|
- app/controllers/bible270/readers_controller.rb
|
|
75
76
|
- app/controllers/bible270/sessions_controller.rb
|
|
76
77
|
- app/helpers/bible270/plan_helper.rb
|
|
@@ -80,6 +81,7 @@ files:
|
|
|
80
81
|
- app/models/bible270/checkoff.rb
|
|
81
82
|
- app/models/bible270/comment.rb
|
|
82
83
|
- app/models/bible270/reader.rb
|
|
84
|
+
- app/models/bible270/setting.rb
|
|
83
85
|
- app/models/bible270/sign_in_token.rb
|
|
84
86
|
- app/views/bible270/admin/comments.html.erb
|
|
85
87
|
- app/views/bible270/admin/index.html.erb
|
|
@@ -93,9 +95,11 @@ files:
|
|
|
93
95
|
- app/views/bible270/days/_start_date.html.erb
|
|
94
96
|
- app/views/bible270/days/index.html.erb
|
|
95
97
|
- app/views/bible270/days/show.html.erb
|
|
98
|
+
- app/views/bible270/profiles/edit.html.erb
|
|
96
99
|
- app/views/bible270/readers/index.html.erb
|
|
97
100
|
- app/views/bible270/readers/show.html.erb
|
|
98
101
|
- app/views/bible270/sessions/new.html.erb
|
|
102
|
+
- app/views/bible270/shared/_day_index.html.erb
|
|
99
103
|
- app/views/bible270/shared/_header.html.erb
|
|
100
104
|
- app/views/bible270/shared/_styles.html.erb
|
|
101
105
|
- app/views/bible270/sign_in_mailer/magic_link.html.erb
|
|
@@ -108,10 +112,13 @@ files:
|
|
|
108
112
|
- db/migrate/20260101000004_create_bible270_sign_in_tokens.rb
|
|
109
113
|
- db/migrate/20260101000005_add_names_to_bible270_readers.rb
|
|
110
114
|
- db/migrate/20260101000006_add_approval_to_bible270_comments.rb
|
|
115
|
+
- db/migrate/20260101000007_create_bible270_settings.rb
|
|
111
116
|
- lib/bible270.rb
|
|
117
|
+
- lib/bible270/avatars.rb
|
|
112
118
|
- lib/bible270/configuration.rb
|
|
113
119
|
- lib/bible270/email_sign_in.rb
|
|
114
120
|
- lib/bible270/engine.rb
|
|
121
|
+
- lib/bible270/names.rb
|
|
115
122
|
- lib/bible270/plan.rb
|
|
116
123
|
- lib/bible270/versification.rb
|
|
117
124
|
- lib/bible270/version.rb
|