bible270 1.4.1 → 1.5.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -3
  3. data/README.md +11 -3
  4. data/app/controllers/bible270/admin_controller.rb +19 -2
  5. data/app/controllers/bible270/checkoffs_controller.rb +20 -16
  6. data/app/controllers/bible270/comments_controller.rb +11 -1
  7. data/app/controllers/bible270/days_controller.rb +3 -10
  8. data/app/helpers/bible270/plan_helper.rb +33 -27
  9. data/app/mailers/bible270/application_mailer.rb +1 -0
  10. data/app/models/bible270/comment.rb +20 -0
  11. data/app/models/bible270/reader.rb +29 -0
  12. data/app/views/bible270/admin/comments.html.erb +1 -1
  13. data/app/views/bible270/admin/show.html.erb +23 -0
  14. data/app/views/bible270/checkoffs/toggle.turbo_stream.erb +1 -1
  15. data/app/views/bible270/comments/_body.html.erb +6 -0
  16. data/app/views/bible270/comments/_comment.html.erb +1 -1
  17. data/app/views/bible270/comments/_edit_fields.html.erb +2 -1
  18. data/app/views/bible270/comments/_editor.html.erb +57 -0
  19. data/app/views/bible270/comments/_form.html.erb +3 -12
  20. data/app/views/bible270/comments/create.turbo_stream.erb +2 -1
  21. data/app/views/bible270/days/_completers.html.erb +2 -3
  22. data/app/views/bible270/days/_day_progress.html.erb +4 -2
  23. data/app/views/bible270/days/show.html.erb +4 -3
  24. data/app/views/bible270/notice_mailer/comment_notice.html.erb +1 -1
  25. data/app/views/bible270/notice_mailer/comment_notice.text.erb +1 -1
  26. data/app/views/bible270/shared/_comment_drafts.html.erb +9 -1
  27. data/app/views/bible270/shared/_day_completion_dove.html.erb +7 -5
  28. data/app/views/bible270/shared/_interaction_ui.html.erb +62 -1
  29. data/app/views/bible270/shared/_markdown_toolbar.html.erb +239 -0
  30. data/app/views/bible270/shared/_reflections.html.erb +1 -1
  31. data/app/views/bible270/shared/_styles.html.erb +40 -7
  32. data/config/routes.rb +4 -0
  33. data/db/migrate/20260101000020_add_completion_dove_disabled_to_bible270_readers.rb +7 -0
  34. data/db/migrate/20260101000021_add_body_format_to_bible270_comments.rb +11 -0
  35. data/lib/bible270/comment_formatter.rb +135 -0
  36. data/lib/bible270/migration_reconciler.rb +17 -0
  37. data/lib/bible270/version.rb +1 -1
  38. data/lib/bible270.rb +1 -0
  39. metadata +41 -1
@@ -2,9 +2,8 @@
2
2
  <div class="b270-completers" id="completers_<%= day %>">
3
3
  <% if completers.any? %>
4
4
  <span>Finished this day:</span>
5
- <% completers.first(12).each do |reader| %>
6
- <%= link_to b270_avatar(reader), reader_path(reader), title: reader.display_name %>
5
+ <% completers.each do |reader| %>
6
+ <%= link_to b270_avatar(reader, size: 56), reader_path(reader), title: reader.display_name %>
7
7
  <% end %>
8
- <% if completers.size > 12 %><span><%= completers.size - 12 %> more</span><% end %>
9
8
  <% end %>
10
9
  </div>
@@ -1,4 +1,4 @@
1
- <%# locals: (day:, reader:, just_completed: false) %>
1
+ <%# locals: (day:, reader:, just_completed: false, completion_event_id: nil) %>
2
2
  <%
3
3
  previous_day = day > 1 ? day - 1 : nil
4
4
  next_day = day < Bible270::Plan::DAYS ? day + 1 : nil
@@ -33,7 +33,9 @@
33
33
 
34
34
 
35
35
  <% if complete %>
36
- <%= render "bible270/shared/day_completion_dove", day: day if just_completed %>
36
+ <% if just_completed && completion_event_id && !reader&.completion_dove_disabled? %>
37
+ <%= render "bible270/shared/day_completion_dove", day: day, completion_event_id: completion_event_id %>
38
+ <% end %>
37
39
  <div class="b270-day-complete" role="status" aria-live="polite"
38
40
  <%= tag.attributes(data: completion_data) %>>
39
41
  <div>
@@ -2,7 +2,7 @@
2
2
 
3
3
  <div class="b270-day-page">
4
4
  <%= render "bible270/days/day_progress", day: @day, reader: current_reader,
5
- just_completed: @day_just_completed %>
5
+ just_completed: @day_just_completed, completion_event_id: @completion_event_id %>
6
6
 
7
7
  <%= render "bible270/shared/interaction_status" %>
8
8
 
@@ -16,8 +16,6 @@
16
16
  <% end %>
17
17
 
18
18
  <div class="b270-day-community">
19
- <%= render "bible270/days/completers", day: @day, completers: @completers %>
20
-
21
19
  <h2 class="b270-section-h">Reflections</h2>
22
20
  <div id="comments">
23
21
  <%= render partial: "bible270/comments/comment", collection: @comments, as: :comment %>
@@ -29,7 +27,10 @@
29
27
  </div>
30
28
  <%= render "bible270/shared/comment_drafts" %>
31
29
  <%= render "bible270/shared/mention_typeahead" %>
30
+ <%= render "bible270/shared/markdown_toolbar" %>
32
31
  <% end %>
32
+
33
+ <%= render "bible270/days/completers", day: @day, completers: @completers %>
33
34
  </div>
34
35
  </div>
35
36
 
@@ -10,7 +10,7 @@
10
10
  </p>
11
11
 
12
12
  <blockquote style="margin:0;padding:14px 16px;background:#ece6da;border-left:3px solid #a5812c;border-radius:8px;font-size:15px;line-height:1.55">
13
- <%= simple_format(@comment.body) %>
13
+ <%= b270_comment_body(@comment, link_mentions: false) %>
14
14
  </blockquote>
15
15
 
16
16
  <% if @day_url %>
@@ -1,6 +1,6 @@
1
1
  <%= @introduction %>
2
2
 
3
- <%= @comment.body %>
3
+ <%= b270_comment_text(@comment) %>
4
4
 
5
5
  <% if @day_url %>
6
6
  Read it and reply: <%= @day_url %>
@@ -56,6 +56,7 @@
56
56
  const key = form.dataset.b270DraftKey;
57
57
  const body = form.querySelector("textarea[name='comment[body]']");
58
58
  const track = form.querySelector("select[name='comment[track]']");
59
+ const format = form.querySelector("[data-b270-body-format]");
59
60
  if (!key || !body) return;
60
61
 
61
62
  if (!body.value.trim()) {
@@ -65,8 +66,10 @@
65
66
  }
66
67
 
67
68
  const trackValue = track ? track.value : "";
69
+ const bodyFormat = format ? format.value : null;
68
70
  const current = readDraft(key);
69
- if (current && current.body === body.value && current.track === trackValue) {
71
+ if (current && current.body === body.value && current.track === trackValue &&
72
+ current.bodyFormat === bodyFormat) {
70
73
  setStatus(form, "Draft saved in this browser.");
71
74
  return;
72
75
  }
@@ -74,6 +77,7 @@
74
77
  const draft = {
75
78
  body: body.value,
76
79
  track: trackValue,
80
+ bodyFormat,
77
81
  savedAt: Date.now(),
78
82
  revision: `${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}`
79
83
  };
@@ -115,10 +119,14 @@
115
119
 
116
120
  const body = form.querySelector("textarea[name='comment[body]']");
117
121
  const track = form.querySelector("select[name='comment[track]']");
122
+ const format = form.querySelector("[data-b270-body-format]");
118
123
  if (body) body.value = draft.body;
119
124
  if (track && Array.from(track.options).some((option) => option.value === draft.track)) {
120
125
  track.value = draft.track;
121
126
  }
127
+ if (format) {
128
+ format.value = ["plain", "markdown"].includes(draft.bodyFormat) ? draft.bodyFormat : "plain";
129
+ }
122
130
  setStatus(form, "Draft restored from this browser.");
123
131
  }
124
132
 
@@ -1,17 +1,19 @@
1
- <%# locals: (day:) %>
1
+ <%# locals: (day:, completion_event_id:) %>
2
2
  <svg class="b270-completion-dove" data-b270-completion-dove data-b270-day="<%= day %>"
3
+ data-b270-completion-event="<%= completion_event_id %>" data-turbo-temporary
3
4
  viewBox="0 0 260 200" aria-hidden="true" focusable="false">
4
5
  <defs>
5
6
  <radialGradient id="b270-dove-light" cx="50%" cy="43%" r="68%">
6
- <stop offset="0%" stop-color="#fff" />
7
- <stop offset="58%" stop-color="#fffef8" />
8
- <stop offset="100%" stop-color="#fff9e2" />
7
+ <stop class="b270-dove-light-center" offset="0%" stop-color="#fff" />
8
+ <stop class="b270-dove-light-middle" offset="58%" stop-color="#fffef8" />
9
+ <stop class="b270-dove-light-edge" offset="100%" stop-color="#fff9e2" />
9
10
  </radialGradient>
10
11
  </defs>
11
12
  <g class="b270-dove-silhouette">
12
13
  <path d="M122 94C103 82 87 69 72 53L39 17l11 28-23-23 15 32-23-20 19 30-24-16 22 26-25-12 27 23-24-7 30 19-22-3 31 17c20 11 42 16 59 8 5-9 8-18 10-25Z" />
13
14
  <path d="M138 94c19-12 35-25 50-41l33-36-11 28 23-23-15 32 23-20-19 30 24-16-22 26 25-12-27 23 24-7-30 19 22-3-31 17c-20 11-42 16-59 8-5-9-8-18-10-25Z" />
14
15
  <path d="M148 140c6 7 16 14 24 19 2 2 1 5-2 6-5 2-10-1-14-5 4 8 7 17 4 22-2 3-5 1-7-2l-6-11c3 10 2 19-3 24-2 3-5 1-6-3l-4-15c0 11-3 21-8 24-3 2-5-1-6-5l-1-19c-3 10-6 17-11 20-3 2-5-1-5-5l2-19c-4 8-9 13-14 14-3 1-5-2-4-6l6-18c-5 5-11 7-16 4-3-1-3-4 0-7 7-7 17-13 26-19Z" />
15
- <path d="M108 116c5-10 9-17 14-17-1-11-2-23 4-29 5-4 12-3 15 1 2 3 2 6 1 9 3 .2 6 .8 8 1.5-2.5 1.3-5.5 2.1-8 2.5-1 5-3 10-5 15 6 3 11 9 15 17-1 15-6 28-16 39-3 4-7 7-10 9-4-2-8-5-11-9-9-11-14-25-11-37 1-1 2-2 4-2Z" />
16
+ <path class="b270-dove-beak" d="M140 80C144 80 148 80.8 151 82 152 82.4 152 83.2 151 83.8 148 85.1 144 85.8 140 85 138.5 83.5 138.5 81.2 140 80Z" />
17
+ <path d="M108 116c5-10 9-17 14-17-1-11-2-23 4-29 5-4 12-3 15 1 2 3 2 6 1 9 1 1 1 3 0 4-1 5-3 10-5 15 6 3 11 9 15 17-1 15-6 28-16 39-3 4-7 7-10 9-4-2-8-5-11-9-9-11-14-25-11-37 1-1 2-2 4-2Z" />
16
18
  </g>
17
19
  </svg>
@@ -18,6 +18,53 @@
18
18
  region.dataset.state = state;
19
19
  }
20
20
 
21
+ const shownCompletionDoves = new Set();
22
+
23
+ function claimCompletionDove(dove) {
24
+ const eventId = dove.dataset.b270CompletionEvent;
25
+ if (!eventId) return false;
26
+
27
+ const key = `b270-completion-dove:${eventId}`;
28
+ if (shownCompletionDoves.has(key)) return false;
29
+
30
+ try {
31
+ if (window.sessionStorage.getItem(key) === "shown") return false;
32
+ window.sessionStorage.setItem(key, "shown");
33
+ } catch (_error) {
34
+ // Some privacy modes disable session storage. The in-memory set still
35
+ // prevents duplicate Turbo rendering within the current page.
36
+ }
37
+ shownCompletionDoves.add(key);
38
+ return true;
39
+ }
40
+
41
+ function initializeCompletionDove(dove) {
42
+ if (dove.dataset.b270DoveInitialized === "true") return;
43
+ dove.dataset.b270DoveInitialized = "true";
44
+
45
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
46
+ dove.remove();
47
+ return;
48
+ }
49
+
50
+ if (!claimCompletionDove(dove)) {
51
+ dove.remove();
52
+ return;
53
+ }
54
+
55
+ // Keep an active flight outside the Turbo-replaced progress panel so a
56
+ // replayed response cannot restart or cut short the same completion event.
57
+ if (dove.parentElement !== document.body) document.body.appendChild(dove);
58
+ requestAnimationFrame(() => {
59
+ if (dove.isConnected) dove.classList.add("b270-completion-dove-active");
60
+ });
61
+ }
62
+
63
+ function initializeCompletionDoves(root = document) {
64
+ if (root.matches && root.matches("[data-b270-completion-dove]")) initializeCompletionDove(root);
65
+ root.querySelectorAll?.("[data-b270-completion-dove]").forEach(initializeCompletionDove);
66
+ }
67
+
21
68
  function resetForm(form) {
22
69
  form.removeAttribute("aria-busy");
23
70
  form.dataset.b270Submitting = "false";
@@ -29,8 +76,18 @@
29
76
 
30
77
  function reset() {
31
78
  document.querySelectorAll("form[data-b270-submit='true']").forEach(resetForm);
79
+ initializeCompletionDoves();
32
80
  }
33
81
 
82
+ const completionDoveObserver = new MutationObserver((mutations) => {
83
+ mutations.forEach((mutation) => {
84
+ mutation.addedNodes.forEach((node) => {
85
+ if (node.nodeType === Node.ELEMENT_NODE) initializeCompletionDoves(node);
86
+ });
87
+ });
88
+ });
89
+ completionDoveObserver.observe(document.documentElement, { childList: true, subtree: true });
90
+
34
91
  const SESSION_REFRESH_AFTER = 5 * 60 * 1000;
35
92
  let sessionRefreshedAt = Date.now();
36
93
  let sessionRefresh = null;
@@ -201,8 +258,12 @@
201
258
  }
202
259
  });
203
260
  document.addEventListener("turbo:load", reset);
261
+ document.addEventListener("turbo:before-cache", () => {
262
+ document.querySelectorAll("[data-b270-completion-dove]").forEach((dove) => dove.remove());
263
+ });
204
264
  document.addEventListener("animationend", (event) => {
205
- if (event.target.matches("[data-b270-completion-dove]")) event.target.remove();
265
+ if (event.animationName === "b270-dove-flight" &&
266
+ event.target.matches("[data-b270-completion-dove]")) event.target.remove();
206
267
  });
207
268
 
208
269
  window.Bible270InteractionUI = { reset };
@@ -0,0 +1,239 @@
1
+ <script nonce="<%= content_security_policy_nonce %>">
2
+ (() => {
3
+ if (window.Bible270MarkdownToolbar) {
4
+ window.Bible270MarkdownToolbar.scan();
5
+ return;
6
+ }
7
+
8
+ const openEditors = new Set();
9
+
10
+ function editorKey(editor) {
11
+ return editor.dataset.b270EditorKey;
12
+ }
13
+
14
+ function select(textarea, start, finish) {
15
+ textarea.focus();
16
+ textarea.setSelectionRange(start, finish);
17
+ }
18
+
19
+ function resizeTextarea(textarea) {
20
+ if (!textarea || textarea.dataset.b270Autosize !== "true") return;
21
+
22
+ textarea.style.height = "auto";
23
+ const borders = textarea.offsetHeight - textarea.clientHeight;
24
+ textarea.style.height = `${textarea.scrollHeight + borders}px`;
25
+ }
26
+
27
+ function initializeAutosize(textarea) {
28
+ if (!textarea || textarea.dataset.b270Autosize !== "true" ||
29
+ textarea.dataset.b270AutosizeReady === "true") return;
30
+
31
+ textarea.dataset.b270AutosizeReady = "true";
32
+ textarea.addEventListener("input", () => resizeTextarea(textarea));
33
+ window.requestAnimationFrame(() => resizeTextarea(textarea));
34
+ }
35
+
36
+ function resizeEditors() {
37
+ document.querySelectorAll("textarea[data-b270-autosize='true']").forEach(resizeTextarea);
38
+ }
39
+
40
+ function update(editor, textarea, replacement, start, finish, selectedStart, selectedEnd) {
41
+ textarea.value = `${textarea.value.slice(0, start)}${replacement}${textarea.value.slice(finish)}`;
42
+ const format = editor.querySelector("[data-b270-body-format]");
43
+ if (format) format.value = "markdown";
44
+ select(textarea, start + selectedStart, start + selectedEnd);
45
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
46
+ }
47
+
48
+ function wrap(editor, textarea, before, after, placeholder) {
49
+ const start = textarea.selectionStart;
50
+ const finish = textarea.selectionEnd;
51
+ const selected = textarea.value.slice(start, finish) || placeholder;
52
+ const replacement = `${before}${selected}${after}`;
53
+ update(editor, textarea, replacement, start, finish, before.length, before.length + selected.length);
54
+ }
55
+
56
+ function prefixLines(editor, textarea, action) {
57
+ const selectionStart = textarea.selectionStart;
58
+ const selectionEnd = textarea.selectionEnd;
59
+ const start = textarea.value.lastIndexOf("\n", Math.max(selectionStart - 1, 0)) + 1;
60
+ const nextNewline = textarea.value.indexOf("\n", selectionEnd);
61
+ const finish = nextNewline === -1 ? textarea.value.length : nextNewline;
62
+ const source = textarea.value.slice(start, finish) || (action === "quote" ? "quoted text" : "list item");
63
+ const lines = source.split("\n");
64
+ const replacement = lines.map((line, index) => {
65
+ if (action === "quote") return `> ${line}`;
66
+ if (action === "number-list") return `${index + 1}. ${line}`;
67
+ return `- ${line}`;
68
+ }).join("\n");
69
+ update(editor, textarea, replacement, start, finish, 0, replacement.length);
70
+ }
71
+
72
+ function addLink(editor, textarea) {
73
+ const href = window.prompt("Link address (https://…)");
74
+ if (!href) return;
75
+
76
+ const start = textarea.selectionStart;
77
+ const finish = textarea.selectionEnd;
78
+ const selected = textarea.value.slice(start, finish) || "link text";
79
+ const replacement = `[${selected}](${href.trim()})`;
80
+ update(editor, textarea, replacement, start, finish, 1, selected.length + 1);
81
+ }
82
+
83
+ function requestPreview(editor, state) {
84
+ const textarea = editor.querySelector("textarea[name='comment[body]']");
85
+ const preview = editor.querySelector("[data-b270-preview-panel]");
86
+ const url = editor.dataset.b270PreviewUrl;
87
+ if (!textarea || !preview || !url) return;
88
+
89
+ if (!textarea.value.trim()) {
90
+ preview.innerHTML = '<p class="b270-hint">Nothing to preview yet.</p>';
91
+ return;
92
+ }
93
+
94
+ if (state.abort) state.abort.abort();
95
+ state.abort = new AbortController();
96
+ preview.textContent = "Loading preview…";
97
+ const body = new URLSearchParams({ "comment[body]": textarea.value });
98
+ const token = document.querySelector("meta[name='csrf-token']")?.content;
99
+ const headers = {
100
+ Accept: "text/html",
101
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
102
+ };
103
+ if (token) headers["X-CSRF-Token"] = token;
104
+
105
+ fetch(url, {
106
+ method: "POST",
107
+ credentials: "same-origin",
108
+ cache: "no-store",
109
+ redirect: "error",
110
+ headers,
111
+ body: body.toString(),
112
+ signal: state.abort.signal
113
+ }).then((response) => {
114
+ if (!response.ok) throw new Error("Preview unavailable");
115
+ return response.text();
116
+ }).then((html) => {
117
+ preview.innerHTML = html;
118
+ }).catch((error) => {
119
+ if (error.name !== "AbortError") preview.textContent = "Preview unavailable. Return to Write and try again.";
120
+ });
121
+ }
122
+
123
+ function setFormattingOpen(editor, open, remember = true) {
124
+ const drawer = editor.querySelector("[data-b270-formatting-drawer]");
125
+ const toggle = editor.querySelector("[data-b270-formatting-toggle]");
126
+ if (!drawer || !toggle) return;
127
+
128
+ editor.dataset.b270FormattingOpen = open ? "true" : "false";
129
+ toggle.setAttribute("aria-expanded", open ? "true" : "false");
130
+ drawer.setAttribute("aria-hidden", open ? "false" : "true");
131
+ drawer.inert = !open;
132
+ if (!remember) return;
133
+
134
+ if (open) openEditors.add(editorKey(editor));
135
+ else openEditors.delete(editorKey(editor));
136
+ }
137
+
138
+ function showMode(editor, state, mode) {
139
+ const previewing = mode === "preview";
140
+ const writePanel = editor.querySelector("[data-b270-write-panel]");
141
+ const previewPanel = editor.querySelector("[data-b270-preview-panel]");
142
+ const previewToggle = editor.querySelector("[data-b270-preview-toggle]");
143
+ const formattingToggle = editor.querySelector("[data-b270-formatting-toggle]");
144
+ if (!writePanel || !previewPanel || !previewToggle || !formattingToggle) return;
145
+
146
+ editor.dataset.b270EditorMode = mode;
147
+ writePanel.hidden = previewing;
148
+ previewPanel.hidden = !previewing;
149
+ previewToggle.textContent = previewing ? "Write" : "Preview";
150
+ previewToggle.setAttribute("aria-pressed", previewing ? "true" : "false");
151
+ previewToggle.setAttribute("aria-label", previewing ? "Return to writing" : "Preview reflection");
152
+ formattingToggle.disabled = previewing;
153
+ if (previewing) {
154
+ setFormattingOpen(editor, false, false);
155
+ requestPreview(editor, state);
156
+ } else {
157
+ setFormattingOpen(editor, openEditors.has(editorKey(editor)), false);
158
+ const textarea = editor.querySelector("textarea[name='comment[body]']");
159
+ resizeTextarea(textarea);
160
+ textarea?.focus();
161
+ }
162
+ }
163
+
164
+ function apply(editor, action) {
165
+ const textarea = editor.querySelector("textarea[name='comment[body]']");
166
+ if (!textarea) return;
167
+
168
+ if (action === "bold") {
169
+ wrap(editor, textarea, "**", "**", "bold text");
170
+ } else if (action === "italic") {
171
+ wrap(editor, textarea, "*", "*", "italic text");
172
+ } else if (action === "link") {
173
+ addLink(editor, textarea);
174
+ } else {
175
+ prefixLines(editor, textarea, action);
176
+ }
177
+ }
178
+
179
+ function initialize(editor) {
180
+ const textarea = editor.querySelector("textarea[name='comment[body]']");
181
+ if (!textarea) return;
182
+ initializeAutosize(textarea);
183
+ if (editor.dataset.b270MarkdownReady === "true") return;
184
+
185
+ const utilities = editor.querySelector("[data-b270-editor-utilities]");
186
+ const toolbar = editor.querySelector("[data-b270-markdown-toolbar]");
187
+ const drawer = editor.querySelector("[data-b270-formatting-drawer]");
188
+ const formattingToggle = editor.querySelector("[data-b270-formatting-toggle]");
189
+ const previewToggle = editor.querySelector("[data-b270-preview-toggle]");
190
+ if (!utilities || !toolbar || !drawer || !formattingToggle || !previewToggle) return;
191
+
192
+ editor.dataset.b270MarkdownReady = "true";
193
+ if (editor.closest("form")?.dataset.b270DraftPosted === "true") openEditors.delete(editorKey(editor));
194
+ const state = { abort: null };
195
+ utilities.hidden = false;
196
+ setFormattingOpen(editor, openEditors.has(editorKey(editor)), false);
197
+ formattingToggle.addEventListener("click", () => {
198
+ setFormattingOpen(editor, editor.dataset.b270FormattingOpen !== "true");
199
+ });
200
+ previewToggle.addEventListener("click", () => {
201
+ const mode = editor.dataset.b270EditorMode === "preview" ? "write" : "preview";
202
+ showMode(editor, state, mode);
203
+ });
204
+ toolbar.querySelectorAll("[data-b270-markdown-action]").forEach((button) => {
205
+ button.addEventListener("click", () => apply(editor, button.dataset.b270MarkdownAction));
206
+ });
207
+ toolbar.addEventListener("keydown", (event) => {
208
+ if (event.key !== "Escape") return;
209
+
210
+ event.preventDefault();
211
+ setFormattingOpen(editor, false);
212
+ formattingToggle.focus();
213
+ });
214
+ textarea.addEventListener("keydown", (event) => {
215
+ if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
216
+ const action = event.key.toLocaleLowerCase() === "b" ? "bold" :
217
+ event.key.toLocaleLowerCase() === "i" ? "italic" : null;
218
+ if (!action) return;
219
+
220
+ event.preventDefault();
221
+ apply(editor, action);
222
+ });
223
+ }
224
+
225
+ function scan() {
226
+ document.querySelectorAll("[data-b270-markdown-editor]").forEach(initialize);
227
+ }
228
+
229
+ scan();
230
+ new MutationObserver(scan).observe(document.documentElement, { childList: true, subtree: true });
231
+ window.addEventListener("pageshow", () => {
232
+ scan();
233
+ resizeEditors();
234
+ });
235
+ window.addEventListener("resize", resizeEditors);
236
+ document.addEventListener("turbo:load", scan);
237
+ window.Bible270MarkdownToolbar = { scan };
238
+ })();
239
+ </script>
@@ -23,7 +23,7 @@
23
23
  <% if moderate && comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
24
24
  </div>
25
25
 
26
- <p class="b270-cbody"><%= comment.body %></p>
26
+ <div class="b270-cbody"><%= b270_comment_body(comment) %></div>
27
27
 
28
28
  <% if moderate %>
29
29
  <div style="display:flex;gap:10px;margin-top:6px">
@@ -148,16 +148,20 @@
148
148
  .b270-day-complete span{font-size:12px;color:var(--muted)}
149
149
  .b270 .b270-day-complete .b270-btn{flex:none;margin:0 0 0 auto}
150
150
  .b270-completion-dove{position:fixed;z-index:1000;top:72vh;left:50vw;
151
- width:min(75vw,97.5vh);height:auto;overflow:visible;pointer-events:none;opacity:0;
151
+ width:min(80vw,104vh);height:auto;overflow:visible;pointer-events:none;opacity:0;
152
+ color-scheme:only light;forced-color-adjust:none;
152
153
  filter:drop-shadow(0 0 8px rgba(255,255,255,1)) drop-shadow(0 0 24px rgba(255,252,220,1)) drop-shadow(0 0 52px rgba(225,191,92,.82));
153
- will-change:top,left,transform,opacity;animation:b270-dove-flight 1.6s cubic-bezier(.18,.72,.28,1) both}
154
- .b270-dove-silhouette{fill:url(#b270-dove-light);stroke:none}
154
+ will-change:top,left,transform,opacity}
155
+ .b270-completion-dove-active{animation:b270-dove-flight 1.7s cubic-bezier(.18,.72,.28,1) both}
156
+ .b270-dove-silhouette{fill:url(#b270-dove-light)!important;stroke:none}
157
+ .b270-dove-light-center{stop-color:#fff!important}
158
+ .b270-dove-light-middle{stop-color:#fffef8!important}
159
+ .b270-dove-light-edge{stop-color:#fff9e2!important}
155
160
  @keyframes b270-dove-flight{
156
161
  0%{top:72vh;left:50vw;opacity:.22;visibility:visible;transform:translate(-50%,-50%) scale(.02) rotate(-8deg)}
157
162
  8%{top:calc(72vh - 10px);left:50vw;opacity:.78;transform:translate(-50%,-50%) scale(.08) rotate(-5deg)}
158
- 64%{top:38vh;left:52vw;opacity:.92;transform:translate(-50%,-50%) scale(1) rotate(4deg)}
159
- 99%{visibility:visible}
160
- 100%{top:-25vh;left:115vw;opacity:0;visibility:hidden;transform:translate(-50%,-50%) scale(1.55) rotate(12deg)}
163
+ 33%{top:50vh;left:50vw;opacity:1;visibility:visible;transform:translate(-50%,-50%) scale(1) rotate(4deg)}
164
+ 100%{top:50vh;left:50vw;opacity:0;visibility:hidden;transform:translate(-50%,-50%) scale(1) rotate(4deg)}
161
165
  }
162
166
 
163
167
  /* readings */
@@ -252,7 +256,15 @@
252
256
  .b270-likes:active .b270-liketooltip{display:block}
253
257
  .b270-liker{display:block;white-space:nowrap}
254
258
  .b270-creply{margin-top:6px}
255
- .b270-cbody{margin:3px 0 0;white-space:pre-wrap}
259
+ .b270-cbody{margin:3px 0 0;line-height:1.2}
260
+ .b270-cbody p{margin:0;white-space:pre-wrap}
261
+ .b270-cbody p+p,.b270-cbody p+ul,.b270-cbody p+ol,.b270-cbody p+blockquote,
262
+ .b270-cbody ul+p,.b270-cbody ol+p,.b270-cbody blockquote+p{margin-top:8px}
263
+ .b270-cbody ul,.b270-cbody ol{margin:6px 0 0;padding-left:24px}
264
+ .b270-cbody li{margin:2px 0;white-space:pre-wrap}
265
+ .b270-cbody blockquote{margin:7px 0 0;padding:2px 0 2px 12px;border-left:3px solid var(--line);color:var(--muted)}
266
+ .b270-cbody a{color:var(--teal);text-decoration:underline;text-decoration-color:var(--line)}
267
+ .b270-cbody a:hover{text-decoration-color:currentColor}
256
268
  .b270-editform{margin-top:6px}
257
269
  .b270-editform textarea{min-height:64px}
258
270
  .b270-editerror{margin:0 0 8px;font-size:13px;color:var(--oxblood)}
@@ -264,6 +276,27 @@
264
276
  .b270-cdel{font-size:12px;color:var(--oxblood);background:none;border:none;cursor:pointer;padding:0;margin-left:8px}
265
277
  .b270-input{width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:9px;background:#fff;font:inherit;font-size:14px;margin-bottom:8px}
266
278
  .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}
279
+ .b270-form textarea[data-b270-autosize=false]{min-height:114px}
280
+ .b270-editor-utilities[hidden]{display:none}
281
+ .b270-editor-utilities{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 2px 5px}
282
+ .b270 .b270-editor-utility{padding:2px 0;border:0;background:transparent;color:var(--muted);font:inherit;font-size:12px;cursor:pointer}
283
+ .b270 .b270-editor-utility:hover,.b270 .b270-editor-utility:focus-visible{color:var(--gold)}
284
+ .b270 .b270-editor-utility:disabled{color:var(--faint);opacity:.55;cursor:default}
285
+ .b270-format-chevron{display:inline-block;margin-left:2px;transition:transform .14s ease}
286
+ .b270-markdown-editor[data-b270-formatting-open=true] .b270-format-chevron{transform:rotate(180deg)}
287
+ .b270-formatting-drawer{display:grid;grid-template-rows:0fr;opacity:0;transition:grid-template-rows .14s ease,opacity .1s ease}
288
+ .b270-markdown-editor[data-b270-formatting-open=true] .b270-formatting-drawer{grid-template-rows:1fr;opacity:1}
289
+ .b270-formatting-drawer-inner{min-height:0;overflow:hidden}
290
+ .b270-markdown-toolbar{display:flex;align-items:center;gap:2px;flex-wrap:wrap;padding:5px;border:1px solid var(--line);border-bottom:0;border-radius:10px 10px 0 0;background:var(--paper)}
291
+ .b270 .b270-markdown-toolbar button{min-height:32px;padding:5px 9px;border:0;border-radius:6px;background:transparent;color:var(--muted);font:inherit;font-size:12.5px;cursor:pointer}
292
+ .b270 .b270-markdown-toolbar button:hover,.b270 .b270-markdown-toolbar button:focus-visible{background:var(--card);color:var(--ink)}
293
+ .b270-format-glyph{display:inline-block;min-width:13px;font-family:Georgia,"Times New Roman",serif;font-size:18px;line-height:1}
294
+ .b270-format-bold{font-weight:700}
295
+ .b270-format-italic{font-style:italic;font-weight:600}
296
+ .b270-markdown-editor[data-b270-formatting-open=true] textarea{border-radius:0 0 10px 10px}
297
+ .b270-markdown-preview{min-height:76px;margin:0;padding:11px 13px;border:1px solid var(--line);border-radius:10px;background:#fff;color:var(--ink)}
298
+ .b270-markdown-preview[hidden]{display:none}
299
+ .b270-markdown-preview>.b270-hint{margin:0}
267
300
  .b270-sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}
268
301
  .b270-mention-composer{position:relative}
269
302
  .b270-mention-options{position:absolute;z-index:30;left:0;right:0;top:calc(100% + 4px);overflow:hidden;
data/config/routes.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Rails routes are intentionally kept in one cohesive DSL block.
3
4
  Bible270::Engine.routes.draw do
4
5
  root to: 'days#index'
5
6
 
@@ -12,6 +13,7 @@ Bible270::Engine.routes.draw do
12
13
  constraints: { day: %r{\d+} }
13
14
  post 'day/:day/comments', to: 'comments#create', as: :day_comments,
14
15
  constraints: { day: %r{\d+} }
16
+ post 'comments/preview', to: 'comments#preview', as: :comment_preview
15
17
  post 'comments/:id/like', to: 'likes#toggle', as: :like_comment
16
18
  patch 'comments/:id', to: 'comments#update'
17
19
  delete 'comments/:id', to: 'comments#destroy', as: :comment
@@ -22,6 +24,8 @@ Bible270::Engine.routes.draw do
22
24
  delete 'admin/readers/:id', to: 'admin#destroy'
23
25
  patch 'admin/readers/:id/profile', to: 'admin#update_profile', as: :admin_reader_profile
24
26
  patch 'admin/readers/:id/notifications', to: 'admin#update_notifications', as: :admin_reader_notifications
27
+ patch 'admin/readers/:id/completion-animation', to: 'admin#update_completion_animation',
28
+ as: :admin_reader_completion_animation
25
29
  delete 'admin/readers/:id/avatar', to: 'admin#remove_avatar', as: :admin_reader_avatar
26
30
  patch 'admin/readers/:id/version', to: 'admin#update_bible_version', as: :admin_reader_version
27
31
  patch 'admin/readers/:id/start', to: 'admin#update_start', as: :admin_reader_start
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddCompletionDoveDisabledToBible270Readers < ActiveRecord::Migration[7.0]
4
+ def change
5
+ add_column :bible270_readers, :completion_dove_disabled, :boolean, null: false, default: false
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddBodyFormatToBible270Comments < ActiveRecord::Migration[7.0]
4
+ def up
5
+ add_column :bible270_comments, :body_format, :string, null: false, default: 'plain'
6
+ end
7
+
8
+ def down
9
+ remove_column :bible270_comments, :body_format
10
+ end
11
+ end