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
@@ -28,13 +28,21 @@
28
28
  </div>
29
29
 
30
30
  <div>
31
- <%= form.label :name, "Identifier Name", class: "block text-sm font-semibold text-zinc-900" %>
31
+ <%= form.label :slug, "Identifier Name", class: "block text-sm font-semibold text-zinc-900" %>
32
32
  <div class="mt-1.5">
33
- <%= form.text_field :name, placeholder: "Leave blank to generate from title", class: "block w-full rounded-lg border border-zinc-300 px-3.5 py-2 text-zinc-900 shadow-xs placeholder:text-zinc-400 focus:outline-none focus:border-zinc-900 focus:ring-1 focus:ring-zinc-900 text-sm font-mono" %>
33
+ <%= form.text_field :slug, placeholder: "Leave blank to generate from title", class: "block w-full rounded-lg border border-zinc-300 px-3.5 py-2 text-zinc-900 shadow-xs placeholder:text-zinc-400 focus:outline-none focus:border-zinc-900 focus:ring-1 focus:ring-zinc-900 text-sm font-mono" %>
34
34
  </div>
35
35
  <p class="mt-1 text-xs text-zinc-500">Optional. Unique identifier used by FriendlyId to generate clean URLs (derived from title if left blank).</p>
36
36
  </div>
37
37
 
38
+ <div>
39
+ <%= form.label :published_at, "Published Date & Time", class: "block text-sm font-semibold text-zinc-900" %>
40
+ <div class="mt-1.5">
41
+ <%= form.datetime_local_field :published_at, value: (article.published_at || Time.current).strftime("%Y-%m-%dT%H:%M"), class: "block w-full rounded-lg border border-zinc-300 px-3.5 py-2 text-zinc-900 shadow-xs placeholder:text-zinc-400 focus:outline-none focus:border-zinc-900 focus:ring-1 focus:ring-zinc-900 text-sm" %>
42
+ </div>
43
+ <p class="mt-1 text-xs text-zinc-500">Controls article sort order across the site and public publication date.</p>
44
+ </div>
45
+
38
46
  <div class="pt-6 border-t border-zinc-200 space-y-4">
39
47
  <div>
40
48
  <h3 class="text-sm font-semibold text-zinc-900">Styling & Background Settings</h3>
@@ -5,7 +5,7 @@
5
5
  <thead class="bg-zinc-50 text-zinc-700 font-semibold text-xs uppercase tracking-wider">
6
6
  <tr>
7
7
  <th scope="col" class="px-6 py-3.5">Title</th>
8
- <th scope="col" class="px-6 py-3.5">Created At</th>
8
+ <th scope="col" class="px-6 py-3.5">Published At</th>
9
9
  <th scope="col" class="px-6 py-3.5 text-right">Actions</th>
10
10
  </tr>
11
11
  </thead>
@@ -16,7 +16,7 @@
16
16
  <%= link_to article.title, article_path(article), class: "hover:underline" %>
17
17
  </td>
18
18
  <td class="px-6 py-4 text-zinc-500 text-xs whitespace-nowrap">
19
- <%= article.created_at.strftime("%b %d, %Y") %>
19
+ <%= (article.published_at || article.created_at).strftime("%b %d, %Y %H:%M") %>
20
20
  </td>
21
21
  <td class="px-6 py-4 text-right text-xs font-medium whitespace-nowrap">
22
22
  <div class="flex items-center justify-end gap-3 whitespace-nowrap">
@@ -26,7 +26,7 @@
26
26
  <%= link_to "Show", article_path(article), class: "text-zinc-700 hover:text-zinc-900 hover:underline" %>
27
27
  <%= link_to "Edit", edit_article_path(article), class: "text-zinc-700 hover:text-zinc-900 hover:underline" %>
28
28
  <% delete_confirm = "Are you sure you want to delete \"#{article.title}\"?" %>
29
- <%= button_to "Delete", article_path(article), method: :delete, form_class: "inline", onclick: "return confirm(#{delete_confirm.to_json});", data: { turbo_confirm: delete_confirm }, class: "text-rose-600 hover:text-rose-800 hover:underline cursor-pointer" %>
29
+ <%= button_to "Delete", article_path(article), method: :delete, form_class: "inline", data: { turbo_confirm: delete_confirm }, class: "text-rose-600 hover:text-rose-800 hover:underline cursor-pointer" %>
30
30
  </div>
31
31
  </td>
32
32
  </tr>
@@ -13,6 +13,7 @@
13
13
  </span>
14
14
  </div>
15
15
  <div class="text-xs text-zinc-500 space-y-0.5">
16
+ <p>Published: <%= (@article.published_at || @article.created_at).strftime("%B %d, %Y at %H:%M UTC") %></p>
16
17
  <p>Created: <%= @article.created_at.strftime("%B %d, %Y at %H:%M UTC") %></p>
17
18
  <p>Updated: <%= time_ago_in_words(@article.updated_at) %> ago</p>
18
19
  </div>
@@ -37,7 +38,7 @@
37
38
  <% end %>
38
39
 
39
40
  <% delete_confirm = "Are you sure you want to delete \"#{@article.title}\"?" %>
40
- <%= button_to article_path(@article), method: :delete, onclick: "return confirm(#{delete_confirm.to_json});", data: { turbo_confirm: delete_confirm }, class: "inline-flex items-center gap-1.5 rounded-lg border border-rose-200 bg-rose-50 px-3.5 py-2 text-sm font-semibold text-rose-700 hover:bg-rose-100 transition-colors cursor-pointer" do %>
41
+ <%= button_to article_path(@article), method: :delete, data: { turbo_confirm: delete_confirm }, class: "inline-flex items-center gap-1.5 rounded-lg border border-rose-200 bg-rose-50 px-3.5 py-2 text-sm font-semibold text-rose-700 hover:bg-rose-100 transition-colors cursor-pointer" do %>
41
42
  <svg class="w-4 h-4 text-rose-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
42
43
  <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
43
44
  </svg>
@@ -69,6 +70,7 @@
69
70
  article: @article,
70
71
  article_contents: @article_contents,
71
72
  content_types: @content_types,
73
+ compact: (params[:view] == "compact"),
72
74
  newly_added_id: nil,
73
75
  updated_id: nil %>
74
76
  </div>
@@ -23,6 +23,8 @@
23
23
  <tr>
24
24
  <th scope="col" class="px-6 py-3.5">Name</th>
25
25
  <th scope="col" class="px-6 py-3.5">Fields Summary</th>
26
+ <th scope="col" class="px-6 py-3.5">Convertible To</th>
27
+ <th scope="col" class="px-6 py-3.5">Featured Image</th>
26
28
  <th scope="col" class="px-6 py-3.5">Created At</th>
27
29
  <th scope="col" class="px-6 py-3.5 text-right">Actions</th>
28
30
  </tr>
@@ -58,6 +60,34 @@
58
60
  <span class="text-zinc-600 italic">No fields defined</span>
59
61
  <% end %>
60
62
  </td>
63
+ <td class="px-6 py-4 text-zinc-600 text-xs">
64
+ <% convertible_names = content_type.normalized_convertible_to %>
65
+ <% if convertible_names.any? %>
66
+ <div class="flex flex-wrap gap-1.5">
67
+ <% convertible_names.each do |target_name| %>
68
+ <% target_ct = (@content_types_by_name || {})[target_name] %>
69
+ <% if target_ct %>
70
+ <%= link_to target_name, content_type_path(target_ct), class: "inline-flex items-center rounded-md bg-emerald-50 px-2 py-0.5 font-mono text-[11px] font-medium text-emerald-800 ring-1 ring-inset ring-emerald-200 hover:bg-emerald-100 transition-colors" %>
71
+ <% else %>
72
+ <span class="inline-flex items-center rounded-md bg-zinc-100 px-2 py-0.5 font-mono text-[11px] text-zinc-700 ring-1 ring-inset ring-zinc-200">
73
+ <%= target_name %>
74
+ </span>
75
+ <% end %>
76
+ <% end %>
77
+ </div>
78
+ <% else %>
79
+ <span class="text-zinc-400">—</span>
80
+ <% end %>
81
+ </td>
82
+ <td class="px-6 py-4 text-zinc-600 text-xs whitespace-nowrap">
83
+ <% if content_type.featured_image? %>
84
+ <span class="inline-flex items-center rounded-md bg-emerald-50 px-2 py-0.5 font-mono text-[11px] font-medium text-emerald-800 ring-1 ring-inset ring-emerald-200">
85
+ Yes
86
+ </span>
87
+ <% else %>
88
+ <span class="text-zinc-400">—</span>
89
+ <% end %>
90
+ </td>
61
91
  <td class="px-6 py-4 text-zinc-500 text-xs whitespace-nowrap">
62
92
  <%= content_type.created_at.strftime("%b %d, %Y") rescue "—" %>
63
93
  </td>
@@ -57,6 +57,40 @@
57
57
  <dt class="text-sm font-medium text-zinc-500">Field Count</dt>
58
58
  <dd class="mt-1 text-sm text-zinc-900 sm:col-span-2 sm:mt-0"><%= field_list.count %></dd>
59
59
  </div>
60
+ <div class="px-6 py-4 sm:grid sm:grid-cols-3 sm:gap-4">
61
+ <dt class="text-sm font-medium text-zinc-500">Convertible To</dt>
62
+ <dd class="mt-1 text-sm text-zinc-900 sm:col-span-2 sm:mt-0">
63
+ <% convertible_names = @content_type.normalized_convertible_to %>
64
+ <% if convertible_names.any? %>
65
+ <div class="flex flex-wrap gap-1.5">
66
+ <% convertible_names.each do |target_name| %>
67
+ <% target_ct = (@convertible_targets_by_name || {})[target_name] %>
68
+ <% if target_ct %>
69
+ <%= link_to target_name, content_type_path(target_ct), class: "inline-flex items-center rounded-md bg-emerald-50 px-2.5 py-0.5 text-xs font-mono font-medium text-emerald-800 ring-1 ring-inset ring-emerald-200 hover:bg-emerald-100 transition-colors" %>
70
+ <% else %>
71
+ <span class="inline-flex items-center rounded-md bg-zinc-100 px-2.5 py-0.5 text-xs font-mono text-zinc-700 ring-1 ring-inset ring-zinc-200">
72
+ <%= target_name %>
73
+ </span>
74
+ <% end %>
75
+ <% end %>
76
+ </div>
77
+ <% else %>
78
+ <span class="text-zinc-500 italic">None</span>
79
+ <% end %>
80
+ </dd>
81
+ </div>
82
+ <div class="px-6 py-4 sm:grid sm:grid-cols-3 sm:gap-4">
83
+ <dt class="text-sm font-medium text-zinc-500">Featured Image</dt>
84
+ <dd class="mt-1 text-sm text-zinc-900 sm:col-span-2 sm:mt-0">
85
+ <% if @content_type.featured_image? %>
86
+ <span class="inline-flex items-center rounded-md bg-emerald-50 px-2.5 py-0.5 text-xs font-mono font-medium text-emerald-800 ring-1 ring-inset ring-emerald-200">
87
+ Yes
88
+ </span>
89
+ <% else %>
90
+ <span class="text-zinc-500 italic">No</span>
91
+ <% end %>
92
+ </dd>
93
+ </div>
60
94
  <div class="px-6 py-4 sm:grid sm:grid-cols-3 sm:gap-4">
61
95
  <dt class="text-sm font-medium text-zinc-500">Created At</dt>
62
96
  <dd class="mt-1 text-sm text-zinc-900 sm:col-span-2 sm:mt-0"><%= @content_type.created_at.to_fs(:long) rescue @content_type.created_at.to_s %></dd>
data/config/routes.rb CHANGED
@@ -7,6 +7,7 @@ Macron::Cms::Engine.routes.draw do
7
7
  resources :article_contents, only: %i[create update destroy] do
8
8
  member do
9
9
  patch :move
10
+ patch :convert
10
11
  end
11
12
  collection do
12
13
  patch :reorder
@@ -18,7 +19,17 @@ Macron::Cms::Engine.routes.draw do
18
19
  namespace :api do
19
20
  namespace :v1 do
20
21
  resources :content_types, only: %i[index]
21
- resources :articles, only: %i[create show update]
22
+ resources :articles, only: %i[index create show update destroy] do
23
+ resources :content_blocks, controller: "article_contents", only: %i[create update destroy] do
24
+ member do
25
+ patch :move
26
+ patch :convert
27
+ end
28
+ collection do
29
+ patch :reorder
30
+ end
31
+ end
32
+ end
22
33
  end
23
34
  end
24
35
  end
@@ -0,0 +1,5 @@
1
+ class AddConvertibleToToMacronCmsContents < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :macron_cms_contents, :convertible_to, :jsonb, default: [], null: false
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class AddFeaturedImageToMacronCmsContents < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :macron_cms_contents, :featured_image, :boolean, default: false, null: false
4
+ end
5
+ end
@@ -0,0 +1,8 @@
1
+ class AddFeaturedImageBlobToMacronCmsArticles < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_reference :macron_cms_articles,
4
+ :featured_image_blob,
5
+ null: true,
6
+ foreign_key: { to_table: :active_storage_blobs, on_delete: :nullify }
7
+ end
8
+ end
@@ -0,0 +1,26 @@
1
+ class CreateFriendlyIdSlugs < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :friendly_id_slugs do |t|
4
+ t.string :slug, null: false
5
+ t.integer :sluggable_id, null: false
6
+ t.string :sluggable_type, limit: 50
7
+ t.string :scope
8
+ t.datetime :created_at
9
+ end
10
+
11
+ add_index :friendly_id_slugs, [ :sluggable_type, :sluggable_id ]
12
+ add_index :friendly_id_slugs, [ :slug, :sluggable_type ], length: { slug: 140, sluggable_type: 50 }
13
+ add_index :friendly_id_slugs, [ :slug, :sluggable_type, :scope ], length: { slug: 70, sluggable_type: 50, scope: 70 }, unique: true
14
+
15
+ reversible do |dir|
16
+ dir.up do
17
+ execute <<~SQL
18
+ INSERT INTO friendly_id_slugs (slug, sluggable_id, sluggable_type, created_at)
19
+ SELECT slug, id, 'Macron::Cms::Article', CURRENT_TIMESTAMP
20
+ FROM macron_cms_articles
21
+ WHERE slug IS NOT NULL AND slug <> '';
22
+ SQL
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,6 @@
1
+ class RemoveNameFromMacronCmsArticles < ActiveRecord::Migration[8.0]
2
+ def change
3
+ remove_index :macron_cms_articles, :name, unique: true, if_exists: true
4
+ remove_column :macron_cms_articles, :name, :string
5
+ end
6
+ end
@@ -0,0 +1,14 @@
1
+ class AddPublishedAtToMacronCmsArticles < ActiveRecord::Migration[8.0]
2
+ def up
3
+ add_column :macron_cms_articles, :published_at, :datetime
4
+ add_index :macron_cms_articles, :published_at
5
+
6
+ Macron::Cms::Article.reset_column_information
7
+ execute("UPDATE macron_cms_articles SET published_at = created_at WHERE published_at IS NULL")
8
+ end
9
+
10
+ def down
11
+ remove_index :macron_cms_articles, :published_at, if_exists: true
12
+ remove_column :macron_cms_articles, :published_at
13
+ end
14
+ end
data/db/seeds.rb CHANGED
@@ -51,11 +51,12 @@ articles = [
51
51
  }
52
52
  ]
53
53
 
54
- articles.each do |data|
55
- article = Macron::Cms::Article.find_or_initialize_by(name: data[:title].parameterize)
54
+ articles.each_with_index do |data, index|
55
+ article = Macron::Cms::Article.find_or_initialize_by(slug: data[:title].parameterize)
56
56
  article.title = data[:title]
57
57
  article.meta_title = data[:meta_title]
58
58
  article.meta_description = data[:meta_description]
59
+ article.published_at ||= (Time.current - (articles.size - index).days)
59
60
  article.save!
60
61
  end
61
62
 
@@ -1,5 +1,5 @@
1
1
  module Macron
2
2
  module Cms
3
- VERSION = "3.0.0"
3
+ VERSION = "5.0.0"
4
4
  end
5
5
  end
@@ -24,9 +24,15 @@ namespace :macron do
24
24
  content_type_name = File.basename(file, ".*")
25
25
  data = YAML.safe_load_file(file, permitted_classes: [ Symbol ], aliases: true) || {}
26
26
  fields = data["fields"] || data[:fields] || {}
27
+ raw_convertible = data["convertible_to"] || data[:convertible_to] || []
28
+ convertible_to = Array(raw_convertible).filter_map { |v| v.to_s.strip.presence }.uniq
29
+ raw_featured_image = data.key?("featured_image") ? data["featured_image"] : data[:featured_image]
30
+ featured_image = ActiveModel::Type::Boolean.new.cast(raw_featured_image) || false
27
31
 
28
32
  content = Macron::Cms::Content.find_or_initialize_by(name: content_type_name)
29
33
  content.fields = fields
34
+ content.convertible_to = convertible_to
35
+ content.featured_image = featured_image
30
36
  content.save!
31
37
 
32
38
  puts "Imported content type: #{content_type_name}"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: macron-cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 5.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leonid Medovyy
@@ -161,6 +161,9 @@ files:
161
161
  - app/assets/stylesheets/macron/cms/katex.min.css
162
162
  - app/assets/tailwind/macron/cms/application.css
163
163
  - app/components/macron/cms/widget_component.rb
164
+ - app/controllers/concerns/macron/cms/api_serialization.rb
165
+ - app/controllers/concerns/macron/cms/slug_redirectable.rb
166
+ - app/controllers/macron/cms/api/v1/article_contents_controller.rb
164
167
  - app/controllers/macron/cms/api/v1/articles_controller.rb
165
168
  - app/controllers/macron/cms/api/v1/base_controller.rb
166
169
  - app/controllers/macron/cms/api/v1/content_types_controller.rb
@@ -179,6 +182,7 @@ files:
179
182
  - app/models/macron/cms/article.rb
180
183
  - app/models/macron/cms/article_content.rb
181
184
  - app/models/macron/cms/content.rb
185
+ - app/services/macron/cms/inline_asset_attacher.rb
182
186
  - app/services/macron/cms/remote_asset_attacher.rb
183
187
  - app/views/layouts/macron/cms/application.html.erb
184
188
  - app/views/macron/cms/articles/_content_blocks_section.html.erb
@@ -210,6 +214,12 @@ files:
210
214
  - db/migrate/20261028000006_move_payload_to_macron_cms_article_contents.rb
211
215
  - db/migrate/20261028000007_add_background_fields_to_macron_cms_articles.rb
212
216
  - db/migrate/20261028000008_add_text_color_to_macron_cms_articles.rb
217
+ - db/migrate/20261029000001_add_convertible_to_to_macron_cms_contents.rb
218
+ - db/migrate/20261029120001_add_featured_image_to_macron_cms_contents.rb
219
+ - db/migrate/20261029120002_add_featured_image_blob_to_macron_cms_articles.rb
220
+ - db/migrate/20261029140000_create_friendly_id_slugs.rb
221
+ - db/migrate/20261029150000_remove_name_from_macron_cms_articles.rb
222
+ - db/migrate/20261029160000_add_published_at_to_macron_cms_articles.rb
213
223
  - db/seeds.rb
214
224
  - lib/generators/macron/cms/content_type/content_type_generator.rb
215
225
  - lib/macron/cms.rb