macron-cms 3.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +338 -25
  3. data/app/assets/stylesheets/macron/cms/application.css +1 -1
  4. data/app/controllers/concerns/macron/cms/api_serialization.rb +79 -0
  5. data/app/controllers/concerns/macron/cms/slug_redirectable.rb +27 -0
  6. data/app/controllers/macron/cms/api/v1/article_contents_controller.rb +221 -0
  7. data/app/controllers/macron/cms/api/v1/articles_controller.rb +140 -66
  8. data/app/controllers/macron/cms/api/v1/content_types_controller.rb +3 -1
  9. data/app/controllers/macron/cms/article_contents_controller.rb +44 -10
  10. data/app/controllers/macron/cms/articles_controller.rb +4 -3
  11. data/app/controllers/macron/cms/content_types_controller.rb +2 -0
  12. data/app/controllers/macron/cms/dashboard_controller.rb +1 -1
  13. data/app/helpers/macron/cms/content_helper.rb +1 -1
  14. data/app/helpers/macron/cms/fields_helper.rb +14 -0
  15. data/app/models/macron/cms/article.rb +45 -15
  16. data/app/models/macron/cms/article_content.rb +24 -0
  17. data/app/models/macron/cms/content.rb +27 -0
  18. data/app/services/macron/cms/inline_asset_attacher.rb +70 -0
  19. data/app/services/macron/cms/remote_asset_attacher.rb +16 -6
  20. data/app/views/macron/cms/articles/_content_blocks_section.html.erb +494 -199
  21. data/app/views/macron/cms/articles/_form.html.erb +10 -2
  22. data/app/views/macron/cms/articles/_table.html.erb +3 -3
  23. data/app/views/macron/cms/articles/show.html.erb +3 -1
  24. data/app/views/macron/cms/content_types/index.html.erb +30 -0
  25. data/app/views/macron/cms/content_types/show.html.erb +34 -0
  26. data/config/routes.rb +12 -1
  27. data/db/migrate/20261029000001_add_convertible_to_to_macron_cms_contents.rb +5 -0
  28. data/db/migrate/20261029120001_add_featured_image_to_macron_cms_contents.rb +5 -0
  29. data/db/migrate/20261029120002_add_featured_image_blob_to_macron_cms_articles.rb +8 -0
  30. data/db/migrate/20261029140000_create_friendly_id_slugs.rb +26 -0
  31. data/db/migrate/20261029150000_remove_name_from_macron_cms_articles.rb +6 -0
  32. data/db/migrate/20261029160000_add_published_at_to_macron_cms_articles.rb +14 -0
  33. data/db/seeds.rb +3 -2
  34. data/lib/macron/cms/version.rb +1 -1
  35. data/lib/tasks/macron/cms_tasks.rake +6 -0
  36. metadata +11 -1
@@ -2,51 +2,91 @@
2
2
  <% content_types ||= [] %>
3
3
  <% newly_added_id ||= nil %>
4
4
  <% updated_id ||= nil %>
5
+ <% compact = local_assigns.key?(:compact) ? !!local_assigns[:compact] : (params[:view] == "compact") %>
5
6
 
6
7
  <div class="space-y-6">
7
8
  <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border-b border-zinc-200 pb-4">
8
9
  <div>
9
- <div class="flex items-center gap-3">
10
+ <div class="flex items-center gap-3 flex-wrap">
10
11
  <h2 class="text-xl font-bold tracking-tight text-zinc-900">Article Content Blocks</h2>
11
12
  <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
13
  <%= pluralize(article_contents.size, "block") %>
13
14
  </span>
15
+ <% if compact && article_contents.any? %>
16
+ <span id="selection-status-badge" class="hidden inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-2.5 py-0.5 text-xs font-semibold text-white">
17
+ <span id="selected-count">0</span> selected
18
+ <button
19
+ type="button"
20
+ onclick="deselectAllBlocks();"
21
+ class="text-zinc-400 hover:text-white cursor-pointer ml-1 text-[11px] underline"
22
+ >
23
+ Clear
24
+ </button>
25
+ </span>
26
+ <% end %>
14
27
  </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>
28
+ <p class="text-xs text-zinc-500 mt-1">
29
+ <% if compact %>
30
+ Re-order blocks using drag-and-drop or Move buttons. Select multiple checkboxes (Shift+Click for range) to drag multiple blocks together.
31
+ <% else %>
32
+ Re-order blocks using drag-and-drop or the Move Up/Down buttons.
33
+ <% end %>
34
+ </p>
35
+ </div>
36
+
37
+ <!-- View Mode Switcher -->
38
+ <div class="inline-flex items-center rounded-lg bg-zinc-100 p-1 border border-zinc-200 text-xs font-medium self-start sm:self-auto shadow-2xs">
39
+ <%= link_to article_path(article),
40
+ class: "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md transition-colors #{!compact ? 'bg-white text-zinc-900 font-semibold shadow-2xs ring-1 ring-zinc-900/5' : 'text-zinc-600 hover:text-zinc-900'}" do %>
41
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor">
42
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
43
+ </svg>
44
+ Expanded
45
+ <% end %>
46
+ <%= link_to article_path(article, view: "compact"),
47
+ class: "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md transition-colors #{compact ? 'bg-white text-zinc-900 font-semibold shadow-2xs ring-1 ring-zinc-900/5' : 'text-zinc-600 hover:text-zinc-900'}" do %>
48
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor">
49
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 5.25h16.5m-16.5 4.5h16.5m-16.5 4.5h16.5m-16.5 4.5h16.5" />
50
+ </svg>
51
+ Compact
52
+ <% end %>
16
53
  </div>
17
54
  </div>
18
55
 
19
56
  <!-- Content Blocks List -->
20
57
  <div
21
58
  id="content-blocks-list"
22
- data-reorder-url="<%= reorder_article_article_contents_path(article) %>"
59
+ data-reorder-url="<%= reorder_article_article_contents_path(article, view: (compact ? 'compact' : nil)) %>"
23
60
  class="space-y-2"
24
61
  >
25
62
  <% 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>
63
+ <% unless compact %>
64
+ <!-- Inserter above first block (Position 1) -->
65
+ <div id="inserter-zone-top" class="block-inserter-zone py-0.5" data-position="1">
66
+ <div class="block-inserter-divider relative py-0.5 flex items-center justify-center group">
67
+ <div class="absolute inset-0 flex items-center" aria-hidden="true">
68
+ <div class="w-full border-t border-dashed border-zinc-200 group-hover:border-zinc-300 transition-colors"></div>
69
+ </div>
70
+ <button
71
+ type="button"
72
+ onclick="toggleInsertForm(1, this);"
73
+ aria-label="Add block at position 1"
74
+ 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"
75
+ >
76
+ <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">
77
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
78
+ </svg>
79
+ <span class="text-[11px] font-medium">Add block</span>
80
+ </button>
31
81
  </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>
82
+ <div id="insert-slot-1" class="insert-slot-target my-3 hidden"></div>
43
83
  </div>
44
- <div id="insert-slot-1" class="insert-slot-target my-3 hidden"></div>
45
- </div>
84
+ <% end %>
46
85
 
47
86
  <% article_contents.each_with_index do |ac, index| %>
48
87
  <% content_type = ac.content %>
49
88
  <% fields = content_type ? content_type.normalized_fields : [] %>
89
+ <% convertible_targets = content_type ? content_type.convertible_targets(content_types) : [] %>
50
90
  <% is_new = (ac.id == newly_added_id) %>
51
91
  <% is_updated = (ac.id == updated_id) %>
52
92
  <div
@@ -56,8 +96,17 @@
56
96
  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
97
  >
58
98
  <!-- 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">
99
+ <div class="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-100 bg-zinc-50/70 <%= compact ? 'px-4 py-2' : 'px-5 py-3.5' %> rounded-t-xl">
60
100
  <div class="flex items-center gap-3">
101
+ <% if compact %>
102
+ <input
103
+ type="checkbox"
104
+ class="block-select-checkbox w-4 h-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-900 cursor-pointer"
105
+ data-id="<%= ac.id %>"
106
+ aria-label="Select block #<%= index + 1 %>"
107
+ />
108
+ <% end %>
109
+
61
110
  <!-- Drag Handle -->
62
111
  <div class="cursor-grab active:cursor-grabbing text-zinc-400 group-hover:text-zinc-600 transition-colors" title="Drag to reorder">
63
112
  <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
@@ -77,19 +126,62 @@
77
126
  <!-- Action Buttons -->
78
127
  <div class="flex items-center gap-1.5">
79
128
  <!-- 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 %>
129
+ <%= button_to move_article_article_content_path(article, ac, direction: "up", view: (compact ? "compact" : nil)), 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
130
  <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
82
131
  <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
83
132
  </svg>
84
133
  <% end %>
85
134
 
86
135
  <!-- 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 %>
136
+ <%= button_to move_article_article_content_path(article, ac, direction: "down", view: (compact ? "compact" : nil)), 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
137
  <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
89
138
  <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
90
139
  </svg>
91
140
  <% end %>
92
141
 
142
+ <% if convertible_targets.any? %>
143
+ <!-- Convert Dropdown -->
144
+ <div class="relative convert-dropdown-container" id="convert-dropdown-<%= ac.id %>">
145
+ <button
146
+ type="button"
147
+ onclick="toggleConvertMenu(<%= ac.id %>, event);"
148
+ aria-haspopup="true"
149
+ aria-expanded="false"
150
+ aria-controls="convert-menu-<%= ac.id %>"
151
+ class="convert-menu-trigger 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"
152
+ >
153
+ <svg class="w-3.5 h-3.5 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
154
+ <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
155
+ </svg>
156
+ Convert
157
+ <svg class="w-3 h-3 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
158
+ <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
159
+ </svg>
160
+ </button>
161
+
162
+ <div
163
+ id="convert-menu-<%= ac.id %>"
164
+ role="menu"
165
+ class="convert-dropdown-menu hidden absolute right-0 z-20 mt-1.5 min-w-44 origin-top-right rounded-lg border border-zinc-200 bg-white py-1 shadow-lg ring-1 ring-zinc-900/5"
166
+ >
167
+ <div class="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-zinc-400">
168
+ Convert to
169
+ </div>
170
+ <% convertible_targets.each do |target_ct| %>
171
+ <%= button_to convert_article_article_content_path(article, ac, target_content_id: target_ct.id, view: (compact ? "compact" : nil)),
172
+ method: :patch,
173
+ form: { class: "block w-full" },
174
+ class: "w-full text-left px-3 py-1.5 text-xs font-mono font-medium text-zinc-700 hover:bg-zinc-100 hover:text-zinc-900 flex items-center justify-between gap-2 transition-colors cursor-pointer" do %>
175
+ <span><%= target_ct.name %></span>
176
+ <svg class="w-3.5 h-3.5 text-zinc-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor">
177
+ <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
178
+ </svg>
179
+ <% end %>
180
+ <% end %>
181
+ </div>
182
+ </div>
183
+ <% end %>
184
+
93
185
  <!-- Edit Toggle Button -->
94
186
  <button
95
187
  type="button"
@@ -104,7 +196,7 @@
104
196
 
105
197
  <!-- Delete Block Button -->
106
198
  <% 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 %>
199
+ <%= button_to article_article_content_path(article, ac, view: (compact ? "compact" : nil)), method: :delete, 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
200
  <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
109
201
  <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
202
  </svg>
@@ -113,70 +205,125 @@
113
205
  </div>
114
206
 
115
207
  <!-- 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 latex]) ? '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 == 'latex' %>
135
- <div class="py-2 overflow-x-auto text-center" data-cms-latex="<%= fval %>">
136
- <span class="font-mono text-xs text-zinc-600"><%= fval %></span>
137
- </div>
138
- <% elsif fkind == 'text' %>
139
- <div class="whitespace-pre-wrap"><%= fval %></div>
140
- <% elsif fkind == 'file' && ac.files.attached? %>
141
- <div class="space-y-2">
142
- <% ac.files.each do |att| %>
143
- <div class="flex items-center gap-2.5">
144
- <% if att.image? %>
145
- <div class="w-10 h-10 rounded-md overflow-hidden bg-zinc-100 border border-zinc-200 shrink-0">
146
- <%= image_tag main_app.rails_blob_path(att), class: "w-full h-full object-cover" %>
147
- </div>
148
- <% else %>
149
- <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">
150
- <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
151
- <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" />
152
- </svg>
208
+ <% if compact %>
209
+ <div id="view-payload-<%= ac.id %>" class="px-4 py-2.5">
210
+ <% top_field = cms_top_field_definition(ac) %>
211
+ <% if top_field %>
212
+ <% fname = top_field["name"] %>
213
+ <% fkind = top_field["kind"] %>
214
+ <% fval = ac.payload[fname] %>
215
+ <div class="text-xs text-zinc-700">
216
+ <% if fkind == 'markdown' %>
217
+ <% plain = cms_strip_markdown(fval) %>
218
+ <% if plain.present? %>
219
+ <div class="line-clamp-2 break-words leading-relaxed font-sans" title="<%= plain %>"><%= plain %></div>
220
+ <% else %>
221
+ <span class="italic text-zinc-400">Empty</span>
222
+ <% end %>
223
+ <% elsif fkind == 'latex' %>
224
+ <% if fval.present? %>
225
+ <div class="font-mono text-[11px] text-zinc-800 line-clamp-2 break-words leading-relaxed" title="<%= fval %>"><%= fval %></div>
226
+ <% else %>
227
+ <span class="italic text-zinc-400">Empty</span>
228
+ <% end %>
229
+ <% elsif fkind == 'file' && ac.files.attached? %>
230
+ <% att = ac.files.first %>
231
+ <div class="flex items-center gap-2">
232
+ <% if att.image? %>
233
+ <div class="w-6 h-6 rounded overflow-hidden bg-zinc-100 border border-zinc-200 shrink-0">
234
+ <%= image_tag main_app.rails_blob_path(att), class: "w-full h-full object-cover" %>
235
+ </div>
236
+ <% else %>
237
+ <div class="w-6 h-6 rounded bg-zinc-100 border border-zinc-200 flex items-center justify-center shrink-0 text-zinc-500">
238
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
239
+ <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" />
240
+ </svg>
241
+ </div>
242
+ <% end %>
243
+ <span class="font-mono text-zinc-900 truncate max-w-xs"><%= att.filename %></span>
244
+ <span class="text-[10px] text-zinc-400">(<%= number_to_human_size(att.byte_size) %>)</span>
245
+ </div>
246
+ <% elsif fkind == 'color' && fval.present? %>
247
+ <div class="flex items-center gap-1.5 font-mono text-zinc-800">
248
+ <span class="w-3.5 h-3.5 rounded-full border border-zinc-300 shrink-0" style="background-color: <%= fval %>;"></span>
249
+ <span><%= fval %></span>
250
+ </div>
251
+ <% elsif fval.present? %>
252
+ <div class="line-clamp-2 break-words leading-relaxed font-sans" title="<%= fval %>"><%= fval %></div>
253
+ <% else %>
254
+ <span class="italic text-zinc-400">Empty</span>
255
+ <% end %>
256
+ </div>
257
+ <% else %>
258
+ <p class="text-xs text-zinc-400 italic">No fields defined for this content type.</p>
259
+ <% end %>
260
+ </div>
261
+ <% else %>
262
+ <div id="view-payload-<%= ac.id %>" class="p-5">
263
+ <% if fields.any? %>
264
+ <dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
265
+ <% fields.each do |f| %>
266
+ <% fname = f["name"] %>
267
+ <% fkind = f["kind"] %>
268
+ <% fval = ac.payload[fname] %>
269
+ <div class="<%= fkind.in?(%w[text markdown latex]) ? 'sm:col-span-2' : '' %>">
270
+ <dt class="text-xs font-mono font-medium text-zinc-500 uppercase tracking-wide">
271
+ <%= fname %>
272
+ <span class="text-[10px] text-zinc-400 lowercase font-normal">(<%= fkind %>)</span>
273
+ </dt>
274
+ <dd class="mt-1 text-sm text-zinc-900 bg-zinc-50/80 rounded-lg p-2.5 border border-zinc-100 font-sans">
275
+ <% if fval.present? || ac.files.attached? %>
276
+ <% if fkind == 'markdown' %>
277
+ <div class="prose prose-zinc prose-sm max-w-none">
278
+ <%= cms_render_markdown(fval) %>
279
+ </div>
280
+ <% elsif fkind == 'latex' %>
281
+ <div class="py-2 overflow-x-auto text-center" data-cms-latex="<%= fval %>">
282
+ <span class="font-mono text-xs text-zinc-600"><%= fval %></span>
283
+ </div>
284
+ <% elsif fkind == 'text' %>
285
+ <div class="whitespace-pre-wrap"><%= fval %></div>
286
+ <% elsif fkind == 'file' && ac.files.attached? %>
287
+ <div class="space-y-2">
288
+ <% ac.files.each do |att| %>
289
+ <div class="flex items-center gap-2.5">
290
+ <% if att.image? %>
291
+ <div class="w-10 h-10 rounded-md overflow-hidden bg-zinc-100 border border-zinc-200 shrink-0">
292
+ <%= image_tag main_app.rails_blob_path(att), class: "w-full h-full object-cover" %>
293
+ </div>
294
+ <% else %>
295
+ <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">
296
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
297
+ <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" />
298
+ </svg>
299
+ </div>
300
+ <% end %>
301
+ <div class="min-w-0">
302
+ <p class="text-xs font-medium font-mono text-zinc-900 truncate"><%= att.filename %></p>
303
+ <p class="text-[10px] text-zinc-400"><%= number_to_human_size(att.byte_size) %></p>
153
304
  </div>
154
- <% end %>
155
- <div class="min-w-0">
156
- <p class="text-xs font-medium font-mono text-zinc-900 truncate"><%= att.filename %></p>
157
- <p class="text-[10px] text-zinc-400"><%= number_to_human_size(att.byte_size) %></p>
158
305
  </div>
159
- </div>
160
- <% end %>
161
- </div>
306
+ <% end %>
307
+ </div>
308
+ <% else %>
309
+ <span><%= fval %></span>
310
+ <% end %>
162
311
  <% else %>
163
- <span><%= fval %></span>
312
+ <span class="italic text-zinc-400 text-xs">Empty</span>
164
313
  <% end %>
165
- <% else %>
166
- <span class="italic text-zinc-400 text-xs">Empty</span>
167
- <% end %>
168
- </dd>
169
- </div>
170
- <% end %>
171
- </dl>
172
- <% else %>
173
- <p class="text-xs text-zinc-500 italic">No fields defined for this content type.</p>
174
- <% end %>
175
- </div>
314
+ </dd>
315
+ </div>
316
+ <% end %>
317
+ </dl>
318
+ <% else %>
319
+ <p class="text-xs text-zinc-500 italic">No fields defined for this content type.</p>
320
+ <% end %>
321
+ </div>
322
+ <% end %>
176
323
 
177
324
  <!-- Inline Edit Form -->
178
325
  <div id="edit-form-<%= ac.id %>" class="hidden p-5 border-t border-zinc-100 bg-zinc-50/30">
179
- <%= form_with(model: [article, ac], multipart: true, class: "space-y-4") do |f| %>
326
+ <%= form_with(model: [article, ac], url: article_article_content_path(article, ac, view: (compact ? "compact" : nil)), multipart: true, class: "space-y-4") do |f| %>
180
327
  <div class="space-y-4">
181
328
  <% fields.each do |field_def| %>
182
329
  <% fname = field_def["name"] %>
@@ -203,26 +350,28 @@
203
350
  </div>
204
351
  </div>
205
352
 
206
- <!-- Inserter after block #<%= index + 1 %> (Position <%= index + 2 %>) -->
207
- <div class="block-inserter-zone py-0.5" data-position="<%= index + 2 %>" data-block-id="<%= ac.id %>">
208
- <div class="block-inserter-divider relative py-0.5 flex items-center justify-center group">
209
- <div class="absolute inset-0 flex items-center" aria-hidden="true">
210
- <div class="w-full border-t border-dashed border-zinc-200 group-hover:border-zinc-300 transition-colors"></div>
353
+ <% unless compact %>
354
+ <!-- Inserter after block #<%= index + 1 %> (Position <%= index + 2 %>) -->
355
+ <div class="block-inserter-zone py-0.5" data-position="<%= index + 2 %>" data-block-id="<%= ac.id %>">
356
+ <div class="block-inserter-divider relative py-0.5 flex items-center justify-center group">
357
+ <div class="absolute inset-0 flex items-center" aria-hidden="true">
358
+ <div class="w-full border-t border-dashed border-zinc-200 group-hover:border-zinc-300 transition-colors"></div>
359
+ </div>
360
+ <button
361
+ type="button"
362
+ onclick="toggleInsertForm(<%= index + 2 %>, this);"
363
+ aria-label="Add block at position <%= index + 2 %>"
364
+ 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"
365
+ >
366
+ <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">
367
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
368
+ </svg>
369
+ <span class="text-[11px] font-medium">Add block</span>
370
+ </button>
211
371
  </div>
212
- <button
213
- type="button"
214
- onclick="toggleInsertForm(<%= index + 2 %>, this);"
215
- aria-label="Add block at position <%= index + 2 %>"
216
- 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"
217
- >
218
- <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">
219
- <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
220
- </svg>
221
- <span class="text-[11px] font-medium">Add block</span>
222
- </button>
372
+ <div id="insert-slot-<%= index + 2 %>" class="insert-slot-target my-3 hidden"></div>
223
373
  </div>
224
- <div id="insert-slot-<%= index + 2 %>" class="insert-slot-target my-3 hidden"></div>
225
- </div>
374
+ <% end %>
226
375
  <% end %>
227
376
  <% else %>
228
377
  <div class="rounded-xl border border-dashed border-zinc-300 bg-white p-8 text-center">
@@ -249,100 +398,102 @@
249
398
  <% end %>
250
399
  </div>
251
400
 
252
- <!-- Reusable In-Place Add Content Block Card (hidden staging storage) -->
253
- <div id="form-dock-storage" class="hidden">
254
- <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">
255
- <div class="px-6 py-4 border-b border-zinc-200 bg-zinc-50 flex items-center justify-between">
256
- <div class="flex items-center gap-2.5">
257
- <div class="w-6 h-6 rounded-md bg-zinc-900 text-white flex items-center justify-center shrink-0">
258
- <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
259
- <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
260
- </svg>
261
- </div>
262
- <div>
263
- <div class="flex items-center gap-2">
264
- <h3 class="text-sm font-semibold text-zinc-900">Add Content Block</h3>
265
- <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">
266
- Position #1
267
- </span>
401
+ <% unless compact %>
402
+ <!-- Reusable In-Place Add Content Block Card (hidden staging storage) -->
403
+ <div id="form-dock-storage" class="hidden">
404
+ <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">
405
+ <div class="px-6 py-4 border-b border-zinc-200 bg-zinc-50 flex items-center justify-between">
406
+ <div class="flex items-center gap-2.5">
407
+ <div class="w-6 h-6 rounded-md bg-zinc-900 text-white flex items-center justify-center shrink-0">
408
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
409
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
410
+ </svg>
268
411
  </div>
269
- <p class="text-xs text-zinc-500 mt-0.5">Select a Content Type and populate its fields.</p>
270
- </div>
271
- </div>
272
- <button
273
- type="button"
274
- onclick="closeInsertForm();"
275
- class="text-zinc-400 hover:text-zinc-700 transition-colors rounded-lg p-1 hover:bg-zinc-100 cursor-pointer"
276
- title="Close (Esc)"
277
- >
278
- <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
279
- <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
280
- </svg>
281
- </button>
282
- </div>
283
-
284
- <div class="p-6">
285
- <% if content_types.any? %>
286
- <div class="space-y-4">
287
412
  <div>
288
- <label for="new_content_type_select" class="block text-xs font-semibold text-zinc-900 mb-1.5">
289
- Select Content Type
290
- </label>
291
- <select
292
- id="new_content_type_select"
293
- 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"
294
- onchange="switchNewContentType(this.value);"
295
- >
296
- <option value="">-- Choose a Content Type --</option>
297
- <% content_types.each do |ct| %>
298
- <option value="<%= ct.id %>"><%= ct.name %> (<%= pluralize(ct.normalized_fields.size, "field") %>)</option>
299
- <% end %>
300
- </select>
413
+ <div class="flex items-center gap-2">
414
+ <h3 class="text-sm font-semibold text-zinc-900">Add Content Block</h3>
415
+ <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">
416
+ Position #1
417
+ </span>
418
+ </div>
419
+ <p class="text-xs text-zinc-500 mt-0.5">Select a Content Type and populate its fields.</p>
301
420
  </div>
421
+ </div>
422
+ <button
423
+ type="button"
424
+ onclick="closeInsertForm();"
425
+ class="text-zinc-400 hover:text-zinc-700 transition-colors rounded-lg p-1 hover:bg-zinc-100 cursor-pointer"
426
+ title="Close (Esc)"
427
+ >
428
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
429
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
430
+ </svg>
431
+ </button>
432
+ </div>
302
433
 
303
- <!-- Dynamic Form per Content Type -->
304
- <% content_types.each do |ct| %>
305
- <% ct_fields = ct.normalized_fields %>
306
- <div id="new-content-type-form-<%= ct.id %>" class="new-content-type-form hidden pt-4 border-t border-zinc-100">
307
- <%= form_with(url: article_article_contents_path(article), scope: :article_content, multipart: true, class: "space-y-4") do |f| %>
308
- <%= f.hidden_field :content_id, value: ct.id %>
309
- <%= f.hidden_field :insert_at, value: 1, class: "insert-at-field" %>
310
-
311
- <div class="space-y-4">
312
- <% ct_fields.each do |field_def| %>
313
- <% fname = field_def["name"] %>
314
- <%= cms_render_field(
315
- field_def,
316
- value: nil,
317
- input_name: "article_content[payload][#{fname}]",
318
- input_id: "new_content_#{ct.id}_payload_#{fname}"
319
- ) %>
320
- <% end %>
321
- </div>
322
-
323
- <div class="flex items-center gap-3 pt-2">
324
- <%= 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" %>
325
- <button
326
- type="button"
327
- onclick="closeInsertForm();"
328
- 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"
329
- >
330
- Cancel
331
- </button>
332
- </div>
333
- <% end %>
434
+ <div class="p-6">
435
+ <% if content_types.any? %>
436
+ <div class="space-y-4">
437
+ <div>
438
+ <label for="new_content_type_select" class="block text-xs font-semibold text-zinc-900 mb-1.5">
439
+ Select Content Type
440
+ </label>
441
+ <select
442
+ id="new_content_type_select"
443
+ 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"
444
+ onchange="switchNewContentType(this.value);"
445
+ >
446
+ <option value="">-- Choose a Content Type --</option>
447
+ <% content_types.each do |ct| %>
448
+ <option value="<%= ct.id %>"><%= ct.name %> (<%= pluralize(ct.normalized_fields.size, "field") %>)</option>
449
+ <% end %>
450
+ </select>
334
451
  </div>
335
- <% end %>
336
- </div>
337
- <% else %>
338
- <div class="text-center py-4">
339
- <p class="text-sm text-zinc-500">No content types defined yet.</p>
340
- <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>
341
- </div>
342
- <% end %>
452
+
453
+ <!-- Dynamic Form per Content Type -->
454
+ <% content_types.each do |ct| %>
455
+ <% ct_fields = ct.normalized_fields %>
456
+ <div id="new-content-type-form-<%= ct.id %>" class="new-content-type-form hidden pt-4 border-t border-zinc-100">
457
+ <%= form_with(url: article_article_contents_path(article, view: (compact ? "compact" : nil)), scope: :article_content, multipart: true, class: "space-y-4") do |f| %>
458
+ <%= f.hidden_field :content_id, value: ct.id %>
459
+ <%= f.hidden_field :insert_at, value: 1, class: "insert-at-field" %>
460
+
461
+ <div class="space-y-4">
462
+ <% ct_fields.each do |field_def| %>
463
+ <% fname = field_def["name"] %>
464
+ <%= cms_render_field(
465
+ field_def,
466
+ value: nil,
467
+ input_name: "article_content[payload][#{fname}]",
468
+ input_id: "new_content_#{ct.id}_payload_#{fname}"
469
+ ) %>
470
+ <% end %>
471
+ </div>
472
+
473
+ <div class="flex items-center gap-3 pt-2">
474
+ <%= 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" %>
475
+ <button
476
+ type="button"
477
+ onclick="closeInsertForm();"
478
+ 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"
479
+ >
480
+ Cancel
481
+ </button>
482
+ </div>
483
+ <% end %>
484
+ </div>
485
+ <% end %>
486
+ </div>
487
+ <% else %>
488
+ <div class="text-center py-4">
489
+ <p class="text-sm text-zinc-500">No content types defined yet.</p>
490
+ <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>
491
+ </div>
492
+ <% end %>
493
+ </div>
343
494
  </div>
344
495
  </div>
345
- </div>
496
+ <% end %>
346
497
  </div>
347
498
 
348
499
  <script>
@@ -420,10 +571,55 @@
420
571
  window.activeInsertPosition = null;
421
572
  };
422
573
 
574
+ window.closeAllConvertMenus = function(exceptId) {
575
+ document.querySelectorAll('.convert-dropdown-menu').forEach(function(menu) {
576
+ if (exceptId && menu.id === 'convert-menu-' + exceptId) return;
577
+ menu.classList.add('hidden');
578
+ var container = menu.closest('.convert-dropdown-container');
579
+ if (container) {
580
+ var btn = container.querySelector('.convert-menu-trigger');
581
+ if (btn) btn.setAttribute('aria-expanded', 'false');
582
+ }
583
+ });
584
+ };
585
+
586
+ window.toggleConvertMenu = function(blockId, event) {
587
+ if (event) event.stopPropagation();
588
+ var menu = document.getElementById('convert-menu-' + blockId);
589
+ if (!menu) return;
590
+
591
+ var willOpen = menu.classList.contains('hidden');
592
+ window.closeAllConvertMenus(blockId);
593
+
594
+ if (willOpen) {
595
+ menu.classList.remove('hidden');
596
+ var container = menu.closest('.convert-dropdown-container');
597
+ if (container) {
598
+ var btn = container.querySelector('.convert-menu-trigger');
599
+ if (btn) btn.setAttribute('aria-expanded', 'true');
600
+ }
601
+ } else {
602
+ menu.classList.add('hidden');
603
+ var container = menu.closest('.convert-dropdown-container');
604
+ if (container) {
605
+ var btn = container.querySelector('.convert-menu-trigger');
606
+ if (btn) btn.setAttribute('aria-expanded', 'false');
607
+ }
608
+ }
609
+ };
610
+
423
611
  if (!window._macronEscapeBound) {
424
612
  document.addEventListener('keydown', function(e) {
425
- if (e.key === 'Escape' && window.activeInsertPosition !== null) {
426
- window.closeInsertForm();
613
+ if (e.key === 'Escape') {
614
+ if (window.activeInsertPosition !== null) {
615
+ window.closeInsertForm();
616
+ }
617
+ window.closeAllConvertMenus();
618
+ }
619
+ });
620
+ document.addEventListener('click', function(e) {
621
+ if (!e.target.closest('.convert-dropdown-container')) {
622
+ window.closeAllConvertMenus();
427
623
  }
428
624
  });
429
625
  window._macronEscapeBound = true;
@@ -436,39 +632,136 @@
436
632
  });
437
633
  }, 2500);
438
634
 
635
+ // Multi-Selection State & Handlers
636
+ window.lastCheckedBlockIndex = null;
637
+
638
+ window.deselectAllBlocks = function() {
639
+ var checkboxes = document.querySelectorAll('.block-select-checkbox');
640
+ checkboxes.forEach(function(cb) {
641
+ cb.checked = false;
642
+ var card = cb.closest('.content-block-item');
643
+ if (card) {
644
+ card.classList.remove('ring-2', 'ring-zinc-900', 'bg-zinc-50/70');
645
+ }
646
+ });
647
+ window.lastCheckedBlockIndex = null;
648
+ window.updateBlockSelectionState();
649
+ };
650
+
651
+ window.updateBlockSelectionState = function() {
652
+ var checkboxes = document.querySelectorAll('.block-select-checkbox');
653
+ var selectedCount = 0;
654
+ checkboxes.forEach(function(cb) {
655
+ var card = cb.closest('.content-block-item');
656
+ if (cb.checked) {
657
+ selectedCount++;
658
+ if (card) {
659
+ card.classList.add('ring-2', 'ring-zinc-900', 'bg-zinc-50/70');
660
+ }
661
+ } else if (card) {
662
+ card.classList.remove('ring-2', 'ring-zinc-900', 'bg-zinc-50/70');
663
+ }
664
+ });
665
+
666
+ var badge = document.getElementById('selection-status-badge');
667
+ var countEl = document.getElementById('selected-count');
668
+ if (badge && countEl) {
669
+ countEl.textContent = selectedCount;
670
+ if (selectedCount > 0) {
671
+ badge.classList.remove('hidden');
672
+ } else {
673
+ badge.classList.add('hidden');
674
+ }
675
+ }
676
+ };
677
+
678
+ (function initCheckboxListeners() {
679
+ var container = document.getElementById('content-blocks-list');
680
+ if (!container) return;
681
+
682
+ container.addEventListener('click', function(e) {
683
+ var cb = e.target.closest('.block-select-checkbox');
684
+ if (!cb) return;
685
+
686
+ var allCheckboxes = Array.from(container.querySelectorAll('.block-select-checkbox'));
687
+ var currentIndex = allCheckboxes.indexOf(cb);
688
+
689
+ if (e.shiftKey && window.lastCheckedBlockIndex !== null && window.lastCheckedBlockIndex !== currentIndex) {
690
+ var start = Math.min(window.lastCheckedBlockIndex, currentIndex);
691
+ var end = Math.max(window.lastCheckedBlockIndex, currentIndex);
692
+ var state = cb.checked;
693
+ for (var i = start; i <= end; i++) {
694
+ allCheckboxes[i].checked = state;
695
+ }
696
+ }
697
+
698
+ window.lastCheckedBlockIndex = currentIndex;
699
+ window.updateBlockSelectionState();
700
+ });
701
+ })();
702
+
439
703
  // Initialize Drag & Drop
440
704
  (function initDragAndDrop() {
441
705
  var container = document.getElementById('content-blocks-list');
442
706
  if (!container) return;
443
707
 
444
- var draggedItem = null;
708
+ var draggedItems = [];
445
709
 
446
710
  container.addEventListener('dragstart', function(e) {
447
711
  var item = e.target.closest('.content-block-item');
448
712
  if (!item) return;
449
713
  window.closeInsertForm();
450
- draggedItem = item;
714
+ window.closeAllConvertMenus();
715
+
716
+ var cb = item.querySelector('.block-select-checkbox');
717
+ var allCards = Array.from(container.querySelectorAll('.content-block-item'));
718
+
719
+ if (cb && cb.checked) {
720
+ draggedItems = allCards.filter(function(card) {
721
+ var cardCb = card.querySelector('.block-select-checkbox');
722
+ return cardCb && cardCb.checked;
723
+ });
724
+ } else {
725
+ draggedItems = [item];
726
+ }
727
+
451
728
  e.dataTransfer.effectAllowed = 'move';
452
- item.classList.add('opacity-50', 'border-zinc-400');
729
+ draggedItems.forEach(function(dItem) {
730
+ dItem.classList.add('opacity-50', 'border-zinc-400');
731
+ });
453
732
  });
454
733
 
455
734
  container.addEventListener('dragend', function(e) {
456
- var item = e.target.closest('.content-block-item');
457
- if (item) {
458
- item.classList.remove('opacity-50', 'border-zinc-400');
735
+ if (draggedItems && draggedItems.length > 0) {
736
+ draggedItems.forEach(function(dItem) {
737
+ dItem.classList.remove('opacity-50', 'border-zinc-400');
738
+ });
459
739
  }
460
- draggedItem = null;
740
+ draggedItems = [];
461
741
  sendNewOrder();
462
742
  });
463
743
 
464
744
  container.addEventListener('dragover', function(e) {
465
745
  e.preventDefault();
466
746
  e.dataTransfer.dropEffect = 'move';
747
+
748
+ if (!draggedItems || draggedItems.length === 0) return;
749
+
467
750
  var targetItem = e.target.closest('.content-block-item');
468
- if (targetItem && targetItem !== draggedItem) {
751
+ if (targetItem && draggedItems.indexOf(targetItem) === -1) {
469
752
  var rect = targetItem.getBoundingClientRect();
470
753
  var next = (e.clientY - rect.top) / (rect.bottom - rect.top) > 0.5;
471
- container.insertBefore(draggedItem, next ? targetItem.nextSibling : targetItem);
754
+
755
+ if (next) {
756
+ var ref = targetItem.nextSibling;
757
+ draggedItems.forEach(function(dItem) {
758
+ container.insertBefore(dItem, ref);
759
+ });
760
+ } else {
761
+ draggedItems.forEach(function(dItem) {
762
+ container.insertBefore(dItem, targetItem);
763
+ });
764
+ }
472
765
  }
473
766
  });
474
767
 
@@ -502,6 +795,9 @@
502
795
  }
503
796
 
504
797
  function refreshInserterSlots() {
798
+ var allZones = container.querySelectorAll('.block-inserter-zone');
799
+ if (!allZones || allZones.length === 0) return;
800
+
505
801
  var items = container.querySelectorAll('.content-block-item');
506
802
  var topZone = document.getElementById('inserter-zone-top');
507
803
  if (topZone && container.firstElementChild !== topZone) {
@@ -516,7 +812,6 @@
516
812
  }
517
813
  });
518
814
 
519
- var allZones = container.querySelectorAll('.block-inserter-zone');
520
815
  allZones.forEach(function(zone, idx) {
521
816
  var pos = idx + 1;
522
817
  zone.setAttribute('data-position', pos);