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
@@ -0,0 +1,531 @@
1
+ <% article_contents ||= [] %>
2
+ <% content_types ||= [] %>
3
+ <% newly_added_id ||= nil %>
4
+ <% updated_id ||= nil %>
5
+
6
+ <div class="space-y-6">
7
+ <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border-b border-zinc-200 pb-4">
8
+ <div>
9
+ <div class="flex items-center gap-3">
10
+ <h2 class="text-xl font-bold tracking-tight text-zinc-900">Article Content Blocks</h2>
11
+ <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">
12
+ <%= pluralize(article_contents.size, "block") %>
13
+ </span>
14
+ </div>
15
+ <p class="text-xs text-zinc-500 mt-1">Re-order blocks using drag-and-drop or the Move Up/Down buttons.</p>
16
+ </div>
17
+ </div>
18
+
19
+ <!-- Content Blocks List -->
20
+ <div
21
+ id="content-blocks-list"
22
+ data-reorder-url="<%= reorder_article_article_contents_path(article) %>"
23
+ class="space-y-2"
24
+ >
25
+ <% if article_contents.any? %>
26
+ <!-- Inserter above first block (Position 1) -->
27
+ <div id="inserter-zone-top" class="block-inserter-zone py-0.5" data-position="1">
28
+ <div class="block-inserter-divider relative py-0.5 flex items-center justify-center group">
29
+ <div class="absolute inset-0 flex items-center" aria-hidden="true">
30
+ <div class="w-full border-t border-dashed border-zinc-200 group-hover:border-zinc-300 transition-colors"></div>
31
+ </div>
32
+ <button
33
+ type="button"
34
+ onclick="toggleInsertForm(1, this);"
35
+ aria-label="Add block at position 1"
36
+ class="inserter-toggle-btn relative z-10 inline-flex items-center gap-1 rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-zinc-500 shadow-2xs border border-zinc-200 opacity-60 hover:opacity-100 hover:text-zinc-900 hover:border-zinc-400 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-zinc-900 group-hover:opacity-100 transition-all cursor-pointer"
37
+ >
38
+ <svg class="w-3.5 h-3.5 transition-transform duration-150" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
39
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
40
+ </svg>
41
+ <span class="text-[11px] font-medium">Add block</span>
42
+ </button>
43
+ </div>
44
+ <div id="insert-slot-1" class="insert-slot-target my-3 hidden"></div>
45
+ </div>
46
+
47
+ <% article_contents.each_with_index do |ac, index| %>
48
+ <% content_type = ac.content %>
49
+ <% fields = content_type ? content_type.normalized_fields : [] %>
50
+ <% is_new = (ac.id == newly_added_id) %>
51
+ <% is_updated = (ac.id == updated_id) %>
52
+ <div
53
+ id="article-content-<%= ac.id %>"
54
+ data-id="<%= ac.id %>"
55
+ draggable="true"
56
+ class="content-block-item group rounded-xl border border-zinc-200 bg-white shadow-2xs transition-all duration-300 hover:shadow-xs <%= 'ring-2 ring-emerald-500/80 bg-emerald-50/15' if is_new %> <%= 'ring-2 ring-blue-500/80 bg-blue-50/15' if is_updated %>"
57
+ >
58
+ <!-- Card Header & Actions -->
59
+ <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">
60
+ <div class="flex items-center gap-3">
61
+ <!-- Drag Handle -->
62
+ <div class="cursor-grab active:cursor-grabbing text-zinc-400 group-hover:text-zinc-600 transition-colors" title="Drag to reorder">
63
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
64
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9h16.5m-16.5 6.75h16.5" />
65
+ </svg>
66
+ </div>
67
+
68
+ <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">
69
+ #<%= index + 1 %>
70
+ </span>
71
+
72
+ <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">
73
+ <%= content_type ? content_type.name : "Unknown" %>
74
+ </span>
75
+ </div>
76
+
77
+ <!-- Action Buttons -->
78
+ <div class="flex items-center gap-1.5">
79
+ <!-- Move Up -->
80
+ <%= 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 cursor-pointer" do %>
81
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
82
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
83
+ </svg>
84
+ <% end %>
85
+
86
+ <!-- Move Down -->
87
+ <%= 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 cursor-pointer" do %>
88
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
89
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
90
+ </svg>
91
+ <% end %>
92
+
93
+ <!-- Edit Toggle Button -->
94
+ <button
95
+ type="button"
96
+ onclick="document.getElementById('edit-form-<%= ac.id %>').classList.toggle('hidden'); document.getElementById('view-payload-<%= ac.id %>').classList.toggle('hidden');"
97
+ 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"
98
+ >
99
+ <svg class="w-3.5 h-3.5 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
100
+ <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" />
101
+ </svg>
102
+ Edit
103
+ </button>
104
+
105
+ <!-- Delete Block Button -->
106
+ <% del_block_confirm = "Are you sure you want to remove this #{content_type ? content_type.name : 'content'} block?" %>
107
+ <%= 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 %>
108
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
109
+ <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" />
110
+ </svg>
111
+ <% end %>
112
+ </div>
113
+ </div>
114
+
115
+ <!-- View Payload Mode -->
116
+ <div id="view-payload-<%= ac.id %>" class="p-5">
117
+ <% if fields.any? %>
118
+ <dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
119
+ <% fields.each do |f| %>
120
+ <% fname = f["name"] %>
121
+ <% fkind = f["kind"] %>
122
+ <% fval = ac.payload[fname] %>
123
+ <div class="<%= fkind.in?(%w[text markdown]) ? 'sm:col-span-2' : '' %>">
124
+ <dt class="text-xs font-mono font-medium text-zinc-500 uppercase tracking-wide">
125
+ <%= fname %>
126
+ <span class="text-[10px] text-zinc-400 lowercase font-normal">(<%= fkind %>)</span>
127
+ </dt>
128
+ <dd class="mt-1 text-sm text-zinc-900 bg-zinc-50/80 rounded-lg p-2.5 border border-zinc-100 font-sans">
129
+ <% if fval.present? || ac.files.attached? %>
130
+ <% if fkind == 'markdown' %>
131
+ <div class="prose prose-zinc prose-sm max-w-none">
132
+ <%= cms_render_markdown(fval) %>
133
+ </div>
134
+ <% elsif fkind == 'text' %>
135
+ <div class="whitespace-pre-wrap"><%= fval %></div>
136
+ <% elsif fkind == 'file' && ac.files.attached? %>
137
+ <div class="space-y-2">
138
+ <% ac.files.each do |att| %>
139
+ <div class="flex items-center gap-2.5">
140
+ <% if att.image? %>
141
+ <div class="w-10 h-10 rounded-md overflow-hidden bg-zinc-100 border border-zinc-200 shrink-0">
142
+ <%= image_tag main_app.rails_blob_path(att), class: "w-full h-full object-cover" %>
143
+ </div>
144
+ <% else %>
145
+ <div class="w-8 h-8 rounded-md bg-zinc-100 border border-zinc-200 flex items-center justify-center shrink-0 text-zinc-500">
146
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
147
+ <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" />
148
+ </svg>
149
+ </div>
150
+ <% end %>
151
+ <div class="min-w-0">
152
+ <p class="text-xs font-medium font-mono text-zinc-900 truncate"><%= att.filename %></p>
153
+ <p class="text-[10px] text-zinc-400"><%= number_to_human_size(att.byte_size) %></p>
154
+ </div>
155
+ </div>
156
+ <% end %>
157
+ </div>
158
+ <% else %>
159
+ <span><%= fval %></span>
160
+ <% end %>
161
+ <% else %>
162
+ <span class="italic text-zinc-400 text-xs">Empty</span>
163
+ <% end %>
164
+ </dd>
165
+ </div>
166
+ <% end %>
167
+ </dl>
168
+ <% else %>
169
+ <p class="text-xs text-zinc-500 italic">No fields defined for this content type.</p>
170
+ <% end %>
171
+ </div>
172
+
173
+ <!-- Inline Edit Form -->
174
+ <div id="edit-form-<%= ac.id %>" class="hidden p-5 border-t border-zinc-100 bg-zinc-50/30">
175
+ <%= form_with(model: [article, ac], multipart: true, class: "space-y-4") do |f| %>
176
+ <div class="space-y-4">
177
+ <% fields.each do |field_def| %>
178
+ <% fname = field_def["name"] %>
179
+ <%= cms_render_field(
180
+ field_def,
181
+ value: ac.payload[fname],
182
+ input_name: "article_content[payload][#{fname}]",
183
+ input_id: "edit_article_content_#{ac.id}_payload_#{fname}"
184
+ ) %>
185
+ <% end %>
186
+ </div>
187
+
188
+ <div class="flex items-center gap-3 pt-2">
189
+ <%= 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" %>
190
+ <button
191
+ type="button"
192
+ onclick="document.getElementById('edit-form-<%= ac.id %>').classList.add('hidden'); document.getElementById('view-payload-<%= ac.id %>').classList.remove('hidden');"
193
+ 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"
194
+ >
195
+ Cancel
196
+ </button>
197
+ </div>
198
+ <% end %>
199
+ </div>
200
+ </div>
201
+
202
+ <!-- Inserter after block #<%= index + 1 %> (Position <%= index + 2 %>) -->
203
+ <div class="block-inserter-zone py-0.5" data-position="<%= index + 2 %>" data-block-id="<%= ac.id %>">
204
+ <div class="block-inserter-divider relative py-0.5 flex items-center justify-center group">
205
+ <div class="absolute inset-0 flex items-center" aria-hidden="true">
206
+ <div class="w-full border-t border-dashed border-zinc-200 group-hover:border-zinc-300 transition-colors"></div>
207
+ </div>
208
+ <button
209
+ type="button"
210
+ onclick="toggleInsertForm(<%= index + 2 %>, this);"
211
+ aria-label="Add block at position <%= index + 2 %>"
212
+ class="inserter-toggle-btn relative z-10 inline-flex items-center gap-1 rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-zinc-500 shadow-2xs border border-zinc-200 opacity-60 hover:opacity-100 hover:text-zinc-900 hover:border-zinc-400 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-zinc-900 group-hover:opacity-100 transition-all cursor-pointer"
213
+ >
214
+ <svg class="w-3.5 h-3.5 transition-transform duration-150" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
215
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
216
+ </svg>
217
+ <span class="text-[11px] font-medium">Add block</span>
218
+ </button>
219
+ </div>
220
+ <div id="insert-slot-<%= index + 2 %>" class="insert-slot-target my-3 hidden"></div>
221
+ </div>
222
+ <% end %>
223
+ <% else %>
224
+ <div class="rounded-xl border border-dashed border-zinc-300 bg-white p-8 text-center">
225
+ <svg class="mx-auto h-10 w-10 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
226
+ <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" />
227
+ </svg>
228
+ <h3 class="mt-2 text-sm font-semibold text-zinc-900">No content blocks yet</h3>
229
+ <p class="mt-1 text-xs text-zinc-500">Get started by adding the first block to this article.</p>
230
+ <div class="mt-4">
231
+ <button
232
+ type="button"
233
+ onclick="toggleInsertForm(1, this);"
234
+ aria-label="Add first block"
235
+ class="inserter-toggle-btn inline-flex items-center gap-1.5 rounded-lg bg-zinc-900 px-3.5 py-2 text-xs font-semibold text-white shadow-2xs hover:bg-zinc-800 transition-colors cursor-pointer"
236
+ >
237
+ <svg class="w-4 h-4 transition-transform duration-150" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
238
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
239
+ </svg>
240
+ Add First Block
241
+ </button>
242
+ </div>
243
+ <div id="insert-slot-1" class="insert-slot-target mt-4 text-left hidden"></div>
244
+ </div>
245
+ <% end %>
246
+ </div>
247
+
248
+ <!-- Reusable In-Place Add Content Block Card (hidden staging storage) -->
249
+ <div id="form-dock-storage" class="hidden">
250
+ <div id="reusable-add-block-card" class="overflow-hidden rounded-xl border border-zinc-300 bg-white shadow-md ring-1 ring-zinc-900/5 transition-all">
251
+ <div class="px-6 py-4 border-b border-zinc-200 bg-zinc-50 flex items-center justify-between">
252
+ <div class="flex items-center gap-2.5">
253
+ <div class="w-6 h-6 rounded-md bg-zinc-900 text-white flex items-center justify-center shrink-0">
254
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
255
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
256
+ </svg>
257
+ </div>
258
+ <div>
259
+ <div class="flex items-center gap-2">
260
+ <h3 class="text-sm font-semibold text-zinc-900">Add Content Block</h3>
261
+ <span id="insert-form-position-badge" class="inline-flex items-center rounded-md bg-zinc-200/80 px-2 py-0.5 text-xs font-mono font-semibold text-zinc-800">
262
+ Position #1
263
+ </span>
264
+ </div>
265
+ <p class="text-xs text-zinc-500 mt-0.5">Select a Content Type and populate its fields.</p>
266
+ </div>
267
+ </div>
268
+ <button
269
+ type="button"
270
+ onclick="closeInsertForm();"
271
+ class="text-zinc-400 hover:text-zinc-700 transition-colors rounded-lg p-1 hover:bg-zinc-100 cursor-pointer"
272
+ title="Close (Esc)"
273
+ >
274
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
275
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
276
+ </svg>
277
+ </button>
278
+ </div>
279
+
280
+ <div class="p-6">
281
+ <% if content_types.any? %>
282
+ <div class="space-y-4">
283
+ <div>
284
+ <label for="new_content_type_select" class="block text-xs font-semibold text-zinc-900 mb-1.5">
285
+ Select Content Type
286
+ </label>
287
+ <select
288
+ id="new_content_type_select"
289
+ 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"
290
+ onchange="switchNewContentType(this.value);"
291
+ >
292
+ <option value="">-- Choose a Content Type --</option>
293
+ <% content_types.each do |ct| %>
294
+ <option value="<%= ct.id %>"><%= ct.name %> (<%= pluralize(ct.normalized_fields.size, "field") %>)</option>
295
+ <% end %>
296
+ </select>
297
+ </div>
298
+
299
+ <!-- Dynamic Form per Content Type -->
300
+ <% content_types.each do |ct| %>
301
+ <% ct_fields = ct.normalized_fields %>
302
+ <div id="new-content-type-form-<%= ct.id %>" class="new-content-type-form hidden pt-4 border-t border-zinc-100">
303
+ <%= form_with(url: article_article_contents_path(article), scope: :article_content, multipart: true, class: "space-y-4") do |f| %>
304
+ <%= f.hidden_field :content_id, value: ct.id %>
305
+ <%= f.hidden_field :insert_at, value: 1, class: "insert-at-field" %>
306
+
307
+ <div class="space-y-4">
308
+ <% ct_fields.each do |field_def| %>
309
+ <% fname = field_def["name"] %>
310
+ <%= cms_render_field(
311
+ field_def,
312
+ value: nil,
313
+ input_name: "article_content[payload][#{fname}]",
314
+ input_id: "new_content_#{ct.id}_payload_#{fname}"
315
+ ) %>
316
+ <% end %>
317
+ </div>
318
+
319
+ <div class="flex items-center gap-3 pt-2">
320
+ <%= 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" %>
321
+ <button
322
+ type="button"
323
+ onclick="closeInsertForm();"
324
+ 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"
325
+ >
326
+ Cancel
327
+ </button>
328
+ </div>
329
+ <% end %>
330
+ </div>
331
+ <% end %>
332
+ </div>
333
+ <% else %>
334
+ <div class="text-center py-4">
335
+ <p class="text-sm text-zinc-500">No content types defined yet.</p>
336
+ <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>
337
+ </div>
338
+ <% end %>
339
+ </div>
340
+ </div>
341
+ </div>
342
+ </div>
343
+
344
+ <script>
345
+ window.activeInsertPosition = null;
346
+ window.activeTriggerBtn = null;
347
+
348
+ window.switchNewContentType = function(selectedId) {
349
+ document.querySelectorAll('.new-content-type-form').forEach(function(el) {
350
+ el.classList.add('hidden');
351
+ });
352
+ if (selectedId) {
353
+ var target = document.getElementById('new-content-type-form-' + selectedId);
354
+ if (target) target.classList.remove('hidden');
355
+ }
356
+ };
357
+
358
+ window.toggleInsertForm = function(position, triggerBtn) {
359
+ if (window.activeInsertPosition === position) {
360
+ window.closeInsertForm();
361
+ return;
362
+ }
363
+
364
+ var targetSlot = document.getElementById('insert-slot-' + position);
365
+ var card = document.getElementById('reusable-add-block-card');
366
+ if (!targetSlot || !card) return;
367
+
368
+ window.closeInsertForm(true);
369
+
370
+ window.activeInsertPosition = position;
371
+ window.activeTriggerBtn = triggerBtn;
372
+
373
+ var badge = document.getElementById('insert-form-position-badge');
374
+ if (badge) {
375
+ badge.textContent = 'Position #' + position;
376
+ }
377
+
378
+ document.querySelectorAll('.insert-at-field').forEach(function(input) {
379
+ input.value = position;
380
+ });
381
+
382
+ targetSlot.appendChild(card);
383
+ targetSlot.classList.remove('hidden');
384
+
385
+ if (triggerBtn) {
386
+ triggerBtn.classList.add('!bg-zinc-900', '!text-white', '!border-zinc-900', '!opacity-100');
387
+ var svg = triggerBtn.querySelector('svg');
388
+ if (svg) svg.classList.add('rotate-45');
389
+ }
390
+
391
+ var select = document.getElementById('new_content_type_select');
392
+ if (select) {
393
+ setTimeout(function() { select.focus(); }, 50);
394
+ }
395
+ };
396
+
397
+ window.closeInsertForm = function(skipResetStorage) {
398
+ var card = document.getElementById('reusable-add-block-card');
399
+ var storage = document.getElementById('form-dock-storage');
400
+
401
+ if (window.activeTriggerBtn) {
402
+ window.activeTriggerBtn.classList.remove('!bg-zinc-900', '!text-white', '!border-zinc-900', '!opacity-100');
403
+ var svg = window.activeTriggerBtn.querySelector('svg');
404
+ if (svg) svg.classList.remove('rotate-45');
405
+ window.activeTriggerBtn = null;
406
+ }
407
+
408
+ document.querySelectorAll('.insert-slot-target').forEach(function(slot) {
409
+ slot.classList.add('hidden');
410
+ });
411
+
412
+ if (card && storage && !skipResetStorage) {
413
+ storage.appendChild(card);
414
+ }
415
+
416
+ window.activeInsertPosition = null;
417
+ };
418
+
419
+ if (!window._macronEscapeBound) {
420
+ document.addEventListener('keydown', function(e) {
421
+ if (e.key === 'Escape' && window.activeInsertPosition !== null) {
422
+ window.closeInsertForm();
423
+ }
424
+ });
425
+ window._macronEscapeBound = true;
426
+ }
427
+
428
+ // Fade out any highlight rings
429
+ setTimeout(function() {
430
+ document.querySelectorAll('.content-block-item.ring-2').forEach(function(el) {
431
+ el.classList.remove('ring-2', 'ring-emerald-500/80', 'bg-emerald-50/15', 'ring-blue-500/80', 'bg-blue-50/15');
432
+ });
433
+ }, 2500);
434
+
435
+ // Initialize Drag & Drop
436
+ (function initDragAndDrop() {
437
+ var container = document.getElementById('content-blocks-list');
438
+ if (!container) return;
439
+
440
+ var draggedItem = null;
441
+
442
+ container.addEventListener('dragstart', function(e) {
443
+ var item = e.target.closest('.content-block-item');
444
+ if (!item) return;
445
+ window.closeInsertForm();
446
+ draggedItem = item;
447
+ e.dataTransfer.effectAllowed = 'move';
448
+ item.classList.add('opacity-50', 'border-zinc-400');
449
+ });
450
+
451
+ container.addEventListener('dragend', function(e) {
452
+ var item = e.target.closest('.content-block-item');
453
+ if (item) {
454
+ item.classList.remove('opacity-50', 'border-zinc-400');
455
+ }
456
+ draggedItem = null;
457
+ sendNewOrder();
458
+ });
459
+
460
+ container.addEventListener('dragover', function(e) {
461
+ e.preventDefault();
462
+ e.dataTransfer.dropEffect = 'move';
463
+ var targetItem = e.target.closest('.content-block-item');
464
+ if (targetItem && targetItem !== draggedItem) {
465
+ var rect = targetItem.getBoundingClientRect();
466
+ var next = (e.clientY - rect.top) / (rect.bottom - rect.top) > 0.5;
467
+ container.insertBefore(draggedItem, next ? targetItem.nextSibling : targetItem);
468
+ }
469
+ });
470
+
471
+ function sendNewOrder() {
472
+ var items = container.querySelectorAll('.content-block-item');
473
+ var orderedIds = [];
474
+ items.forEach(function(item, idx) {
475
+ orderedIds.push(item.getAttribute('data-id'));
476
+ var badge = item.querySelector('.position-badge');
477
+ if (badge) badge.textContent = '#' + (idx + 1);
478
+ });
479
+
480
+ refreshInserterSlots();
481
+
482
+ var reorderUrl = container.getAttribute('data-reorder-url');
483
+ var csrfToken = document.querySelector('meta[name="csrf-token"]');
484
+
485
+ if (reorderUrl && orderedIds.length > 0) {
486
+ fetch(reorderUrl, {
487
+ method: 'PATCH',
488
+ headers: {
489
+ 'Content-Type': 'application/json',
490
+ 'X-CSRF-Token': csrfToken ? csrfToken.content : '',
491
+ 'Accept': 'application/json'
492
+ },
493
+ body: JSON.stringify({ ordered_ids: orderedIds })
494
+ }).catch(function(err) {
495
+ console.error('Failed to update content block order:', err);
496
+ });
497
+ }
498
+ }
499
+
500
+ function refreshInserterSlots() {
501
+ var items = container.querySelectorAll('.content-block-item');
502
+ var topZone = document.getElementById('inserter-zone-top');
503
+ if (topZone && container.firstElementChild !== topZone) {
504
+ container.insertBefore(topZone, container.firstElementChild);
505
+ }
506
+
507
+ items.forEach(function(item) {
508
+ var blockId = item.getAttribute('data-id');
509
+ var trailingZone = container.querySelector('.block-inserter-zone[data-block-id="' + blockId + '"]');
510
+ if (trailingZone) {
511
+ item.after(trailingZone);
512
+ }
513
+ });
514
+
515
+ var allZones = container.querySelectorAll('.block-inserter-zone');
516
+ allZones.forEach(function(zone, idx) {
517
+ var pos = idx + 1;
518
+ zone.setAttribute('data-position', pos);
519
+ var btn = zone.querySelector('.inserter-toggle-btn');
520
+ if (btn) {
521
+ btn.setAttribute('onclick', 'toggleInsertForm(' + pos + ', this);');
522
+ btn.setAttribute('aria-label', 'Add block at position ' + pos);
523
+ }
524
+ var slot = zone.querySelector('.insert-slot-target');
525
+ if (slot) {
526
+ slot.id = 'insert-slot-' + pos;
527
+ }
528
+ });
529
+ }
530
+ })();
531
+ </script>