bible270 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +196 -237
  3. data/README.md +159 -79
  4. data/Rakefile +5 -4
  5. data/app/controllers/bible270/admin_controller.rb +147 -0
  6. data/app/controllers/bible270/application_controller.rb +24 -3
  7. data/app/controllers/bible270/checkoffs_controller.rb +2 -3
  8. data/app/controllers/bible270/comments_controller.rb +6 -2
  9. data/app/controllers/bible270/days_controller.rb +5 -6
  10. data/app/controllers/bible270/profiles_controller.rb +45 -0
  11. data/app/controllers/bible270/readers_controller.rb +5 -4
  12. data/app/controllers/bible270/sessions_controller.rb +89 -26
  13. data/app/helpers/bible270/plan_helper.rb +22 -12
  14. data/app/mailers/bible270/application_mailer.rb +4 -1
  15. data/app/mailers/bible270/sign_in_mailer.rb +1 -0
  16. data/app/models/bible270/application_record.rb +1 -0
  17. data/app/models/bible270/checkoff.rb +4 -3
  18. data/app/models/bible270/comment.rb +26 -5
  19. data/app/models/bible270/reader.rb +167 -17
  20. data/app/models/bible270/setting.rb +57 -0
  21. data/app/models/bible270/sign_in_token.rb +5 -2
  22. data/app/views/bible270/admin/comments.html.erb +44 -0
  23. data/app/views/bible270/admin/index.html.erb +48 -0
  24. data/app/views/bible270/admin/show.html.erb +104 -0
  25. data/app/views/bible270/days/_start_date.html.erb +1 -1
  26. data/app/views/bible270/days/index.html.erb +9 -21
  27. data/app/views/bible270/profiles/edit.html.erb +44 -0
  28. data/app/views/bible270/readers/index.html.erb +2 -2
  29. data/app/views/bible270/readers/show.html.erb +5 -5
  30. data/app/views/bible270/sessions/new.html.erb +17 -3
  31. data/app/views/bible270/shared/_day_index.html.erb +12 -0
  32. data/app/views/bible270/shared/_header.html.erb +2 -0
  33. data/app/views/bible270/shared/_styles.html.erb +17 -7
  34. data/app/views/layouts/bible270/application.html.erb +2 -4
  35. data/config/routes.rb +38 -18
  36. data/db/migrate/20260101000001_create_bible270_readers.rb +3 -2
  37. data/db/migrate/20260101000002_create_bible270_checkoffs.rb +3 -2
  38. data/db/migrate/20260101000003_create_bible270_comments.rb +4 -3
  39. data/db/migrate/20260101000004_create_bible270_sign_in_tokens.rb +4 -3
  40. data/db/migrate/20260101000005_add_names_to_bible270_readers.rb +12 -0
  41. data/db/migrate/20260101000006_add_approval_to_bible270_comments.rb +11 -0
  42. data/db/migrate/20260101000007_create_bible270_settings.rb +15 -0
  43. data/lib/bible270/avatars.rb +46 -0
  44. data/lib/bible270/configuration.rb +96 -24
  45. data/lib/bible270/email_sign_in.rb +7 -7
  46. data/lib/bible270/engine.rb +1 -0
  47. data/lib/bible270/names.rb +47 -0
  48. data/lib/bible270/plan.rb +356 -102
  49. data/lib/bible270/versification.rb +84 -67
  50. data/lib/bible270/version.rb +2 -1
  51. data/lib/bible270.rb +8 -5
  52. data/lib/generators/bible270/install/install_generator.rb +521 -38
  53. data/lib/generators/bible270/install/templates/bible270.rb.tt +2 -2
  54. metadata +15 -44
@@ -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>
@@ -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,14 +20,21 @@
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>
26
+ <label class="b270-field">
27
+ <span>First name <em>(new readers)</em></span>
28
+ <%= text_field_tag :first_name, params[:first_name], autocomplete: "given-name" %>
29
+ </label>
16
30
  <label class="b270-field">
17
- <span>Name shown beside your reflections</span>
18
- <%= text_field_tag :display_name, nil, placeholder: "e.g. Andrew", autocomplete: "name" %>
31
+ <span>Last name <em>(new readers)</em></span>
32
+ <%= text_field_tag :last_name, params[:last_name], autocomplete: "family-name" %>
19
33
  </label>
20
34
  <% end %>
21
35
  <label class="b270-field">
22
36
  <span>Email address</span>
23
- <%= email_field_tag :email, nil, required: true, placeholder: "you@example.com",
37
+ <%= email_field_tag :email, params[:email], required: true, placeholder: "you@example.com",
24
38
  autocomplete: "email", inputmode: "email" %>
25
39
  </label>
26
40
  <%= f.submit "Email me a sign-in link", class: "b270-btn" %>
@@ -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 %>
@@ -7,7 +7,9 @@
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" %>
12
+ <%= link_to "Profile", profile_path %>
11
13
  <%= button_to "Sign out", sign_out_path, method: :delete, class: "b270-linkbtn",
12
14
  form: { style: "display:inline" } %>
13
15
  <% elsif Bible270.config.any_sign_in_method? %>
@@ -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
 
@@ -17,9 +20,16 @@
17
20
  .b270-nav{display:flex;align-items:center;gap:16px;font-size:14px}
18
21
  .b270-nav a{color:var(--muted);text-decoration:none}
19
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)}
20
28
  .b270-signin,.b270-me{color:var(--ink)!important;font-weight:600}
21
29
 
22
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%}
23
33
  .b270-emailform{display:grid;gap:12px;max-width:340px;margin:12px 0 0}
24
34
  .b270-field{display:grid;gap:5px;font-size:12.5px;color:var(--muted)}
25
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%}
@@ -28,10 +38,10 @@
28
38
  .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
39
  .b270-or::before,.b270-or::after{content:"";flex:1;height:1px;background:var(--line-soft)}
30
40
  .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}
41
+ .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
42
  .b270-provider:hover{border-color:var(--gold);color:var(--gold)}
33
43
  .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}
44
+ .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
45
  .b270-signinbtn:hover{border-color:var(--gold);color:var(--gold)}
36
46
  .b270-signin-hint a{color:var(--gold);font-weight:600}
37
47
  .b270-flash{border-radius:10px;padding:11px 15px;margin-bottom:18px;font-size:14px}
@@ -41,7 +51,7 @@
41
51
  .b270-eyebrow{font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--gold);font-weight:600;margin:0 0 10px}
42
52
  .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
53
  .b270 h1 em{font-style:italic;color:var(--muted);font-weight:400}
44
- .b270 .lede{color:var(--muted);max-width:54ch;margin:0 0 24px;font-size:15px}
54
+ .b270 .lede{color:var(--muted);max-width:77ch;margin:0 0 24px;font-size:15px}
45
55
 
46
56
  .b270-panel{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px 22px;box-shadow:var(--shadow);margin-bottom:24px}
47
57
  .b270-bignum{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
@@ -72,7 +82,7 @@
72
82
 
73
83
  /* day nav */
74
84
  .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}
85
+ .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
86
  .b270-arrow:hover{border-color:var(--gold);color:var(--gold)}
77
87
  .b270-arrow.disabled{opacity:.35;pointer-events:none}
78
88
  .b270-dayhead{flex:1}
@@ -101,7 +111,7 @@
101
111
  .b270-ref a:hover{border-bottom-color:var(--gold)}
102
112
  .b270-reading.done .b270-ref{color:var(--muted)}
103
113
  .b270-reading.rest .b270-ref{font-style:italic;font-size:16px;color:var(--faint)}
104
- .b270-signin-hint{font-size:12px;color:var(--faint)}
114
+ .b270 .b270-signin-hint{font-size:12px;color:var(--faint)}
105
115
 
106
116
  /* completers */
107
117
  .b270-completers{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:16px;font-size:13px;color:var(--muted)}
@@ -118,7 +128,7 @@
118
128
  .b270-cdel{font-size:12px;color:var(--oxblood);background:none;border:none;cursor:pointer;padding:0;margin-left:8px}
119
129
  .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
130
  .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}
131
+ .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
132
  .b270-btn:hover{background:#3a352b}
123
133
 
124
134
  /* community */
@@ -133,7 +143,7 @@
133
143
 
134
144
  /* calendar grid */
135
145
  .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}
146
+ .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
147
  .b270-cell:hover{border-color:var(--gold);color:var(--gold)}
138
148
  .b270-cell.partial{background:linear-gradient(135deg,var(--card) 50%,rgba(165,129,44,.35) 50%);color:var(--muted)}
139
149
  .b270-cell.complete{background:var(--teal);border-color:var(--teal);color:#eef3f2;font-weight:600}
@@ -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 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>
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
@@ -1,30 +1,50 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  Bible270::Engine.routes.draw do
3
- root to: "days#index"
4
+ root to: 'days#index'
5
+
6
+ get 'day/:day', to: 'days#show', as: :day, constraints: { day: %r{\d+} }
7
+
8
+ post 'day/:day/toggle/:track', to: 'checkoffs#toggle', as: :toggle_checkoff,
9
+ constraints: { day: %r{\d+} }
10
+ post 'day/:day/comments', to: 'comments#create', as: :day_comments,
11
+ constraints: { day: %r{\d+} }
12
+ delete 'comments/:id', to: 'comments#destroy', as: :comment
13
+
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/name', to: 'admin#update_name', as: :admin_reader_name
19
+ patch 'admin/readers/:id/start', to: 'admin#update_start', as: :admin_reader_start
20
+ patch 'admin/readers/:id/through', to: 'admin#complete_through', as: :admin_reader_through
21
+ post 'admin/readers/:id/days/:day', to: 'admin#toggle_day', as: :admin_reader_day
4
22
 
5
- get "day/:day", to: "days#show", as: :day, constraints: { day: /\d+/ }
23
+ patch 'admin/enrollment', to: 'admin#update_enrollment', as: :admin_enrollment
24
+ get 'admin/comments', to: 'admin#comments', as: :admin_comments
25
+ patch 'admin/comments/:id/hide', to: 'admin#hide_comment', as: :admin_hide_comment
26
+ patch 'admin/comments/:id/unhide', to: 'admin#unhide_comment', as: :admin_unhide_comment
27
+ delete 'admin/comments/:id', to: 'admin#destroy_comment', as: :admin_comment
6
28
 
7
- post "day/:day/toggle/:track", to: "checkoffs#toggle", as: :toggle_checkoff,
8
- constraints: { day: /\d+/ }
9
- post "day/:day/comments", to: "comments#create", as: :day_comments,
10
- constraints: { day: /\d+/ }
11
- delete "comments/:id", to: "comments#destroy", as: :comment
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
12
32
 
13
- get "community", to: "readers#index", as: :community
14
- get "readers/:id", to: "readers#show", as: :reader
33
+ get 'community', to: 'readers#index', as: :community
34
+ get 'readers/:id', to: 'readers#show', as: :reader
15
35
 
16
- patch "start-date", to: "readers#update_start_date", as: :start_date
17
- delete "start-date", to: "readers#clear_start_date", as: :clear_start_date
36
+ patch 'start-date', to: 'readers#update_start_date', as: :start_date
37
+ delete 'start-date', to: 'readers#clear_start_date', as: :clear_start_date
18
38
 
19
39
  # Built-in OmniAuth sign-in. The request phase (POST <mount>/auth/:provider)
20
40
  # is handled by the OmniAuth middleware in the host app, not by these routes.
21
- get "sign_in", to: "sessions#new", as: :sign_in
41
+ get 'sign_in', to: 'sessions#new', as: :sign_in
22
42
 
23
43
  # Email (magic link) sign-in, for readers without a social account.
24
- post "sign_in/email", to: "sessions#email_link", as: :email_sign_in_link
25
- get "sign_in/email/:token", to: "sessions#email_callback", as: :email_sign_in
26
- get "auth/:provider/callback", to: "sessions#create"
27
- post "auth/:provider/callback", to: "sessions#create"
28
- get "auth/failure", to: "sessions#failure"
29
- delete "sign_out", to: "sessions#destroy", as: :sign_out
44
+ post 'sign_in/email', to: 'sessions#email_link', as: :email_sign_in_link
45
+ get 'sign_in/email/:token', to: 'sessions#email_callback', as: :email_sign_in
46
+ get 'auth/:provider/callback', to: 'sessions#create'
47
+ post 'auth/:provider/callback', to: 'sessions#create'
48
+ get 'auth/failure', to: 'sessions#failure'
49
+ delete 'sign_out', to: 'sessions#destroy', as: :sign_out
30
50
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  class CreateBible270Readers < ActiveRecord::Migration[7.0]
3
4
  def change
4
5
  create_table :bible270_readers do |t|
@@ -8,10 +9,10 @@ class CreateBible270Readers < ActiveRecord::Migration[7.0]
8
9
  t.string :provider
9
10
  t.string :uid
10
11
  t.references :owner, polymorphic: true, index: true
11
- t.date :started_on
12
+ t.date :started_on
12
13
  t.timestamps
13
14
  end
14
15
  add_index :bible270_readers, %i[provider uid], unique: true,
15
- name: "idx_b270_readers_provider_uid"
16
+ name: 'idx_b270_readers_provider_uid'
16
17
  end
17
18
  end
@@ -1,15 +1,16 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  class CreateBible270Checkoffs < ActiveRecord::Migration[7.0]
3
4
  def change
4
5
  create_table :bible270_checkoffs do |t|
5
6
  t.references :reader, null: false, index: true,
6
- foreign_key: { to_table: :bible270_readers }
7
+ foreign_key: { to_table: :bible270_readers }
7
8
  t.integer :day, null: false
8
9
  t.string :track, null: false
9
10
  t.timestamps
10
11
  end
11
12
  add_index :bible270_checkoffs, %i[reader_id day track], unique: true,
12
- name: "idx_b270_checkoffs_unique"
13
+ name: 'idx_b270_checkoffs_unique'
13
14
  add_index :bible270_checkoffs, :day
14
15
  end
15
16
  end
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  class CreateBible270Comments < ActiveRecord::Migration[7.0]
3
4
  def change
4
5
  create_table :bible270_comments do |t|
5
6
  t.references :reader, null: false, index: true,
6
- foreign_key: { to_table: :bible270_readers }
7
- t.integer :day, null: false
7
+ foreign_key: { to_table: :bible270_readers }
8
+ t.integer :day, null: false
8
9
  t.string :track
9
- t.text :body, null: false
10
+ t.text :body, null: false
10
11
  t.timestamps
11
12
  end
12
13
  add_index :bible270_comments, %i[day created_at]
@@ -1,18 +1,19 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  class CreateBible270SignInTokens < ActiveRecord::Migration[7.0]
3
4
  def change
4
5
  create_table :bible270_sign_in_tokens do |t|
5
6
  t.string :email, null: false
6
7
  t.string :token_digest, null: false
7
8
  t.string :display_name
8
- t.datetime :expires_at, null: false
9
+ t.datetime :expires_at, null: false
9
10
  t.datetime :consumed_at
10
11
  t.timestamps
11
12
  end
12
13
 
13
14
  add_index :bible270_sign_in_tokens, :token_digest, unique: true,
14
- name: "idx_b270_signin_tokens_digest"
15
+ name: 'idx_b270_signin_tokens_digest'
15
16
  add_index :bible270_sign_in_tokens, %i[email created_at],
16
- name: "idx_b270_signin_tokens_email"
17
+ name: 'idx_b270_signin_tokens_email'
17
18
  end
18
19
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddNamesToBible270Readers < ActiveRecord::Migration[7.0]
4
+ def change
5
+ add_column :bible270_readers, :first_name, :string
6
+ add_column :bible270_readers, :last_name, :string
7
+ add_column :bible270_sign_in_tokens, :first_name, :string
8
+ add_column :bible270_sign_in_tokens, :last_name, :string
9
+
10
+ add_index :bible270_readers, %i[last_name first_name], name: 'idx_b270_readers_name'
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddApprovalToBible270Comments < ActiveRecord::Migration[7.0]
4
+ def change
5
+ # Reflections are visible as soon as they are written; moderation is for
6
+ # taking something down after the fact, not for gatekeeping every post.
7
+ add_column :bible270_comments, :approved, :boolean, null: false, default: true
8
+ add_column :bible270_comments, :moderated_at, :datetime
9
+ add_index :bible270_comments, %i[approved day]
10
+ end
11
+ end
@@ -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