bible270 1.4.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -6
- data/README.md +11 -3
- data/app/controllers/bible270/comments_controller.rb +11 -1
- data/app/controllers/bible270/days_controller.rb +1 -1
- data/app/helpers/bible270/plan_helper.rb +33 -27
- data/app/mailers/bible270/application_mailer.rb +1 -0
- data/app/models/bible270/comment.rb +20 -0
- data/app/views/bible270/admin/comments.html.erb +1 -1
- data/app/views/bible270/comments/_body.html.erb +6 -0
- data/app/views/bible270/comments/_comment.html.erb +1 -1
- data/app/views/bible270/comments/_edit_fields.html.erb +2 -1
- data/app/views/bible270/comments/_editor.html.erb +57 -0
- data/app/views/bible270/comments/_form.html.erb +3 -12
- data/app/views/bible270/comments/create.turbo_stream.erb +2 -1
- data/app/views/bible270/days/show.html.erb +1 -0
- data/app/views/bible270/notice_mailer/comment_notice.html.erb +1 -1
- data/app/views/bible270/notice_mailer/comment_notice.text.erb +1 -1
- data/app/views/bible270/shared/_comment_drafts.html.erb +9 -1
- data/app/views/bible270/shared/_markdown_toolbar.html.erb +239 -0
- data/app/views/bible270/shared/_reflections.html.erb +1 -1
- data/app/views/bible270/shared/_styles.html.erb +30 -1
- data/config/routes.rb +1 -0
- data/db/migrate/20260101000021_add_body_format_to_bible270_comments.rb +11 -0
- data/lib/bible270/comment_formatter.rb +135 -0
- data/lib/bible270/migration_reconciler.rb +12 -0
- data/lib/bible270/version.rb +1 -1
- data/lib/bible270.rb +1 -0
- metadata +40 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7867bb4de49fe177447a5f7b1cfa9fa031e1926cdb8c4c73f2071eb8a7620c82
|
|
4
|
+
data.tar.gz: '0499e0931abdd3890f07945f7fc82f6de8dc5da1a156fbb8147e1b5b2be79596'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 422848afb4e96f1a94cbfc26078f7c343afdb519e431c25aa6f2de10be0578d51fa65aa6d04683490b792685d5888474b8f711f0e1839ccd6ccaeb45c52c26d0
|
|
7
|
+
data.tar.gz: 81cd1f5df1028fa4064bc77ec5ee20866406d21c1ae5d4d992e8137bde59e5c6d6c505c7fc92b85111c931b7450da60b125692cb0bacbe63652e63012f9257da
|
data/CHANGELOG.md
CHANGED
|
@@ -2,22 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to bible270. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html), treating pre-1.0 minor bumps as the place breaking changes may land.
|
|
4
4
|
|
|
5
|
-
## Unreleased
|
|
5
|
+
## [Unreleased]
|
|
6
6
|
|
|
7
7
|
### Added
|
|
8
8
|
|
|
9
|
-
-
|
|
9
|
+
- add an on-demand Markdown formatting drawer and separate server-rendered Preview control for reflections and replies, with safe rendering, formatted notification emails, mention and browser-draft compatibility, and literal display of existing plain-text reflections; upgrading hosts must copy and run the new `body_format` comment migration
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- keep `json` below 3.0 while supported Rails releases still pass positional parse options, preventing session-cookie decoding from crashing every request under `json` 3.0
|
|
14
|
+
|
|
15
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.2...main)**
|
|
16
|
+
|
|
17
|
+
## [1.4.2] - 2026-09-03
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- let administrators disable the day-completion dove animation for individual readers without affecting progress tracking; upgrading hosts must copy and run the new `completion_dove_disabled` reader migration before changing this setting
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- keep the completion dove bright in dark mode, grow it to an 80%-of-viewport fit with a smoother rounded silhouette, and finish with a centered fade instead of veering offscreen
|
|
26
|
+
- show every reader who finished a day after the Reflections section, using compact 56px avatars ordered from earliest to latest completion
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- show the dove only once for each persisted day completion, including during concurrent final checkoffs, Turbo response replays, and stale-session retries
|
|
31
|
+
|
|
32
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.1...v1.4.2)**
|
|
33
|
+
|
|
34
|
+
## [1.4.1] - 2026-09-02
|
|
10
35
|
|
|
11
36
|
### Changed
|
|
12
37
|
|
|
13
|
-
-
|
|
14
|
-
-
|
|
38
|
+
- run the day-completion dove as one continuous, server-confirmed animation from the lower center, avoiding a stagger between submission and the Turbo or HTML response
|
|
39
|
+
- improve installer guidance for OmniAuth providers, deferred bundling, Active Storage, pending migrations, and OAuth callback setup
|
|
40
|
+
- detect existing engine mounts more reliably and move them above catch-all routes when necessary
|
|
15
41
|
|
|
16
42
|
### Fixed
|
|
17
43
|
|
|
18
|
-
-
|
|
44
|
+
- reject invalid enrollment states and malformed or out-of-range admin day values instead of silently interpreting them
|
|
45
|
+
- apply admin profile name and avatar changes atomically, preventing partial updates when validation fails
|
|
46
|
+
- report broadcast queueing, delivery failures, and partial success accurately without recording an entirely failed broadcast as sent
|
|
47
|
+
- restrict reflection return redirects to safe application URLs
|
|
48
|
+
- honor the install generator's `--mailer-from` option, reject unsafe provider identifiers, and safely quote generated configuration values
|
|
19
49
|
|
|
20
|
-
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.0...
|
|
50
|
+
**[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.4.0...v1.4.1)**
|
|
21
51
|
|
|
22
52
|
## [1.4.0] - 2026-09-02
|
|
23
53
|
|
|
@@ -631,6 +661,9 @@ This is the initial public release.
|
|
|
631
661
|
|
|
632
662
|
* 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.
|
|
633
663
|
|
|
664
|
+
[Unreleased]: https://github.com/avonderluft/bible270/compare/v1.4.2...HEAD
|
|
665
|
+
[1.4.2]: https://github.com/avonderluft/bible270/releases/tag/v1.4.2
|
|
666
|
+
[1.4.1]: https://github.com/avonderluft/bible270/releases/tag/v1.4.1
|
|
634
667
|
[1.4.0]: https://github.com/avonderluft/bible270/releases/tag/v1.4.0
|
|
635
668
|
[1.3.2]: https://github.com/avonderluft/bible270/releases/tag/v1.3.2
|
|
636
669
|
[1.3.1]: https://github.com/avonderluft/bible270/releases/tag/v1.3.1
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Bible 270
|
|
2
2
|
|
|
3
|
-
[](https://rubygems.org/gems/bible270) [](https://rubygems.org/gems/bible270) [](https://coveralls.io/github/avonderluft/bible270?branch=main) [](https://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
|
|
|
@@ -283,7 +283,14 @@ filters, and paginates older threads. Signed-in readers see which conversations
|
|
|
283
283
|
their previous visit. Reflection and reply drafts are retained for up to 30 days in that browser and
|
|
284
284
|
cleared after a successful post. On a shared device, clearing browser storage also removes saved drafts.
|
|
285
285
|
Typing `@` in the composer offers up to five unambiguous reader handles; manual mentions continue to
|
|
286
|
-
work without JavaScript.
|
|
286
|
+
work without JavaScript. A quiet **Formatting** disclosure opens a toolbar for bold, italic,
|
|
287
|
+
quotations, bulleted and numbered lists, and links without requiring readers to know Markdown syntax.
|
|
288
|
+
A separate **Preview** control checks the current draft through the same server-side sanitizer used
|
|
289
|
+
after posting; formatting tools stay out of sight until requested. The source remains
|
|
290
|
+
editable in the ordinary textarea, drafts preserve its format, and all rendered HTML is restricted to that small safe
|
|
291
|
+
set. Existing reflections remain literal plain text until a reader intentionally formats or converts
|
|
292
|
+
them. Upgrading hosts must copy and run migration `20260101000021`, which records whether each body is
|
|
293
|
+
plain text or Markdown; until then, the composer safely falls back to plain text.
|
|
287
294
|
|
|
288
295
|
When `config.mention_notifications` is enabled, each reader can request email for every new reflection
|
|
289
296
|
and reply, only replies to their reflections and mentions of them, or none. Administrators can change
|
|
@@ -483,7 +490,7 @@ The CMS serves your content; this is a separate mounted app. Either **just link
|
|
|
483
490
|
|-------|-------|
|
|
484
491
|
| `bible270_readers` | identity, avatar, provider/uid or polymorphic `owner`, start date, translation, passage-reader, and reminder preferences |
|
|
485
492
|
| `bible270_checkoffs` | one row per reader per day per track (`ot`/`nt`/`pp`), unique-indexed |
|
|
486
|
-
| `bible270_comments` | a reflection on a day, optionally scoped to a track, public to all |
|
|
493
|
+
| `bible270_comments` | a plain-text or safely rendered Markdown reflection on a day, optionally scoped to a track, public to all |
|
|
487
494
|
| `bible270_sign_in_tokens` | magic-link tokens: email, **digest only**, expiry, consumed-at |
|
|
488
495
|
|
|
489
496
|
A day is "complete" once all three tracks are ticked. Progress and comments are **public** by
|
|
@@ -496,6 +503,7 @@ GET / days#index overview + community + calendar
|
|
|
496
503
|
GET /day/:day days#show readings, who's finished, reflections
|
|
497
504
|
POST /day/:day/toggle/:track checkoffs#toggle
|
|
498
505
|
POST /day/:day/comments comments#create
|
|
506
|
+
POST /comments/preview comments#preview
|
|
499
507
|
DELETE /comments/:id comments#destroy
|
|
500
508
|
GET /community readers#index reader directory
|
|
501
509
|
GET /mention-suggestions readers#mention_suggestions (signed-in JSON)
|
|
@@ -11,6 +11,14 @@ module Bible270
|
|
|
11
11
|
current_reader&.mark_reflections_seen!(visited_at)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
def preview
|
|
15
|
+
return unless require_reader!
|
|
16
|
+
|
|
17
|
+
attributes = { body: params.dig(:comment, :body).to_s }
|
|
18
|
+
attributes[:body_format] = CommentFormatter::MARKDOWN if Comment.body_format_column?
|
|
19
|
+
render partial: 'bible270/comments/body', locals: { comment: Comment.new(attributes) }
|
|
20
|
+
end
|
|
21
|
+
|
|
14
22
|
def create
|
|
15
23
|
return unless require_reader!
|
|
16
24
|
|
|
@@ -134,7 +142,9 @@ module Bible270
|
|
|
134
142
|
end
|
|
135
143
|
|
|
136
144
|
def comment_params
|
|
137
|
-
|
|
145
|
+
permitted = %i[body track parent_id]
|
|
146
|
+
permitted << :body_format if Comment.body_format_column?
|
|
147
|
+
params.require(:comment).permit(*permitted)
|
|
138
148
|
end
|
|
139
149
|
|
|
140
150
|
# Scoped to the reader, so someone else's reflection is simply not found —
|
|
@@ -18,7 +18,7 @@ module Bible270
|
|
|
18
18
|
@readings = Plan.readings_for(@day)
|
|
19
19
|
@comments = Comment.threads_for_day(@day)
|
|
20
20
|
@editing_comment_id = editing_comment_id
|
|
21
|
-
@new_comment = Comment.
|
|
21
|
+
@new_comment = Comment.new_for_composer(day: @day, parent_id: reply_parent&.id)
|
|
22
22
|
@reader_tracks = current_reader ? current_reader.read_tracks_for(@day) : []
|
|
23
23
|
@day_just_completed = flash[:b270_day_just_completed].to_i == @day
|
|
24
24
|
@completion_event_id = flash[:b270_completion_event_id] if @day_just_completed
|
|
@@ -53,40 +53,46 @@ module Bible270
|
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
56
|
+
# Formats every reflection surface through the same sanitizer. Web views link
|
|
57
|
+
# resolved mentions; email can opt out because it has no mounted request path.
|
|
58
|
+
def b270_comment_body(comment, link_mentions: true)
|
|
59
|
+
format = comment.rendered_body_format
|
|
60
|
+
html = if link_mentions
|
|
61
|
+
readers = Bible270::Reader.mentioned_in(comment.body)
|
|
62
|
+
Bible270::CommentFormatter.html(comment.body, format: format) do |handle|
|
|
63
|
+
reader = readers.find { |candidate| candidate.answers_to?(handle) }
|
|
64
|
+
reader_path(reader) if reader
|
|
65
|
+
end
|
|
66
|
+
else
|
|
67
|
+
Bible270::CommentFormatter.html(comment.body, format: format)
|
|
68
|
+
end
|
|
69
|
+
html.html_safe
|
|
70
|
+
end
|
|
67
71
|
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
def b270_comment_text(comment)
|
|
73
|
+
Bible270::CommentFormatter.text(comment.body, format: comment.rendered_body_format)
|
|
74
|
+
end
|
|
70
75
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
76
|
+
def b270_editor_rows(body, columns: 48, minimum: 4)
|
|
77
|
+
rows = body.to_s.split("\n", -1).sum do |line|
|
|
78
|
+
[1, (line.length.to_f / columns).ceil].max
|
|
79
|
+
end
|
|
80
|
+
[minimum, rows].max
|
|
81
|
+
end
|
|
74
82
|
|
|
75
|
-
|
|
83
|
+
# Backward-compatible plain-text entry point for host views that called the old
|
|
84
|
+
# mention helper directly.
|
|
85
|
+
def b270_with_mentions(body)
|
|
86
|
+
readers = Bible270::Reader.mentioned_in(body)
|
|
87
|
+
html = Bible270::CommentFormatter.html(body, format: Bible270::CommentFormatter::PLAIN) do |handle|
|
|
76
88
|
reader = readers.find { |candidate| candidate.answers_to?(handle) }
|
|
77
|
-
|
|
78
|
-
link_to(match[0], reader_path(reader), class: 'b270-mention')
|
|
79
|
-
else
|
|
80
|
-
ERB::Util.html_escape(match[0])
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
cursor = match.end(0)
|
|
89
|
+
reader_path(reader) if reader
|
|
84
90
|
end
|
|
85
|
-
|
|
86
|
-
pieces << ERB::Util.html_escape(text[cursor..].to_s)
|
|
87
|
-
safe_join(pieces)
|
|
91
|
+
html.html_safe
|
|
88
92
|
end
|
|
89
93
|
|
|
94
|
+
# The mark beside the title in the header. Larger than the favicon and inlined
|
|
95
|
+
# as SVG, so it stays crisp at any size and needs no asset pipeline.
|
|
90
96
|
def b270_mark(size: 38)
|
|
91
97
|
configured = Bible270.config.header_mark
|
|
92
98
|
return nil if configured == false
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module Bible270
|
|
4
4
|
class ApplicationMailer < ActionMailer::Base
|
|
5
5
|
default from: -> { Bible270.config.mailer_from }
|
|
6
|
+
helper Bible270::PlanHelper
|
|
6
7
|
# `layout nil` means "normal lookup", which finds the host app's mailer layout and
|
|
7
8
|
# nests our complete HTML document inside theirs. Only `false` disables it.
|
|
8
9
|
layout false
|
|
@@ -11,6 +11,8 @@ module Bible270
|
|
|
11
11
|
# the paper background; an outline glyph like U+2661 is drawn by the font
|
|
12
12
|
# rather than the emoji set and needs hand-tuning to match.
|
|
13
13
|
HEART = '❤️'
|
|
14
|
+
BODY_FORMAT_COLUMN = 'body_format'
|
|
15
|
+
BODY_FORMATS = CommentFormatter::FORMATS
|
|
14
16
|
ThreadPage = Struct.new(:threads, :activity_by_id, :page, :pages, :total, keyword_init: true)
|
|
15
17
|
|
|
16
18
|
self.table_name = 'bible270_comments'
|
|
@@ -26,6 +28,7 @@ module Bible270
|
|
|
26
28
|
|
|
27
29
|
validates :day, inclusion: { in: 1..Plan::DAYS }
|
|
28
30
|
validates :body, presence: true, length: { maximum: 4000 }
|
|
31
|
+
validates :body_format, inclusion: { in: BODY_FORMATS }, if: :body_format_column?
|
|
29
32
|
# A reflection can be about one track or about the whole day. The form's
|
|
30
33
|
# "The whole day" option submits an empty string, so normalise it to NULL
|
|
31
34
|
# before validating — allow_nil alone rejects "" with
|
|
@@ -54,6 +57,23 @@ module Bible270
|
|
|
54
57
|
}
|
|
55
58
|
scope :recent, -> { approved.order(created_at: :desc) }
|
|
56
59
|
|
|
60
|
+
def self.body_format_column?
|
|
61
|
+
column_names.include?(BODY_FORMAT_COLUMN)
|
|
62
|
+
rescue ActiveRecord::StatementInvalid
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.new_for_composer(attributes = {})
|
|
67
|
+
attributes = attributes.merge(BODY_FORMAT_COLUMN => CommentFormatter::MARKDOWN) if body_format_column?
|
|
68
|
+
new(attributes)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def body_format_column? = self.class.body_format_column?
|
|
72
|
+
|
|
73
|
+
def rendered_body_format
|
|
74
|
+
body_format_column? ? body_format.presence || CommentFormatter::PLAIN : CommentFormatter::PLAIN
|
|
75
|
+
end
|
|
76
|
+
|
|
57
77
|
# One page of top-level reflections ordered by the newest activity in each
|
|
58
78
|
# conversation. Loading only IDs and timestamps keeps older archives cheap to
|
|
59
79
|
# sort, while the displayed page still eager-loads its readers, replies and likes.
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
<% if comment.reply? %>· reply to <strong><%= comment.parent&.reader&.display_name || 'a deleted reflection' %></strong><% end %>
|
|
23
23
|
<% if comment.hidden? %><span class="b270-badge">Hidden</span><% end %>
|
|
24
24
|
</div>
|
|
25
|
-
<
|
|
25
|
+
<div class="b270-cbody"><%= b270_comment_body(comment) %></div>
|
|
26
26
|
<div style="display:flex;gap:10px;align-items:center;margin-top:6px">
|
|
27
27
|
<% if comment.hidden? %>
|
|
28
28
|
<%= button_to 'Restore', admin_unhide_comment_path(comment), method: :patch,
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
<% if editing %>
|
|
33
33
|
<%= render partial: "bible270/comments/edit_fields", locals: { comment: comment } %>
|
|
34
34
|
<% else %>
|
|
35
|
-
<
|
|
35
|
+
<div class="b270-cbody"><%= b270_comment_body(comment) %></div>
|
|
36
36
|
<% end %>
|
|
37
37
|
|
|
38
38
|
<%# Only a top-level reflection can be replied to: one level deep, so a reply
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
<p class="b270-editerror"><%= comment.errors.full_messages.to_sentence %></p>
|
|
14
14
|
<% end %>
|
|
15
15
|
|
|
16
|
-
<%= f
|
|
16
|
+
<%= render "bible270/comments/editor", form: f, comment: comment,
|
|
17
|
+
label_text: (comment.reply? ? "Reply" : "Reflection") %>
|
|
17
18
|
<div class="row">
|
|
18
19
|
<% unless comment.reply? %>
|
|
19
20
|
<%= f.select :track,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<%# locals: (form:, comment:, label_text:, placeholder: nil) %>
|
|
2
|
+
<% editor_key = comment.persisted? ? "edit-#{comment.id}" : "new" %>
|
|
3
|
+
<% textarea_id = "b270-comment-body-#{editor_key}" %>
|
|
4
|
+
<% options_id = "b270-mention-options-#{editor_key}" %>
|
|
5
|
+
<% write_id = "b270-comment-write-#{editor_key}" %>
|
|
6
|
+
<% preview_id = "b270-comment-preview-#{editor_key}" %>
|
|
7
|
+
<% drawer_id = "b270-comment-formatting-#{editor_key}" %>
|
|
8
|
+
<% markdown_available = Bible270::Comment.body_format_column? %>
|
|
9
|
+
|
|
10
|
+
<div class="b270-mention-composer" data-b270-mention-composer
|
|
11
|
+
data-suggestions-url="<%= mention_suggestions_path %>">
|
|
12
|
+
<div class="b270-markdown-editor" data-b270-markdown-editor data-b270-editor-key="<%= editor_key %>"
|
|
13
|
+
data-b270-preview-url="<%= comment_preview_path if markdown_available %>"
|
|
14
|
+
data-b270-formatting-open="false" data-b270-editor-mode="write">
|
|
15
|
+
<% if markdown_available %>
|
|
16
|
+
<div class="b270-editor-utilities" data-b270-editor-utilities hidden>
|
|
17
|
+
<button type="button" class="b270-editor-utility" aria-expanded="false"
|
|
18
|
+
aria-controls="<%= drawer_id %>" data-b270-formatting-toggle>
|
|
19
|
+
Formatting <span class="b270-format-chevron" aria-hidden="true">▾</span>
|
|
20
|
+
</button>
|
|
21
|
+
<button type="button" class="b270-editor-utility" aria-pressed="false"
|
|
22
|
+
aria-controls="<%= write_id %> <%= preview_id %>" data-b270-preview-toggle>Preview</button>
|
|
23
|
+
</div>
|
|
24
|
+
<div id="<%= drawer_id %>" class="b270-formatting-drawer" aria-hidden="true"
|
|
25
|
+
data-b270-formatting-drawer inert>
|
|
26
|
+
<div class="b270-formatting-drawer-inner">
|
|
27
|
+
<div class="b270-markdown-toolbar" role="toolbar" aria-label="Reflection formatting"
|
|
28
|
+
data-b270-markdown-toolbar>
|
|
29
|
+
<button type="button" data-b270-markdown-action="bold" aria-label="Bold selected text" title="Bold (Ctrl+B)"><span class="b270-format-glyph b270-format-bold" aria-hidden="true">B</span></button>
|
|
30
|
+
<button type="button" data-b270-markdown-action="italic" aria-label="Italicize selected text" title="Italic (Ctrl+I)"><span class="b270-format-glyph b270-format-italic" aria-hidden="true">I</span></button>
|
|
31
|
+
<button type="button" data-b270-markdown-action="quote" aria-label="Format as a quotation" title="Quotation">“ ”</button>
|
|
32
|
+
<button type="button" data-b270-markdown-action="bullet-list" aria-label="Format as a bulleted list" title="Bulleted list">• List</button>
|
|
33
|
+
<button type="button" data-b270-markdown-action="number-list" aria-label="Format as a numbered list" title="Numbered list">1. List</button>
|
|
34
|
+
<button type="button" data-b270-markdown-action="link" aria-label="Add a link" title="Link">Link</button>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
<%= form.hidden_field :body_format, data: { b270_body_format: true } %>
|
|
39
|
+
<% end %>
|
|
40
|
+
|
|
41
|
+
<div id="<%= write_id %>" data-b270-write-panel>
|
|
42
|
+
<%= form.label :body, label_text, for: textarea_id, class: "b270-sr-only" %>
|
|
43
|
+
<%= form.text_area :body, id: textarea_id, placeholder: placeholder,
|
|
44
|
+
rows: (b270_editor_rows(comment.body) if comment.persisted?),
|
|
45
|
+
role: "combobox", autocomplete: "off", data: { b270_autosize: comment.persisted? },
|
|
46
|
+
aria: { autocomplete: "list", expanded: "false", haspopup: "listbox", controls: options_id } %>
|
|
47
|
+
</div>
|
|
48
|
+
<% if markdown_available %>
|
|
49
|
+
<div id="<%= preview_id %>" class="b270-markdown-preview b270-cbody"
|
|
50
|
+
aria-label="Reflection preview" aria-live="polite" data-b270-preview-panel hidden></div>
|
|
51
|
+
<% end %>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="b270-mention-options" id="<%= options_id %>" role="listbox"
|
|
55
|
+
data-b270-mention-options hidden></div>
|
|
56
|
+
<p class="b270-sr-only" data-b270-mention-status aria-live="polite"></p>
|
|
57
|
+
</div>
|
|
@@ -18,18 +18,9 @@
|
|
|
18
18
|
</p>
|
|
19
19
|
<% end %>
|
|
20
20
|
<%= f.hidden_field :parent_id %>
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
<%= f.text_area :body,
|
|
25
|
-
placeholder: posting_reply ? "Write your reply…" : "Share a reflection on today's reading…",
|
|
26
|
-
role: "combobox", autocomplete: "off",
|
|
27
|
-
aria: { autocomplete: "list", expanded: "false", haspopup: "listbox",
|
|
28
|
-
controls: "b270-mention-options" } %>
|
|
29
|
-
<div class="b270-mention-options" id="b270-mention-options" role="listbox"
|
|
30
|
-
data-b270-mention-options hidden></div>
|
|
31
|
-
<p class="b270-sr-only" data-b270-mention-status aria-live="polite"></p>
|
|
32
|
-
</div>
|
|
21
|
+
<%= render "bible270/comments/editor", form: f, comment: comment,
|
|
22
|
+
label_text: (posting_reply ? "Reply" : "Reflection"),
|
|
23
|
+
placeholder: (posting_reply ? "Write your reply…" : "Share a reflection on today's reading…") %>
|
|
33
24
|
<p class="b270-draft-status" data-b270-draft-status aria-live="polite"></p>
|
|
34
25
|
<div class="row">
|
|
35
26
|
<%= f.select :track,
|
|
@@ -10,5 +10,6 @@
|
|
|
10
10
|
<% end %>
|
|
11
11
|
|
|
12
12
|
<%= turbo_stream.replace "new_comment_form" do %>
|
|
13
|
-
<%= render "bible270/comments/form", day: @day,
|
|
13
|
+
<%= render "bible270/comments/form", day: @day,
|
|
14
|
+
comment: Bible270::Comment.new_for_composer(day: @day), posted: true %>
|
|
14
15
|
<% end %>
|
|
@@ -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
|
-
<%=
|
|
13
|
+
<%= b270_comment_body(@comment, link_mentions: false) %>
|
|
14
14
|
</blockquote>
|
|
15
15
|
|
|
16
16
|
<% if @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
|
|
|
@@ -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
|
-
<
|
|
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">
|
|
@@ -256,7 +256,15 @@
|
|
|
256
256
|
.b270-likes:active .b270-liketooltip{display:block}
|
|
257
257
|
.b270-liker{display:block;white-space:nowrap}
|
|
258
258
|
.b270-creply{margin-top:6px}
|
|
259
|
-
.b270-cbody{margin:3px 0 0;
|
|
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}
|
|
260
268
|
.b270-editform{margin-top:6px}
|
|
261
269
|
.b270-editform textarea{min-height:64px}
|
|
262
270
|
.b270-editerror{margin:0 0 8px;font-size:13px;color:var(--oxblood)}
|
|
@@ -268,6 +276,27 @@
|
|
|
268
276
|
.b270-cdel{font-size:12px;color:var(--oxblood);background:none;border:none;cursor:pointer;padding:0;margin-left:8px}
|
|
269
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}
|
|
270
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}
|
|
271
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}
|
|
272
301
|
.b270-mention-composer{position:relative}
|
|
273
302
|
.b270-mention-options{position:absolute;z-index:30;left:0;right:0;top:calc(100% + 4px);overflow:hidden;
|
data/config/routes.rb
CHANGED
|
@@ -13,6 +13,7 @@ Bible270::Engine.routes.draw do
|
|
|
13
13
|
constraints: { day: %r{\d+} }
|
|
14
14
|
post 'day/:day/comments', to: 'comments#create', as: :day_comments,
|
|
15
15
|
constraints: { day: %r{\d+} }
|
|
16
|
+
post 'comments/preview', to: 'comments#preview', as: :comment_preview
|
|
16
17
|
post 'comments/:id/like', to: 'likes#toggle', as: :like_comment
|
|
17
18
|
patch 'comments/:id', to: 'comments#update'
|
|
18
19
|
delete 'comments/:id', to: 'comments#destroy', as: :comment
|
|
@@ -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
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'kramdown'
|
|
5
|
+
require 'nokogiri'
|
|
6
|
+
require 'rails-html-sanitizer'
|
|
7
|
+
require 'bible270/mentions'
|
|
8
|
+
|
|
9
|
+
module Bible270
|
|
10
|
+
# Converts stored reflection source into a deliberately small, safe subset of
|
|
11
|
+
# HTML. Plain bodies remain literal; Markdown bodies gain only the formatting
|
|
12
|
+
# exposed by the reflection toolbar.
|
|
13
|
+
module CommentFormatter
|
|
14
|
+
PLAIN = 'plain'
|
|
15
|
+
MARKDOWN = 'markdown'
|
|
16
|
+
FORMATS = [PLAIN, MARKDOWN].freeze
|
|
17
|
+
ALLOWED_TAGS = %w[p br strong em blockquote ul ol li a].freeze
|
|
18
|
+
ALLOWED_ATTRIBUTES = %w[href rel].freeze
|
|
19
|
+
LINK_REL = 'nofollow ugc noopener'
|
|
20
|
+
SKIP_MENTIONS_IN = %w[a code pre].freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def html(body, format: PLAIN, &mention_path)
|
|
25
|
+
source = body.to_s
|
|
26
|
+
rendered = format == MARKDOWN ? markdown_html(source) : plain_html(source)
|
|
27
|
+
fragment = fragment_for(sanitize(rendered))
|
|
28
|
+
hard_wrap(fragment) if format == MARKDOWN
|
|
29
|
+
decorate_links(fragment)
|
|
30
|
+
link_mentions(fragment, &mention_path) if mention_path
|
|
31
|
+
fragment.to_html
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def text(body, format: PLAIN)
|
|
35
|
+
return body.to_s if format != MARKDOWN
|
|
36
|
+
|
|
37
|
+
fragment = fragment_for(html(body, format: format))
|
|
38
|
+
fragment.css('a[href]').each do |link|
|
|
39
|
+
href = link['href'].to_s
|
|
40
|
+
next if href.empty? || link.text == href
|
|
41
|
+
|
|
42
|
+
link.add_next_sibling(Nokogiri::XML::Text.new(" (#{href})", fragment.document))
|
|
43
|
+
end
|
|
44
|
+
fragment.css('br').each do |break_node|
|
|
45
|
+
break_node.replace(Nokogiri::XML::Text.new("\n", fragment.document))
|
|
46
|
+
end
|
|
47
|
+
fragment.css('p, blockquote, li').each do |block|
|
|
48
|
+
block.add_next_sibling(Nokogiri::XML::Text.new("\n", fragment.document))
|
|
49
|
+
end
|
|
50
|
+
fragment.text.lines.map(&:strip).join("\n").gsub(%r{\n{3,}}, "\n\n").strip
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def markdown_html(source)
|
|
54
|
+
Kramdown::Document.new(source).to_html
|
|
55
|
+
end
|
|
56
|
+
private_class_method :markdown_html
|
|
57
|
+
|
|
58
|
+
def plain_html(source)
|
|
59
|
+
paragraphs = source.split(%r{(?:\r?\n){2,}}, -1).filter_map do |paragraph|
|
|
60
|
+
next if paragraph.empty?
|
|
61
|
+
|
|
62
|
+
escaped = ERB::Util.html_escape(paragraph).gsub(%r{\r?\n}, "<br>\n")
|
|
63
|
+
"<p>#{escaped}</p>"
|
|
64
|
+
end
|
|
65
|
+
paragraphs.join("\n")
|
|
66
|
+
end
|
|
67
|
+
private_class_method :plain_html
|
|
68
|
+
|
|
69
|
+
def sanitize(rendered)
|
|
70
|
+
Rails::Html::SafeListSanitizer.new.sanitize(
|
|
71
|
+
rendered,
|
|
72
|
+
tags: ALLOWED_TAGS,
|
|
73
|
+
attributes: ALLOWED_ATTRIBUTES
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
private_class_method :sanitize
|
|
77
|
+
|
|
78
|
+
def fragment_for(rendered)
|
|
79
|
+
Nokogiri::HTML::DocumentFragment.parse(rendered)
|
|
80
|
+
end
|
|
81
|
+
private_class_method :fragment_for
|
|
82
|
+
|
|
83
|
+
def hard_wrap(fragment)
|
|
84
|
+
fragment.xpath('.//p//text() | .//li//text()').to_a.each do |node|
|
|
85
|
+
next unless node.text.include?("\n")
|
|
86
|
+
|
|
87
|
+
replacement = Nokogiri::HTML::DocumentFragment.parse('')
|
|
88
|
+
node.text.split("\n", -1).each_with_index do |line, index|
|
|
89
|
+
replacement.add_child(Nokogiri::XML::Node.new('br', fragment.document)) if index.positive?
|
|
90
|
+
replacement.add_child(Nokogiri::XML::Text.new(line, fragment.document))
|
|
91
|
+
end
|
|
92
|
+
node.replace(replacement)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
private_class_method :hard_wrap
|
|
96
|
+
|
|
97
|
+
def decorate_links(fragment)
|
|
98
|
+
fragment.css('a:not([href])').each do |link|
|
|
99
|
+
link.replace(Nokogiri::XML::Text.new(link.text, fragment.document))
|
|
100
|
+
end
|
|
101
|
+
fragment.css('a[href]').each { |link| link['rel'] = LINK_REL }
|
|
102
|
+
end
|
|
103
|
+
private_class_method :decorate_links
|
|
104
|
+
|
|
105
|
+
def link_mentions(fragment)
|
|
106
|
+
text_nodes = fragment.xpath('.//text()').to_a
|
|
107
|
+
text_nodes.each do |node|
|
|
108
|
+
next if node.ancestors.any? { |ancestor| SKIP_MENTIONS_IN.include?(ancestor.name) }
|
|
109
|
+
next unless node.text.match?(Mentions::PATTERN)
|
|
110
|
+
|
|
111
|
+
replacement = Nokogiri::HTML::DocumentFragment.parse('')
|
|
112
|
+
cursor = 0
|
|
113
|
+
node.text.to_enum(:scan, Mentions::PATTERN).each do
|
|
114
|
+
match = Regexp.last_match
|
|
115
|
+
replacement.add_child(Nokogiri::XML::Text.new(node.text[cursor...match.begin(0)], fragment.document))
|
|
116
|
+
href = yield Mentions.normalize(match[1])
|
|
117
|
+
if href
|
|
118
|
+
link = Nokogiri::XML::Node.new('a', fragment.document)
|
|
119
|
+
link['href'] = href
|
|
120
|
+
link['class'] = 'b270-mention'
|
|
121
|
+
link['rel'] = LINK_REL
|
|
122
|
+
link.content = match[0]
|
|
123
|
+
replacement.add_child(link)
|
|
124
|
+
else
|
|
125
|
+
replacement.add_child(Nokogiri::XML::Text.new(match[0], fragment.document))
|
|
126
|
+
end
|
|
127
|
+
cursor = match.end(0)
|
|
128
|
+
end
|
|
129
|
+
replacement.add_child(Nokogiri::XML::Text.new(node.text[cursor..].to_s, fragment.document))
|
|
130
|
+
node.replace(replacement)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
private_class_method :link_mentions
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -30,6 +30,7 @@ module Bible270
|
|
|
30
30
|
'AddAllCommentNoticesToBible270Readers' => :check_all_comment_notices,
|
|
31
31
|
'ChangePassageSourceDefaultToBibleCom' => :check_passage_source_default,
|
|
32
32
|
'AddCompletionDoveDisabledToBible270Readers' => :check_completion_dove_disabled,
|
|
33
|
+
'AddBodyFormatToBible270Comments' => :check_comment_body_format,
|
|
33
34
|
'CreateActiveStorageTables' => :check_active_storage
|
|
34
35
|
}.freeze
|
|
35
36
|
|
|
@@ -209,6 +210,17 @@ module Bible270
|
|
|
209
210
|
table_missing(:bible270_readers, columns: %i[completion_dove_disabled])
|
|
210
211
|
end
|
|
211
212
|
|
|
213
|
+
def check_comment_body_format
|
|
214
|
+
missing = table_missing(:bible270_comments, columns: %i[body_format])
|
|
215
|
+
return missing if missing.any?
|
|
216
|
+
|
|
217
|
+
column = connection.columns(:bible270_comments).find { |candidate| candidate.name == 'body_format' }
|
|
218
|
+
missing = []
|
|
219
|
+
missing << 'column bible270_comments.body_format default plain' unless column&.default == 'plain'
|
|
220
|
+
missing << 'column bible270_comments.body_format null false' unless column && !column.null
|
|
221
|
+
missing
|
|
222
|
+
end
|
|
223
|
+
|
|
212
224
|
# The Bible270 installer can install Active Storage for reader avatars. A
|
|
213
225
|
# copied database can therefore have the same timestamp mismatch for Rails'
|
|
214
226
|
# generated Active Storage migration as it has for the engine migrations.
|
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: 1.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew vonderLuft
|
|
@@ -9,6 +9,40 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: json
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "<"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "<"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: kramdown
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.5'
|
|
33
|
+
- - "<"
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '3'
|
|
36
|
+
type: :runtime
|
|
37
|
+
prerelease: false
|
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '2.5'
|
|
43
|
+
- - "<"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '3'
|
|
12
46
|
- !ruby/object:Gem::Dependency
|
|
13
47
|
name: rails
|
|
14
48
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -106,9 +140,11 @@ files:
|
|
|
106
140
|
- app/views/bible270/admin/index.html.erb
|
|
107
141
|
- app/views/bible270/admin/show.html.erb
|
|
108
142
|
- app/views/bible270/checkoffs/toggle.turbo_stream.erb
|
|
143
|
+
- app/views/bible270/comments/_body.html.erb
|
|
109
144
|
- app/views/bible270/comments/_comment.html.erb
|
|
110
145
|
- app/views/bible270/comments/_edit_fields.html.erb
|
|
111
146
|
- app/views/bible270/comments/_edit_form.html.erb
|
|
147
|
+
- app/views/bible270/comments/_editor.html.erb
|
|
112
148
|
- app/views/bible270/comments/_form.html.erb
|
|
113
149
|
- app/views/bible270/comments/create.turbo_stream.erb
|
|
114
150
|
- app/views/bible270/comments/destroy.turbo_stream.erb
|
|
@@ -145,6 +181,7 @@ files:
|
|
|
145
181
|
- app/views/bible270/shared/_header.html.erb
|
|
146
182
|
- app/views/bible270/shared/_interaction_status.html.erb
|
|
147
183
|
- app/views/bible270/shared/_interaction_ui.html.erb
|
|
184
|
+
- app/views/bible270/shared/_markdown_toolbar.html.erb
|
|
148
185
|
- app/views/bible270/shared/_mention_typeahead.html.erb
|
|
149
186
|
- app/views/bible270/shared/_nav_links.html.erb
|
|
150
187
|
- app/views/bible270/shared/_passage_source.html.erb
|
|
@@ -176,8 +213,10 @@ files:
|
|
|
176
213
|
- db/migrate/20260101000018_add_all_comment_notices_to_bible270_readers.rb
|
|
177
214
|
- db/migrate/20260101000019_change_passage_source_default_to_bible_com.rb
|
|
178
215
|
- db/migrate/20260101000020_add_completion_dove_disabled_to_bible270_readers.rb
|
|
216
|
+
- db/migrate/20260101000021_add_body_format_to_bible270_comments.rb
|
|
179
217
|
- lib/bible270.rb
|
|
180
218
|
- lib/bible270/avatars.rb
|
|
219
|
+
- lib/bible270/comment_formatter.rb
|
|
181
220
|
- lib/bible270/configuration.rb
|
|
182
221
|
- lib/bible270/daily_reminders.rb
|
|
183
222
|
- lib/bible270/email_sign_in.rb
|