spina-blocks 0.3.1 → 0.5.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +173 -8
  3. data/app/assets/javascripts/spina/controllers/block_collection_controller.js +5 -29
  4. data/app/assets/javascripts/spina/controllers/block_reference_mode_controller.js +55 -0
  5. data/app/assets/javascripts/spina/controllers/content_blocks_controller.js +118 -0
  6. data/app/assets/javascripts/spina/controllers/nested_content_blocks_controller.js +4 -0
  7. data/app/assets/javascripts/spina/utils/dropdown_position.js +30 -0
  8. data/app/controllers/spina/blocks/admin/blocks_controller.rb +22 -4
  9. data/app/controllers/spina/blocks/admin/page_blocks_controller.rb +7 -0
  10. data/app/helpers/spina/blocks/blocks_helper.rb +20 -4
  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/blocks/inline_block.rb +63 -0
  14. data/app/models/spina/parts/block_reference.rb +109 -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 +4 -4
  19. data/app/views/spina/admin/parts/block_references/_form.html.erb +118 -10
  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/admin/pro/search/results/_block.html.erb +2 -2
  24. data/app/views/spina/blocks/admin/blocks/_form.html.erb +9 -5
  25. data/app/views/spina/blocks/admin/blocks/_form_block_settings.html.erb +12 -0
  26. data/app/views/spina/blocks/admin/blocks/index.html.erb +31 -6
  27. data/app/views/spina/blocks/admin/page_blocks/index.html.erb +14 -8
  28. data/config/locales/en.yml +25 -0
  29. data/db/migrate/6_add_custom_block_fields_to_spina_blocks_blocks.rb +14 -0
  30. data/lib/spina/blocks/engine.rb +16 -2
  31. data/lib/spina/blocks/version.rb +1 -1
  32. metadata +21 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4bb4bd87c20dc0578090ce0f7e9b26a0ff511f79c52c1e11319527fb9a132362
4
- data.tar.gz: 768fbfeb07527abc7d752e7f8af4d51ebd0e86f0e7fc643f313e11d11874de3d
3
+ metadata.gz: 101db90d52972f83d5d308f7aa1cb1538b722105bba4e45676b520ba805bb438
4
+ data.tar.gz: cf2d2db9b0eacb4f1a18c2747c317a9da13003df8fa672954bcd3ce2ea75ad84
5
5
  SHA512:
6
- metadata.gz: 6887a086f16feb25de70d36706ef1f5492c8b3f2831a3e1e7d583b1e62dcf79d547fa9e46edeca27805260a3e68835d9b1234a7144df0a1affd7ef5acf548fbc
7
- data.tar.gz: 3741e38abf77005b798a73bdfbfc335f10c7a6109c88551ad6c649f287761714f15aa2e4467e5d65feed3e957c233210da0411e3691dc5204b3b479311dc52a2
6
+ metadata.gz: ce7bcbb151b613732b0510c9d3f7c356ce161efa33b40df71116d5ebd96f3ab651290fc05408d3d89ad71786efb07c96b3689a40f3734ef54600ac4b530ec35a
7
+ data.tar.gz: 2c2db687f6312363ecaddf5ab424b24f1ea560d28d7a4ebaadc6817a03e90ed7501952222256139c684da693c41585184be37d53086b4f770c6cf13d1771799c
data/README.md CHANGED
@@ -1,9 +1,13 @@
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
 
7
+ ## Requirements
8
+
9
+ - Spina 2.21 or newer. Spina 2.21 builds its admin UI with Tailwind CSS 4, and the plugin's admin views use Tailwind 4 utility names. On Spina 2.20 or older, use spina-blocks 0.4.x.
10
+
7
11
  ## Installation
8
12
 
9
13
  Add to your Gemfile:
@@ -21,8 +25,16 @@ rails generate spina:blocks:install
21
25
 
22
26
  This copies the plugin's migrations into your app and runs `db:migrate`.
23
27
 
28
+ The plugin registers its views, helpers and JavaScript in `Spina.config.tailwind_content`, so `rails spina:tailwind:build` (also run by `assets:precompile`) picks up the classes it uses in Spina's admin CSS.
29
+
24
30
  ## Upgrading
25
31
 
32
+ ### To 0.5.0 (Spina 2.21)
33
+
34
+ Upgrade Spina to 2.21 together with spina-blocks 0.5.0, then rebuild the admin CSS with `rails spina:tailwind:build`. Spina 2.21 needs `tailwindcss-ruby` 4. It generates `app/assets/stylesheets/spina/application.tailwind.css` (with an `@source` line for every `tailwind_content` path) in place of the old `app/assets/config/spina/tailwind.config.js`.
35
+
36
+ ### From timestamp migrations
37
+
26
38
  If you are upgrading from a version that used timestamp migrations (`20250101000001`–`20250101000004`), the new migrations will detect the old version numbers in your `schema_migrations` table, clean them up automatically, and skip any tables that already exist. No manual intervention is needed.
27
39
 
28
40
  ## Configuration
@@ -72,6 +84,12 @@ Spina::Theme.register do |theme|
72
84
  }
73
85
  ]
74
86
 
87
+ # Custom blocks (layout blocks, created automatically during bootstrap)
88
+ theme.custom_blocks = [
89
+ { name: "header", title: "Header Block", block_template: "header", category: "heroes" },
90
+ { name: "footer", title: "Footer Block", block_template: "footer", category: "cta" }
91
+ ]
92
+
75
93
  # Page templates (can use BlockCollection or BlockReference parts)
76
94
  theme.view_templates = [
77
95
  {
@@ -137,6 +155,31 @@ theme.parts = [
137
155
 
138
156
  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).
139
157
 
158
+ #### Filling a reference in inline
159
+
160
+ A `BlockReference` normally points at a shared block. Pass `inline: true` to also let editors fill the block template's fields in on the page itself, storing that content in the page rather than in a block record:
161
+
162
+ ```ruby
163
+ theme.parts = [
164
+ { name: "testimonials", title: "Testimonials",
165
+ part_type: "Spina::Parts::BlockReference",
166
+ options: { block_template: "testimonials", inline: true } }
167
+ ]
168
+ ```
169
+
170
+ The part form then offers two modes: **Select existing** (pick a shared block) and **Fill in here** (page-local content). Both render through the same block template partial, so `render_block(content(:testimonials))` is unchanged and the output is identical either way.
171
+
172
+ Offering inline mode requires `block_template` — it is the field set editors fill in — and it is not available on parts bound to a `custom_block`. Without `inline: true` the part stays a plain block picker.
173
+
174
+ Content already stored inline keeps rendering whatever you change later — the gate applies to the editor, not to rendering:
175
+
176
+ - Remove `inline: true`: the content still shows on the site, and the page editor presents it as read-only rather than offering a block picker that couldn't override it.
177
+ - Remove `block_template` but keep `inline: true`: the content stays editable, because the form falls back to the template it was filled in against.
178
+
179
+ Either way a save preserves the stored content untouched.
180
+
181
+ `inline: true` controls what the page editor offers, not what the model accepts; it is an authoring affordance rather than an access control.
182
+
140
183
  Then in your template:
141
184
 
142
185
  ```erb
@@ -149,6 +192,122 @@ Then in your template:
149
192
  <% end %>
150
193
  ```
151
194
 
195
+ ### Yielding content into block templates
196
+
197
+ `render_block` and `render_custom_block` forward an optional content block to the partial, so block templates can expose `<%= yield %>` slots that callers fill at render time. This lets a single shared block adapt per-page without splitting it into template variants.
198
+
199
+ In the block partial:
200
+
201
+ ```erb
202
+ <%# app/views/my_theme/blocks/_funnel_form.html.erb %>
203
+ <section class="funnel-form">
204
+ <% if block_given? %>
205
+ <%= yield %>
206
+ <% end %>
207
+ <%# ...rest of the form... %>
208
+ </section>
209
+ ```
210
+
211
+ In the page that includes it:
212
+
213
+ ```erb
214
+ <%= render_block(content(:funnel_form_block)) do %>
215
+ <% if (banner = content(:discount_banner_section))&.content(:enabled) %>
216
+ <%= render "default/shared/blocks/discount_banner", block: banner %>
217
+ <% end %>
218
+ <% end %>
219
+ ```
220
+
221
+ Pages without anything to inject just call `render_block(...)` without a block — `block_given?` is `false` and the slot stays empty.
222
+
223
+ ## Custom blocks (layout blocks)
224
+
225
+ 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.
226
+
227
+ ### Defining custom blocks
228
+
229
+ Add `custom_blocks` to your theme initializer:
230
+
231
+ ```ruby
232
+ theme.custom_blocks = [
233
+ { name: "header", title: "Header Block", block_template: "header", category: "heroes" },
234
+ { name: "footer", title: "Footer Block", block_template: "footer", category: "cta" }
235
+ ]
236
+ ```
237
+
238
+ Each custom block accepts:
239
+
240
+ | Key | Description |
241
+ | ---------------- | ------------------------------------------------------------------------ |
242
+ | `name` | Immutable machine key (used for lookups in code and templates) |
243
+ | `title` | (Optional) Initial display name shown in admin. Defaults to `name.titleize`. Editable by users after creation |
244
+ | `block_template` | Must match a template defined in `theme.block_templates` |
245
+ | `category` | (Optional) Name of a category from `theme.block_categories` |
246
+
247
+ ### Key vs Name
248
+
249
+ Custom blocks have two identifiers:
250
+
251
+ - **`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.
252
+ - **`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.
253
+
254
+ Regular (non-layout) blocks do not have a `key` — it is `nil` for them.
255
+
256
+ ### How it works
257
+
258
+ - **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`.
259
+ - **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.
260
+ - **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.
261
+ - **Template restriction**: Block templates used by custom blocks are excluded from the "New block" form, preventing users from manually creating blocks with layout templates.
262
+ - **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.
263
+
264
+ ### Using custom blocks in templates
265
+
266
+ #### Direct rendering by key
267
+
268
+ Use `render_custom_block` to render a system block directly in any template:
269
+
270
+ ```erb
271
+ <%# app/views/my_theme/pages/homepage.html.erb %>
272
+ <%= render_custom_block("header") %>
273
+
274
+ <main>
275
+ <%= render_blocks %>
276
+ </main>
277
+
278
+ <%= render_custom_block("footer") %>
279
+ ```
280
+
281
+ 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.
282
+
283
+ #### As a pre-defined BlockReference
284
+
285
+ Use the `custom_block` option in a `BlockReference` part to hardcode which block it points to:
286
+
287
+ ```ruby
288
+ theme.parts = [
289
+ { name: "header_block", title: "Header",
290
+ part_type: "Spina::Parts::BlockReference",
291
+ options: { custom_block: "header" } },
292
+
293
+ { name: "footer_block", title: "Footer",
294
+ part_type: "Spina::Parts::BlockReference",
295
+ options: { custom_block: "footer" } }
296
+ ]
297
+ ```
298
+
299
+ When `custom_block` is set:
300
+ - The part's `content` method returns the block by `key` (ignoring `block_id`)
301
+ - In the admin form, instead of a dropdown, a clickable link to the block's edit page is shown
302
+ - If the custom block does not exist yet, a warning message is displayed
303
+
304
+ Then in your template:
305
+
306
+ ```erb
307
+ <%= render_block(content(:header_block)) %>
308
+ <%= render_block(content(:footer_block)) %>
309
+ ```
310
+
152
311
  ## Models
153
312
 
154
313
  | Model | Description |
@@ -162,18 +321,24 @@ Then in your template:
162
321
  The plugin adds:
163
322
 
164
323
  - **Blocks** link in the Content section of the admin sidebar
165
- - Block library with category tabs for filtering
324
+ - Block library with template filter dropdown
166
325
  - Block editor with content fields (same as page editor)
167
326
  - Page Blocks management page (per-page block assignment and ordering)
327
+ - **Layout** badge on non-deletable blocks in the block library (right-aligned)
328
+ - **Layout blocks** filter in the template filter dropdown
329
+ - `...` menu shows "Block can't be deleted" for layout blocks (like Spina pages)
330
+ - Block templates used by layout blocks are hidden from the "New block" form
331
+ - Immutable **key** shown as read-only in block settings for layout blocks
168
332
 
169
333
  ## Helper methods
170
334
 
171
- | Helper | Description |
172
- | --------------------------------------- | --------------------------------------------------- |
173
- | `render_blocks(page)` | Render all blocks attached to a page via PageBlocks |
174
- | `render_block(block)` | Render a single block using its template partial |
175
- | `block_content(block, :part_name)` | Access a block's content |
176
- | `block_has_content?(block, :part_name)` | Check if a block has content |
335
+ | Helper | Description |
336
+ | --------------------------------------- | ------------------------------------------------------ |
337
+ | `render_blocks(page)` | Render all blocks attached to a page via PageBlocks |
338
+ | `render_block(block, &content_block)` | Render a single block using its template partial; an optional content block is forwarded to the partial via `yield` |
339
+ | `render_custom_block(key, &content_block)` | Render a custom (layout) block by its immutable key; forwards a content block to the partial like `render_block` |
340
+ | `block_content(block, :part_name)` | Access a block's content |
341
+ | `block_has_content?(block, :part_name)` | Check if a block has content |
177
342
 
178
343
  ## License
179
344
 
@@ -1,5 +1,6 @@
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 {
5
6
  #abortController = null;
@@ -209,7 +210,7 @@ export default class extends Controller {
209
210
 
210
211
  // Reposition if dropdown is currently visible
211
212
  if (this.dropdownTarget.style.display !== "none") {
212
- this.#positionDropdown();
213
+ positionDropdown(this.dropdownTarget);
213
214
  }
214
215
  }
215
216
 
@@ -255,12 +256,12 @@ export default class extends Controller {
255
256
  openDropdown() {
256
257
  if (this.dropdownTarget.style.display === "block") {
257
258
  // Already open, just reposition
258
- this.#positionDropdown();
259
+ positionDropdown(this.dropdownTarget);
259
260
  return;
260
261
  }
261
262
 
262
263
  this.dropdownTarget.style.display = "block";
263
- this.#positionDropdown();
264
+ positionDropdown(this.dropdownTarget);
264
265
 
265
266
  this.#abortController = new AbortController();
266
267
  const { signal } = this.#abortController;
@@ -273,7 +274,7 @@ export default class extends Controller {
273
274
  let scrollTimeout;
274
275
  const debouncedPosition = () => {
275
276
  clearTimeout(scrollTimeout);
276
- scrollTimeout = setTimeout(() => this.#positionDropdown(), 100);
277
+ scrollTimeout = setTimeout(() => positionDropdown(this.dropdownTarget), 100);
277
278
  };
278
279
  window.addEventListener("scroll", debouncedPosition, {
279
280
  capture: true,
@@ -297,31 +298,6 @@ export default class extends Controller {
297
298
 
298
299
  // --- Private ---
299
300
 
300
- #positionDropdown() {
301
- const dropdown = this.dropdownTarget;
302
- const container = dropdown.parentElement;
303
- const containerRect = container.getBoundingClientRect();
304
- const dropdownHeight = dropdown.offsetHeight;
305
- const viewportHeight = window.innerHeight;
306
-
307
- const spaceBelow = viewportHeight - containerRect.bottom;
308
- const spaceAbove = containerRect.top;
309
-
310
- if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) {
311
- // Not enough space below and more space above, show above
312
- dropdown.style.bottom = "100%";
313
- dropdown.style.top = "auto";
314
- dropdown.style.marginBottom = "4px";
315
- dropdown.style.marginTop = "0";
316
- } else {
317
- // Default: show below
318
- dropdown.style.bottom = "auto";
319
- dropdown.style.top = "100%";
320
- dropdown.style.marginTop = "4px";
321
- dropdown.style.marginBottom = "0";
322
- }
323
- }
324
-
325
301
  #observeModal() {
326
302
  const modalFrame = document.querySelector("turbo-frame[id='modal']");
327
303
  if (!modalFrame || !this.hasBlocksDataUrlValue || !this.blocksDataUrlValue)
@@ -0,0 +1,55 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Toggles a BlockReference part between "reference" (select a shared block)
4
+ // and "inline" (fill the block template's fields in on the page itself).
5
+ //
6
+ // Styled and driven like Spina's own tabs: active/inactive class lists come
7
+ // from data attributes on the controller element. We don't reuse the `tabs`
8
+ // controller because it always activates the first button on connect, which
9
+ // would misreport the persisted mode.
10
+ export default class extends Controller {
11
+ static get targets() {
12
+ return ["button", "referencePane", "inlinePane", "modeField"];
13
+ }
14
+
15
+ connect() {
16
+ this.#apply(this.hasModeFieldTarget ? this.modeFieldTarget.value : "reference");
17
+ }
18
+
19
+ switch(event) {
20
+ this.#apply(event.currentTarget.dataset.mode);
21
+ }
22
+
23
+ #apply(mode) {
24
+ const selected = mode === "inline" ? "inline" : "reference";
25
+
26
+ if (this.hasModeFieldTarget) {
27
+ this.modeFieldTarget.value = selected;
28
+ }
29
+
30
+ this.buttonTargets.forEach((button) => {
31
+ const isActive = button.dataset.mode === selected;
32
+ button.classList.remove(...(isActive ? this.#inactiveClasses : this.#activeClasses));
33
+ button.classList.add(...(isActive ? this.#activeClasses : this.#inactiveClasses));
34
+ });
35
+
36
+ if (this.hasReferencePaneTarget) {
37
+ this.referencePaneTarget.hidden = selected !== "reference";
38
+ }
39
+ if (this.hasInlinePaneTarget) {
40
+ this.inlinePaneTarget.hidden = selected !== "inline";
41
+ }
42
+ }
43
+
44
+ get #activeClasses() {
45
+ return this.#classList("blockReferenceModeActive", "active");
46
+ }
47
+
48
+ get #inactiveClasses() {
49
+ return this.#classList("blockReferenceModeInactive", "inactive");
50
+ }
51
+
52
+ #classList(datasetKey, fallback) {
53
+ return (this.element.dataset[datasetKey] || fallback).split(" ").filter(Boolean);
54
+ }
55
+ }
@@ -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
+ }
@@ -17,17 +17,19 @@ module Spina
17
17
 
18
18
  @block_templates = current_theme.try(:block_templates) || []
19
19
 
20
- if params[:block_template].present?
20
+ if params[:filter] == "layout"
21
+ @current_filter = "layout"
22
+ @blocks = Spina::Blocks::Block.undeletable.sorted
23
+ elsif params[:block_template].present?
21
24
  @current_block_template = params[:block_template]
22
25
  @blocks = Spina::Blocks::Block.where(block_template: @current_block_template).sorted
23
26
  else
24
- @current_block_template = nil
25
27
  @blocks = Spina::Blocks::Block.sorted
26
28
  end
27
29
  end
28
30
 
29
31
  def new
30
- @block_templates = current_theme.try(:block_templates) || []
32
+ @block_templates = creatable_block_templates
31
33
  @block = Spina::Blocks::Block.new(block_template: params[:block_template])
32
34
  @modal = params[:modal]
33
35
  end
@@ -41,7 +43,7 @@ module Spina
41
43
  redirect_to(spina.edit_blocks_admin_block_url(@block))
42
44
  end
43
45
  else
44
- @block_templates = current_theme.try(:block_templates) || []
46
+ @block_templates = creatable_block_templates
45
47
  if params[:modal]
46
48
  @modal = true
47
49
  render(turbo_stream: turbo_stream.update(
@@ -115,6 +117,12 @@ module Spina
115
117
  end
116
118
 
117
119
  def destroy
120
+ unless @block.deletable?
121
+ flash[:error] = I18n.t("spina.blocks.cannot_be_deleted")
122
+ redirect_to(spina.edit_blocks_admin_block_url(@block))
123
+ return
124
+ end
125
+
118
126
  flash[:info] = I18n.t("spina.blocks.deleted")
119
127
  @block.destroy
120
128
  redirect_to(spina.blocks_admin_blocks_url)
@@ -142,6 +150,16 @@ module Spina
142
150
  titles
143
151
  end
144
152
 
153
+ # Returns block templates available for manual block creation,
154
+ # excluding templates reserved for layout (custom) blocks.
155
+ def creatable_block_templates
156
+ all_templates = current_theme.try(:block_templates) || []
157
+ layout_template_names = (current_theme.try(:custom_blocks) || [])
158
+ .map { |cb| cb[:block_template].to_s }.uniq
159
+
160
+ all_templates.reject { |bt| layout_template_names.include?(bt[:name].to_s) }
161
+ end
162
+
145
163
  def block_params
146
164
  params.require(:block).permit!
147
165
  end
@@ -32,6 +32,13 @@ module Spina
32
32
 
33
33
  def destroy
34
34
  @page_block = @page.page_blocks.find(params[:id])
35
+
36
+ unless @page_block.block.deletable?
37
+ flash[:error] = I18n.t("spina.page_blocks.cannot_remove_layout_block")
38
+ redirect_to(spina.blocks_admin_page_page_blocks_url(page_id: @page.id))
39
+ return
40
+ end
41
+
35
42
  @page_block.destroy
36
43
  flash[:info] = I18n.t("spina.page_blocks.removed")
37
44
  redirect_to(spina.blocks_admin_page_page_blocks_url(page_id: @page.id))
@@ -17,9 +17,13 @@ module Spina
17
17
  end
18
18
  end
19
19
 
20
- # Render a single block using its block_template partial
21
- # Usage: <%= render_block(some_block) %>
22
- def render_block(block)
20
+ # Render a single block using its block_template partial.
21
+ # An optional content block is forwarded to the partial, so block
22
+ # templates can expose `<%= yield %>` slots for callers to inject markup.
23
+ # Usage:
24
+ # <%= render_block(some_block) %>
25
+ # <%= render_block(some_block) do %>...<% end %>
26
+ def render_block(block, &content_block)
23
27
  return unless block&.active?
24
28
 
25
29
  current_spina_theme = Spina::Current.theme || current_theme
@@ -28,7 +32,10 @@ module Spina
28
32
  partial_path = "#{theme_name}/blocks/#{block.block_template}"
29
33
 
30
34
  if lookup_context.exists?(partial_path, [], true)
31
- render(partial: partial_path, locals: { block: block })
35
+ # Use the string-form `render(string, locals, &block)` so passing a
36
+ # content block doesn't trigger `render`'s implicit layout branch
37
+ # (which would treat `:partial` as the wrapped content instead).
38
+ render(partial_path, { block: block }, &content_block)
32
39
  else
33
40
  render_block_fallback(block)
34
41
  end
@@ -45,6 +52,15 @@ module Spina
45
52
  block.has_content?(part_name)
46
53
  end
47
54
 
55
+ # Render a custom (system) block by its unique key
56
+ # Usage:
57
+ # <%= render_custom_block("header") %>
58
+ # <%= render_custom_block("header") do %>...<% end %>
59
+ def render_custom_block(key, &content_block)
60
+ block = Spina::Blocks::Block.find_by(key: key)
61
+ render_block(block, &content_block) if block
62
+ end
63
+
48
64
  private
49
65
 
50
66
  def render_block_fallback(block)