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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +338 -25
  3. data/app/assets/stylesheets/macron/cms/application.css +1 -1
  4. data/app/controllers/concerns/macron/cms/api_serialization.rb +79 -0
  5. data/app/controllers/concerns/macron/cms/slug_redirectable.rb +27 -0
  6. data/app/controllers/macron/cms/api/v1/article_contents_controller.rb +221 -0
  7. data/app/controllers/macron/cms/api/v1/articles_controller.rb +140 -66
  8. data/app/controllers/macron/cms/api/v1/content_types_controller.rb +3 -1
  9. data/app/controllers/macron/cms/article_contents_controller.rb +44 -10
  10. data/app/controllers/macron/cms/articles_controller.rb +4 -3
  11. data/app/controllers/macron/cms/content_types_controller.rb +2 -0
  12. data/app/controllers/macron/cms/dashboard_controller.rb +1 -1
  13. data/app/helpers/macron/cms/content_helper.rb +1 -1
  14. data/app/helpers/macron/cms/fields_helper.rb +14 -0
  15. data/app/models/macron/cms/article.rb +45 -15
  16. data/app/models/macron/cms/article_content.rb +24 -0
  17. data/app/models/macron/cms/content.rb +27 -0
  18. data/app/services/macron/cms/inline_asset_attacher.rb +70 -0
  19. data/app/services/macron/cms/remote_asset_attacher.rb +16 -6
  20. data/app/views/macron/cms/articles/_content_blocks_section.html.erb +494 -199
  21. data/app/views/macron/cms/articles/_form.html.erb +10 -2
  22. data/app/views/macron/cms/articles/_table.html.erb +3 -3
  23. data/app/views/macron/cms/articles/show.html.erb +3 -1
  24. data/app/views/macron/cms/content_types/index.html.erb +30 -0
  25. data/app/views/macron/cms/content_types/show.html.erb +34 -0
  26. data/config/routes.rb +12 -1
  27. data/db/migrate/20261029000001_add_convertible_to_to_macron_cms_contents.rb +5 -0
  28. data/db/migrate/20261029120001_add_featured_image_to_macron_cms_contents.rb +5 -0
  29. data/db/migrate/20261029120002_add_featured_image_blob_to_macron_cms_articles.rb +8 -0
  30. data/db/migrate/20261029140000_create_friendly_id_slugs.rb +26 -0
  31. data/db/migrate/20261029150000_remove_name_from_macron_cms_articles.rb +6 -0
  32. data/db/migrate/20261029160000_add_published_at_to_macron_cms_articles.rb +14 -0
  33. data/db/seeds.rb +3 -2
  34. data/lib/macron/cms/version.rb +1 -1
  35. data/lib/tasks/macron/cms_tasks.rake +6 -0
  36. metadata +11 -1
@@ -3,7 +3,50 @@ module Macron
3
3
  module Api
4
4
  module V1
5
5
  class ArticlesController < BaseController
6
- before_action :set_article, only: %i[show update]
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 = @article.previously_new_record? ? :created : :ok
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
- # Cleanly replace existing content blocks
107
- article.article_contents.destroy_all
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
- if content_type.nil?
115
- raise ArgumentError, "Unknown content type: '#{content_type_name}'. Available types: #{Content.pluck(:name).join(', ')}"
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
- raw_payload = raw_block["payload"] || raw_block[:payload] || {}
119
- remote_url = raw_block["image_url"] || raw_block[:image_url] ||
120
- raw_block["file_url"] || raw_block[:file_url] ||
121
- raw_block["upload_url"] || raw_block[:upload_url] ||
122
- raw_payload["upload"]
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
- order = (raw_block["sort_order"] || raw_block[:sort_order]).presence || (index + 1)
206
+ article_content.sort_order = order.to_i
125
207
 
126
- article_content = article.article_contents.build(
127
- content: content_type,
128
- sort_order: order.to_i
129
- )
130
- article_content.payload = article_content.filter_payload(raw_payload)
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
- if remote_url.present?
133
- article_content.payload["upload"] = remote_url
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
- article_content.save!
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
- if remote_url.present? && (remote_url.start_with?("http://") || remote_url.start_with?("https://"))
139
- RemoteAssetAttacher.attach(article_content: article_content, url: remote_url)
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
- def serialize_article_with_blocks(article)
145
- article.reload
146
- {
147
- article: {
148
- id: article.id,
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
- block_data
179
- end
180
- }
254
+ normalize_sort_orders!(article)
181
255
  end
182
256
  end
183
257
  end
@@ -8,7 +8,9 @@ module Macron
8
8
  {
9
9
  id: ct.id,
10
10
  name: ct.name,
11
- fields: ct.normalized_fields
11
+ fields: ct.normalized_fields,
12
+ convertible_to: ct.normalized_convertible_to,
13
+ featured_image: ct.featured_image
12
14
  }
13
15
  end
14
16
 
@@ -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
- if @article_content.update(payload: filtered)
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.reload,
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.order(created_at: :desc)
7
+ @articles = Article.all
8
8
  end
9
9
 
10
10
  def show
11
- @article_contents = @article.article_contents.includes(:content).order(:sort_order, :id)
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
- :name,
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
@@ -3,7 +3,7 @@ module Macron
3
3
  class DashboardController < ApplicationController
4
4
  def index
5
5
  @articles_count = Article.count
6
- @recent_articles = Article.order(created_at: :desc).limit(5)
6
+ @recent_articles = Article.limit(5)
7
7
  end
8
8
  end
9
9
  end
@@ -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.includes(:content).order(:sort_order, :id)
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
- before_validation :derive_name_from_title
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 :name, use: :slugged
21
+ friendly_id :slug_candidate, use: %i[slugged history]
18
22
 
19
23
  validates :title, presence: true
20
- validates :name, presence: true, uniqueness: true
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 derive_name_from_title
62
- return if name.present? || title.blank?
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
- counter = 1
69
- while scope.exists?(name: candidate)
70
- counter += 1
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
- self.name = candidate
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