macron-cms 1.0.0 → 2.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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +298 -21
  3. data/app/assets/javascripts/macron/cms/codemirror.js +6 -0
  4. data/app/assets/javascripts/macron/cms/editor.js +101 -0
  5. data/app/assets/stylesheets/macron/cms/application.css +1 -1
  6. data/app/assets/stylesheets/macron/cms/codemirror.css +64 -0
  7. data/app/assets/tailwind/macron/cms/application.css +1 -0
  8. data/app/components/macron/cms/widget_component.rb +25 -3
  9. data/app/controllers/macron/cms/article_contents_controller.rb +117 -10
  10. data/app/controllers/macron/cms/articles_controller.rb +16 -2
  11. data/app/helpers/macron/cms/content_helper.rb +31 -0
  12. data/app/helpers/macron/cms/fields_helper.rb +11 -1
  13. data/app/models/macron/cms/article.rb +36 -0
  14. data/app/models/macron/cms/article_content.rb +33 -1
  15. data/app/views/layouts/macron/cms/application.html.erb +6 -18
  16. data/app/views/macron/cms/articles/_content_blocks_section.html.erb +531 -0
  17. data/app/views/macron/cms/articles/_form.html.erb +121 -0
  18. data/app/views/macron/cms/articles/show.html.erb +11 -298
  19. data/app/views/macron/cms/content_types/show.html.erb +33 -9
  20. data/app/views/macron/cms/fields/_color.html.erb +35 -0
  21. data/app/views/macron/cms/fields/_dropdown.html.erb +28 -0
  22. data/app/views/macron/cms/fields/_file.html.erb +27 -0
  23. data/app/views/macron/cms/fields/_json.html.erb +17 -0
  24. data/app/views/macron/cms/fields/_markdown.html.erb +17 -0
  25. data/app/views/macron/cms/shared/_flash.html.erb +34 -0
  26. data/config/routes.rb +3 -0
  27. data/db/migrate/20261028000007_add_background_fields_to_macron_cms_articles.rb +6 -0
  28. data/db/migrate/20261028000008_add_text_color_to_macron_cms_articles.rb +5 -0
  29. data/lib/macron/cms/version.rb +1 -1
  30. metadata +55 -1
@@ -35,6 +35,127 @@
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 class="pt-6 border-t border-zinc-200 space-y-4">
39
+ <div>
40
+ <h3 class="text-sm font-semibold text-zinc-900">Styling & Background Settings</h3>
41
+ <p class="text-xs text-zinc-500">Configure page background color, background image, text color, and display options.</p>
42
+ </div>
43
+
44
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
45
+ <!-- Background Color -->
46
+ <div class="space-y-1.5">
47
+ <%= form.label :background_color, "Background Color", class: "block text-sm font-medium text-zinc-700" %>
48
+ <%= form.hidden_field :background_color, id: "article_background_color" %>
49
+ <div class="flex items-center gap-3">
50
+ <input
51
+ type="color"
52
+ id="article_background_color_picker"
53
+ value="<%= article.background_color.presence || "#ffffff" %>"
54
+ class="h-9 w-12 cursor-pointer rounded-lg border border-zinc-300 bg-white p-1 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900"
55
+ oninput="document.getElementById('article_background_color').value = this.value; document.getElementById('article_background_color_label').textContent = this.value;"
56
+ />
57
+ <span id="article_background_color_label" class="text-xs font-mono text-zinc-600">
58
+ <%= article.background_color.presence || "Default (transparent / theme)" %>
59
+ </span>
60
+ <button
61
+ type="button"
62
+ class="rounded-md border border-zinc-200 bg-white px-2.5 py-1 text-xs font-medium text-zinc-600 shadow-2xs hover:bg-zinc-50 hover:text-zinc-900 transition-colors cursor-pointer"
63
+ onclick="document.getElementById('article_background_color').value = ''; document.getElementById('article_background_color_label').textContent = 'Default (transparent / theme)';"
64
+ >
65
+ Reset
66
+ </button>
67
+ </div>
68
+ <p class="text-xs text-zinc-500">Optional hex color for the page background.</p>
69
+ </div>
70
+
71
+ <!-- Text Color -->
72
+ <div class="space-y-1.5">
73
+ <%= form.label :text_color, "Text Color", class: "block text-sm font-medium text-zinc-700" %>
74
+ <%= form.hidden_field :text_color, id: "article_text_color" %>
75
+ <div class="flex items-center gap-3">
76
+ <input
77
+ type="color"
78
+ id="article_text_color_picker"
79
+ value="<%= article.text_color.presence || "#000000" %>"
80
+ class="h-9 w-12 cursor-pointer rounded-lg border border-zinc-300 bg-white p-1 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900"
81
+ oninput="document.getElementById('article_text_color').value = this.value; document.getElementById('article_text_color_label').textContent = this.value;"
82
+ />
83
+ <span id="article_text_color_label" class="text-xs font-mono text-zinc-600">
84
+ <%= article.text_color.presence || "Default (theme color)" %>
85
+ </span>
86
+ <button
87
+ type="button"
88
+ class="rounded-md border border-zinc-200 bg-white px-2.5 py-1 text-xs font-medium text-zinc-600 shadow-2xs hover:bg-zinc-50 hover:text-zinc-900 transition-colors cursor-pointer"
89
+ onclick="document.getElementById('article_text_color').value = ''; document.getElementById('article_text_color_label').textContent = 'Default (theme color)';"
90
+ >
91
+ Reset
92
+ </button>
93
+ </div>
94
+ <p class="text-xs text-zinc-500">Optional text color applied to widgets across this article.</p>
95
+ </div>
96
+ </div>
97
+
98
+ <!-- Background Image -->
99
+ <div class="space-y-1.5">
100
+ <%= form.label :background_image, "Background Image", class: "block text-sm font-medium text-zinc-700" %>
101
+ <%= form.file_field :background_image, accept: "image/*", class: "block w-full text-xs text-zinc-600 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border border-zinc-300 file:text-xs file:font-semibold file:bg-white hover:file:bg-zinc-50 file:cursor-pointer" %>
102
+
103
+ <% if article.persisted? && article.background_image.attached? %>
104
+ <div class="mt-2 flex items-center gap-4 p-3 rounded-lg border border-zinc-200 bg-zinc-50">
105
+ <div class="h-12 w-12 shrink-0 rounded border border-zinc-200 overflow-hidden bg-white flex items-center justify-center">
106
+ <%= image_tag main_app.rails_blob_path(article.background_image), class: "h-full w-full object-cover" %>
107
+ </div>
108
+ <div class="min-w-0 flex-1 text-xs">
109
+ <p class="font-medium text-zinc-900 truncate"><%= article.background_image.filename %></p>
110
+ <p class="text-zinc-500"><%= number_to_human_size(article.background_image.byte_size) %></p>
111
+ </div>
112
+ <%= link_to "Delete image", purge_background_image_article_path(article), data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to remove the background image?" }, class: "rounded-md border border-rose-200 bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700 hover:bg-rose-100 transition-colors cursor-pointer shrink-0" %>
113
+ </div>
114
+ <% end %>
115
+ </div>
116
+
117
+ <!-- Background Display Options -->
118
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2">
119
+ <div>
120
+ <label for="article_background_options_repeat" class="block text-xs font-medium text-zinc-700">Tiling / Repeat</label>
121
+ <select name="article[background_options][repeat]" id="article_background_options_repeat" class="mt-1 block w-full rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-xs text-zinc-900 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900">
122
+ <option value="no-repeat" <%= "selected" if article.background_repeat == "no-repeat" %>>No repeat</option>
123
+ <option value="repeat" <%= "selected" if article.background_repeat == "repeat" %>>Tile (repeat both)</option>
124
+ <option value="repeat-x" <%= "selected" if article.background_repeat == "repeat-x" %>>Tile horizontally</option>
125
+ <option value="repeat-y" <%= "selected" if article.background_repeat == "repeat-y" %>>Tile vertically</option>
126
+ </select>
127
+ </div>
128
+
129
+ <div>
130
+ <label for="article_background_options_size" class="block text-xs font-medium text-zinc-700">Sizing</label>
131
+ <select name="article[background_options][size]" id="article_background_options_size" class="mt-1 block w-full rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-xs text-zinc-900 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900">
132
+ <option value="cover" <%= "selected" if article.background_size == "cover" %>>Cover (fill area)</option>
133
+ <option value="contain" <%= "selected" if article.background_size == "contain" %>>Contain (fit inside)</option>
134
+ <option value="auto" <%= "selected" if article.background_size == "auto" %>>Auto (original size)</option>
135
+ </select>
136
+ </div>
137
+
138
+ <div>
139
+ <label for="article_background_options_position" class="block text-xs font-medium text-zinc-700">Position</label>
140
+ <select name="article[background_options][position]" id="article_background_options_position" class="mt-1 block w-full rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-xs text-zinc-900 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900">
141
+ <option value="center" <%= "selected" if article.background_position == "center" %>>Center</option>
142
+ <option value="top" <%= "selected" if article.background_position == "top" %>>Top</option>
143
+ <option value="bottom" <%= "selected" if article.background_position == "bottom" %>>Bottom</option>
144
+ <option value="left" <%= "selected" if article.background_position == "left" %>>Left</option>
145
+ <option value="right" <%= "selected" if article.background_position == "right" %>>Right</option>
146
+ </select>
147
+ </div>
148
+
149
+ <div>
150
+ <label for="article_background_options_attachment" class="block text-xs font-medium text-zinc-700">Attachment</label>
151
+ <select name="article[background_options][attachment]" id="article_background_options_attachment" class="mt-1 block w-full rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-xs text-zinc-900 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900">
152
+ <option value="scroll" <%= "selected" if article.background_attachment == "scroll" %>>Scroll (scrolls with page)</option>
153
+ <option value="fixed" <%= "selected" if article.background_attachment == "fixed" %>>Fixed (fixed in place)</option>
154
+ </select>
155
+ </div>
156
+ </div>
157
+ </div>
158
+
38
159
  <div class="pt-6 border-t border-zinc-200 space-y-4">
39
160
  <div>
40
161
  <h3 class="text-sm font-semibold text-zinc-900">SEO Settings</h3>
@@ -12,9 +12,10 @@
12
12
  <%= @article.slug %>
13
13
  </span>
14
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>
15
+ <div class="text-xs text-zinc-500 space-y-0.5">
16
+ <p>Created: <%= @article.created_at.strftime("%B %d, %Y at %H:%M UTC") %></p>
17
+ <p>Updated: <%= time_ago_in_words(@article.updated_at) %> ago</p>
18
+ </div>
18
19
  </div>
19
20
 
20
21
  <!-- Actions -->
@@ -63,304 +64,16 @@
63
64
  </div>
64
65
 
65
66
  <!-- 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>
67
+ <div id="content-blocks-section">
68
+ <%= render "macron/cms/articles/content_blocks_section",
69
+ article: @article,
70
+ article_contents: @article_contents,
71
+ content_types: @content_types,
72
+ newly_added_id: nil,
73
+ updated_id: nil %>
286
74
  </div>
287
75
 
288
76
  <div class="pt-2">
289
77
  <%= 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
78
  </div>
291
79
  </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>
@@ -22,9 +22,10 @@
22
22
  <%= pluralize(field_list.count, "field") %>
23
23
  </span>
24
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>
25
+ <div class="text-xs text-zinc-500 space-y-0.5">
26
+ <p>Created: <%= @content_type.created_at.strftime("%B %d, %Y at %H:%M UTC") rescue "—" %></p>
27
+ <p>Updated: <%= @content_type.updated_at ? "#{time_ago_in_words(@content_type.updated_at)} ago" : "—" rescue "—" %></p>
28
+ </div>
28
29
  </div>
29
30
 
30
31
  <!-- Actions -->
@@ -107,14 +108,37 @@
107
108
  </td>
108
109
  <td class="px-6 py-3.5 text-xs text-zinc-600">
109
110
  <% if extra_keys.any? %>
110
- <dl class="flex flex-wrap gap-x-4 gap-y-1">
111
+ <div class="space-y-1.5">
111
112
  <% 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>
113
+ <% if k.to_s == "options" && v.is_a?(Array) %>
114
+ <div class="flex items-start gap-1.5 flex-wrap">
115
+ <span class="font-mono text-zinc-500 shrink-0">options:</span>
116
+ <div class="flex flex-wrap gap-1">
117
+ <% v.each do |opt| %>
118
+ <%
119
+ opt_label = if opt.is_a?(Hash)
120
+ h = opt.stringify_keys
121
+ name_part = h["name"] || h["label"] || h["value"]
122
+ val_part = h["value"]
123
+ name_part == val_part ? name_part : "#{name_part} (#{val_part})"
124
+ else
125
+ opt.to_s
126
+ end
127
+ %>
128
+ <span class="inline-flex items-center rounded bg-zinc-100 px-1.5 py-0.5 text-[11px] font-mono text-zinc-700 ring-1 ring-inset ring-zinc-200">
129
+ <%= opt_label %>
130
+ </span>
131
+ <% end %>
132
+ </div>
133
+ </div>
134
+ <% else %>
135
+ <div class="flex items-center gap-1">
136
+ <span class="font-mono text-zinc-500"><%= k %>:</span>
137
+ <span class="font-mono font-medium text-zinc-800"><%= v.inspect %></span>
138
+ </div>
139
+ <% end %>
116
140
  <% end %>
117
- </dl>
141
+ </div>
118
142
  <% else %>
119
143
  <span class="text-zinc-600 italic">None</span>
120
144
  <% end %>
@@ -0,0 +1,35 @@
1
+ <div class="space-y-1.5">
2
+ <label for="<%= input_id %>_picker" class="block text-xs font-mono font-semibold text-zinc-700">
3
+ <%= field_name %>
4
+ <span class="ml-1 text-[10px] font-normal text-zinc-400">(color)</span>
5
+ </label>
6
+
7
+ <input
8
+ type="hidden"
9
+ name="<%= input_name %>"
10
+ id="<%= input_id %>"
11
+ value="<%= value %>"
12
+ />
13
+
14
+ <div class="flex items-center gap-3">
15
+ <input
16
+ type="color"
17
+ id="<%= input_id %>_picker"
18
+ value="<%= value.presence || "#000000" %>"
19
+ class="h-9 w-12 cursor-pointer rounded-lg border border-zinc-300 bg-white p-1 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900"
20
+ oninput="document.getElementById('<%= input_id %>').value = this.value; document.getElementById('<%= input_id %>_label').textContent = this.value;"
21
+ />
22
+
23
+ <span id="<%= input_id %>_label" class="text-xs font-mono text-zinc-600">
24
+ <%= value.presence || "Default (theme color)" %>
25
+ </span>
26
+
27
+ <button
28
+ type="button"
29
+ class="rounded-md border border-zinc-200 bg-white px-2.5 py-1 text-xs font-medium text-zinc-600 shadow-2xs hover:bg-zinc-50 hover:text-zinc-900 transition-colors cursor-pointer"
30
+ onclick="document.getElementById('<%= input_id %>').value = ''; document.getElementById('<%= input_id %>_label').textContent = 'Default (theme color)';"
31
+ >
32
+ Reset to default
33
+ </button>
34
+ </div>
35
+ </div>
@@ -0,0 +1,28 @@
1
+ <%
2
+ raw_options = field["options"] || field[:options] || []
3
+ normalized_options = raw_options.map do |opt|
4
+ if opt.is_a?(Hash)
5
+ opt_hash = opt.stringify_keys
6
+ label = opt_hash["name"].presence || opt_hash["label"].presence || opt_hash["value"].to_s
7
+ [label, opt_hash["value"]]
8
+ else
9
+ [opt.to_s, opt.to_s]
10
+ end
11
+ end
12
+ %>
13
+ <div class="space-y-1.5">
14
+ <label for="<%= input_id %>" class="block text-xs font-mono font-semibold text-zinc-700">
15
+ <%= field_name %>
16
+ <span class="ml-1 text-[10px] font-normal text-zinc-400">(dropdown)</span>
17
+ </label>
18
+ <select
19
+ name="<%= input_name %>"
20
+ id="<%= input_id %>"
21
+ class="block w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900"
22
+ >
23
+ <option value="">Select <%= field_name.to_s.humanize.downcase %>...</option>
24
+ <% normalized_options.each do |opt_name, opt_val| %>
25
+ <option value="<%= opt_val %>"<%= " selected" if value.present? && opt_val.to_s == value.to_s %>><%= opt_name %></option>
26
+ <% end %>
27
+ </select>
28
+ </div>
@@ -0,0 +1,27 @@
1
+ <div class="space-y-1.5">
2
+ <label for="<%= input_id %>" class="block text-xs font-mono font-semibold text-zinc-700">
3
+ <%= field_name %>
4
+ <span class="ml-1 text-[10px] font-normal text-zinc-400">(file)</span>
5
+ </label>
6
+
7
+ <% if value.present? %>
8
+ <div class="flex items-center gap-2 p-2 bg-zinc-50 border border-zinc-200 rounded-lg text-xs text-zinc-700 font-mono">
9
+ <svg class="w-4 h-4 text-zinc-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
10
+ <path stroke-linecap="round" stroke-linejoin="round" d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
11
+ </svg>
12
+ <span class="truncate font-medium"><%= value %></span>
13
+ <span class="text-[10px] text-zinc-400 font-sans ml-auto shrink-0">Current file</span>
14
+ </div>
15
+ <% end %>
16
+
17
+ <input
18
+ type="file"
19
+ name="<%= input_name %>"
20
+ id="<%= input_id %>"
21
+ class="block w-full text-xs text-zinc-700 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-zinc-900 file:text-white hover:file:bg-zinc-800 file:cursor-pointer border border-zinc-300 rounded-lg bg-white shadow-2xs focus:outline-none focus:border-zinc-900 cursor-pointer"
22
+ />
23
+
24
+ <% if value.present? %>
25
+ <p class="text-[11px] text-zinc-400">Choose a new file to replace the existing one.</p>
26
+ <% end %>
27
+ </div>
@@ -0,0 +1,17 @@
1
+ <div class="space-y-1.5">
2
+ <div class="flex items-center justify-between">
3
+ <label for="<%= input_id %>" class="block text-xs font-mono font-semibold text-zinc-700">
4
+ <%= field_name %>
5
+ <span class="ml-1 text-[10px] font-normal text-amber-600">(json)</span>
6
+ </label>
7
+ <span class="text-[10px] text-zinc-400 font-mono">CodeMirror</span>
8
+ </div>
9
+ <textarea
10
+ name="<%= input_name %>"
11
+ id="<%= input_id %>"
12
+ rows="6"
13
+ data-cms-editor="json"
14
+ class="block w-full font-mono rounded-lg border border-zinc-300 bg-white px-3 py-2 text-xs text-zinc-900 shadow-2xs placeholder:text-zinc-400 focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900"
15
+ placeholder="{}"
16
+ ><%= value %></textarea>
17
+ </div>
@@ -0,0 +1,17 @@
1
+ <div class="space-y-1.5">
2
+ <div class="flex items-center justify-between">
3
+ <label for="<%= input_id %>" class="block text-xs font-mono font-semibold text-zinc-700">
4
+ <%= field_name %>
5
+ <span class="ml-1 text-[10px] font-normal text-emerald-600">(markdown)</span>
6
+ </label>
7
+ <span class="text-[10px] text-zinc-400 font-mono">CodeMirror</span>
8
+ </div>
9
+ <textarea
10
+ name="<%= input_name %>"
11
+ id="<%= input_id %>"
12
+ rows="6"
13
+ data-cms-editor="markdown"
14
+ class="block w-full font-mono rounded-lg border border-zinc-300 bg-white px-3 py-2 text-xs text-zinc-900 shadow-2xs placeholder:text-zinc-400 focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900"
15
+ placeholder="Enter markdown content..."
16
+ ><%= value %></textarea>
17
+ </div>