macron-cms 3.0.0 → 5.0.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/README.md +338 -25
- data/app/assets/stylesheets/macron/cms/application.css +1 -1
- data/app/controllers/concerns/macron/cms/api_serialization.rb +79 -0
- data/app/controllers/concerns/macron/cms/slug_redirectable.rb +27 -0
- data/app/controllers/macron/cms/api/v1/article_contents_controller.rb +221 -0
- data/app/controllers/macron/cms/api/v1/articles_controller.rb +140 -66
- data/app/controllers/macron/cms/api/v1/content_types_controller.rb +3 -1
- data/app/controllers/macron/cms/article_contents_controller.rb +44 -10
- data/app/controllers/macron/cms/articles_controller.rb +4 -3
- data/app/controllers/macron/cms/content_types_controller.rb +2 -0
- data/app/controllers/macron/cms/dashboard_controller.rb +1 -1
- data/app/helpers/macron/cms/content_helper.rb +1 -1
- data/app/helpers/macron/cms/fields_helper.rb +14 -0
- data/app/models/macron/cms/article.rb +45 -15
- data/app/models/macron/cms/article_content.rb +24 -0
- data/app/models/macron/cms/content.rb +27 -0
- data/app/services/macron/cms/inline_asset_attacher.rb +70 -0
- data/app/services/macron/cms/remote_asset_attacher.rb +16 -6
- data/app/views/macron/cms/articles/_content_blocks_section.html.erb +494 -199
- data/app/views/macron/cms/articles/_form.html.erb +10 -2
- data/app/views/macron/cms/articles/_table.html.erb +3 -3
- data/app/views/macron/cms/articles/show.html.erb +3 -1
- data/app/views/macron/cms/content_types/index.html.erb +30 -0
- data/app/views/macron/cms/content_types/show.html.erb +34 -0
- data/config/routes.rb +12 -1
- data/db/migrate/20261029000001_add_convertible_to_to_macron_cms_contents.rb +5 -0
- data/db/migrate/20261029120001_add_featured_image_to_macron_cms_contents.rb +5 -0
- data/db/migrate/20261029120002_add_featured_image_blob_to_macron_cms_articles.rb +8 -0
- data/db/migrate/20261029140000_create_friendly_id_slugs.rb +26 -0
- data/db/migrate/20261029150000_remove_name_from_macron_cms_articles.rb +6 -0
- data/db/migrate/20261029160000_add_published_at_to_macron_cms_articles.rb +14 -0
- data/db/seeds.rb +3 -2
- data/lib/macron/cms/version.rb +1 -1
- data/lib/tasks/macron/cms_tasks.rake +6 -0
- metadata +11 -1
|
@@ -3,7 +3,50 @@ module Macron
|
|
|
3
3
|
module Api
|
|
4
4
|
module V1
|
|
5
5
|
class ArticlesController < BaseController
|
|
6
|
-
|
|
6
|
+
include Macron::Cms::ApiSerialization
|
|
7
|
+
|
|
8
|
+
before_action :set_article, only: %i[show update destroy]
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
articles = Article.all
|
|
12
|
+
if params[:q].present?
|
|
13
|
+
query = "%#{params[:q].to_s.strip.downcase}%"
|
|
14
|
+
articles = articles.where(
|
|
15
|
+
"LOWER(title) LIKE :q OR LOWER(slug) LIKE :q OR LOWER(COALESCE(meta_title, '')) LIKE :q OR LOWER(COALESCE(meta_description, '')) LIKE :q",
|
|
16
|
+
q: query
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
page = [ params[:page].to_i, 1 ].max
|
|
21
|
+
per_page = params[:per_page].present? ? [ [ params[:per_page].to_i, 1 ].max, 100 ].min : 20
|
|
22
|
+
total_count = articles.count
|
|
23
|
+
total_pages = (total_count.to_f / per_page).ceil
|
|
24
|
+
|
|
25
|
+
include_blocks = ActiveModel::Type::Boolean.new.cast(params[:include_blocks])
|
|
26
|
+
|
|
27
|
+
scope = articles.includes(:featured_image_blob, background_image_attachment: :blob)
|
|
28
|
+
scope = scope.includes(article_contents: [ :content, files_attachments: :blob ]) if include_blocks
|
|
29
|
+
|
|
30
|
+
records = scope.offset((page - 1) * per_page).limit(per_page)
|
|
31
|
+
|
|
32
|
+
serialized = records.map do |art|
|
|
33
|
+
data = serialize_article(art)
|
|
34
|
+
if include_blocks
|
|
35
|
+
data[:content_blocks] = serialize_blocks_collection(art.article_contents.order(:sort_order, :id))
|
|
36
|
+
end
|
|
37
|
+
data
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
render json: {
|
|
41
|
+
articles: serialized,
|
|
42
|
+
meta: {
|
|
43
|
+
page: page,
|
|
44
|
+
per_page: per_page,
|
|
45
|
+
total_count: total_count,
|
|
46
|
+
total_pages: total_pages
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
end
|
|
7
50
|
|
|
8
51
|
def show
|
|
9
52
|
render json: serialize_article_with_blocks(@article)
|
|
@@ -19,6 +62,7 @@ module Macron
|
|
|
19
62
|
Article.new
|
|
20
63
|
end
|
|
21
64
|
|
|
65
|
+
was_new_record = @article.new_record?
|
|
22
66
|
@article.assign_attributes(article_attributes)
|
|
23
67
|
|
|
24
68
|
ActiveRecord::Base.transaction do
|
|
@@ -27,12 +71,16 @@ module Macron
|
|
|
27
71
|
return
|
|
28
72
|
end
|
|
29
73
|
|
|
74
|
+
handle_background_image!(@article)
|
|
75
|
+
|
|
30
76
|
if params.key?(:content_blocks)
|
|
31
77
|
sync_content_blocks!(@article, params[:content_blocks])
|
|
32
78
|
end
|
|
79
|
+
|
|
80
|
+
@article.sync_featured_image!
|
|
33
81
|
end
|
|
34
82
|
|
|
35
|
-
status =
|
|
83
|
+
status = was_new_record ? :created : :ok
|
|
36
84
|
render json: serialize_article_with_blocks(@article), status: status
|
|
37
85
|
rescue ArgumentError => e
|
|
38
86
|
render json: { errors: [ e.message ] }, status: :unprocessable_entity
|
|
@@ -47,9 +95,13 @@ module Macron
|
|
|
47
95
|
return
|
|
48
96
|
end
|
|
49
97
|
|
|
98
|
+
handle_background_image!(@article)
|
|
99
|
+
|
|
50
100
|
if params.key?(:content_blocks)
|
|
51
101
|
sync_content_blocks!(@article, params[:content_blocks])
|
|
52
102
|
end
|
|
103
|
+
|
|
104
|
+
@article.sync_featured_image!
|
|
53
105
|
end
|
|
54
106
|
|
|
55
107
|
render json: serialize_article_with_blocks(@article), status: :ok
|
|
@@ -59,6 +111,13 @@ module Macron
|
|
|
59
111
|
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
|
|
60
112
|
end
|
|
61
113
|
|
|
114
|
+
def destroy
|
|
115
|
+
id = @article.id
|
|
116
|
+
slug = @article.slug
|
|
117
|
+
@article.destroy!
|
|
118
|
+
render json: { deleted: true, id: id, slug: slug }, status: :ok
|
|
119
|
+
end
|
|
120
|
+
|
|
62
121
|
private
|
|
63
122
|
|
|
64
123
|
def set_article
|
|
@@ -68,11 +127,6 @@ module Macron
|
|
|
68
127
|
end
|
|
69
128
|
|
|
70
129
|
def find_existing_article(attrs)
|
|
71
|
-
if attrs[:name].present?
|
|
72
|
-
existing = Article.find_by(name: attrs[:name])
|
|
73
|
-
return existing if existing
|
|
74
|
-
end
|
|
75
|
-
|
|
76
130
|
if attrs[:slug].present?
|
|
77
131
|
existing = Article.find_by(slug: attrs[:slug])
|
|
78
132
|
return existing if existing
|
|
@@ -90,8 +144,8 @@ module Macron
|
|
|
90
144
|
raw = params[:article].presence || params
|
|
91
145
|
raw.permit(
|
|
92
146
|
:title,
|
|
93
|
-
:name,
|
|
94
147
|
:slug,
|
|
148
|
+
:published_at,
|
|
95
149
|
:meta_title,
|
|
96
150
|
:meta_description,
|
|
97
151
|
:background_color,
|
|
@@ -100,84 +154,104 @@ module Macron
|
|
|
100
154
|
)
|
|
101
155
|
end
|
|
102
156
|
|
|
157
|
+
def handle_background_image!(article)
|
|
158
|
+
raw = params[:article].presence || params
|
|
159
|
+
|
|
160
|
+
purge = ActiveModel::Type::Boolean.new.cast(raw[:purge_background_image] || params[:purge_background_image])
|
|
161
|
+
if purge && article.background_image.attached?
|
|
162
|
+
article.background_image.purge
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
bg_upload = raw[:background_image_upload] || params[:background_image_upload]
|
|
166
|
+
if bg_upload.present?
|
|
167
|
+
InlineAssetAttacher.attach_background(article: article, upload_data: bg_upload, replace: true)
|
|
168
|
+
return
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
bg_url = raw[:background_image_url] || params[:background_image_url]
|
|
172
|
+
if bg_url.present?
|
|
173
|
+
RemoteAssetAttacher.attach_background(article: article, url: bg_url, replace: true)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
103
177
|
def sync_content_blocks!(article, blocks_params)
|
|
104
178
|
return unless blocks_params.is_a?(Array)
|
|
105
179
|
|
|
106
|
-
|
|
107
|
-
|
|
180
|
+
existing_blocks = article.article_contents.index_by(&:id)
|
|
181
|
+
seen_ids = []
|
|
108
182
|
|
|
109
183
|
blocks_params.each_with_index do |block_data, index|
|
|
110
184
|
raw_block = block_data.respond_to?(:to_unsafe_h) ? block_data.to_unsafe_h : block_data.to_h
|
|
185
|
+
block_id = (raw_block["id"] || raw_block[:id]).presence&.to_i
|
|
186
|
+
|
|
111
187
|
content_type_name = raw_block["content_type"] || raw_block[:content_type]
|
|
112
|
-
content_type = Content.find_by(name: content_type_name.to_s.strip)
|
|
188
|
+
content_type = Content.find_by(name: content_type_name.to_s.strip) if content_type_name.present?
|
|
113
189
|
|
|
114
|
-
|
|
115
|
-
|
|
190
|
+
raw_payload = raw_block["payload"] || raw_block[:payload] || {}
|
|
191
|
+
order = (raw_block["sort_order"] || raw_block[:sort_order]).presence || (index + 1)
|
|
192
|
+
|
|
193
|
+
article_content = if block_id && existing_blocks.key?(block_id)
|
|
194
|
+
seen_ids << block_id
|
|
195
|
+
existing_blocks[block_id]
|
|
196
|
+
else
|
|
197
|
+
article.article_contents.build
|
|
116
198
|
end
|
|
117
199
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
200
|
+
if content_type
|
|
201
|
+
article_content.content = content_type
|
|
202
|
+
elsif article_content.new_record?
|
|
203
|
+
raise ArgumentError, "Unknown content type: '#{content_type_name}'. Available types: #{Content.pluck(:name).join(', ')}"
|
|
204
|
+
end
|
|
123
205
|
|
|
124
|
-
|
|
206
|
+
article_content.sort_order = order.to_i
|
|
125
207
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
208
|
+
filtered = article_content.filter_payload(raw_payload)
|
|
209
|
+
replace_payload = ActiveModel::Type::Boolean.new.cast(raw_block["replace_payload"] || raw_block[:replace_payload])
|
|
210
|
+
article_content.payload = if article_content.new_record? || replace_payload
|
|
211
|
+
filtered
|
|
212
|
+
else
|
|
213
|
+
(article_content.payload || {}).merge(filtered)
|
|
214
|
+
end
|
|
131
215
|
|
|
132
|
-
|
|
133
|
-
|
|
216
|
+
# Check attachment purging
|
|
217
|
+
purge = ActiveModel::Type::Boolean.new.cast(raw_block["purge_attachment"] || raw_block[:purge_attachment])
|
|
218
|
+
if purge && article_content.files.attached?
|
|
219
|
+
article_content.files.purge
|
|
134
220
|
end
|
|
135
221
|
|
|
136
|
-
|
|
222
|
+
# Check inline base64 upload
|
|
223
|
+
file_upload = raw_block["file_upload"] || raw_block[:file_upload] || raw_block["upload_data"] || raw_block[:upload_data]
|
|
224
|
+
if file_upload.present?
|
|
225
|
+
article_content.save!
|
|
226
|
+
InlineAssetAttacher.attach(article_content: article_content, upload_data: file_upload, replace: true)
|
|
227
|
+
else
|
|
228
|
+
# Check remote URL
|
|
229
|
+
remote_url = raw_block["image_url"] || raw_block[:image_url] ||
|
|
230
|
+
raw_block["file_url"] || raw_block[:file_url] ||
|
|
231
|
+
raw_block["upload_url"] || raw_block[:upload_url] ||
|
|
232
|
+
raw_payload["upload"]
|
|
233
|
+
|
|
234
|
+
if remote_url.present?
|
|
235
|
+
article_content.payload ||= {}
|
|
236
|
+
article_content.payload["upload"] = remote_url
|
|
237
|
+
article_content.save!
|
|
137
238
|
|
|
138
|
-
|
|
139
|
-
|
|
239
|
+
if remote_url.start_with?("http://") || remote_url.start_with?("https://")
|
|
240
|
+
RemoteAssetAttacher.attach(article_content: article_content, url: remote_url, replace: true)
|
|
241
|
+
end
|
|
242
|
+
else
|
|
243
|
+
article_content.save!
|
|
244
|
+
end
|
|
140
245
|
end
|
|
141
246
|
end
|
|
142
|
-
end
|
|
143
247
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
article:
|
|
148
|
-
|
|
149
|
-
title: article.title,
|
|
150
|
-
name: article.name,
|
|
151
|
-
slug: article.slug,
|
|
152
|
-
meta_title: article.meta_title,
|
|
153
|
-
meta_description: article.meta_description,
|
|
154
|
-
background_color: article.background_color,
|
|
155
|
-
text_color: article.text_color,
|
|
156
|
-
background_options: article.background_options,
|
|
157
|
-
created_at: article.created_at&.iso8601,
|
|
158
|
-
updated_at: article.updated_at&.iso8601
|
|
159
|
-
},
|
|
160
|
-
content_blocks: article.article_contents.includes(:content).order(:sort_order, :id).map do |ac|
|
|
161
|
-
block_data = {
|
|
162
|
-
id: ac.id,
|
|
163
|
-
content_type: ac.content.name,
|
|
164
|
-
sort_order: ac.sort_order,
|
|
165
|
-
payload: ac.payload
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if ac.files.attached?
|
|
169
|
-
file = ac.files.first
|
|
170
|
-
block_data[:attached_file] = {
|
|
171
|
-
filename: file.filename.to_s,
|
|
172
|
-
content_type: file.content_type,
|
|
173
|
-
byte_size: file.byte_size,
|
|
174
|
-
url: Rails.application.routes.url_helpers.rails_blob_path(file, only_path: true)
|
|
175
|
-
}
|
|
176
|
-
end
|
|
248
|
+
# Remove omitted existing blocks
|
|
249
|
+
blocks_to_destroy = existing_blocks.keys - seen_ids
|
|
250
|
+
if blocks_to_destroy.any?
|
|
251
|
+
article.article_contents.where(id: blocks_to_destroy).destroy_all
|
|
252
|
+
end
|
|
177
253
|
|
|
178
|
-
|
|
179
|
-
end
|
|
180
|
-
}
|
|
254
|
+
normalize_sort_orders!(article)
|
|
181
255
|
end
|
|
182
256
|
end
|
|
183
257
|
end
|
|
@@ -2,7 +2,7 @@ module Macron
|
|
|
2
2
|
module Cms
|
|
3
3
|
class ArticleContentsController < ApplicationController
|
|
4
4
|
before_action :set_article
|
|
5
|
-
before_action :set_article_content, only: %i[update destroy move]
|
|
5
|
+
before_action :set_article_content, only: %i[update destroy move convert]
|
|
6
6
|
|
|
7
7
|
def create
|
|
8
8
|
content = Content.find(params.dig(:article_content, :content_id))
|
|
@@ -36,7 +36,7 @@ module Macron
|
|
|
36
36
|
newly_added_id: @article_content.id
|
|
37
37
|
)
|
|
38
38
|
end
|
|
39
|
-
format.html { redirect_to article_path(@article), notice: "Content block was successfully added." }
|
|
39
|
+
format.html { redirect_to article_path(@article, view: params[:view].presence), notice: "Content block was successfully added." }
|
|
40
40
|
end
|
|
41
41
|
else
|
|
42
42
|
respond_to do |format|
|
|
@@ -47,7 +47,7 @@ module Macron
|
|
|
47
47
|
locals: { alert: "Failed to add content block: #{@article_content.errors.full_messages.to_sentence}" }
|
|
48
48
|
)
|
|
49
49
|
end
|
|
50
|
-
format.html { redirect_to article_path(@article), alert: "Failed to add content block: #{@article_content.errors.full_messages.to_sentence}" }
|
|
50
|
+
format.html { redirect_to article_path(@article, view: params[:view].presence), alert: "Failed to add content block: #{@article_content.errors.full_messages.to_sentence}" }
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
53
|
end
|
|
@@ -71,7 +71,9 @@ module Macron
|
|
|
71
71
|
|
|
72
72
|
attach_files_if_present! unless remove_files.include?("upload") || remove_files.include?("file")
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
merged_payload = (@article_content.payload || {}).merge(filtered)
|
|
75
|
+
|
|
76
|
+
if @article_content.update(payload: merged_payload)
|
|
75
77
|
respond_to do |format|
|
|
76
78
|
format.turbo_stream do
|
|
77
79
|
render_content_blocks_stream(
|
|
@@ -79,7 +81,7 @@ module Macron
|
|
|
79
81
|
updated_id: @article_content.id
|
|
80
82
|
)
|
|
81
83
|
end
|
|
82
|
-
format.html { redirect_to article_path(@article), notice: "Content block was successfully updated." }
|
|
84
|
+
format.html { redirect_to article_path(@article, view: params[:view].presence), notice: "Content block was successfully updated." }
|
|
83
85
|
end
|
|
84
86
|
else
|
|
85
87
|
respond_to do |format|
|
|
@@ -90,7 +92,7 @@ module Macron
|
|
|
90
92
|
locals: { alert: "Failed to update content block: #{@article_content.errors.full_messages.to_sentence}" }
|
|
91
93
|
)
|
|
92
94
|
end
|
|
93
|
-
format.html { redirect_to article_path(@article), alert: "Failed to update content block: #{@article_content.errors.full_messages.to_sentence}" }
|
|
95
|
+
format.html { redirect_to article_path(@article, view: params[:view].presence), alert: "Failed to update content block: #{@article_content.errors.full_messages.to_sentence}" }
|
|
94
96
|
end
|
|
95
97
|
end
|
|
96
98
|
end
|
|
@@ -102,7 +104,7 @@ module Macron
|
|
|
102
104
|
format.turbo_stream do
|
|
103
105
|
render_content_blocks_stream(notice: "Content block was successfully removed.")
|
|
104
106
|
end
|
|
105
|
-
format.html { redirect_to article_path(@article), notice: "Content block was successfully removed." }
|
|
107
|
+
format.html { redirect_to article_path(@article, view: params[:view].presence), notice: "Content block was successfully removed." }
|
|
106
108
|
end
|
|
107
109
|
end
|
|
108
110
|
|
|
@@ -128,7 +130,37 @@ module Macron
|
|
|
128
130
|
format.turbo_stream do
|
|
129
131
|
render_content_blocks_stream(notice: "Content block order updated.")
|
|
130
132
|
end
|
|
131
|
-
format.html { redirect_to article_path(@article), notice: "Content block order updated." }
|
|
133
|
+
format.html { redirect_to article_path(@article, view: params[:view].presence), notice: "Content block order updated." }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def convert
|
|
138
|
+
target_content = Content.find_by(id: params[:target_content_id]) ||
|
|
139
|
+
Content.find_by(name: params[:target_content_type].to_s)
|
|
140
|
+
|
|
141
|
+
if @article_content.convert_to(target_content)
|
|
142
|
+
notice_msg = "Content block was converted to #{target_content.name}."
|
|
143
|
+
respond_to do |format|
|
|
144
|
+
format.turbo_stream do
|
|
145
|
+
render_content_blocks_stream(
|
|
146
|
+
notice: notice_msg,
|
|
147
|
+
updated_id: @article_content.id
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
format.html { redirect_to article_path(@article, view: params[:view].presence), notice: notice_msg }
|
|
151
|
+
end
|
|
152
|
+
else
|
|
153
|
+
alert_msg = "Failed to convert content block: #{@article_content.errors.full_messages.to_sentence}"
|
|
154
|
+
respond_to do |format|
|
|
155
|
+
format.turbo_stream do
|
|
156
|
+
render turbo_stream: turbo_stream.update(
|
|
157
|
+
"flash-messages",
|
|
158
|
+
partial: "macron/cms/shared/flash",
|
|
159
|
+
locals: { alert: alert_msg }
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
format.html { redirect_to article_path(@article, view: params[:view].presence), alert: alert_msg }
|
|
163
|
+
end
|
|
132
164
|
end
|
|
133
165
|
end
|
|
134
166
|
|
|
@@ -146,7 +178,7 @@ module Macron
|
|
|
146
178
|
|
|
147
179
|
respond_to do |format|
|
|
148
180
|
format.json { render json: { status: "ok" } }
|
|
149
|
-
format.html { redirect_to article_path(@article), notice: "Content block order updated." }
|
|
181
|
+
format.html { redirect_to article_path(@article, view: params[:view].presence), notice: "Content block order updated." }
|
|
150
182
|
end
|
|
151
183
|
end
|
|
152
184
|
|
|
@@ -192,14 +224,16 @@ module Macron
|
|
|
192
224
|
|
|
193
225
|
def render_content_blocks_stream(notice: nil, alert: nil, newly_added_id: nil, updated_id: nil)
|
|
194
226
|
@content_types = Content.order(:name)
|
|
227
|
+
compact = params[:view] == "compact"
|
|
195
228
|
turbo_stream_actions = [
|
|
196
229
|
turbo_stream.update(
|
|
197
230
|
"content-blocks-section",
|
|
198
231
|
partial: "macron/cms/articles/content_blocks_section",
|
|
199
232
|
locals: {
|
|
200
233
|
article: @article,
|
|
201
|
-
article_contents: @article.article_contents.
|
|
234
|
+
article_contents: @article.article_contents.for_display.order(:sort_order, :id).load,
|
|
202
235
|
content_types: @content_types,
|
|
236
|
+
compact: compact,
|
|
203
237
|
newly_added_id: newly_added_id,
|
|
204
238
|
updated_id: updated_id
|
|
205
239
|
}
|
|
@@ -4,11 +4,11 @@ module Macron
|
|
|
4
4
|
before_action :set_article, only: %i[show edit update destroy purge_background_image]
|
|
5
5
|
|
|
6
6
|
def index
|
|
7
|
-
@articles = Article.
|
|
7
|
+
@articles = Article.all
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def show
|
|
11
|
-
@article_contents = @article.article_contents.
|
|
11
|
+
@article_contents = @article.article_contents.for_display.order(:sort_order, :id).load
|
|
12
12
|
@content_types = Content.order(:name)
|
|
13
13
|
end
|
|
14
14
|
|
|
@@ -56,7 +56,8 @@ module Macron
|
|
|
56
56
|
def article_params
|
|
57
57
|
params.require(:article).permit(
|
|
58
58
|
:title,
|
|
59
|
-
:
|
|
59
|
+
:slug,
|
|
60
|
+
:published_at,
|
|
60
61
|
:meta_title,
|
|
61
62
|
:meta_description,
|
|
62
63
|
:background_color,
|
|
@@ -5,9 +5,11 @@ module Macron
|
|
|
5
5
|
|
|
6
6
|
def index
|
|
7
7
|
@content_types = Content.order(:name)
|
|
8
|
+
@content_types_by_name = @content_types.index_by(&:name)
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
def show
|
|
12
|
+
@convertible_targets_by_name = Content.where(name: @content_type.normalized_convertible_to).index_by(&:name)
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
private
|
|
@@ -4,7 +4,7 @@ module Macron
|
|
|
4
4
|
def cms_render_article_contents(article)
|
|
5
5
|
return "".html_safe if article.nil?
|
|
6
6
|
|
|
7
|
-
contents = article.article_contents.
|
|
7
|
+
contents = article.article_contents.for_display.order(:sort_order, :id)
|
|
8
8
|
rendered = contents.map { |ac| cms_render_widget(ac) }
|
|
9
9
|
safe_join(rendered)
|
|
10
10
|
end
|
|
@@ -13,6 +13,20 @@ module Macron
|
|
|
13
13
|
sanitize(raw_html)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
def cms_strip_markdown(text)
|
|
17
|
+
return "" if text.blank?
|
|
18
|
+
|
|
19
|
+
raw_html = Kramdown::Document.new(text.to_s, input: "GFM").to_html
|
|
20
|
+
strip_tags(raw_html).to_s.squish
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def cms_top_field_definition(article_content)
|
|
24
|
+
content = article_content.content
|
|
25
|
+
return nil unless content
|
|
26
|
+
|
|
27
|
+
content.normalized_fields.first
|
|
28
|
+
end
|
|
29
|
+
|
|
16
30
|
def cms_render_field(field_def, value: nil, input_name: nil, input_id: nil)
|
|
17
31
|
field_hash = field_def.stringify_keys
|
|
18
32
|
fname = field_hash["name"]
|
|
@@ -11,18 +11,52 @@ module Macron
|
|
|
11
11
|
DEFAULT_BACKGROUND_POSITION = "center"
|
|
12
12
|
DEFAULT_BACKGROUND_ATTACHMENT = "scroll"
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
default_scope { order(published_at: :desc, created_at: :desc) }
|
|
15
|
+
|
|
16
|
+
before_validation :normalize_slug
|
|
17
|
+
before_validation :set_default_published_at, on: :create
|
|
18
|
+
before_save :ensure_published_at
|
|
15
19
|
|
|
16
20
|
extend FriendlyId
|
|
17
|
-
friendly_id :
|
|
21
|
+
friendly_id :slug_candidate, use: %i[slugged history]
|
|
18
22
|
|
|
19
23
|
validates :title, presence: true
|
|
20
|
-
validates :
|
|
24
|
+
validates :slug, presence: true, uniqueness: true
|
|
25
|
+
|
|
26
|
+
def should_generate_new_friendly_id?
|
|
27
|
+
slug_changed? || super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def slug_candidate
|
|
31
|
+
slug.presence || title
|
|
32
|
+
end
|
|
21
33
|
|
|
22
34
|
has_many :article_contents, -> { order(:sort_order) }, class_name: "Macron::Cms::ArticleContent", dependent: :destroy
|
|
23
35
|
has_many :contents, through: :article_contents
|
|
24
36
|
|
|
25
37
|
has_one_attached :background_image
|
|
38
|
+
belongs_to :featured_image_blob, class_name: "ActiveStorage::Blob", optional: true
|
|
39
|
+
|
|
40
|
+
def featured_image
|
|
41
|
+
featured_image_blob
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def sync_featured_image!
|
|
45
|
+
return if destroyed? || marked_for_destruction?
|
|
46
|
+
|
|
47
|
+
next_blob_id = article_contents
|
|
48
|
+
.unscoped
|
|
49
|
+
.where(article_id: id)
|
|
50
|
+
.joins(:content, :files_attachments)
|
|
51
|
+
.where(macron_cms_contents: { featured_image: true })
|
|
52
|
+
.order(:sort_order, :id, Arel.sql("active_storage_attachments.id ASC"))
|
|
53
|
+
.pick(Arel.sql("active_storage_attachments.blob_id"))
|
|
54
|
+
|
|
55
|
+
if featured_image_blob_id != next_blob_id
|
|
56
|
+
update_column(:featured_image_blob_id, next_blob_id)
|
|
57
|
+
self.featured_image_blob_id = next_blob_id
|
|
58
|
+
end
|
|
59
|
+
end
|
|
26
60
|
|
|
27
61
|
def seo_title
|
|
28
62
|
meta_title.presence || title
|
|
@@ -58,20 +92,16 @@ module Macron
|
|
|
58
92
|
|
|
59
93
|
private
|
|
60
94
|
|
|
61
|
-
def
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
base = title.parameterize.presence || "article"
|
|
65
|
-
candidate = base
|
|
66
|
-
scope = id.present? ? self.class.where.not(id: id) : self.class.all
|
|
95
|
+
def set_default_published_at
|
|
96
|
+
self.published_at = Time.current if published_at.blank?
|
|
97
|
+
end
|
|
67
98
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
candidate = "#{base}-#{counter}"
|
|
72
|
-
end
|
|
99
|
+
def ensure_published_at
|
|
100
|
+
self.published_at ||= (created_at.presence || Time.current)
|
|
101
|
+
end
|
|
73
102
|
|
|
74
|
-
|
|
103
|
+
def normalize_slug
|
|
104
|
+
self.slug = slug.to_s.parameterize.presence
|
|
75
105
|
end
|
|
76
106
|
end
|
|
77
107
|
end
|
|
@@ -6,12 +6,17 @@ module Macron
|
|
|
6
6
|
|
|
7
7
|
has_many_attached :files
|
|
8
8
|
|
|
9
|
+
after_save :sync_article_featured_image
|
|
10
|
+
after_destroy :sync_article_featured_image, unless: -> { destroyed_by_association.present? }
|
|
11
|
+
|
|
9
12
|
before_validation :assign_default_sort_order, on: :create
|
|
10
13
|
|
|
11
14
|
validates :sort_order, numericality: { only_integer: true }
|
|
12
15
|
|
|
13
16
|
default_scope -> { order(:sort_order, :id) }
|
|
14
17
|
|
|
18
|
+
scope :for_display, -> { with_attached_files.includes(:content) }
|
|
19
|
+
|
|
15
20
|
def upload
|
|
16
21
|
files.first
|
|
17
22
|
end
|
|
@@ -49,6 +54,21 @@ module Macron
|
|
|
49
54
|
end
|
|
50
55
|
end
|
|
51
56
|
|
|
57
|
+
def convert_to(target_content)
|
|
58
|
+
unless target_content.is_a?(Content) && content&.convertible_to?(target_content)
|
|
59
|
+
errors.add(:base, "Cannot convert #{content&.name || 'unknown'} block to #{target_content&.name || 'unknown'}")
|
|
60
|
+
return false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
updated_payload = (payload || {}).deep_dup
|
|
64
|
+
if files.attached? && updated_payload["upload"].blank? &&
|
|
65
|
+
target_content.normalized_fields.any? { |f| f["name"] == "upload" && f["kind"] == "file" }
|
|
66
|
+
updated_payload["upload"] = upload.filename.to_s
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
update(content: target_content, payload: updated_payload)
|
|
70
|
+
end
|
|
71
|
+
|
|
52
72
|
private
|
|
53
73
|
|
|
54
74
|
def assign_default_sort_order
|
|
@@ -58,6 +78,10 @@ module Macron
|
|
|
58
78
|
max_order = article.article_contents.unscoped.where(article_id: article.id).maximum(:sort_order)
|
|
59
79
|
self.sort_order = max_order ? max_order + 1 : 1
|
|
60
80
|
end
|
|
81
|
+
|
|
82
|
+
def sync_article_featured_image
|
|
83
|
+
article&.sync_featured_image!
|
|
84
|
+
end
|
|
61
85
|
end
|
|
62
86
|
end
|
|
63
87
|
end
|