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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +78 -0
- data/README.md +10 -2
- data/app/assets/builds/tailwind.css +1 -1
- data/app/assets/stylesheets/plum/comments.css +1 -0
- data/app/controllers/plum/cp/comments_controller.rb +55 -0
- data/app/controllers/plum/cp/content_types_controller.rb +1 -1
- data/app/controllers/plum/cp/entries_controller.rb +1 -1
- data/app/controllers/plum/discussions_controller.rb +46 -0
- data/app/helpers/plum/comments_helper.rb +20 -0
- data/app/models/plum/comment.rb +40 -0
- data/app/models/plum/comment_moderation.rb +7 -0
- data/app/models/plum/comment_report.rb +7 -0
- data/app/models/plum/commentable.rb +25 -0
- data/app/models/plum/discussion.rb +17 -0
- data/app/models/plum/entry.rb +11 -0
- data/app/models/plum/site.rb +1 -0
- data/app/services/plum/comments/context.rb +70 -0
- data/app/services/plum/comments/rate_limited.rb +5 -0
- data/app/services/plum/comments/theme_renderer.rb +66 -0
- data/app/services/plum/config_sync.rb +3 -0
- data/app/services/plum/liquid_context.rb +8 -0
- data/app/services/plum/liquid_renderer.rb +8 -0
- data/app/services/plum/site_archive.rb +4 -4
- data/app/themes/default/layouts/base.liquid +1 -1
- data/app/themes/default/templates/comments/_comment.liquid +8 -0
- data/app/themes/default/templates/comments/_form.liquid +7 -0
- data/app/themes/default/templates/comments/_report.liquid +6 -0
- data/app/themes/default/templates/comments/show.liquid +15 -0
- data/app/themes/default/templates/entries/_default.liquid +1 -0
- data/app/themes/default/templates/entries/landing.liquid +1 -0
- data/app/themes/default/templates/entries/pages.liquid +1 -0
- data/app/themes/default/templates/entries/posts.liquid +1 -0
- data/app/views/layouts/plum/cp.html.erb +4 -1
- data/app/views/layouts/plum/discussion.html.erb +5 -0
- data/app/views/layouts/plum/session.html.erb +1 -1
- data/app/views/layouts/plum/write.html.erb +1 -1
- data/app/views/plum/cp/comments/index.html.erb +16 -0
- data/app/views/plum/cp/content_types/_form.html.erb +3 -0
- data/app/views/plum/cp/entries/_form.html.erb +3 -0
- data/app/views/plum/discussions/_comments.html.erb +13 -0
- data/app/views/plum/discussions/_form.html.erb +6 -0
- data/app/views/plum/discussions/show.html.erb +19 -0
- data/config/plum_routes.rb +6 -0
- data/db/engine_migrate/20261005190000_create_plum_comments.rb +48 -0
- data/docs/comments.md +140 -0
- data/docs/plum-cli.md +66 -0
- data/docs/releases/0.3.0-validation.md +15 -0
- data/docs/zero-to-agency.md +172 -0
- data/lib/generators/plum/install/install_generator.rb +13 -1
- data/lib/plum/comments/configuration.rb +43 -0
- data/lib/plum/configuration.rb +3 -1
- data/lib/plum/engine.rb +11 -4
- data/lib/plum/version.rb +1 -1
- data/lib/plum.rb +7 -0
- 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,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 & 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>
|
|
@@ -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 & 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 %>
|
data/config/plum_routes.rb
CHANGED
|
@@ -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
|