plum-cms 0.2.2 → 0.3.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -0
  3. data/README.md +10 -2
  4. data/app/assets/builds/tailwind.css +1 -1
  5. data/app/assets/stylesheets/plum/comments.css +1 -0
  6. data/app/controllers/plum/cp/comments_controller.rb +55 -0
  7. data/app/controllers/plum/cp/content_types_controller.rb +1 -1
  8. data/app/controllers/plum/cp/entries_controller.rb +1 -1
  9. data/app/controllers/plum/discussions_controller.rb +46 -0
  10. data/app/helpers/plum/comments_helper.rb +20 -0
  11. data/app/models/plum/comment.rb +40 -0
  12. data/app/models/plum/comment_moderation.rb +7 -0
  13. data/app/models/plum/comment_report.rb +7 -0
  14. data/app/models/plum/commentable.rb +25 -0
  15. data/app/models/plum/discussion.rb +17 -0
  16. data/app/models/plum/entry.rb +11 -0
  17. data/app/models/plum/site.rb +1 -0
  18. data/app/services/plum/comments/context.rb +70 -0
  19. data/app/services/plum/comments/rate_limited.rb +5 -0
  20. data/app/services/plum/comments/theme_renderer.rb +66 -0
  21. data/app/services/plum/config_sync.rb +3 -0
  22. data/app/services/plum/liquid_context.rb +8 -0
  23. data/app/services/plum/liquid_renderer.rb +8 -0
  24. data/app/services/plum/site_archive.rb +4 -4
  25. data/app/themes/default/layouts/base.liquid +1 -1
  26. data/app/themes/default/templates/comments/_comment.liquid +8 -0
  27. data/app/themes/default/templates/comments/_form.liquid +7 -0
  28. data/app/themes/default/templates/comments/_report.liquid +6 -0
  29. data/app/themes/default/templates/comments/show.liquid +15 -0
  30. data/app/themes/default/templates/entries/_default.liquid +1 -0
  31. data/app/themes/default/templates/entries/landing.liquid +1 -0
  32. data/app/themes/default/templates/entries/pages.liquid +1 -0
  33. data/app/themes/default/templates/entries/posts.liquid +1 -0
  34. data/app/views/layouts/plum/cp.html.erb +4 -1
  35. data/app/views/layouts/plum/discussion.html.erb +5 -0
  36. data/app/views/layouts/plum/session.html.erb +1 -1
  37. data/app/views/layouts/plum/write.html.erb +1 -1
  38. data/app/views/plum/cp/comments/index.html.erb +16 -0
  39. data/app/views/plum/cp/content_types/_form.html.erb +3 -0
  40. data/app/views/plum/cp/entries/_form.html.erb +3 -0
  41. data/app/views/plum/discussions/_comments.html.erb +13 -0
  42. data/app/views/plum/discussions/_form.html.erb +6 -0
  43. data/app/views/plum/discussions/show.html.erb +19 -0
  44. data/config/plum_routes.rb +6 -0
  45. data/db/engine_migrate/20261005190000_create_plum_comments.rb +48 -0
  46. data/docs/comments.md +140 -0
  47. data/docs/plum-cli.md +66 -0
  48. data/docs/releases/0.3.0-validation.md +15 -0
  49. data/docs/zero-to-agency.md +172 -0
  50. data/lib/generators/plum/install/install_generator.rb +13 -1
  51. data/lib/plum/comments/configuration.rb +43 -0
  52. data/lib/plum/configuration.rb +3 -1
  53. data/lib/plum/engine.rb +11 -4
  54. data/lib/plum/version.rb +1 -1
  55. data/lib/plum.rb +7 -0
  56. metadata +36 -4
@@ -0,0 +1,70 @@
1
+ module Plum
2
+ module Comments
3
+ class Context
4
+ attr_reader :controller, :site, :subject, :handle
5
+
6
+ def initialize(controller, handle:, id:, action: :read)
7
+ @controller, @handle = controller, handle.to_s
8
+ @site = Plum.current_site(controller)
9
+ raise ActiveRecord::RecordNotFound unless config.enabled && site
10
+ if @handle == "entries"
11
+ @subject = site.entries.find(id)
12
+ raise ActiveRecord::RecordNotFound unless action == :moderate || site.entries.live.exists?(subject.id)
13
+ allowed = config.entry_authorizer.call(controller, action, subject)
14
+ allowed &&= config.entry_authorizer.call(controller, :read, subject) unless action == :moderate
15
+ else
16
+ registration = config.subjects.fetch(@handle) { raise ActiveRecord::RecordNotFound }
17
+ @subject = registration[:resolve].call(controller, site, id)
18
+ allowed = subject && registration[:authorize].call(controller, action, subject)
19
+ allowed &&= registration[:authorize].call(controller, :read, subject) unless action == :moderate
20
+ end
21
+ unless allowed && subject.is_a?(Plum::Commentable) && subject.plum_comment_site == site && (action == :moderate || subject.plum_comments_enabled?)
22
+ raise ActiveRecord::RecordNotFound
23
+ end
24
+ end
25
+
26
+ def config
27
+ Plum.configuration.comments
28
+ end
29
+
30
+ def discussion
31
+ @discussion ||= site.discussions.find_by(commentable: subject)
32
+ end
33
+
34
+ def open?
35
+ !!(subject.plum_comments_open? && !discussion&.closed?)
36
+ end
37
+
38
+ def author
39
+ @author ||= config.commenter_resolver.call(controller)
40
+ end
41
+
42
+ def author_gid
43
+ self.class.identity(author)
44
+ end
45
+
46
+ def self.identity(author)
47
+ author.to_global_id.to_s if author.respond_to?(:persisted?) && author.persisted? && author.respond_to?(:to_global_id)
48
+ end
49
+
50
+ def create_comment!(attributes)
51
+ raise ActiveRecord::RecordNotFound unless author_gid && open?
52
+ thread = discussion || site.discussions.create_or_find_by!(commentable: subject) { |record| record.subject_handle = handle }
53
+ thread.with_lock do
54
+ raise ActiveRecord::RecordNotFound if thread.closed? || !subject.reload.plum_comments_open?
55
+ # Serialize posting per discussion; the host may apply stricter account/IP limits.
56
+ if thread.comments.where(author_gid: author_gid).where("created_at > ?", 10.seconds.ago).exists?
57
+ raise RateLimited
58
+ end
59
+ parent = attributes[:parent_id].presence && thread.comments.publicly_visible.find(attributes[:parent_id])
60
+ thread.comments.create!(attributes.except(:parent_id).merge(
61
+ parent: parent, author_gid: author_gid,
62
+ author_name: config.author_name_resolver.call(author),
63
+ author_badge: config.author_badge_resolver.call(controller, author),
64
+ status: config.auto_approve.call(controller, author, subject) ? "approved" : "pending"
65
+ ))
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,5 @@
1
+ module Plum
2
+ module Comments
3
+ class RateLimited < StandardError; end
4
+ end
5
+ end
@@ -0,0 +1,66 @@
1
+ module Plum
2
+ module Comments
3
+ class ThemeRenderer
4
+ def initialize(controller:, context:, comments:, page:)
5
+ @controller, @context, @comments, @page = controller, context, comments.to_a, page
6
+ end
7
+
8
+ def render
9
+ data = LiquidContext.new(controller: controller, site: context.site).layout_context
10
+ discussion = {
11
+ "title" => h(context.subject.plum_discussion_title),
12
+ "open" => context.open?, "signed_in" => context.author_gid.present?,
13
+ "back_url" => h(context.config.subject_url_resolver.call(controller, context.subject)),
14
+ "login_url" => h(context.config.login_url_resolver.call(controller)),
15
+ "post_url" => h(controller.discussion_comments_path(handle: context.handle, subject_id: context.subject.id)),
16
+ "csrf_token" => h(controller.send(:form_authenticity_token)),
17
+ "stylesheet_url" => h(controller.view_context.asset_path("plum/comments.css")),
18
+ "previous_url" => page > 1 ? h(url(page - 1)) : nil,
19
+ "next_url" => comments.size == 50 ? h(url(page + 1)) : nil,
20
+ "notices" => controller.flash.map { |_key, value| h(value) }
21
+ }
22
+ data["discussion"] = discussion
23
+ # Only approved, authorized records reach this renderer. No account IDs,
24
+ # report reasons, audit records or pending bodies enter the Liquid context.
25
+ discussion["comments"] = comments.map { |comment| comment_data(comment) }
26
+ discussion["form_html"] = form(data, nil)
27
+ discussion["comments_html"] = discussion["comments"].map do |comment|
28
+ locals = data.merge("comment" => comment)
29
+ comment["reply_form_html"] = form(data, comment["id"]) if discussion["signed_in"] && discussion["open"] && !comment["parent_id"]
30
+ comment["report_form_html"] = LiquidRenderer.render_partial("comments/_report", locals) if discussion["signed_in"]
31
+ LiquidRenderer.render_partial("comments/_comment", locals)
32
+ end.join
33
+ LiquidRenderer.render_template("comments/show", data)
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :controller, :context, :comments, :page
39
+
40
+ def h(value)
41
+ ERB::Util.html_escape(value.to_s)
42
+ end
43
+
44
+ def url(number)
45
+ controller.discussion_path(handle: context.handle, subject_id: context.subject.id, page: number)
46
+ end
47
+
48
+ def form(data, parent_id)
49
+ return "" unless data["discussion"]["signed_in"] && data["discussion"]["open"]
50
+ LiquidRenderer.render_partial("comments/_form", data.merge("form" => { "parent_id" => parent_id, "id" => parent_id || "new" }))
51
+ end
52
+
53
+ def comment_data(comment)
54
+ {
55
+ "id" => comment.id, "body" => h(comment.body), "author_name" => h(comment.author_name),
56
+ "author_badge" => h(comment.author_badge), "pinned" => comment.pinned?,
57
+ "parent_id" => comment.parent_id, "parent_author_name" => h(comment.parent&.author_name),
58
+ "created_at" => comment.created_at.iso8601, "date" => h(comment.created_at.to_date.to_fs(:long)),
59
+ "video_seconds" => comment.video_seconds,
60
+ "video_time" => comment.video_seconds && "#{comment.video_seconds / 60}:#{format('%02d', comment.video_seconds % 60)}",
61
+ "report_url" => h(controller.report_discussion_comment_path(handle: context.handle, subject_id: context.subject.id, id: comment.id))
62
+ }
63
+ end
64
+ end
65
+ end
66
+ end
@@ -109,6 +109,7 @@ module Plum
109
109
  record.name = config["name"]
110
110
  record.icon = config["icon"]
111
111
  record.singleton = config["singleton"] unless config["singleton"].nil?
112
+ record.comments_enabled = config["comments_enabled"] unless config["comments_enabled"].nil?
112
113
  record.blueprint = merged_blueprint(record, config)
113
114
  track(result, record, file)
114
115
  end
@@ -207,6 +208,7 @@ module Plum
207
208
  "handle" => config["handle"].to_s,
208
209
  "icon" => config["icon"].presence,
209
210
  "singleton" => !!config["singleton"],
211
+ "comments_enabled" => !!config["comments_enabled"],
210
212
  "route_prefix" => config["route_prefix"].presence,
211
213
  "fields" => config["fields"] || []
212
214
  }
@@ -225,6 +227,7 @@ module Plum
225
227
  "handle" => record.handle,
226
228
  "icon" => record.icon,
227
229
  "singleton" => record.singleton,
230
+ "comments_enabled" => record.comments_enabled,
228
231
  "route_prefix" => record.route_prefix,
229
232
  "fields" => record.fields
230
233
  }
@@ -24,6 +24,11 @@ module Plum
24
24
  }.compact.merge(Plum.content_sources_for(controller, site: site))
25
25
  end
26
26
 
27
+ # Shared layout data without exposing the site's other entries or host sources.
28
+ def layout_context
29
+ { "site" => site_context, "globals" => globals_context, "nav" => nav_context, "forms" => forms_context }
30
+ end
31
+
27
32
  private
28
33
 
29
34
  attr_reader :controller, :site, :entry, :theme_name, :theme_settings
@@ -60,6 +65,9 @@ module Plum
60
65
  "url" => public_entry_path(entry),
61
66
  "data" => entry_data_context(entry, relationship_depth: relationship_depth, expand_blocks: expand_blocks)
62
67
  }
68
+ if Plum.configuration.comments.enabled && entry.plum_comments_enabled?
69
+ ctx["discussion_url"] = "#{controller.request.script_name.to_s.chomp('/')}/discussions/entries/#{entry.id}"
70
+ end
63
71
  if entry.association(:terms).loaded? && entry.terms.any?
64
72
  ctx["terms"] = entry.terms.group_by { |t| t.taxonomy.handle }.transform_values do |terms|
65
73
  terms.map { |t| { "name" => t.name, "slug" => t.slug, "url" => "/#{t.taxonomy.slug}/#{t.slug}" } }
@@ -30,6 +30,14 @@ module Plum
30
30
  end
31
31
  end
32
32
 
33
+ # Render a theme fragment through the same inheritance and filter pipeline.
34
+ def render_partial(template_name, context = {})
35
+ path = ThemeResolver.new.find_template(current_theme(context), template_name)
36
+ raise "Template not found: #{template_name}" unless path
37
+
38
+ Liquid::Template.parse(File.read(path)).render(context, filters: [ LiquidFilters ], registers: render_registers(context))
39
+ end
40
+
33
41
  private
34
42
 
35
43
  def current_theme(context)
@@ -56,7 +56,7 @@ module Plum
56
56
  "exported_at" => Time.current.iso8601,
57
57
  "site" => record(site, %w[id name domain theme_name settings theme_settings custom_css]),
58
58
  "site_setting" => site.site_setting && record(site.site_setting, site_setting_fields),
59
- "content_types" => records(site.content_types, %w[id name handle singleton blueprint icon]),
59
+ "content_types" => records(site.content_types, %w[id name handle singleton blueprint icon comments_enabled]),
60
60
  "fieldsets" => records(site.fieldsets, %w[id name handle fields]),
61
61
  "taxonomies" => records(site.taxonomies, %w[id name handle slug]),
62
62
  "terms" => records(site.terms, %w[id taxonomy_id name slug position]),
@@ -76,7 +76,7 @@ module Plum
76
76
  end
77
77
 
78
78
  def entry_record(entry)
79
- record(entry, %w[id content_type_id title slug status data published_at author_name author_email author_gid locale origin_id]).merge(
79
+ record(entry, %w[id content_type_id title slug status data published_at author_name author_email author_gid locale origin_id comments_mode]).merge(
80
80
  "term_ids" => entry.term_ids
81
81
  )
82
82
  end
@@ -167,7 +167,7 @@ module Plum
167
167
  )
168
168
  maps[:sites][source["id"]] = @site.id
169
169
 
170
- import_simple(:content_types, ContentType, %w[name handle singleton blueprint icon])
170
+ import_simple(:content_types, ContentType, %w[name handle singleton blueprint icon comments_enabled])
171
171
  import_simple(:fieldsets, Fieldset, %w[name handle fields])
172
172
  import_simple(:taxonomies, Taxonomy, %w[name handle slug])
173
173
  import_terms
@@ -239,7 +239,7 @@ module Plum
239
239
 
240
240
  def import_entries
241
241
  Array(data["entries"]).each do |source|
242
- entry = Entry.create!(source.slice("title", "slug", "status", "data", "published_at", "author_name", "author_email", "author_gid", "locale").merge(
242
+ entry = Entry.create!(source.slice("title", "slug", "status", "data", "published_at", "author_name", "author_email", "author_gid", "locale", "comments_mode").merge(
243
243
  "site_id" => @site.id,
244
244
  "content_type_id" => mapped!(:content_types, source["content_type_id"])
245
245
  ))
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>{{ entry.title | default: site.seo_title | default: site.name }} - {{ site.name }}</title>
6
+ <title>{{ discussion.title | default: entry.title | default: site.seo_title | default: site.name }} - {{ site.name }}</title>
7
7
  <meta name="description" content="{{ site.seo_description }}">
8
8
  <link rel="icon" href="/icon.svg" type="image/svg+xml">
9
9
  <link rel="stylesheet" href="{{ 'theme.css' | theme_asset_url }}">
@@ -0,0 +1,8 @@
1
+ <article id="comment-{{ comment.id }}" class="plum-comment">
2
+ <header><strong>{{ comment.author_name }}</strong> {% if comment.author_badge != blank %}<span>{{ comment.author_badge }}</span>{% endif %} <time datetime="{{ comment.created_at }}">{{ comment.date }}</time> {% if comment.pinned %}Pinned{% endif %}</header>
3
+ {% if comment.parent_id %}<p>In reply to {{ comment.parent_author_name }}</p>{% endif %}
4
+ {% if comment.video_seconds %}<p>At {{ comment.video_time }}</p>{% endif %}
5
+ <p class="plum-comment-body">{{ comment.body }}</p>
6
+ {% if comment.reply_form_html %}<details><summary>Reply</summary>{{ comment.reply_form_html }}</details>{% endif %}
7
+ {% if comment.report_form_html %}<details><summary>Report</summary>{{ comment.report_form_html }}</details>{% endif %}
8
+ </article>
@@ -0,0 +1,7 @@
1
+ <form method="post" action="{{ discussion.post_url }}" class="plum-comment-form">
2
+ <input type="hidden" name="authenticity_token" value="{{ discussion.csrf_token }}">
3
+ {% if form.parent_id %}<input type="hidden" name="comment[parent_id]" value="{{ form.parent_id }}">{% endif %}
4
+ <label for="comment-body-{{ form.id }}">{% if form.parent_id %}Your reply{% else %}Your comment{% endif %}</label>
5
+ <textarea id="comment-body-{{ form.id }}" name="comment[body]" maxlength="10000" rows="4" required></textarea>
6
+ <button type="submit">{% if form.parent_id %}Send reply{% else %}Send comment{% endif %}</button>
7
+ </form>
@@ -0,0 +1,6 @@
1
+ <form method="post" action="{{ comment.report_url }}" class="plum-comment-report-form">
2
+ <input type="hidden" name="authenticity_token" value="{{ discussion.csrf_token }}">
3
+ <label for="report-reason-{{ comment.id }}">Reason for reporting</label>
4
+ <textarea id="report-reason-{{ comment.id }}" name="report[reason]" maxlength="1000" required></textarea>
5
+ <button type="submit">Send report</button>
6
+ </form>
@@ -0,0 +1,15 @@
1
+ <link rel="stylesheet" href="{{ discussion.stylesheet_url }}">
2
+ <section class="plum-discussion" aria-labelledby="discussion-title">
3
+ {% for notice in discussion.notices %}<p role="status">{{ notice }}</p>{% endfor %}
4
+ {% if discussion.back_url != blank %}<a href="{{ discussion.back_url }}">Back to {{ discussion.title }}</a>{% endif %}
5
+ <h1 id="discussion-title">{{ discussion.title }}</h1>
6
+ <h2>Questions &amp; discussion</h2>
7
+ <p>Ask a question, share a reflection, or join the conversation. Please be kind and stay on topic. Comments may be reviewed before appearing.</p>
8
+ {% if discussion.comments == empty %}<p>No comments to show yet.</p>{% endif %}
9
+ {{ discussion.comments_html }}
10
+ <nav aria-label="Discussion pages">{% if discussion.previous_url %}<a href="{{ discussion.previous_url }}">Previous</a>{% endif %}{% if discussion.next_url %}<a href="{{ discussion.next_url }}">Next</a>{% endif %}</nav>
11
+ {% if discussion.open == false %}<p>This discussion is closed to new comments.</p>
12
+ {% elsif discussion.signed_in %}{{ discussion.form_html }}
13
+ {% else %}<p>Sign in to join the discussion.</p>{% if discussion.login_url != blank %}<a href="{{ discussion.login_url }}">Sign in</a>{% endif %}
14
+ {% endif %}
15
+ </section>
@@ -23,4 +23,5 @@
23
23
  <p><em>{{ entry.data.excerpt }}</em></p>
24
24
  </div>
25
25
  {% endif %}
26
+ {% if entry.discussion_url %}<p class="plum-discussion-link"><a href="{{ entry.discussion_url }}">Questions &amp; discussion →</a></p>{% endif %}
26
27
  </article>
@@ -2,4 +2,5 @@
2
2
  <h1>{{ entry.title }}</h1>
3
3
 
4
4
  {{ entry.data.sections }}
5
+ {% if entry.discussion_url %}<p class="plum-discussion-link"><a href="{{ entry.discussion_url }}">Questions &amp; discussion →</a></p>{% endif %}
5
6
  </article>
@@ -14,4 +14,5 @@
14
14
  {% if sections_html != "" %}
15
15
  {{ sections_html }}
16
16
  {% endif %}
17
+ {% if entry.discussion_url %}<p class="plum-discussion-link"><a href="{{ entry.discussion_url }}">Questions &amp; discussion →</a></p>{% endif %}
17
18
  </article>
@@ -17,4 +17,5 @@
17
17
  <p><strong>Summary:</strong> {{ entry.data.excerpt }}</p>
18
18
  </aside>
19
19
  {% endif %}
20
+ {% if entry.discussion_url %}<p class="plum-discussion-link"><a href="{{ entry.discussion_url }}">Questions &amp; discussion →</a></p>{% endif %}
20
21
  </article>
@@ -8,7 +8,7 @@
8
8
  <%= favicon_link_tag "plum-mark.svg", type: "image/svg+xml" %>
9
9
  <%= stylesheet_link_tag "plum/control_panel", "data-turbo-track": "reload" %>
10
10
  <%= stylesheet_link_tag "lexxy", "data-turbo-track": "reload" %>
11
- <%= javascript_importmap_tags %>
11
+ <%= javascript_importmap_tags(importmap: Plum.importmap || Rails.application.importmap) %>
12
12
  <script type="module">import "plum/application"</script>
13
13
  <style>
14
14
  :root {
@@ -88,6 +88,9 @@
88
88
  class: "flex items-center px-4 py-2 mt-2 text-sm font-medium rounded-md plum-sidebar-link #{request.path.include?('/cp/nav_menus') ? 'active' : ''}" %>
89
89
  <%= link_to "Taxonomies", "#{cp_prefix}/taxonomies",
90
90
  class: "flex items-center px-4 py-2 mt-2 text-sm font-medium rounded-md plum-sidebar-link #{request.path.include?('/taxonomies') ? 'active' : ''}" %>
91
+ <% if Plum.configuration.comments.enabled && Plum.configuration.comments.moderator_resolver.call(controller, current_site) %>
92
+ <%= link_to "Comments", cp_comments_path, class: "flex items-center px-4 py-2 mt-2 text-sm font-medium rounded-md plum-sidebar-link" %>
93
+ <% end %>
91
94
  <%= link_to "Forms", cp_form_definitions_path,
92
95
  class: "flex items-center px-4 py-2 mt-2 text-sm font-medium rounded-md plum-sidebar-link #{request.path.include?('/cp/forms') ? 'active' : ''}" %>
93
96
  </div>
@@ -0,0 +1,5 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Discussion</title><%= csrf_meta_tags %><%= stylesheet_link_tag "plum/comments" %></head>
4
+ <body><main class="plum-discussion"><% flash.each do |_kind, message| %><p role="status"><%= message %></p><% end %><%= yield %></main></body>
5
+ </html>
@@ -7,7 +7,7 @@
7
7
  <%= csp_meta_tag %>
8
8
  <%= favicon_link_tag "plum-mark.svg", type: "image/svg+xml" %>
9
9
  <%= stylesheet_link_tag "plum/control_panel", "data-turbo-track": "reload" %>
10
- <%= javascript_importmap_tags %>
10
+ <%= javascript_importmap_tags(importmap: Plum.importmap || Rails.application.importmap) %>
11
11
  <style>
12
12
  :root { --plum-accent: <%= Plum.configuration.cp_accent_color %>; }
13
13
  .bg-purple-600 { background-color: var(--plum-accent) !important; }
@@ -8,7 +8,7 @@
8
8
  <%= favicon_link_tag "plum-mark.svg", type: "image/svg+xml" %>
9
9
  <%= stylesheet_link_tag "plum/control_panel", "data-turbo-track": "reload" %>
10
10
  <%= stylesheet_link_tag "lexxy", "data-turbo-track": "reload" %>
11
- <%= javascript_importmap_tags %>
11
+ <%= javascript_importmap_tags(importmap: Plum.importmap || Rails.application.importmap) %>
12
12
  <script type="module">import "plum/application"</script>
13
13
  <style>
14
14
  :root { --plum-accent: <%= Plum.configuration.cp_accent_color %>; }
@@ -0,0 +1,16 @@
1
+ <h1 class="text-2xl font-bold mb-4">Comments</h1>
2
+ <nav class="flex gap-4 mb-6"><% %w[pending approved hidden reported].each do |status| %><%= link_to status.titleize, cp_comments_path(status: status), class: "underline" %><% end %></nav>
3
+ <% if @comments.empty? %><p>No comments in this view.</p><% end %>
4
+ <% @comments.each do |comment| %>
5
+ <article class="border rounded p-4 mb-4">
6
+ <h2 class="font-bold"><%= comment.discussion.commentable&.plum_discussion_title %></h2>
7
+ <p><%= comment.author_name %> · <%= comment.status %> · <%= comment.created_at.to_fs(:short) %></p>
8
+ <p class="whitespace-pre-wrap my-4"><%= comment.body %></p>
9
+ <% comment.reports.where(resolved_at: nil).each do |report| %><p>Report: <%= report.reason %></p><% end %>
10
+ <div class="flex gap-4 flex-wrap"><% %w[approve hide pin unpin resolve_reports].each do |operation| %><% next if operation == "pin" && comment.parent_id.present? %><%= button_to operation.humanize, cp_comment_path(comment), method: :patch, params: { operation: operation }, class: "underline" %><% end %>
11
+ <%= button_to comment.discussion.closed? ? "Reopen discussion" : "Close discussion", cp_close_discussion_path(comment.discussion), method: :patch, params: { closed: !comment.discussion.closed? }, class: "underline" %></div>
12
+ </article>
13
+ <% end %>
14
+ <% current_page = params[:page].to_i.clamp(1, 10000) %>
15
+ <%= link_to "Previous", cp_comments_path(status: @status, page: current_page - 1) if current_page > 1 %>
16
+ <%= link_to "Next", cp_comments_path(status: @status, page: current_page + 1) %>
@@ -9,6 +9,9 @@
9
9
  </div>
10
10
  <% end %>
11
11
 
12
+ <% if Plum.configuration.comments.enabled %>
13
+ <div><%= f.check_box :comments_enabled %> <%= f.label :comments_enabled, "Enable comments by default for this content type" %></div>
14
+ <% end %>
12
15
  <div class="bg-white shadow rounded-lg p-6 space-y-6">
13
16
  <div>
14
17
  <%= f.label :name, class: "block text-sm font-medium text-gray-700" %>
@@ -10,6 +10,9 @@
10
10
  </div>
11
11
  <% end %>
12
12
 
13
+ <% if Plum.configuration.comments.enabled %>
14
+ <div><%= f.label :comments_mode, "Discussion" %> <%= f.select :comments_mode, [["Use content type setting", "inherit"], ["Open", "open"], ["Closed (existing comments remain visible)", "closed"], ["Disabled (hide discussion)", "disabled"]] %></div>
15
+ <% end %>
13
16
  <div class="grid grid-cols-1 lg:grid-cols-3 gap-6" style="align-items: start;">
14
17
  <!-- Main content -->
15
18
  <div class="lg:col-span-2 space-y-6">
@@ -0,0 +1,13 @@
1
+ <% if comments.empty? %><p>No comments to show yet.</p><% end %>
2
+ <% comments.each do |comment| %>
3
+ <article id="comment-<%= comment.id %>" class="plum-comment">
4
+ <header><strong><%= comment.author_name %></strong> <% if comment.author_badge.present? %><span><%= comment.author_badge %></span><% end %> <time datetime="<%= comment.created_at.iso8601 %>"><%= comment.created_at.to_date.to_fs(:long) %></time> <%= "Pinned" if comment.pinned? %></header>
5
+ <% if comment.parent %><p>In reply to <%= comment.parent.author_name %></p><% end %>
6
+ <% if comment.video_seconds %><p>At <%= comment.video_seconds / 60 %>:<%= "%02d" % (comment.video_seconds % 60) %></p><% end %>
7
+ <p class="plum-comment-body"><%= comment.body %></p>
8
+ <% if context.author_gid %>
9
+ <% if context.open? && comment.parent_id.nil? %><details><summary>Reply</summary><%= render "plum/discussions/form", context: context, parent: comment %></details><% end %>
10
+ <details><summary>Report</summary><%= form_with url: report_discussion_comment_path(handle: context.handle, subject_id: context.subject.id, id: comment.id), scope: :report do |form| %><%= form.label :reason, "Reason for reporting", for: "report-reason-#{comment.id}" %><%= form.text_area :reason, id: "report-reason-#{comment.id}", required: true, maxlength: 1000 %><%= form.submit "Send report" %><% end %></details>
11
+ <% end %>
12
+ </article>
13
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <%= form_with url: discussion_comments_path(handle: context.handle, subject_id: context.subject.id), scope: :comment do |form| %>
2
+ <% if parent %><%= form.hidden_field :parent_id, value: parent.id %><% end %>
3
+ <%= form.label :body, parent ? "Your reply" : "Your comment", for: "comment-body-#{parent&.id || 'new'}" %>
4
+ <%= form.text_area :body, required: true, maxlength: 10000, rows: 4, id: "comment-body-#{parent&.id || 'new'}" %>
5
+ <%= form.submit parent ? "Send reply" : "Send comment" %>
6
+ <% end %>
@@ -0,0 +1,19 @@
1
+ <% back_url = @context.config.subject_url_resolver.call(self.controller, @context.subject) %>
2
+ <% if back_url.present? %><%= link_to "Back to #{@context.subject.plum_discussion_title}", back_url %><% end %>
3
+ <h1><%= @context.subject.plum_discussion_title %></h1>
4
+ <h2>Questions &amp; discussion</h2>
5
+ <p>Ask a question, share a reflection, or join the conversation. Please be kind and stay on topic. Comments may be reviewed before appearing.</p>
6
+ <%= render "plum/discussions/comments", context: @context, comments: @comments %>
7
+ <nav aria-label="Discussion pages">
8
+ <%= link_to "Previous", discussion_path(handle: @context.handle, subject_id: @context.subject.id, page: page - 1) if page > 1 %>
9
+ <%= link_to "Next", discussion_path(handle: @context.handle, subject_id: @context.subject.id, page: page + 1) if @comments.size == 50 %>
10
+ </nav>
11
+ <% if !@context.open? %>
12
+ <p>This discussion is closed to new comments.</p>
13
+ <% elsif @context.author_gid %>
14
+ <%= render "plum/discussions/form", context: @context, parent: nil %>
15
+ <% else %>
16
+ <p>Sign in to join the discussion.</p>
17
+ <% login_url = @context.config.login_url_resolver.call(self.controller) %>
18
+ <%= link_to "Sign in", login_url if login_url.present? %>
19
+ <% end %>
@@ -4,6 +4,8 @@ Plum::Engine.routes.draw do
4
4
  delete "logout", to: "sessions#destroy"
5
5
 
6
6
  namespace :cp do
7
+ resources :comments, only: [ :index, :update ]
8
+ patch "discussions/:id", to: "comments#close", as: :close_discussion
7
9
  root "dashboard#show"
8
10
  resources :content_types do
9
11
  post :apply_fieldset, on: :member
@@ -38,6 +40,10 @@ Plum::Engine.routes.draw do
38
40
  get "theme_previews/:handle", to: "theme_previews#show", as: :theme_preview
39
41
  end
40
42
 
43
+ get "discussions/:handle/:subject_id", to: "discussions#show", as: :discussion
44
+ post "discussions/:handle/:subject_id/comments", to: "discussions#create", as: :discussion_comments
45
+ post "discussions/:handle/:subject_id/comments/:id/report", to: "discussions#report", as: :report_discussion_comment
46
+
41
47
  post "forms/:handle", to: "form_submissions#create", as: :form
42
48
  namespace :api do
43
49
  namespace :v1 do
@@ -0,0 +1,48 @@
1
+ class CreatePlumComments < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :plum_content_types, :comments_enabled, :boolean, default: false, null: false
4
+ add_column :plum_entries, :comments_mode, :string, default: "inherit", null: false
5
+
6
+ create_table :plum_discussions do |t|
7
+ t.references :site, null: false, foreign_key: { to_table: :plum_sites }
8
+ t.string :commentable_type, null: false
9
+ t.string :commentable_id, null: false
10
+ t.string :subject_handle, null: false
11
+ t.boolean :closed, null: false, default: false
12
+ t.timestamps
13
+ end
14
+ add_index :plum_discussions, [ :site_id, :commentable_type, :commentable_id ], unique: true, name: "plum_discussion_subject_unique"
15
+
16
+ create_table :plum_comments do |t|
17
+ t.references :discussion, null: false, foreign_key: { to_table: :plum_discussions }
18
+ t.references :parent, foreign_key: { to_table: :plum_comments }
19
+ t.string :author_gid, null: false
20
+ t.string :author_name, null: false
21
+ t.string :author_badge
22
+ t.text :body, null: false
23
+ t.string :status, null: false, default: "pending"
24
+ t.boolean :pinned, null: false, default: false
25
+ t.integer :video_seconds
26
+ t.timestamps
27
+ end
28
+ add_index :plum_comments, [ :discussion_id, :status, :id ]
29
+ add_index :plum_comments, [ :author_gid, :created_at ]
30
+
31
+ create_table :plum_comment_reports do |t|
32
+ t.references :comment, null: false, foreign_key: { to_table: :plum_comments }
33
+ t.string :reporter_gid, null: false
34
+ t.text :reason, null: false
35
+ t.datetime :resolved_at
36
+ t.timestamps
37
+ end
38
+ add_index :plum_comment_reports, [ :comment_id, :reporter_gid ], unique: true
39
+
40
+ create_table :plum_comment_moderations do |t|
41
+ t.references :comment, null: false, foreign_key: { to_table: :plum_comments }
42
+ t.string :moderator_gid, null: false
43
+ t.string :action, null: false
44
+ t.string :previous_status, null: false
45
+ t.timestamps
46
+ end
47
+ end
48
+ end