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,366 @@
1
+ <% content_for :title, "#{@article.title} - Macron CMS" %>
2
+ <% cms_breadcrumb "Articles", articles_path %>
3
+ <% cms_breadcrumb @article.title %>
4
+
5
+ <div class="space-y-8">
6
+ <!-- Page Header -->
7
+ <div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 border-b border-zinc-200 pb-6">
8
+ <div class="space-y-2">
9
+ <div class="flex items-center gap-2 flex-wrap">
10
+ <h1 class="text-2xl font-bold tracking-tight text-zinc-900 sm:text-3xl"><%= @article.title %></h1>
11
+ <span class="inline-flex items-center rounded-md bg-zinc-100 px-2 py-1 text-xs font-mono font-medium text-zinc-700 ring-1 ring-inset ring-zinc-200">
12
+ <%= @article.slug %>
13
+ </span>
14
+ </div>
15
+ <p class="text-xs text-zinc-500">
16
+ Created on <%= @article.created_at.strftime("%B %d, %Y at %H:%M UTC") %> &middot; Updated <%= time_ago_in_words(@article.updated_at) %> ago
17
+ </p>
18
+ </div>
19
+
20
+ <!-- Actions -->
21
+ <div class="flex items-center gap-2.5 shrink-0">
22
+ <% if main_app.respond_to?(:article_path) %>
23
+ <%= link_to main_app.article_path(@article), class: "inline-flex items-center gap-1.5 rounded-lg border border-zinc-300 bg-white px-3.5 py-2 text-sm font-semibold text-zinc-700 shadow-2xs hover:bg-zinc-50 transition-colors", target: "_blank", rel: "noopener" do %>
24
+ <svg class="w-4 h-4 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
25
+ <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" />
26
+ </svg>
27
+ Public View
28
+ <% end %>
29
+ <% end %>
30
+
31
+ <%= link_to edit_article_path(@article), class: "inline-flex items-center gap-1.5 rounded-lg bg-zinc-900 px-3.5 py-2 text-sm font-semibold text-white shadow-2xs hover:bg-zinc-800 transition-colors" do %>
32
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
33
+ <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
34
+ </svg>
35
+ Edit
36
+ <% end %>
37
+
38
+ <% delete_confirm = "Are you sure you want to delete \"#{@article.title}\"?" %>
39
+ <%= 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 %>
40
+ <svg class="w-4 h-4 text-rose-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
41
+ <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" />
42
+ </svg>
43
+ Delete
44
+ <% end %>
45
+ </div>
46
+ </div>
47
+
48
+ <!-- Article Details Card -->
49
+ <div class="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xs">
50
+ <div class="px-6 py-4 border-b border-zinc-200 bg-zinc-50">
51
+ <h3 class="text-sm font-semibold text-zinc-900">Record Information</h3>
52
+ </div>
53
+ <dl class="divide-y divide-zinc-200">
54
+ <div class="px-6 py-4 sm:grid sm:grid-cols-3 sm:gap-4">
55
+ <dt class="text-sm font-medium text-zinc-500">Record ID</dt>
56
+ <dd class="mt-1 text-sm font-mono text-zinc-900 sm:col-span-2 sm:mt-0"><%= @article.id %></dd>
57
+ </div>
58
+ <div class="px-6 py-4 sm:grid sm:grid-cols-3 sm:gap-4">
59
+ <dt class="text-sm font-medium text-zinc-500">Title</dt>
60
+ <dd class="mt-1 text-sm font-medium text-zinc-900 sm:col-span-2 sm:mt-0"><%= @article.title %></dd>
61
+ </div>
62
+ </dl>
63
+ </div>
64
+
65
+ <!-- Content Blocks Section -->
66
+ <div class="space-y-6">
67
+ <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border-b border-zinc-200 pb-4">
68
+ <div>
69
+ <div class="flex items-center gap-3">
70
+ <h2 class="text-xl font-bold tracking-tight text-zinc-900">Article Content Blocks</h2>
71
+ <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">
72
+ <%= pluralize(@article_contents.count, "block") %>
73
+ </span>
74
+ </div>
75
+ <p class="text-xs text-zinc-500 mt-1">Re-order blocks using drag-and-drop or the Move Up/Down buttons.</p>
76
+ </div>
77
+ </div>
78
+
79
+ <!-- Content Blocks List -->
80
+ <div
81
+ id="content-blocks-list"
82
+ data-reorder-url="<%= reorder_article_article_contents_path(@article) %>"
83
+ class="space-y-4"
84
+ >
85
+ <% if @article_contents.any? %>
86
+ <% @article_contents.each_with_index do |ac, index| %>
87
+ <% content_type = ac.content %>
88
+ <% fields = content_type ? content_type.normalized_fields : [] %>
89
+ <div
90
+ id="article-content-<%= ac.id %>"
91
+ data-id="<%= ac.id %>"
92
+ draggable="true"
93
+ class="content-block-item group rounded-xl border border-zinc-200 bg-white shadow-2xs transition-shadow hover:shadow-xs"
94
+ >
95
+ <!-- Card Header & Actions -->
96
+ <div class="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-100 bg-zinc-50/70 px-5 py-3.5 rounded-t-xl">
97
+ <div class="flex items-center gap-3">
98
+ <!-- Drag Handle -->
99
+ <div class="cursor-grab active:cursor-grabbing text-zinc-400 group-hover:text-zinc-600 transition-colors" title="Drag to reorder">
100
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
101
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9h16.5m-16.5 6.75h16.5" />
102
+ </svg>
103
+ </div>
104
+
105
+ <span class="inline-flex items-center rounded-md bg-zinc-200/70 px-2 py-0.5 text-xs font-mono font-bold text-zinc-800 position-badge">
106
+ #<%= index + 1 %>
107
+ </span>
108
+
109
+ <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">
110
+ <%= content_type ? content_type.name : "Unknown" %>
111
+ </span>
112
+ </div>
113
+
114
+ <!-- Action Buttons -->
115
+ <div class="flex items-center gap-1.5">
116
+ <!-- Move Up -->
117
+ <%= button_to move_article_article_content_path(@article, ac, direction: "up"), method: :patch, disabled: (index == 0), class: "inline-flex items-center justify-center w-8 h-8 rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" do %>
118
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
119
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
120
+ </svg>
121
+ <% end %>
122
+
123
+ <!-- Move Down -->
124
+ <%= button_to move_article_article_content_path(@article, ac, direction: "down"), method: :patch, disabled: (index == @article_contents.size - 1), class: "inline-flex items-center justify-center w-8 h-8 rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" do %>
125
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
126
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
127
+ </svg>
128
+ <% end %>
129
+
130
+ <!-- Edit Toggle Button -->
131
+ <button
132
+ type="button"
133
+ onclick="document.getElementById('edit-form-<%= ac.id %>').classList.toggle('hidden'); document.getElementById('view-payload-<%= ac.id %>').classList.toggle('hidden');"
134
+ class="inline-flex items-center gap-1 rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-zinc-700 hover:bg-zinc-50 transition-colors cursor-pointer"
135
+ >
136
+ <svg class="w-3.5 h-3.5 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
137
+ <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Z" />
138
+ </svg>
139
+ Edit
140
+ </button>
141
+
142
+ <!-- Delete Block Button -->
143
+ <% del_block_confirm = "Are you sure you want to remove this #{content_type ? content_type.name : 'content'} block?" %>
144
+ <%= button_to article_article_content_path(@article, ac), method: :delete, onclick: "return confirm(#{del_block_confirm.to_json});", data: { turbo_confirm: del_block_confirm }, class: "inline-flex items-center justify-center w-8 h-8 rounded-lg border border-rose-200 bg-rose-50 text-rose-600 hover:bg-rose-100 transition-colors cursor-pointer" do %>
145
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
146
+ <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" />
147
+ </svg>
148
+ <% end %>
149
+ </div>
150
+ </div>
151
+
152
+ <!-- View Payload Mode -->
153
+ <div id="view-payload-<%= ac.id %>" class="p-5">
154
+ <% if fields.any? %>
155
+ <dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
156
+ <% fields.each do |f| %>
157
+ <% fname = f["name"] %>
158
+ <% fkind = f["kind"] %>
159
+ <% fval = ac.payload[fname] %>
160
+ <div class="<%= fkind == 'text' ? 'sm:col-span-2' : '' %>">
161
+ <dt class="text-xs font-mono font-medium text-zinc-500 uppercase tracking-wide">
162
+ <%= fname %>
163
+ <span class="text-[10px] text-zinc-400 lowercase font-normal">(<%= fkind %>)</span>
164
+ </dt>
165
+ <dd class="mt-1 text-sm text-zinc-900 bg-zinc-50/80 rounded-lg p-2.5 border border-zinc-100 font-sans">
166
+ <% if fval.present? %>
167
+ <% if fkind == 'text' %>
168
+ <div class="whitespace-pre-wrap"><%= fval %></div>
169
+ <% else %>
170
+ <span><%= fval %></span>
171
+ <% end %>
172
+ <% else %>
173
+ <span class="italic text-zinc-400 text-xs">Empty</span>
174
+ <% end %>
175
+ </dd>
176
+ </div>
177
+ <% end %>
178
+ </dl>
179
+ <% else %>
180
+ <p class="text-xs text-zinc-500 italic">No fields defined for this content type.</p>
181
+ <% end %>
182
+ </div>
183
+
184
+ <!-- Inline Edit Form -->
185
+ <div id="edit-form-<%= ac.id %>" class="hidden p-5 border-t border-zinc-100 bg-zinc-50/30">
186
+ <%= form_with(model: [@article, ac], class: "space-y-4") do |f| %>
187
+ <div class="space-y-4">
188
+ <% fields.each do |field_def| %>
189
+ <% fname = field_def["name"] %>
190
+ <%= cms_render_field(
191
+ field_def,
192
+ value: ac.payload[fname],
193
+ input_name: "article_content[payload][#{fname}]",
194
+ input_id: "edit_article_content_#{ac.id}_payload_#{fname}"
195
+ ) %>
196
+ <% end %>
197
+ </div>
198
+
199
+ <div class="flex items-center gap-3 pt-2">
200
+ <%= f.submit "Save Changes", class: "rounded-lg bg-zinc-900 px-4 py-2 text-xs font-semibold text-white shadow-2xs hover:bg-zinc-800 transition-colors cursor-pointer" %>
201
+ <button
202
+ type="button"
203
+ onclick="document.getElementById('edit-form-<%= ac.id %>').classList.add('hidden'); document.getElementById('view-payload-<%= ac.id %>').classList.remove('hidden');"
204
+ class="rounded-lg border border-zinc-300 bg-white px-3.5 py-2 text-xs font-medium text-zinc-700 hover:bg-zinc-50 transition-colors cursor-pointer"
205
+ >
206
+ Cancel
207
+ </button>
208
+ </div>
209
+ <% end %>
210
+ </div>
211
+ </div>
212
+ <% end %>
213
+ <% else %>
214
+ <div class="rounded-xl border border-dashed border-zinc-300 bg-white p-8 text-center">
215
+ <svg class="mx-auto h-10 w-10 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
216
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-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 00-9-9z" />
217
+ </svg>
218
+ <h3 class="mt-2 text-sm font-semibold text-zinc-900">No content blocks yet</h3>
219
+ <p class="mt-1 text-xs text-zinc-500">Choose a content type below to add the first block to this article.</p>
220
+ </div>
221
+ <% end %>
222
+ </div>
223
+
224
+ <!-- Add Content Block Card -->
225
+ <div class="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xs">
226
+ <div class="px-6 py-4 border-b border-zinc-200 bg-zinc-50 flex items-center justify-between">
227
+ <div>
228
+ <h3 class="text-sm font-semibold text-zinc-900">Add Content Block</h3>
229
+ <p class="text-xs text-zinc-500 mt-0.5">Select a Content Type and populate its fields.</p>
230
+ </div>
231
+ </div>
232
+
233
+ <div class="p-6">
234
+ <% if @content_types.any? %>
235
+ <div class="space-y-4">
236
+ <div>
237
+ <label for="new_content_type_select" class="block text-xs font-semibold text-zinc-900 mb-1.5">
238
+ Select Content Type
239
+ </label>
240
+ <select
241
+ id="new_content_type_select"
242
+ class="block w-full sm:w-80 rounded-lg border border-zinc-300 bg-white px-3.5 py-2 text-sm text-zinc-900 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900"
243
+ onchange="switchNewContentType(this.value);"
244
+ >
245
+ <option value="">-- Choose a Content Type --</option>
246
+ <% @content_types.each do |ct| %>
247
+ <option value="<%= ct.id %>"><%= ct.name %> (<%= pluralize(ct.normalized_fields.size, "field") %>)</option>
248
+ <% end %>
249
+ </select>
250
+ </div>
251
+
252
+ <!-- Dynamic Form per Content Type -->
253
+ <% @content_types.each do |ct| %>
254
+ <% ct_fields = ct.normalized_fields %>
255
+ <div id="new-content-type-form-<%= ct.id %>" class="new-content-type-form hidden pt-4 border-t border-zinc-100">
256
+ <%= form_with(url: article_article_contents_path(@article), scope: :article_content, class: "space-y-4") do |f| %>
257
+ <%= f.hidden_field :content_id, value: ct.id %>
258
+
259
+ <div class="space-y-4">
260
+ <% ct_fields.each do |field_def| %>
261
+ <% fname = field_def["name"] %>
262
+ <%= cms_render_field(
263
+ field_def,
264
+ value: nil,
265
+ input_name: "article_content[payload][#{fname}]",
266
+ input_id: "new_content_#{ct.id}_payload_#{fname}"
267
+ ) %>
268
+ <% end %>
269
+ </div>
270
+
271
+ <div class="pt-2">
272
+ <%= f.submit "Add #{ct.name.titleize} Block", class: "inline-flex items-center gap-1.5 rounded-lg bg-zinc-900 px-4 py-2 text-sm font-semibold text-white shadow-2xs hover:bg-zinc-800 transition-colors cursor-pointer" %>
273
+ </div>
274
+ <% end %>
275
+ </div>
276
+ <% end %>
277
+ </div>
278
+ <% else %>
279
+ <div class="text-center py-4">
280
+ <p class="text-sm text-zinc-500">No content types defined yet.</p>
281
+ <p class="text-xs text-zinc-400 mt-1">Run <code class="font-mono bg-zinc-100 px-1 py-0.5 rounded">bin/rails macron:cms:import_content_types</code> to import defined schemas.</p>
282
+ </div>
283
+ <% end %>
284
+ </div>
285
+ </div>
286
+ </div>
287
+
288
+ <div class="pt-2">
289
+ <%= link_to "&larr; Back to all articles".html_safe, articles_path, class: "text-sm font-medium text-zinc-600 hover:text-zinc-900 transition-colors" %>
290
+ </div>
291
+ </div>
292
+
293
+ <script>
294
+ function switchNewContentType(selectedId) {
295
+ document.querySelectorAll('.new-content-type-form').forEach(function(el) {
296
+ el.classList.add('hidden');
297
+ });
298
+ if (selectedId) {
299
+ var target = document.getElementById('new-content-type-form-' + selectedId);
300
+ if (target) target.classList.remove('hidden');
301
+ }
302
+ }
303
+
304
+ // HTML5 Drag and Drop Reordering
305
+ (function initDragAndDrop() {
306
+ var container = document.getElementById('content-blocks-list');
307
+ if (!container) return;
308
+
309
+ var draggedItem = null;
310
+
311
+ container.addEventListener('dragstart', function(e) {
312
+ var item = e.target.closest('.content-block-item');
313
+ if (!item) return;
314
+ draggedItem = item;
315
+ e.dataTransfer.effectAllowed = 'move';
316
+ item.classList.add('opacity-50', 'border-zinc-400');
317
+ });
318
+
319
+ container.addEventListener('dragend', function(e) {
320
+ var item = e.target.closest('.content-block-item');
321
+ if (item) {
322
+ item.classList.remove('opacity-50', 'border-zinc-400');
323
+ }
324
+ draggedItem = null;
325
+ sendNewOrder();
326
+ });
327
+
328
+ container.addEventListener('dragover', function(e) {
329
+ e.preventDefault();
330
+ e.dataTransfer.dropEffect = 'move';
331
+ var targetItem = e.target.closest('.content-block-item');
332
+ if (targetItem && targetItem !== draggedItem) {
333
+ var rect = targetItem.getBoundingClientRect();
334
+ var next = (e.clientY - rect.top) / (rect.bottom - rect.top) > 0.5;
335
+ container.insertBefore(draggedItem, next ? targetItem.nextSibling : targetItem);
336
+ }
337
+ });
338
+
339
+ function sendNewOrder() {
340
+ var items = container.querySelectorAll('.content-block-item');
341
+ var orderedIds = [];
342
+ items.forEach(function(item, idx) {
343
+ orderedIds.push(item.getAttribute('data-id'));
344
+ var badge = item.querySelector('.position-badge');
345
+ if (badge) badge.textContent = '#' + (idx + 1);
346
+ });
347
+
348
+ var reorderUrl = container.getAttribute('data-reorder-url');
349
+ var csrfToken = document.querySelector('meta[name="csrf-token"]');
350
+
351
+ if (reorderUrl && orderedIds.length > 0) {
352
+ fetch(reorderUrl, {
353
+ method: 'PATCH',
354
+ headers: {
355
+ 'Content-Type': 'application/json',
356
+ 'X-CSRF-Token': csrfToken ? csrfToken.content : '',
357
+ 'Accept': 'application/json'
358
+ },
359
+ body: JSON.stringify({ ordered_ids: orderedIds })
360
+ }).catch(function(err) {
361
+ console.error('Failed to update content block order:', err);
362
+ });
363
+ }
364
+ }
365
+ })();
366
+ </script>
@@ -0,0 +1,87 @@
1
+ <% content_for :title, "Content Types - Macron CMS" %>
2
+ <% cms_breadcrumb "Content Types" %>
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">Content Types</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(@content_types.count, "content type") %>
12
+ </span>
13
+ </div>
14
+ <p class="mt-1 text-sm text-zinc-500">Manage and inspect imported content type schemas and field definitions.</p>
15
+ </div>
16
+ </div>
17
+
18
+ <% if @content_types.any? %>
19
+ <div class="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xs">
20
+ <div class="overflow-x-auto">
21
+ <table class="min-w-full divide-y divide-zinc-200 text-left text-sm">
22
+ <thead class="bg-zinc-50 text-zinc-700 font-semibold text-xs uppercase tracking-wider">
23
+ <tr>
24
+ <th scope="col" class="px-6 py-3.5">Name</th>
25
+ <th scope="col" class="px-6 py-3.5">Fields Summary</th>
26
+ <th scope="col" class="px-6 py-3.5">Created At</th>
27
+ <th scope="col" class="px-6 py-3.5 text-right">Actions</th>
28
+ </tr>
29
+ </thead>
30
+ <tbody class="divide-y divide-zinc-200">
31
+ <% @content_types.each do |content_type| %>
32
+ <%
33
+ field_list = if content_type.fields.is_a?(Array)
34
+ content_type.fields
35
+ elsif content_type.fields.is_a?(Hash) && content_type.fields.present?
36
+ [content_type.fields]
37
+ else
38
+ []
39
+ end
40
+ %>
41
+ <tr class="hover:bg-zinc-50/80 transition-colors">
42
+ <td class="px-6 py-4 font-semibold text-zinc-900 whitespace-nowrap">
43
+ <div class="flex items-center gap-2">
44
+ <%= link_to content_type.name, content_type_path(content_type), class: "hover:underline font-mono text-sm" %>
45
+ </div>
46
+ </td>
47
+ <td class="px-6 py-4 text-zinc-600 text-xs">
48
+ <% if field_list.any? %>
49
+ <div class="flex flex-wrap gap-1.5">
50
+ <% field_list.each do |field| %>
51
+ <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">
52
+ <%= field["name"] || field[:name] || "field" %>
53
+ <span class="text-zinc-600 ml-1">(<%= field["kind"] || field[:kind] || "any" %>)</span>
54
+ </span>
55
+ <% end %>
56
+ </div>
57
+ <% else %>
58
+ <span class="text-zinc-600 italic">No fields defined</span>
59
+ <% end %>
60
+ </td>
61
+ <td class="px-6 py-4 text-zinc-500 text-xs whitespace-nowrap">
62
+ <%= content_type.created_at.strftime("%b %d, %Y") rescue "—" %>
63
+ </td>
64
+ <td class="px-6 py-4 text-right text-xs font-medium whitespace-nowrap">
65
+ <%= link_to "Show", content_type_path(content_type), class: "text-zinc-700 hover:text-zinc-900 hover:underline" %>
66
+ </td>
67
+ </tr>
68
+ <% end %>
69
+ </tbody>
70
+ </table>
71
+ </div>
72
+ </div>
73
+ <% else %>
74
+ <div class="rounded-xl border border-dashed border-zinc-300 bg-white p-12 text-center">
75
+ <svg class="mx-auto h-12 w-12 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
76
+ <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" />
77
+ </svg>
78
+ <h3 class="mt-2 text-sm font-semibold text-zinc-900">No content types found</h3>
79
+ <p class="mt-1 text-sm text-zinc-500">Import YAML content types from your application by running the import task.</p>
80
+ <div class="mt-4">
81
+ <code class="rounded-md bg-zinc-100 px-3 py-1.5 font-mono text-xs text-zinc-800 ring-1 ring-inset ring-zinc-200">
82
+ bin/rails macron:cms:import_content_types
83
+ </code>
84
+ </div>
85
+ </div>
86
+ <% end %>
87
+ </div>
@@ -0,0 +1,148 @@
1
+ <% content_for :title, "#{@content_type.name.titleize} - Content Types - Macron CMS" %>
2
+ <% cms_breadcrumb "Content Types", content_types_path %>
3
+ <% cms_breadcrumb @content_type.name %>
4
+
5
+ <%
6
+ field_list = if @content_type.fields.is_a?(Array)
7
+ @content_type.fields
8
+ elsif @content_type.fields.is_a?(Hash) && @content_type.fields.present?
9
+ [@content_type.fields]
10
+ else
11
+ []
12
+ end
13
+ %>
14
+
15
+ <div class="space-y-6">
16
+ <!-- Page Header -->
17
+ <div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 border-b border-zinc-200 pb-6">
18
+ <div class="space-y-2">
19
+ <div class="flex items-center gap-3 flex-wrap">
20
+ <h1 class="text-2xl font-bold tracking-tight text-zinc-900 sm:text-3xl font-mono"><%= @content_type.name %></h1>
21
+ <span class="inline-flex items-center rounded-md bg-zinc-100 px-2.5 py-1 text-xs font-semibold text-zinc-800 ring-1 ring-inset ring-zinc-300">
22
+ <%= pluralize(field_list.count, "field") %>
23
+ </span>
24
+ </div>
25
+ <p class="text-xs text-zinc-500">
26
+ Created on <%= @content_type.created_at.strftime("%B %d, %Y at %H:%M UTC") rescue "—" %> &middot; Updated <%= time_ago_in_words(@content_type.updated_at) rescue "—" %> ago
27
+ </p>
28
+ </div>
29
+
30
+ <!-- Actions -->
31
+ <div class="flex items-center gap-2.5 shrink-0">
32
+ <%= link_to content_types_path, class: "inline-flex items-center gap-1.5 rounded-lg border border-zinc-300 bg-white px-3.5 py-2 text-sm font-semibold text-zinc-700 shadow-2xs hover:bg-zinc-50 transition-colors" do %>
33
+ <svg class="w-4 h-4 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
34
+ <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
35
+ </svg>
36
+ Back to Content Types
37
+ <% end %>
38
+ </div>
39
+ </div>
40
+
41
+ <!-- Record Information Card -->
42
+ <div class="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xs">
43
+ <div class="px-6 py-4 border-b border-zinc-200 bg-zinc-50">
44
+ <h3 class="text-sm font-semibold text-zinc-900">Record Information</h3>
45
+ </div>
46
+ <dl class="divide-y divide-zinc-200">
47
+ <div class="px-6 py-4 sm:grid sm:grid-cols-3 sm:gap-4">
48
+ <dt class="text-sm font-medium text-zinc-500">Record ID</dt>
49
+ <dd class="mt-1 text-sm font-mono text-zinc-900 sm:col-span-2 sm:mt-0"><%= @content_type.id %></dd>
50
+ </div>
51
+ <div class="px-6 py-4 sm:grid sm:grid-cols-3 sm:gap-4">
52
+ <dt class="text-sm font-medium text-zinc-500">Content Type Name</dt>
53
+ <dd class="mt-1 text-sm font-mono font-medium text-zinc-900 sm:col-span-2 sm:mt-0"><%= @content_type.name %></dd>
54
+ </div>
55
+ <div class="px-6 py-4 sm:grid sm:grid-cols-3 sm:gap-4">
56
+ <dt class="text-sm font-medium text-zinc-500">Field Count</dt>
57
+ <dd class="mt-1 text-sm text-zinc-900 sm:col-span-2 sm:mt-0"><%= field_list.count %></dd>
58
+ </div>
59
+ <div class="px-6 py-4 sm:grid sm:grid-cols-3 sm:gap-4">
60
+ <dt class="text-sm font-medium text-zinc-500">Created At</dt>
61
+ <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>
62
+ </div>
63
+ <div class="px-6 py-4 sm:grid sm:grid-cols-3 sm:gap-4">
64
+ <dt class="text-sm font-medium text-zinc-500">Last Updated</dt>
65
+ <dd class="mt-1 text-sm text-zinc-900 sm:col-span-2 sm:mt-0"><%= @content_type.updated_at.to_fs(:long) rescue @content_type.updated_at.to_s %></dd>
66
+ </div>
67
+ </dl>
68
+ </div>
69
+
70
+ <!-- Field Details Card -->
71
+ <div class="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xs">
72
+ <div class="px-6 py-4 border-b border-zinc-200 bg-zinc-50 flex items-center justify-between">
73
+ <div>
74
+ <h3 class="text-sm font-semibold text-zinc-900">Defined Fields</h3>
75
+ <p class="text-xs text-zinc-500 mt-0.5">Attributes and input types schema configured for this content type.</p>
76
+ </div>
77
+ <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">
78
+ <%= pluralize(field_list.count, "attribute") %>
79
+ </span>
80
+ </div>
81
+
82
+ <% if field_list.any? %>
83
+ <div class="overflow-x-auto">
84
+ <table class="min-w-full divide-y divide-zinc-200 text-left text-sm">
85
+ <thead class="bg-zinc-50/50 text-zinc-700 font-semibold text-xs uppercase tracking-wider">
86
+ <tr>
87
+ <th scope="col" class="px-6 py-3">Field Name</th>
88
+ <th scope="col" class="px-6 py-3">Kind / Type</th>
89
+ <th scope="col" class="px-6 py-3">Additional Attributes</th>
90
+ </tr>
91
+ </thead>
92
+ <tbody class="divide-y divide-zinc-200">
93
+ <% field_list.each do |field| %>
94
+ <%
95
+ fname = field["name"] || field[:name] || "—"
96
+ fkind = field["kind"] || field[:kind] || "string"
97
+ extra_keys = field.reject { |k, _| %w[name kind].include?(k.to_s) }
98
+ %>
99
+ <tr class="hover:bg-zinc-50/60 transition-colors">
100
+ <td class="px-6 py-3.5 font-mono font-medium text-zinc-900 whitespace-nowrap">
101
+ <%= fname %>
102
+ </td>
103
+ <td class="px-6 py-3.5 whitespace-nowrap">
104
+ <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">
105
+ <%= fkind %>
106
+ </span>
107
+ </td>
108
+ <td class="px-6 py-3.5 text-xs text-zinc-600">
109
+ <% if extra_keys.any? %>
110
+ <dl class="flex flex-wrap gap-x-4 gap-y-1">
111
+ <% extra_keys.each do |k, v| %>
112
+ <div class="flex items-center gap-1">
113
+ <dt class="font-mono text-zinc-500"><%= k %>:</dt>
114
+ <dd class="font-mono font-medium text-zinc-800"><%= v.inspect %></dd>
115
+ </div>
116
+ <% end %>
117
+ </dl>
118
+ <% else %>
119
+ <span class="text-zinc-600 italic">None</span>
120
+ <% end %>
121
+ </td>
122
+ </tr>
123
+ <% end %>
124
+ </tbody>
125
+ </table>
126
+ </div>
127
+ <% else %>
128
+ <div class="p-6 text-center text-sm text-zinc-500 italic">
129
+ No field schema definitions present.
130
+ </div>
131
+ <% end %>
132
+ </div>
133
+
134
+ <!-- Raw JSON Schema Preview Card -->
135
+ <div class="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xs">
136
+ <div class="px-6 py-4 border-b border-zinc-200 bg-zinc-50 flex items-center justify-between">
137
+ <h3 class="text-sm font-semibold text-zinc-900">Raw Field Schema (JSON)</h3>
138
+ <span class="text-xs font-mono text-zinc-600">application/json</span>
139
+ </div>
140
+ <div class="p-6 bg-zinc-900 overflow-x-auto text-zinc-100">
141
+ <pre class="font-mono text-xs text-zinc-100 leading-relaxed" style="color: #f4f4f5;"><code><%= JSON.pretty_generate(@content_type.fields.presence || {}) rescue (@content_type.fields || {}).inspect %></code></pre>
142
+ </div>
143
+ </div>
144
+
145
+ <div class="pt-2">
146
+ <%= link_to "&larr; Back to all content types".html_safe, content_types_path, class: "text-sm font-medium text-zinc-600 hover:text-zinc-900 transition-colors" %>
147
+ </div>
148
+ </div>