spina-blocks 0.3.0 → 0.4.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +126 -8
  3. data/app/assets/javascripts/spina/controllers/block_collection_controller.js +155 -42
  4. data/app/assets/javascripts/spina/controllers/content_blocks_controller.js +118 -0
  5. data/app/assets/javascripts/spina/controllers/nested_content_blocks_controller.js +4 -0
  6. data/app/assets/javascripts/spina/utils/dropdown_position.js +30 -0
  7. data/app/controllers/spina/blocks/admin/blocks_controller.rb +61 -9
  8. data/app/controllers/spina/blocks/admin/page_blocks_controller.rb +7 -0
  9. data/app/helpers/spina/blocks/blocks_helper.rb +20 -4
  10. data/app/models/concerns/spina/parts/block_filterable.rb +18 -0
  11. data/app/models/spina/blocks/account_extension.rb +32 -0
  12. data/app/models/spina/blocks/block.rb +27 -1
  13. data/app/models/spina/parts/block_collection.rb +2 -0
  14. data/app/models/spina/parts/block_reference.rb +21 -1
  15. data/app/models/spina/parts/content_block.rb +18 -0
  16. data/app/models/spina/parts/content_blocks.rb +25 -0
  17. data/app/models/spina/parts/nested_content_blocks.rb +15 -0
  18. data/app/views/spina/admin/parts/block_collections/_form.html.erb +33 -21
  19. data/app/views/spina/admin/parts/block_references/_form.html.erb +16 -5
  20. data/app/views/spina/admin/parts/content_blocks/_fields.html.erb +57 -0
  21. data/app/views/spina/admin/parts/content_blocks/_form.html.erb +73 -0
  22. data/app/views/spina/admin/parts/nested_content_blocks/_form.html.erb +75 -0
  23. data/app/views/spina/blocks/admin/blocks/_form.html.erb +9 -5
  24. data/app/views/spina/blocks/admin/blocks/_form_block_settings.html.erb +12 -0
  25. data/app/views/spina/blocks/admin/blocks/_new_block_modal_form.html.erb +42 -0
  26. data/app/views/spina/blocks/admin/blocks/index.html.erb +30 -5
  27. data/app/views/spina/blocks/admin/blocks/new.html.erb +5 -1
  28. data/app/views/spina/blocks/admin/page_blocks/index.html.erb +14 -8
  29. data/config/locales/en.yml +22 -0
  30. data/config/routes.rb +1 -0
  31. data/db/migrate/6_add_custom_block_fields_to_spina_blocks_blocks.rb +14 -0
  32. data/lib/spina/blocks/engine.rb +15 -2
  33. data/lib/spina/blocks/version.rb +1 -1
  34. metadata +13 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76633f69018375e8d330eee4b062677dd4dc10bd380ec2e7b08f3900fd448540
4
- data.tar.gz: dd386042f84a6554db3eb15200011e4a953f3559054a44f05caa0e052b10fa3b
3
+ metadata.gz: 24a864994819ca4dd7ee93b0cd6957d008d08586659ad9672654014b2add6961
4
+ data.tar.gz: 264aca6e16771526496af183ef5443091dc4ae70ea5f550bc0e29f0e9c5d1d5a
5
5
  SHA512:
6
- metadata.gz: 19b5d1d156f66e85fb34aa6d2240d0f23cd0ffdb973601e2a4a1f3eca8c9058d39332c33bc986b52a69519b7eee22c3e39c6b713c6aadc9ccb5bdb93abaf5e3e
7
- data.tar.gz: 7442845e4527e7dcfc8e429771b957638b89c053ae6b20767393756429000908af24337853d5899b9af5d06298efd492f53316a1db91712e85d9321f690647f2
6
+ metadata.gz: 297624cd733819fbdb489b8a6cfe6a5290959eac521aa11117fbf11aa4b50f999c9b4fccba3c3821e7d16a679b5023943b8bd2470f8a9f114529591a4bdfa899
7
+ data.tar.gz: 448f9470f24ae571bb6ac2f95aa882bca7b6504c968d5369d591b8e8a1d53711f6fc0d60becb2a793c228e0768b13ab63fc4ea3eb7add2bbf883b864d2fc42af
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Spina Blocks
2
2
 
3
- > ⚠️ **Warning**: This plugin is not production-ready and may cause issues. Use at your own risk.
3
+ > **Warning**: This plugin is not production-ready and may cause issues. Use at your own risk.
4
4
 
5
5
  A plugin for [Spina CMS](https://www.spinacms.com) that adds reusable block components. Blocks are independent content units with their own templates and fields that can be assembled into pages.
6
6
 
@@ -72,6 +72,12 @@ Spina::Theme.register do |theme|
72
72
  }
73
73
  ]
74
74
 
75
+ # Custom blocks (layout blocks, created automatically during bootstrap)
76
+ theme.custom_blocks = [
77
+ { name: "header", title: "Header Block", block_template: "header", category: "heroes" },
78
+ { name: "footer", title: "Footer Block", block_template: "footer", category: "cta" }
79
+ ]
80
+
75
81
  # Page templates (can use BlockCollection or BlockReference parts)
76
82
  theme.view_templates = [
77
83
  {
@@ -119,6 +125,24 @@ theme.parts = [
119
125
  ]
120
126
  ```
121
127
 
128
+ #### Filtering blocks by template
129
+
130
+ By default, `BlockReference` and `BlockCollection` show all active blocks in their selects. To limit the list to blocks of a specific template, pass `block_template` in the part's `options`:
131
+
132
+ ```ruby
133
+ theme.parts = [
134
+ { name: "hero_block", title: "Hero Block",
135
+ part_type: "Spina::Parts::BlockReference",
136
+ options: { block_template: "hero" } },
137
+
138
+ { name: "sidebar_blocks", title: "Sidebar Blocks",
139
+ part_type: "Spina::Parts::BlockCollection",
140
+ options: { block_template: "sidebar_widget" } }
141
+ ]
142
+ ```
143
+
144
+ With this configuration, the "Hero Block" select will only show blocks created with the `hero` template, and "Sidebar Blocks" will only show `sidebar_widget` blocks. If `options` is omitted or does not contain `block_template`, all active blocks are shown (the default behavior).
145
+
122
146
  Then in your template:
123
147
 
124
148
  ```erb
@@ -131,6 +155,94 @@ Then in your template:
131
155
  <% end %>
132
156
  ```
133
157
 
158
+ ## Custom blocks (layout blocks)
159
+
160
+ Custom blocks are non-deletable blocks that are automatically created during `rake spina:bootstrap`. They work similarly to Spina's `custom_pages` — once defined in the theme configuration, they are created on bootstrap and cannot be deleted through the admin interface. In the admin UI they are labeled as **Layout** blocks.
161
+
162
+ ### Defining custom blocks
163
+
164
+ Add `custom_blocks` to your theme initializer:
165
+
166
+ ```ruby
167
+ theme.custom_blocks = [
168
+ { name: "header", title: "Header Block", block_template: "header", category: "heroes" },
169
+ { name: "footer", title: "Footer Block", block_template: "footer", category: "cta" }
170
+ ]
171
+ ```
172
+
173
+ Each custom block accepts:
174
+
175
+ | Key | Description |
176
+ | ---------------- | ------------------------------------------------------------------------ |
177
+ | `name` | Immutable machine key (used for lookups in code and templates) |
178
+ | `title` | (Optional) Initial display name shown in admin. Defaults to `name.titleize`. Editable by users after creation |
179
+ | `block_template` | Must match a template defined in `theme.block_templates` |
180
+ | `category` | (Optional) Name of a category from `theme.block_categories` |
181
+
182
+ ### Key vs Name
183
+
184
+ Custom blocks have two identifiers:
185
+
186
+ - **`key`** — an immutable machine identifier (set from `name` in the config). Used by `render_custom_block`, `BlockReference` with `custom_block` option, and for bootstrap lookups. Cannot be changed after creation. Shown as read-only in the block settings tab.
187
+ - **`name`** — the display name shown in the admin UI. Initially set from `title` (or `name.titleize` if `title` is omitted). Users can freely rename it without breaking any template references.
188
+
189
+ Regular (non-layout) blocks do not have a `key` — it is `nil` for them.
190
+
191
+ ### How it works
192
+
193
+ - **Bootstrap**: Custom blocks are created (or updated) automatically whenever `Spina::Account` is saved — this includes `rake spina:bootstrap` and admin account edits. They are created with `deletable: false` and an immutable `key`.
194
+ - **Idempotent**: Running bootstrap multiple times will not duplicate blocks. Existing blocks are found by `key` and updated. The display `name` is only set on initial creation — subsequent bootstraps preserve user edits to the display name.
195
+ - **Deletion protection**: Layout blocks cannot be deleted through the admin UI or programmatically via `destroy`. In the block editor, the `...` menu shows "Block can't be deleted" (matching Spina's page behavior). The controller also rejects delete requests.
196
+ - **Template restriction**: Block templates used by custom blocks are excluded from the "New block" form, preventing users from manually creating blocks with layout templates.
197
+ - **Admin UI**: Layout blocks are marked with a **Layout** badge (right-aligned) in the block library. A **Layout blocks** filter is available in the template filter dropdown. On page block lists, the remove button is replaced with a "Layout" label. The `key` is displayed as read-only in the block settings tab.
198
+
199
+ ### Using custom blocks in templates
200
+
201
+ #### Direct rendering by key
202
+
203
+ Use `render_custom_block` to render a system block directly in any template:
204
+
205
+ ```erb
206
+ <%# app/views/my_theme/pages/homepage.html.erb %>
207
+ <%= render_custom_block("header") %>
208
+
209
+ <main>
210
+ <%= render_blocks %>
211
+ </main>
212
+
213
+ <%= render_custom_block("footer") %>
214
+ ```
215
+
216
+ This looks up the block by its immutable `key` and renders it using the block's template partial, just like `render_block`. Renaming the block's display name in the admin will not affect this lookup.
217
+
218
+ #### As a pre-defined BlockReference
219
+
220
+ Use the `custom_block` option in a `BlockReference` part to hardcode which block it points to:
221
+
222
+ ```ruby
223
+ theme.parts = [
224
+ { name: "header_block", title: "Header",
225
+ part_type: "Spina::Parts::BlockReference",
226
+ options: { custom_block: "header" } },
227
+
228
+ { name: "footer_block", title: "Footer",
229
+ part_type: "Spina::Parts::BlockReference",
230
+ options: { custom_block: "footer" } }
231
+ ]
232
+ ```
233
+
234
+ When `custom_block` is set:
235
+ - The part's `content` method returns the block by `key` (ignoring `block_id`)
236
+ - In the admin form, instead of a dropdown, a clickable link to the block's edit page is shown
237
+ - If the custom block does not exist yet, a warning message is displayed
238
+
239
+ Then in your template:
240
+
241
+ ```erb
242
+ <%= render_block(content(:header_block)) %>
243
+ <%= render_block(content(:footer_block)) %>
244
+ ```
245
+
134
246
  ## Models
135
247
 
136
248
  | Model | Description |
@@ -144,18 +256,24 @@ Then in your template:
144
256
  The plugin adds:
145
257
 
146
258
  - **Blocks** link in the Content section of the admin sidebar
147
- - Block library with category tabs for filtering
259
+ - Block library with template filter dropdown
148
260
  - Block editor with content fields (same as page editor)
149
261
  - Page Blocks management page (per-page block assignment and ordering)
262
+ - **Layout** badge on non-deletable blocks in the block library (right-aligned)
263
+ - **Layout blocks** filter in the template filter dropdown
264
+ - `...` menu shows "Block can't be deleted" for layout blocks (like Spina pages)
265
+ - Block templates used by layout blocks are hidden from the "New block" form
266
+ - Immutable **key** shown as read-only in block settings for layout blocks
150
267
 
151
268
  ## Helper methods
152
269
 
153
- | Helper | Description |
154
- | --------------------------------------- | --------------------------------------------------- |
155
- | `render_blocks(page)` | Render all blocks attached to a page via PageBlocks |
156
- | `render_block(block)` | Render a single block using its template partial |
157
- | `block_content(block, :part_name)` | Access a block's content |
158
- | `block_has_content?(block, :part_name)` | Check if a block has content |
270
+ | Helper | Description |
271
+ | --------------------------------------- | ------------------------------------------------------ |
272
+ | `render_blocks(page)` | Render all blocks attached to a page via PageBlocks |
273
+ | `render_block(block)` | Render a single block using its template partial |
274
+ | `render_custom_block(key)` | Render a custom (layout) block by its immutable key |
275
+ | `block_content(block, :part_name)` | Access a block's content |
276
+ | `block_has_content?(block, :part_name)` | Check if a block has content |
159
277
 
160
278
  ## License
161
279
 
@@ -1,18 +1,28 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
  import Sortable from "libraries/sortablejs";
3
+ import { positionDropdown } from "spina/utils/dropdown_position";
3
4
 
4
5
  export default class extends Controller {
6
+ #abortController = null;
7
+ #modalWasOpen = false;
8
+ #modalObserver = null;
9
+
10
+ get #searchQuery() {
11
+ return this.hasSearchInputTarget ? this.searchInputTarget.value : "";
12
+ }
13
+
5
14
  static get targets() {
6
15
  return [
7
16
  "list",
8
17
  "hiddenFields",
9
18
  "dropdown",
10
- "addButton",
19
+ "searchInput",
11
20
  "emptyMessage",
12
21
  "listItemTemplate",
13
22
  "groupHeaderTemplate",
14
23
  "dropdownOptionTemplate",
15
24
  "dropdownEmptyTemplate",
25
+ "newBlockTemplate",
16
26
  ];
17
27
  }
18
28
 
@@ -21,6 +31,8 @@ export default class extends Controller {
21
31
  blocks: Array, // [{id, name, templateName, templateTitle}]
22
32
  selectedIds: Array, // [id, id, ...]
23
33
  editUrl: String, // base edit_modal URL with __ID__ placeholder
34
+ newUrl: String, // URL for new block modal
35
+ blocksDataUrl: String, // JSON endpoint to refresh blocks list
24
36
  };
25
37
  }
26
38
 
@@ -36,10 +48,17 @@ export default class extends Controller {
36
48
  onEnd: this.reorderHiddenFields.bind(this),
37
49
  });
38
50
  this.render();
51
+ this.#observeModal();
39
52
  }
40
53
 
41
54
  disconnect() {
42
55
  if (this.sortable) this.sortable.destroy();
56
+ if (this.#modalObserver) {
57
+ this.#modalObserver.disconnect();
58
+ this.#modalObserver = null;
59
+ }
60
+ this.#abortController?.abort();
61
+ this.#abortController = null;
43
62
  }
44
63
 
45
64
  // --- Actions ---
@@ -50,6 +69,9 @@ export default class extends Controller {
50
69
  if (this.selectedIdsValue.indexOf(id) !== -1) return;
51
70
 
52
71
  this.selectedIdsValue = this.selectedIdsValue.concat([id]);
72
+ if (this.hasSearchInputTarget) {
73
+ this.searchInputTarget.value = "";
74
+ }
53
75
  this.render();
54
76
  this.closeDropdown();
55
77
  }
@@ -61,19 +83,23 @@ export default class extends Controller {
61
83
  this.render();
62
84
  }
63
85
 
64
- toggleDropdown(event) {
65
- event.preventDefault();
66
- event.stopPropagation();
67
- const dropdown = this.dropdownTarget;
68
- if (dropdown.style.display === "none" || dropdown.style.display === "") {
69
- this.openDropdown();
70
- } else {
71
- this.closeDropdown();
72
- }
86
+ onSearchInput() {
87
+ this.renderDropdown();
88
+ this.openDropdown();
89
+ }
90
+
91
+ onSearchFocus() {
92
+ this.renderDropdown();
93
+ this.openDropdown();
73
94
  }
74
95
 
75
96
  closeOnOutsideClick(event) {
76
- if (!this.element.contains(event.target)) {
97
+ const clickedInsideSearch =
98
+ this.hasSearchInputTarget &&
99
+ this.searchInputTarget.contains(event.target);
100
+ const clickedInsideDropdown = this.dropdownTarget.contains(event.target);
101
+
102
+ if (!clickedInsideSearch && !clickedInsideDropdown) {
77
103
  this.closeDropdown();
78
104
  }
79
105
  }
@@ -118,43 +144,74 @@ export default class extends Controller {
118
144
  }
119
145
 
120
146
  renderDropdown() {
121
- const availableBlocks = this.blocksValue.filter((b) => {
147
+ let availableBlocks = this.blocksValue.filter((b) => {
122
148
  return this.selectedIdsValue.indexOf(b.id) === -1;
123
149
  });
124
150
 
151
+ // Filter by search query
152
+ const query = this.#searchQuery.toLowerCase().trim();
153
+ if (query) {
154
+ availableBlocks = availableBlocks.filter((b) => {
155
+ return (
156
+ b.name.toLowerCase().includes(query) ||
157
+ (b.templateTitle && b.templateTitle.toLowerCase().includes(query)) ||
158
+ (b.templateName && b.templateName.toLowerCase().includes(query))
159
+ );
160
+ });
161
+ }
162
+
125
163
  this.dropdownTarget.innerHTML = "";
126
164
 
127
165
  if (availableBlocks.length === 0) {
128
166
  const empty = this.dropdownEmptyTemplateTarget.content.cloneNode(true);
129
167
  this.dropdownTarget.appendChild(empty);
130
- return;
131
- }
168
+ } else {
169
+ // Group by templateTitle
170
+ const groups = {};
171
+ availableBlocks.forEach((b) => {
172
+ const key = b.templateTitle || b.templateName || "Other";
173
+ if (!groups[key]) groups[key] = [];
174
+ groups[key].push(b);
175
+ });
132
176
 
133
- // Group by templateTitle
134
- const groups = {};
135
- availableBlocks.forEach((b) => {
136
- const key = b.templateTitle || b.templateName || "Other";
137
- if (!groups[key]) groups[key] = [];
138
- groups[key].push(b);
139
- });
177
+ Object.keys(groups)
178
+ .sort()
179
+ .forEach((groupName) => {
180
+ const header = this.groupHeaderTemplateTarget.content.cloneNode(true);
181
+ header.querySelector("[data-role='group-name']").textContent =
182
+ groupName;
183
+ this.dropdownTarget.appendChild(header);
140
184
 
141
- Object.keys(groups)
142
- .sort()
143
- .forEach((groupName) => {
144
- const header = this.groupHeaderTemplateTarget.content.cloneNode(true);
145
- header.querySelector("[data-role='group-name']").textContent =
146
- groupName;
147
- this.dropdownTarget.appendChild(header);
148
-
149
- groups[groupName].forEach((block) => {
150
- const option =
151
- this.dropdownOptionTemplateTarget.content.cloneNode(true);
152
- const button = option.querySelector("button");
153
- button.dataset.blockId = block.id;
154
- option.querySelector("[data-role='title']").textContent = block.name;
155
- this.dropdownTarget.appendChild(option);
185
+ groups[groupName].forEach((block) => {
186
+ const option =
187
+ this.dropdownOptionTemplateTarget.content.cloneNode(true);
188
+ const button = option.querySelector("button");
189
+ button.dataset.blockId = block.id;
190
+ option.querySelector("[data-role='title']").textContent =
191
+ block.name;
192
+ this.dropdownTarget.appendChild(option);
193
+ });
156
194
  });
157
- });
195
+ }
196
+
197
+ // "New block" link at the bottom of the dropdown
198
+ if (
199
+ this.hasNewBlockTemplateTarget &&
200
+ this.hasNewUrlValue &&
201
+ this.newUrlValue
202
+ ) {
203
+ const newBlock = this.newBlockTemplateTarget.content.cloneNode(true);
204
+ const link = newBlock.querySelector("[data-role='new-block-link']");
205
+ if (link) {
206
+ link.href = this.newUrlValue;
207
+ }
208
+ this.dropdownTarget.appendChild(newBlock);
209
+ }
210
+
211
+ // Reposition if dropdown is currently visible
212
+ if (this.dropdownTarget.style.display !== "none") {
213
+ positionDropdown(this.dropdownTarget);
214
+ }
158
215
  }
159
216
 
160
217
  renderEmptyMessage() {
@@ -197,20 +254,76 @@ export default class extends Controller {
197
254
  }
198
255
 
199
256
  openDropdown() {
257
+ if (this.dropdownTarget.style.display === "block") {
258
+ // Already open, just reposition
259
+ positionDropdown(this.dropdownTarget);
260
+ return;
261
+ }
262
+
200
263
  this.dropdownTarget.style.display = "block";
201
- this._outsideClickHandler = this.closeOnOutsideClick.bind(this);
202
- document.addEventListener("click", this._outsideClickHandler, true);
264
+ positionDropdown(this.dropdownTarget);
265
+
266
+ this.#abortController = new AbortController();
267
+ const { signal } = this.#abortController;
268
+
269
+ document.addEventListener("click", (e) => this.closeOnOutsideClick(e), {
270
+ capture: true,
271
+ signal,
272
+ });
273
+
274
+ let scrollTimeout;
275
+ const debouncedPosition = () => {
276
+ clearTimeout(scrollTimeout);
277
+ scrollTimeout = setTimeout(() => positionDropdown(this.dropdownTarget), 100);
278
+ };
279
+ window.addEventListener("scroll", debouncedPosition, {
280
+ capture: true,
281
+ signal,
282
+ });
283
+ window.addEventListener("resize", debouncedPosition, { signal });
203
284
  }
204
285
 
205
286
  closeDropdown() {
206
287
  this.dropdownTarget.style.display = "none";
207
- if (this._outsideClickHandler) {
208
- document.removeEventListener("click", this._outsideClickHandler, true);
209
- this._outsideClickHandler = null;
288
+ if (this.hasSearchInputTarget) {
289
+ this.searchInputTarget.value = "";
210
290
  }
291
+ this.#abortController?.abort();
292
+ this.#abortController = null;
211
293
  }
212
294
 
213
295
  findBlock(id) {
214
296
  return this.blocksValue.find((b) => b.id === id);
215
297
  }
298
+
299
+ // --- Private ---
300
+
301
+ #observeModal() {
302
+ const modalFrame = document.querySelector("turbo-frame[id='modal']");
303
+ if (!modalFrame || !this.hasBlocksDataUrlValue || !this.blocksDataUrlValue)
304
+ return;
305
+
306
+ this.#modalWasOpen = false;
307
+ this.#modalObserver = new MutationObserver(() => {
308
+ const hasModal = modalFrame.querySelector(".modal");
309
+ if (hasModal) {
310
+ this.#modalWasOpen = true;
311
+ } else if (this.#modalWasOpen) {
312
+ this.#modalWasOpen = false;
313
+ this.refreshBlocks();
314
+ }
315
+ });
316
+ this.#modalObserver.observe(modalFrame, { childList: true });
317
+ }
318
+
319
+ refreshBlocks() {
320
+ fetch(this.blocksDataUrlValue, {
321
+ headers: { Accept: "application/json" },
322
+ })
323
+ .then((response) => response.json())
324
+ .then((blocks) => {
325
+ this.blocksValue = blocks;
326
+ this.render();
327
+ });
328
+ }
216
329
  }
@@ -0,0 +1,118 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import Sortable from "libraries/sortablejs"
3
+ import { positionDropdown } from "spina/utils/dropdown_position"
4
+
5
+ // Manages a list of typed content blocks with add, remove, reorder, and collapse.
6
+ //
7
+ // Each block type has a pre-rendered <template> element. When the user adds a
8
+ // block, the template is cloned and the placeholder child_index is replaced
9
+ // with a unique timestamp so Rails form params are unique.
10
+ export default class extends Controller {
11
+ static targets = [
12
+ "list",
13
+ "block",
14
+ "blockContent",
15
+ "emptyMessage",
16
+ "typeMenu",
17
+ "blockTemplate"
18
+ ]
19
+
20
+ #abortController = null
21
+
22
+ connect() {
23
+ this.sortable = Sortable.create(this.listTarget, {
24
+ handle: "[data-sortable-handle]",
25
+ animation: 150
26
+ })
27
+ this._updateEmptyMessage()
28
+ }
29
+
30
+ disconnect() {
31
+ if (this.sortable) this.sortable.destroy()
32
+ this.#abortController?.abort()
33
+ this.#abortController = null
34
+ }
35
+
36
+ // --- Actions ---
37
+
38
+ addBlock(event) {
39
+ event.preventDefault()
40
+ const blockType = event.currentTarget.dataset.blockType
41
+ const template = this.blockTemplateTargets.find(
42
+ t => t.dataset.blockType === blockType
43
+ )
44
+ if (!template) return
45
+
46
+ const childIndex = template.dataset.childIndex
47
+ const uniqueId = new Date().getTime().toString()
48
+ const regex = new RegExp(childIndex, "g")
49
+
50
+ // Clone template content and replace placeholder child_index
51
+ const html = template.innerHTML.replace(regex, uniqueId)
52
+ this.listTarget.insertAdjacentHTML("beforeend", html)
53
+
54
+ this._closeTypeMenu()
55
+ this._updateEmptyMessage()
56
+ }
57
+
58
+ removeBlock(event) {
59
+ event.preventDefault()
60
+ if (!confirm("Are you sure you want to remove this block?")) return
61
+
62
+ const block = event.currentTarget.closest(`[data-${this.identifier}-target='block']`)
63
+ if (block) {
64
+ block.remove()
65
+ this._updateEmptyMessage()
66
+ }
67
+ }
68
+
69
+ toggleTypeMenu(event) {
70
+ event.preventDefault()
71
+ const menu = this.typeMenuTarget
72
+ const isOpen = menu.style.display !== "none"
73
+
74
+ if (isOpen) {
75
+ this._closeTypeMenu()
76
+ } else {
77
+ menu.style.display = "block"
78
+ positionDropdown(menu)
79
+ this.#abortController = new AbortController()
80
+ const { signal } = this.#abortController
81
+
82
+ // Close on outside click (delayed to avoid catching the current click)
83
+ setTimeout(() => {
84
+ document.addEventListener("click", (e) => {
85
+ if (!menu.contains(e.target) && !event.currentTarget.contains(e.target)) {
86
+ this._closeTypeMenu()
87
+ }
88
+ }, { capture: true, once: true, signal })
89
+ }, 0)
90
+ }
91
+ }
92
+
93
+ toggleCollapse(event) {
94
+ event.preventDefault()
95
+ const block = event.currentTarget.closest(`[data-${this.identifier}-target='block']`)
96
+ if (!block) return
97
+
98
+ const content = block.querySelector(`[data-${this.identifier}-target='blockContent']`)
99
+ if (content) {
100
+ content.style.display = content.style.display === "none" ? "" : "none"
101
+ }
102
+ }
103
+
104
+ // --- Private ---
105
+
106
+ _closeTypeMenu() {
107
+ this.typeMenuTarget.style.display = "none"
108
+ this.#abortController?.abort()
109
+ this.#abortController = null
110
+ }
111
+
112
+ _updateEmptyMessage() {
113
+ if (this.hasEmptyMessageTarget) {
114
+ const hasBlocks = this.listTarget.children.length > 0
115
+ this.emptyMessageTarget.style.display = hasBlocks ? "none" : ""
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,4 @@
1
+ // Re-exports the content_blocks_controller under the name "nested-content-blocks".
2
+ // This provides Stimulus scope isolation when ContentBlocks are used inside
3
+ // Repeaters or other ContentBlocks containers.
4
+ export { default } from "controllers/content_blocks_controller"
@@ -0,0 +1,30 @@
1
+ // Positions an absolutely-positioned dropdown element above or below its
2
+ // container based on available viewport space.
3
+ //
4
+ // Usage:
5
+ // import { positionDropdown } from "spina/utils/dropdown_position"
6
+ // positionDropdown(this.dropdownTarget)
7
+ //
8
+ export function positionDropdown(dropdown) {
9
+ const container = dropdown.parentElement
10
+ const containerRect = container.getBoundingClientRect()
11
+ const dropdownHeight = dropdown.offsetHeight
12
+ const viewportHeight = window.innerHeight
13
+
14
+ const spaceBelow = viewportHeight - containerRect.bottom
15
+ const spaceAbove = containerRect.top
16
+
17
+ if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) {
18
+ // Not enough space below and more space above — show above
19
+ dropdown.style.bottom = "100%"
20
+ dropdown.style.top = "auto"
21
+ dropdown.style.marginBottom = "4px"
22
+ dropdown.style.marginTop = "0"
23
+ } else {
24
+ // Default: show below
25
+ dropdown.style.bottom = "auto"
26
+ dropdown.style.top = "100%"
27
+ dropdown.style.marginTop = "4px"
28
+ dropdown.style.marginBottom = "0"
29
+ }
30
+ }