macron-cms 1.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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +265 -0
  4. data/Rakefile +4 -0
  5. data/app/assets/stylesheets/macron/cms/application.css +2 -0
  6. data/app/assets/tailwind/macron/cms/application.css +4 -0
  7. data/app/components/macron/cms/widget_component.rb +29 -0
  8. data/app/controllers/macron/cms/application_controller.rb +36 -0
  9. data/app/controllers/macron/cms/article_contents_controller.rb +103 -0
  10. data/app/controllers/macron/cms/articles_controller.rb +56 -0
  11. data/app/controllers/macron/cms/content_types_controller.rb +20 -0
  12. data/app/controllers/macron/cms/dashboard_controller.rb +10 -0
  13. data/app/helpers/macron/cms/application_helper.rb +40 -0
  14. data/app/helpers/macron/cms/content_helper.rb +31 -0
  15. data/app/helpers/macron/cms/fields_helper.rb +30 -0
  16. data/app/helpers/macron/cms/seo_helper.rb +37 -0
  17. data/app/jobs/macron/cms/application_job.rb +6 -0
  18. data/app/mailers/macron/cms/application_mailer.rb +8 -0
  19. data/app/models/macron/cms/application_record.rb +7 -0
  20. data/app/models/macron/cms/article.rb +42 -0
  21. data/app/models/macron/cms/article_content.rb +31 -0
  22. data/app/models/macron/cms/content.rb +34 -0
  23. data/app/views/layouts/macron/cms/application.html.erb +129 -0
  24. data/app/views/macron/cms/articles/_form.html.erb +65 -0
  25. data/app/views/macron/cms/articles/_table.html.erb +54 -0
  26. data/app/views/macron/cms/articles/edit.html.erb +15 -0
  27. data/app/views/macron/cms/articles/index.html.erb +28 -0
  28. data/app/views/macron/cms/articles/new.html.erb +14 -0
  29. data/app/views/macron/cms/articles/show.html.erb +366 -0
  30. data/app/views/macron/cms/content_types/index.html.erb +87 -0
  31. data/app/views/macron/cms/content_types/show.html.erb +148 -0
  32. data/app/views/macron/cms/dashboard/index.html.erb +81 -0
  33. data/app/views/macron/cms/fields/_string.html.erb +14 -0
  34. data/app/views/macron/cms/fields/_text.html.erb +13 -0
  35. data/app/views/macron/cms/shared/_breadcrumbs.html.erb +14 -0
  36. data/config/routes.rb +14 -0
  37. data/db/migrate/20261028000001_create_macron_cms_articles.rb +9 -0
  38. data/db/migrate/20261028000002_add_name_and_slug_to_macron_cms_articles.rb +8 -0
  39. data/db/migrate/20261028000003_add_seo_metadata_to_macron_cms_articles.rb +6 -0
  40. data/db/migrate/20261028000004_create_macron_cms_contents.rb +13 -0
  41. data/db/migrate/20261028000005_create_macron_cms_article_contents.rb +13 -0
  42. data/db/migrate/20261028000006_move_payload_to_macron_cms_article_contents.rb +6 -0
  43. data/db/seeds.rb +83 -0
  44. data/lib/generators/macron/cms/content_type/content_type_generator.rb +88 -0
  45. data/lib/macron/cms/configuration.rb +25 -0
  46. data/lib/macron/cms/engine.rb +20 -0
  47. data/lib/macron/cms/version.rb +5 -0
  48. data/lib/macron/cms.rb +26 -0
  49. data/lib/tasks/macron/cms_tasks.rake +75 -0
  50. metadata +164 -0
@@ -0,0 +1,31 @@
1
+ module Macron
2
+ module Cms
3
+ module ContentHelper
4
+ def cms_render_article_contents(article)
5
+ return "".html_safe if article.nil?
6
+
7
+ contents = article.article_contents.includes(:content).order(:sort_order, :id)
8
+ rendered = contents.map { |ac| cms_render_widget(ac) }
9
+ safe_join(rendered)
10
+ end
11
+
12
+ def cms_render_widget(article_content)
13
+ return "".html_safe if article_content.nil? || article_content.content.nil?
14
+
15
+ content_name = article_content.content.name.to_s
16
+ widget_class_name = "Widgets::#{content_name.camelize}Component"
17
+ widget_class = widget_class_name.safe_constantize
18
+
19
+ unless widget_class
20
+ raise Macron::Cms::MissingWidgetError,
21
+ "Missing widget component #{widget_class_name} for content type '#{content_name}'."
22
+ end
23
+
24
+ render widget_class.new(
25
+ payload: article_content.payload,
26
+ fields: article_content.content.normalized_fields
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,30 @@
1
+ module Macron
2
+ module Cms
3
+ module FieldsHelper
4
+ SUPPORTED_KINDS = %w[string text].freeze
5
+
6
+ def cms_render_field(field_def, value: nil, input_name: nil, input_id: nil)
7
+ field_hash = field_def.stringify_keys
8
+ fname = field_hash["name"]
9
+ fkind = field_hash["kind"].to_s.presence || "string"
10
+
11
+ input_name ||= "article_content[payload][#{fname}]"
12
+ input_id ||= "article_content_payload_#{fname}"
13
+
14
+ partial_path = "macron/cms/fields/#{fkind}"
15
+ unless lookup_context.exists?(partial_path, [], true)
16
+ partial_path = "macron/cms/fields/string"
17
+ end
18
+
19
+ render partial: partial_path, locals: {
20
+ field: field_hash,
21
+ field_name: fname,
22
+ field_kind: fkind,
23
+ value: value,
24
+ input_name: input_name,
25
+ input_id: input_id
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,37 @@
1
+ module Macron
2
+ module Cms
3
+ module SeoHelper
4
+ def cms_seo_meta(record, canonical_url: nil)
5
+ return if record.nil?
6
+
7
+ title = if record.respond_to?(:seo_title)
8
+ record.seo_title
9
+ elsif record.respond_to?(:title)
10
+ record.title
11
+ end
12
+
13
+ description = if record.respond_to?(:seo_description)
14
+ record.seo_description
15
+ elsif record.respond_to?(:meta_description)
16
+ record.meta_description.presence
17
+ end
18
+
19
+ canonical = canonical_url || (respond_to?(:request) && request.respond_to?(:original_url) ? request.original_url : nil)
20
+
21
+ content_for(:title, title) if title.present?
22
+
23
+ content_for(:head) do
24
+ tags = []
25
+ tags << tag.meta(name: "description", content: description) if description.present?
26
+ tags << tag.link(rel: "canonical", href: canonical) if canonical.present?
27
+ tags << tag.meta(property: "og:title", content: title) if title.present?
28
+ tags << tag.meta(property: "og:description", content: description) if description.present?
29
+ tags << tag.meta(property: "og:url", content: canonical) if canonical.present?
30
+ safe_join(tags, "\n ")
31
+ end
32
+
33
+ nil
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,6 @@
1
+ module Macron
2
+ module Cms
3
+ class ApplicationJob < ActiveJob::Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,8 @@
1
+ module Macron
2
+ module Cms
3
+ class ApplicationMailer < ActionMailer::Base
4
+ default from: "from@example.com"
5
+ layout "mailer"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ module Macron
2
+ module Cms
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,42 @@
1
+ module Macron
2
+ module Cms
3
+ class Article < ApplicationRecord
4
+ before_validation :derive_name_from_title
5
+
6
+ extend FriendlyId
7
+ friendly_id :name, use: :slugged
8
+
9
+ validates :title, presence: true
10
+ validates :name, presence: true, uniqueness: true
11
+
12
+ has_many :article_contents, -> { order(:sort_order) }, class_name: "Macron::Cms::ArticleContent", dependent: :destroy
13
+ has_many :contents, through: :article_contents
14
+
15
+ def seo_title
16
+ meta_title.presence || title
17
+ end
18
+
19
+ def seo_description
20
+ meta_description.presence
21
+ end
22
+
23
+ private
24
+
25
+ def derive_name_from_title
26
+ return if name.present? || title.blank?
27
+
28
+ base = title.parameterize.presence || "article"
29
+ candidate = base
30
+ scope = id.present? ? self.class.where.not(id: id) : self.class.all
31
+
32
+ counter = 1
33
+ while scope.exists?(name: candidate)
34
+ counter += 1
35
+ candidate = "#{base}-#{counter}"
36
+ end
37
+
38
+ self.name = candidate
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,31 @@
1
+ module Macron
2
+ module Cms
3
+ class ArticleContent < ApplicationRecord
4
+ belongs_to :article, class_name: "Macron::Cms::Article"
5
+ belongs_to :content, class_name: "Macron::Cms::Content"
6
+
7
+ before_validation :assign_default_sort_order, on: :create
8
+
9
+ validates :sort_order, numericality: { only_integer: true }
10
+
11
+ default_scope -> { order(:sort_order, :id) }
12
+
13
+ def filter_payload(raw_payload)
14
+ return {} unless content && raw_payload.respond_to?(:to_h)
15
+
16
+ allowed = content.field_names
17
+ raw_payload.to_h.stringify_keys.slice(*allowed)
18
+ end
19
+
20
+ private
21
+
22
+ def assign_default_sort_order
23
+ return if sort_order.present? && sort_order != 0
24
+ return unless article
25
+
26
+ max_order = article.article_contents.unscoped.where(article_id: article.id).maximum(:sort_order)
27
+ self.sort_order = max_order ? max_order + 1 : 1
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,34 @@
1
+ module Macron
2
+ module Cms
3
+ class Content < ApplicationRecord
4
+ validates :name, presence: true, uniqueness: true
5
+
6
+ has_many :article_contents, class_name: "Macron::Cms::ArticleContent", dependent: :destroy
7
+ has_many :articles, through: :article_contents
8
+
9
+ def normalized_fields
10
+ raw = if fields.is_a?(Array)
11
+ fields
12
+ elsif fields.is_a?(Hash) && fields.present?
13
+ [ fields ]
14
+ else
15
+ []
16
+ end
17
+
18
+ raw.filter_map do |f|
19
+ next unless f.is_a?(Hash)
20
+
21
+ fname = (f["name"] || f[:name]).to_s.strip
22
+ next if fname.blank?
23
+
24
+ fkind = (f["kind"] || f[:kind]).presence || "string"
25
+ f.stringify_keys.merge("name" => fname, "kind" => fkind.to_s)
26
+ end
27
+ end
28
+
29
+ def field_names
30
+ normalized_fields.map { |f| f["name"] }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,129 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="h-full bg-zinc-50">
3
+ <head>
4
+ <title><%= content_for(:title) || "Macron CMS" %></title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+
9
+ <%= stylesheet_link_tag "macron/cms/application", media: "all" %>
10
+ <%= yield :head %>
11
+ </head>
12
+ <body class="min-h-screen text-zinc-900 antialiased flex bg-zinc-50">
13
+ <!-- Sidebar Navigation -->
14
+ <aside class="sticky top-0 h-screen w-64 shrink-0 bg-white border-r border-zinc-200 flex flex-col justify-between overflow-y-auto z-20">
15
+ <div>
16
+ <!-- Brand Logo / Title -->
17
+ <div class="h-16 flex items-center gap-3 px-6 border-b border-zinc-200">
18
+ <div class="w-8 h-8 rounded-lg bg-zinc-900 text-white flex items-center justify-center font-bold text-sm tracking-wide shadow-sm">
19
+ M
20
+ </div>
21
+ <div>
22
+ <span class="font-bold text-zinc-900 text-base tracking-tight">MacronCMS</span>
23
+ <span class="block text-[10px] font-mono uppercase tracking-wider text-zinc-600 font-semibold">Engine Admin</span>
24
+ </div>
25
+ </div>
26
+
27
+ <!-- Navigation Links -->
28
+ <nav class="p-4 space-y-1" aria-label="Sidebar">
29
+ <% is_dashboard = current_page?(root_path) %>
30
+ <%= link_to root_path, class: cms_nav_link_classes(is_dashboard) do %>
31
+ <svg class="w-5 h-5 <%= is_dashboard ? 'text-white' : 'text-zinc-500' %>" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
32
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
33
+ </svg>
34
+ Dashboard
35
+ <% end %>
36
+
37
+ <% is_articles = controller_name == "articles" %>
38
+ <%= link_to articles_path, class: cms_nav_link_classes(is_articles) do %>
39
+ <svg class="w-5 h-5 <%= is_articles ? 'text-white' : 'text-zinc-500' %>" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
40
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
41
+ </svg>
42
+ Articles
43
+ <% end %>
44
+
45
+ <% is_content_types = controller_name == "content_types" %>
46
+ <%= link_to content_types_path, class: cms_nav_link_classes(is_content_types) do %>
47
+ <svg class="w-5 h-5 <%= is_content_types ? 'text-white' : 'text-zinc-500' %>" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
48
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6.429 9.75 2.25 12l4.179 2.25m0-4.5 5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0L21.75 12l-4.179 2.25m0 0 4.179 2.25L12 21.75 2.25 16.5l4.179-2.25m11.142 0-5.571 3-5.571-3" />
49
+ </svg>
50
+ Content Types
51
+ <% end %>
52
+ </nav>
53
+ </div>
54
+
55
+ <!-- Bottom Sidebar (Host App Link) -->
56
+ <div class="p-4 border-t border-zinc-200">
57
+ <% host_root = main_app.respond_to?(:root_path) ? main_app.root_path : "/" %>
58
+ <%= link_to host_root, class: "flex items-center justify-between px-3 py-2 text-sm text-zinc-600 hover:text-zinc-900 rounded-lg hover:bg-zinc-100 transition-colors group" do %>
59
+ <span class="flex items-center gap-2">
60
+ <svg class="w-4 h-4 text-zinc-600 group-hover:text-zinc-700" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
61
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
62
+ </svg>
63
+ View Main Site
64
+ </span>
65
+ <svg class="w-4 h-4 text-zinc-600 group-hover:text-zinc-700" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
66
+ <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
67
+ </svg>
68
+ <% end %>
69
+ </div>
70
+ </aside>
71
+
72
+ <!-- Main Layout Area -->
73
+ <div class="flex-1 flex flex-col min-w-0 min-h-screen">
74
+ <!-- Top Header -->
75
+ <header class="sticky top-0 z-10 h-16 bg-white border-b border-zinc-200 px-6 sm:px-8 flex items-center justify-between shrink-0">
76
+ <nav class="flex items-center gap-2 text-sm text-zinc-500" aria-label="Breadcrumb">
77
+ <span>MacronCMS</span>
78
+ <span>/</span>
79
+ <%= render "macron/cms/shared/breadcrumbs" %>
80
+ </nav>
81
+
82
+ <div class="flex items-center gap-4">
83
+ <!-- Admin Badge -->
84
+ <span class="inline-flex items-center rounded-full bg-zinc-100 px-2.5 py-0.5 text-xs font-semibold text-zinc-800 ring-1 ring-inset ring-zinc-300">
85
+ Admin
86
+ </span>
87
+
88
+ <!-- User Info & Sign Out -->
89
+ <div class="flex items-center gap-3 pl-4 border-l border-zinc-200">
90
+ <% current_admin = cms_current_user %>
91
+ <span class="text-sm font-medium text-zinc-700">
92
+ <%= cms_user_display_name(current_admin) %>
93
+ </span>
94
+
95
+ <% if main_app.respond_to?(:destroy_user_session_path) %>
96
+ <%= button_to "Sign out", main_app.destroy_user_session_path, method: :delete, class: "text-xs font-medium text-zinc-500 hover:text-zinc-900 transition-colors ml-1 cursor-pointer" %>
97
+ <% end %>
98
+ </div>
99
+ </div>
100
+ </header>
101
+
102
+ <!-- Flash Notifications -->
103
+ <div class="max-w-7xl w-full mx-auto px-6 sm:px-8 pt-6">
104
+ <% if notice.present? %>
105
+ <div class="p-4 mb-4 rounded-xl bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm flex items-center gap-3">
106
+ <svg class="w-5 h-5 text-emerald-600 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
107
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
108
+ </svg>
109
+ <span class="font-medium"><%= notice %></span>
110
+ </div>
111
+ <% end %>
112
+
113
+ <% if alert.present? %>
114
+ <div class="p-4 mb-4 rounded-xl bg-rose-50 border border-rose-200 text-rose-800 text-sm flex items-center gap-3">
115
+ <svg class="w-5 h-5 text-rose-600 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
116
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
117
+ </svg>
118
+ <span class="font-medium"><%= alert %></span>
119
+ </div>
120
+ <% end %>
121
+ </div>
122
+
123
+ <!-- Main Content Canvas -->
124
+ <main class="flex-1 max-w-7xl w-full mx-auto px-6 sm:px-8 pb-12">
125
+ <%= yield %>
126
+ </main>
127
+ </div>
128
+ </body>
129
+ </html>
@@ -0,0 +1,65 @@
1
+ <%= form_with(model: article, class: "space-y-6") do |form| %>
2
+ <% if article.errors.any? %>
3
+ <div class="rounded-xl bg-rose-50 p-4 border border-rose-200">
4
+ <div class="flex">
5
+ <svg class="h-5 w-5 text-rose-500 shrink-0" viewBox="0 0 20 20" fill="currentColor">
6
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
7
+ </svg>
8
+ <div class="ml-3">
9
+ <h3 class="text-sm font-medium text-rose-800">
10
+ <%= pluralize(article.errors.count, "error") %> prohibited this article from being saved:
11
+ </h3>
12
+ <ul role="list" class="mt-2 list-disc pl-5 space-y-1 text-xs text-rose-700">
13
+ <% article.errors.full_messages.each do |message| %>
14
+ <li><%= message %></li>
15
+ <% end %>
16
+ </ul>
17
+ </div>
18
+ </div>
19
+ </div>
20
+ <% end %>
21
+
22
+ <div>
23
+ <%= form.label :title, class: "block text-sm font-semibold text-zinc-900" %>
24
+ <div class="mt-1.5">
25
+ <%= form.text_field :title, required: true, placeholder: "e.g., Getting Started with Macron CMS", 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" %>
26
+ </div>
27
+ <p class="mt-1 text-xs text-zinc-500">The public headline of this article.</p>
28
+ </div>
29
+
30
+ <div>
31
+ <%= form.label :name, "Identifier Name", class: "block text-sm font-semibold text-zinc-900" %>
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" %>
34
+ </div>
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
+ </div>
37
+
38
+ <div class="pt-6 border-t border-zinc-200 space-y-4">
39
+ <div>
40
+ <h3 class="text-sm font-semibold text-zinc-900">SEO Settings</h3>
41
+ <p class="text-xs text-zinc-500">Optional search engine and social metadata overrides.</p>
42
+ </div>
43
+
44
+ <div>
45
+ <%= form.label :meta_title, "Meta Title", class: "block text-sm font-medium text-zinc-700" %>
46
+ <div class="mt-1.5">
47
+ <%= form.text_field :meta_title, placeholder: "Defaults to article title if left blank", 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" %>
48
+ </div>
49
+ <p class="mt-1 text-xs text-zinc-500">Custom title for &lt;title&gt; and Open Graph cards.</p>
50
+ </div>
51
+
52
+ <div>
53
+ <%= form.label :meta_description, "Meta Description", class: "block text-sm font-medium text-zinc-700" %>
54
+ <div class="mt-1.5">
55
+ <%= form.text_area :meta_description, rows: 3, placeholder: "Brief summary of the article for search results...", 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" %>
56
+ </div>
57
+ <p class="mt-1 text-xs text-zinc-500">Recommended length: 150–160 characters.</p>
58
+ </div>
59
+ </div>
60
+
61
+ <div class="flex items-center gap-4 pt-4 border-t border-zinc-200">
62
+ <%= form.submit class: "rounded-lg bg-zinc-900 px-4 py-2 text-sm font-semibold text-white shadow-xs hover:bg-zinc-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 cursor-pointer transition-colors" %>
63
+ <%= link_to "Cancel", (article.persisted? ? article_path(article) : articles_path), class: "text-sm font-medium text-zinc-600 hover:text-zinc-900 transition-colors" %>
64
+ </div>
65
+ <% end %>
@@ -0,0 +1,54 @@
1
+ <% if articles.any? %>
2
+ <div class="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xs">
3
+ <div class="overflow-x-auto">
4
+ <table class="min-w-full divide-y divide-zinc-200 text-left text-sm">
5
+ <thead class="bg-zinc-50 text-zinc-700 font-semibold text-xs uppercase tracking-wider">
6
+ <tr>
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>
9
+ <th scope="col" class="px-6 py-3.5 text-right">Actions</th>
10
+ </tr>
11
+ </thead>
12
+ <tbody class="divide-y divide-zinc-200">
13
+ <% articles.each do |article| %>
14
+ <tr class="hover:bg-zinc-50/80 transition-colors">
15
+ <td class="px-6 py-4 font-semibold text-zinc-900 whitespace-nowrap">
16
+ <%= link_to article.title, article_path(article), class: "hover:underline" %>
17
+ </td>
18
+ <td class="px-6 py-4 text-zinc-500 text-xs whitespace-nowrap">
19
+ <%= article.created_at.strftime("%b %d, %Y") %>
20
+ </td>
21
+ <td class="px-6 py-4 text-right text-xs font-medium whitespace-nowrap">
22
+ <div class="flex items-center justify-end gap-3 whitespace-nowrap">
23
+ <% if main_app.respond_to?(:article_path) %>
24
+ <%= link_to "Public View", main_app.article_path(article), class: "text-zinc-600 hover:text-zinc-900 hover:underline", target: "_blank", rel: "noopener" %>
25
+ <% end %>
26
+ <%= link_to "Show", article_path(article), class: "text-zinc-700 hover:text-zinc-900 hover:underline" %>
27
+ <%= link_to "Edit", edit_article_path(article), class: "text-zinc-700 hover:text-zinc-900 hover:underline" %>
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" %>
30
+ </div>
31
+ </td>
32
+ </tr>
33
+ <% end %>
34
+ </tbody>
35
+ </table>
36
+ </div>
37
+ </div>
38
+ <% else %>
39
+ <div class="rounded-xl border border-dashed border-zinc-300 bg-white p-12 text-center">
40
+ <svg class="mx-auto h-12 w-12 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
41
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
42
+ </svg>
43
+ <h3 class="mt-2 text-sm font-semibold text-zinc-900">No articles found</h3>
44
+ <p class="mt-1 text-sm text-zinc-500">Create your first article to begin managing content.</p>
45
+ <div class="mt-6">
46
+ <%= link_to new_article_path, class: "inline-flex items-center gap-2 rounded-lg bg-zinc-900 px-3.5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-zinc-800 transition-colors" do %>
47
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
48
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
49
+ </svg>
50
+ Create First Article
51
+ <% end %>
52
+ </div>
53
+ </div>
54
+ <% end %>
@@ -0,0 +1,15 @@
1
+ <% content_for :title, "Edit Article - Macron CMS" %>
2
+ <% cms_breadcrumb "Articles", articles_path %>
3
+ <% cms_breadcrumb @article.title, article_path(@article) %>
4
+ <% cms_breadcrumb "Edit" %>
5
+
6
+ <div class="max-w-2xl space-y-6">
7
+ <div class="border-b border-zinc-200 pb-4">
8
+ <h1 class="text-2xl font-bold tracking-tight text-zinc-900">Edit Article</h1>
9
+ <p class="mt-1 text-sm text-zinc-500">Modify article information and update URL identifiers.</p>
10
+ </div>
11
+
12
+ <div class="rounded-xl border border-zinc-200 bg-white p-6 sm:p-8 shadow-xs">
13
+ <%= render "form", article: @article %>
14
+ </div>
15
+ </div>
@@ -0,0 +1,28 @@
1
+ <% content_for :title, "Articles - Macron CMS" %>
2
+ <% cms_breadcrumb "Articles" %>
3
+
4
+ <div class="space-y-6">
5
+ <!-- Page Header -->
6
+ <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border-b border-zinc-200 pb-6">
7
+ <div>
8
+ <div class="flex items-center gap-3">
9
+ <h1 class="text-2xl font-bold tracking-tight text-zinc-900 sm:text-3xl">Articles</h1>
10
+ <span class="inline-flex items-center rounded-full bg-zinc-100 px-2.5 py-0.5 text-xs font-semibold text-zinc-800 ring-1 ring-inset ring-zinc-300">
11
+ <%= pluralize(@articles.count, "article") %>
12
+ </span>
13
+ </div>
14
+ <p class="mt-1 text-sm text-zinc-500">Manage all articles registered across the application.</p>
15
+ </div>
16
+ <div>
17
+ <%= link_to new_article_path, class: "inline-flex items-center gap-2 rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-800 transition-colors" do %>
18
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
19
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
20
+ </svg>
21
+ New Article
22
+ <% end %>
23
+ </div>
24
+ </div>
25
+
26
+ <!-- Articles Table -->
27
+ <%= render "macron/cms/articles/table", articles: @articles %>
28
+ </div>
@@ -0,0 +1,14 @@
1
+ <% content_for :title, "New Article - Macron CMS" %>
2
+ <% cms_breadcrumb "Articles", articles_path %>
3
+ <% cms_breadcrumb "New Article" %>
4
+
5
+ <div class="max-w-2xl space-y-6">
6
+ <div class="border-b border-zinc-200 pb-4">
7
+ <h1 class="text-2xl font-bold tracking-tight text-zinc-900">Create New Article</h1>
8
+ <p class="mt-1 text-sm text-zinc-500">Enter content details to register a new article within Macron CMS.</p>
9
+ </div>
10
+
11
+ <div class="rounded-xl border border-zinc-200 bg-white p-6 sm:p-8 shadow-xs">
12
+ <%= render "form", article: @article %>
13
+ </div>
14
+ </div>