spina-blocks 0.3.1 → 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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +108 -8
  3. data/app/assets/javascripts/spina/controllers/block_collection_controller.js +5 -29
  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 +22 -4
  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/spina/blocks/account_extension.rb +32 -0
  11. data/app/models/spina/blocks/block.rb +27 -1
  12. data/app/models/spina/parts/block_reference.rb +19 -1
  13. data/app/models/spina/parts/content_block.rb +18 -0
  14. data/app/models/spina/parts/content_blocks.rb +25 -0
  15. data/app/models/spina/parts/nested_content_blocks.rb +15 -0
  16. data/app/views/spina/admin/parts/block_references/_form.html.erb +16 -5
  17. data/app/views/spina/admin/parts/content_blocks/_fields.html.erb +57 -0
  18. data/app/views/spina/admin/parts/content_blocks/_form.html.erb +73 -0
  19. data/app/views/spina/admin/parts/nested_content_blocks/_form.html.erb +75 -0
  20. data/app/views/spina/blocks/admin/blocks/_form.html.erb +9 -5
  21. data/app/views/spina/blocks/admin/blocks/_form_block_settings.html.erb +12 -0
  22. data/app/views/spina/blocks/admin/blocks/index.html.erb +30 -5
  23. data/app/views/spina/blocks/admin/page_blocks/index.html.erb +14 -8
  24. data/config/locales/en.yml +22 -0
  25. data/db/migrate/6_add_custom_block_fields_to_spina_blocks_blocks.rb +14 -0
  26. data/lib/spina/blocks/engine.rb +15 -2
  27. data/lib/spina/blocks/version.rb +1 -1
  28. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4bb4bd87c20dc0578090ce0f7e9b26a0ff511f79c52c1e11319527fb9a132362
4
- data.tar.gz: 768fbfeb07527abc7d752e7f8af4d51ebd0e86f0e7fc643f313e11d11874de3d
3
+ metadata.gz: 24a864994819ca4dd7ee93b0cd6957d008d08586659ad9672654014b2add6961
4
+ data.tar.gz: 264aca6e16771526496af183ef5443091dc4ae70ea5f550bc0e29f0e9c5d1d5a
5
5
  SHA512:
6
- metadata.gz: 6887a086f16feb25de70d36706ef1f5492c8b3f2831a3e1e7d583b1e62dcf79d547fa9e46edeca27805260a3e68835d9b1234a7144df0a1affd7ef5acf548fbc
7
- data.tar.gz: 3741e38abf77005b798a73bdfbfc335f10c7a6109c88551ad6c649f287761714f15aa2e4467e5d65feed3e957c233210da0411e3691dc5204b3b479311dc52a2
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
  {
@@ -149,6 +155,94 @@ Then in your template:
149
155
  <% end %>
150
156
  ```
151
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
+
152
246
  ## Models
153
247
 
154
248
  | Model | Description |
@@ -162,18 +256,24 @@ Then in your template:
162
256
  The plugin adds:
163
257
 
164
258
  - **Blocks** link in the Content section of the admin sidebar
165
- - Block library with category tabs for filtering
259
+ - Block library with template filter dropdown
166
260
  - Block editor with content fields (same as page editor)
167
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
168
267
 
169
268
  ## Helper methods
170
269
 
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 |
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 |
177
277
 
178
278
  ## License
179
279
 
@@ -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,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)
@@ -7,6 +7,7 @@ module Spina
7
7
 
8
8
  included do
9
9
  after_save :bootstrap_block_categories
10
+ after_save :bootstrap_custom_blocks
10
11
  end
11
12
 
12
13
  private
@@ -22,6 +23,37 @@ module Spina
22
23
  .update(label: category[:label], position: index)
23
24
  end
24
25
  end
26
+
27
+ def bootstrap_custom_blocks
28
+ theme_config = Spina::Theme.find_by_name(theme)
29
+ return unless theme_config
30
+ return unless theme_config.respond_to?(:custom_blocks) && theme_config.custom_blocks.present?
31
+
32
+ theme_config.custom_blocks.each do |block_config|
33
+ category = if block_config[:category].present?
34
+ Spina::Blocks::Category.find_by(name: block_config[:category])
35
+ end
36
+
37
+ display_name = block_config[:title].presence || block_config[:name].to_s.titleize
38
+
39
+ block = Spina::Blocks::Block
40
+ .where(key: block_config[:name])
41
+ .first_or_initialize
42
+
43
+ block.assign_attributes(
44
+ name: block.persisted? ? block.name : display_name,
45
+ block_template: block_config[:block_template],
46
+ category: category,
47
+ deletable: false,
48
+ active: block.persisted? ? block.active : true,
49
+ )
50
+
51
+ # Set key only on new records (it is immutable after creation)
52
+ block.key = block_config[:name] unless block.persisted?
53
+
54
+ block.save!
55
+ end
56
+ end
25
57
  end
26
58
  end
27
59
  end
@@ -12,15 +12,41 @@ module Spina
12
12
  has_many :page_blocks, class_name: "Spina::Blocks::PageBlock", dependent: :destroy
13
13
  has_many :pages, through: :page_blocks, class_name: "Spina::Page"
14
14
 
15
- validates :name, presence: true
15
+ before_destroy :ensure_deletable
16
+
17
+ validates :name, presence: true, uniqueness: true
16
18
  validates :block_template, presence: true
19
+ validates :key, uniqueness: { allow_nil: true }
20
+ validate :key_must_not_change, on: :update
17
21
 
18
22
  scope :active, -> { where(active: true) }
19
23
  scope :sorted, -> { order(:position) }
24
+ scope :deletable, -> { where(deletable: true) }
25
+ scope :undeletable, -> { where(deletable: false) }
20
26
 
21
27
  def to_s
22
28
  name
23
29
  end
30
+
31
+ # Whether this block is a system (custom) block with an immutable key
32
+ def system?
33
+ key.present?
34
+ end
35
+
36
+ private
37
+
38
+ def ensure_deletable
39
+ unless deletable?
40
+ errors.add(:base, :undeletable)
41
+ throw(:abort)
42
+ end
43
+ end
44
+
45
+ def key_must_not_change
46
+ if key_was.present? && key_changed?
47
+ errors.add(:key, :immutable)
48
+ end
49
+ end
24
50
  end
25
51
  end
26
52
  end
@@ -10,7 +10,25 @@ module Spina
10
10
  attr_accessor :options
11
11
 
12
12
  def content
13
- ::Spina::Blocks::Block.active.find_by(id: block_id)
13
+ if custom_block_name.present?
14
+ custom_block_record
15
+ else
16
+ ::Spina::Blocks::Block.active.find_by(id: block_id)
17
+ end
18
+ end
19
+
20
+ # Returns the custom_block key from options, supporting both symbol and string keys
21
+ def custom_block_name
22
+ return if options.blank?
23
+
24
+ options[:custom_block] || options["custom_block"]
25
+ end
26
+
27
+ # Looks up the custom block record by key
28
+ def custom_block_record
29
+ return if custom_block_name.blank?
30
+
31
+ ::Spina::Blocks::Block.find_by(key: custom_block_name)
14
32
  end
15
33
  end
16
34
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spina
4
+ module Parts
5
+ class ContentBlock < Base
6
+ include Partable
7
+ include AttrJson::NestedAttributes
8
+
9
+ attr_json :block_type, :string
10
+ attr_json :parts, AttrJson::Type::SpinaPartsModel.new, array: true
11
+ attr_json_accepts_nested_attributes_for :parts
12
+
13
+ def find_part(name)
14
+ (parts || []).find { |part| part.name.to_s == name.to_s }
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spina
4
+ module Parts
5
+ class ContentBlocks < Base
6
+ include AttrJson::NestedAttributes
7
+
8
+ attr_json :content, ContentBlock.to_type, array: true
9
+ attr_json_accepts_nested_attributes_for :content
10
+
11
+ attr_accessor :options
12
+
13
+ # Returns available content block templates from the theme,
14
+ # optionally filtered by the :content_block_templates option.
15
+ def available_templates(theme)
16
+ templates = theme.content_block_templates || []
17
+ allowed = options&.dig(:content_block_templates)
18
+ return templates if allowed.blank?
19
+
20
+ allowed_names = Array(allowed).map(&:to_s)
21
+ templates.select { |t| allowed_names.include?(t[:name].to_s) }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spina
4
+ module Parts
5
+ # Same behavior as ContentBlocks but registered as a separate Part type
6
+ # with its own Stimulus controller name ("nested-content-blocks").
7
+ # This avoids Stimulus scope conflicts when used inside Repeaters or
8
+ # other ContentBlocks containers.
9
+ #
10
+ # Use `options[:content_block_templates]` to limit which block types
11
+ # are available (prevents recursive nesting of the same block type).
12
+ class NestedContentBlocks < ContentBlocks
13
+ end
14
+ end
15
+ end
@@ -4,10 +4,21 @@
4
4
  <div class="text-gray-400 text-sm"><%= f.object.hint %></div>
5
5
  <% end %>
6
6
  <div class="mt-1">
7
- <% blocks = f.object.available_blocks %>
8
- <%= f.select :block_id,
9
- blocks.map { |b| [b.name, b.id] },
10
- {include_blank: t('spina.blocks.select_block')},
11
- class: "form-select text-sm w-full shadow-xs" %>
7
+ <% if f.object.custom_block_name.present? %>
8
+ <% custom_block = f.object.custom_block_record %>
9
+ <% if custom_block %>
10
+ <%= link_to custom_block.name,
11
+ spina.edit_blocks_admin_block_path(custom_block),
12
+ class: "inline-flex items-center text-sm font-medium text-spina-primary hover:underline" %>
13
+ <% else %>
14
+ <span class="text-sm text-red-500"><%= t('spina.blocks.custom_block_not_found') %></span>
15
+ <% end %>
16
+ <% else %>
17
+ <% blocks = f.object.available_blocks %>
18
+ <%= f.select :block_id,
19
+ blocks.map { |b| [b.name, b.id] },
20
+ {include_blank: t('spina.blocks.select_block')},
21
+ class: "form-select text-sm w-full shadow-xs" %>
22
+ <% end %>
12
23
  </div>
13
24
  </div>
@@ -0,0 +1,57 @@
1
+ <%#
2
+ Renders form fields for a single ContentBlock item.
3
+ Looks up the content_block_template by block_type to determine which parts to render.
4
+
5
+ Accepts an optional `stimulus_controller` local to support nested contexts.
6
+ Defaults to "content-blocks".
7
+ %>
8
+ <% sc = local_assigns[:stimulus_controller] || "content-blocks" %>
9
+ <% template_config = current_theme.content_block_templates&.find { |t| t[:name].to_s == f.object.block_type.to_s } || {} %>
10
+ <% template_title = template_config[:title] || f.object.block_type&.titleize || "Block" %>
11
+ <% parts = template_config[:parts] || [] %>
12
+
13
+ <div class="border border-gray-200 rounded-md bg-white shadow-sm"
14
+ data-<%= sc %>-target="block"
15
+ data-block-type="<%= f.object.block_type %>">
16
+
17
+ <%= f.hidden_field :name %>
18
+ <%= f.hidden_field :block_type %>
19
+
20
+ <%# Header: drag handle, type label (clickable to collapse), delete with confirm %>
21
+ <div class="flex items-center px-4 py-3 border-b border-gray-100 bg-gray-50 rounded-t-md cursor-pointer select-none"
22
+ data-action="click-><%= sc %>#toggleCollapse">
23
+ <button type="button" data-sortable-handle
24
+ class="mr-3 text-gray-300 hover:text-gray-500 cursor-grab">
25
+ <svg class="w-4 h-4" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
26
+ <path d="M432 288H16c-8.8 0-16 7.2-16 16v16c0 8.8 7.2 16 16 16h416c8.8 0 16-7.2 16-16v-16c0-8.8-7.2-16-16-16zm0-112H16c-8.8 0-16 7.2-16 16v16c0 8.8 7.2 16 16 16h416c8.8 0 16-7.2 16-16v-16c0-8.8-7.2-16-16-16z"/>
27
+ </svg>
28
+ </button>
29
+
30
+ <span class="flex-1 text-sm font-medium text-gray-700"><%= template_title %></span>
31
+
32
+ <button type="button"
33
+ class="ml-3 text-gray-300 hover:text-red-500 transition-colors"
34
+ data-action="click-><%= sc %>#removeBlock:stop"
35
+ title="<%= t('spina.content_blocks.remove') %>">
36
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
37
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
38
+ </svg>
39
+ </button>
40
+ </div>
41
+
42
+ <%# Content: form fields for this block's parts %>
43
+ <div class="px-4 pb-4" data-<%= sc %>-target="blockContent">
44
+ <% if parts.any? %>
45
+ <%= f.fields_for :parts, build_parts(f.object, parts) do |ff| %>
46
+ <%= ff.hidden_field :title %>
47
+ <%= ff.hidden_field :type, value: ff.object.class %>
48
+ <%= ff.hidden_field :name %>
49
+ <%= render "spina/admin/parts/#{parts_partial_namespace(ff.object.class.to_s)}/form", f: ff %>
50
+ <% end %>
51
+ <% else %>
52
+ <p class="text-sm text-gray-400 italic mt-4">
53
+ <%= t('spina.content_blocks.no_template_found', type: f.object.block_type) %>
54
+ </p>
55
+ <% end %>
56
+ </div>
57
+ </div>
@@ -0,0 +1,73 @@
1
+ <%#
2
+ Admin form for Spina::Parts::ContentBlocks.
3
+ Renders an ordered list of typed content blocks with add/remove/reorder.
4
+ %>
5
+ <% available_templates = f.object.available_templates(current_theme) %>
6
+
7
+ <div class="mt-6" data-controller="content-blocks">
8
+ <label class="block text-sm leading-5 font-medium text-gray-700"><%= f.object.title %></label>
9
+ <% if f.object.hint.present? %>
10
+ <div class="text-gray-400 text-sm mb-2"><%= f.object.hint %></div>
11
+ <% end %>
12
+
13
+ <%# List of existing content blocks %>
14
+ <div data-content-blocks-target="list" class="space-y-3 mb-4">
15
+ <% (f.object.content || []).each do |content_block| %>
16
+ <%= f.fields_for :content, [content_block], child_index: content_block.object_id do |ff| %>
17
+ <%= render "spina/admin/parts/content_blocks/fields", f: ff %>
18
+ <% end %>
19
+ <% end %>
20
+ </div>
21
+
22
+ <%# Empty state %>
23
+ <div data-content-blocks-target="emptyMessage"
24
+ class="text-sm text-gray-400 italic py-3 text-center"
25
+ style="<%= (f.object.content || []).any? ? 'display:none' : '' %>">
26
+ <%= t('spina.content_blocks.empty') %>
27
+ </div>
28
+
29
+ <%# Add block button with type picker dropdown %>
30
+ <% if available_templates.any? %>
31
+ <div class="relative">
32
+ <button type="button"
33
+ class="btn btn-default text-sm"
34
+ data-action="content-blocks#toggleTypeMenu">
35
+ <svg class="w-4 h-4 mr-1.5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
36
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
37
+ </svg>
38
+ <%= t('spina.content_blocks.add_block') %>
39
+ </button>
40
+
41
+ <div data-content-blocks-target="typeMenu"
42
+ class="absolute z-10 mt-1 w-56 bg-white border border-gray-200 rounded-md shadow-lg py-1"
43
+ style="display:none">
44
+ <% available_templates.each do |template| %>
45
+ <button type="button"
46
+ class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
47
+ data-action="content-blocks#addBlock"
48
+ data-block-type="<%= template[:name] %>">
49
+ <%= template[:title] || template[:name].titleize %>
50
+ </button>
51
+ <% end %>
52
+ </div>
53
+ </div>
54
+ <% end %>
55
+
56
+ <%# Pre-rendered templates for each block type (used by Stimulus to clone) %>
57
+ <% available_templates.each do |template| %>
58
+ <%
59
+ content_block = Spina::Parts::ContentBlock.new(
60
+ name: f.object.name,
61
+ block_type: template[:name].to_s
62
+ )
63
+ child_index_placeholder = content_block.object_id.to_s
64
+ %>
65
+ <template data-content-blocks-target="blockTemplate"
66
+ data-block-type="<%= template[:name] %>"
67
+ data-child-index="<%= child_index_placeholder %>">
68
+ <%= f.fields_for(:content, [content_block], child_index: child_index_placeholder) do |ff| %>
69
+ <%= render "spina/admin/parts/content_blocks/fields", f: ff %>
70
+ <% end %>
71
+ </template>
72
+ <% end %>
73
+ </div>
@@ -0,0 +1,75 @@
1
+ <%#
2
+ Admin form for Spina::Parts::NestedContentBlocks.
3
+ Same as ContentBlocks but uses "nested-content-blocks" Stimulus controller
4
+ to avoid scope conflicts when rendered inside Repeaters or other ContentBlocks.
5
+ Reuses content_blocks/_fields.html.erb for individual block rendering.
6
+ %>
7
+ <% available_templates = f.object.available_templates(current_theme) %>
8
+
9
+ <div class="mt-4" data-controller="nested-content-blocks">
10
+ <label class="block text-sm leading-5 font-medium text-gray-700"><%= f.object.title %></label>
11
+ <% if f.object.hint.present? %>
12
+ <div class="text-gray-400 text-sm mb-2"><%= f.object.hint %></div>
13
+ <% end %>
14
+
15
+ <%# List of existing content blocks %>
16
+ <div data-nested-content-blocks-target="list" class="space-y-3 mb-4">
17
+ <% (f.object.content || []).each do |content_block| %>
18
+ <%= f.fields_for :content, [content_block], child_index: content_block.object_id do |ff| %>
19
+ <%= render "spina/admin/parts/content_blocks/fields", f: ff, stimulus_controller: "nested-content-blocks" %>
20
+ <% end %>
21
+ <% end %>
22
+ </div>
23
+
24
+ <%# Empty state %>
25
+ <div data-nested-content-blocks-target="emptyMessage"
26
+ class="text-sm text-gray-400 italic py-3 text-center"
27
+ style="<%= (f.object.content || []).any? ? 'display:none' : '' %>">
28
+ <%= t('spina.content_blocks.empty') %>
29
+ </div>
30
+
31
+ <%# Add block button with type picker dropdown %>
32
+ <% if available_templates.any? %>
33
+ <div class="relative">
34
+ <button type="button"
35
+ class="btn btn-default text-sm"
36
+ data-action="nested-content-blocks#toggleTypeMenu">
37
+ <svg class="w-4 h-4 mr-1.5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
38
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
39
+ </svg>
40
+ <%= t('spina.content_blocks.add_block') %>
41
+ </button>
42
+
43
+ <div data-nested-content-blocks-target="typeMenu"
44
+ class="absolute z-10 mt-1 w-56 bg-white border border-gray-200 rounded-md shadow-lg py-1"
45
+ style="display:none">
46
+ <% available_templates.each do |template| %>
47
+ <button type="button"
48
+ class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
49
+ data-action="nested-content-blocks#addBlock"
50
+ data-block-type="<%= template[:name] %>">
51
+ <%= template[:title] || template[:name].titleize %>
52
+ </button>
53
+ <% end %>
54
+ </div>
55
+ </div>
56
+ <% end %>
57
+
58
+ <%# Pre-rendered templates for each block type (used by Stimulus to clone) %>
59
+ <% available_templates.each do |template| %>
60
+ <%
61
+ content_block = Spina::Parts::ContentBlock.new(
62
+ name: f.object.name,
63
+ block_type: template[:name].to_s
64
+ )
65
+ child_index_placeholder = content_block.object_id.to_s
66
+ %>
67
+ <template data-nested-content-blocks-target="blockTemplate"
68
+ data-block-type="<%= template[:name] %>"
69
+ data-child-index="<%= child_index_placeholder %>">
70
+ <%= f.fields_for(:content, [content_block], child_index: child_index_placeholder) do |ff| %>
71
+ <%= render "spina/admin/parts/content_blocks/fields", f: ff, stimulus_controller: "nested-content-blocks" %>
72
+ <% end %>
73
+ </template>
74
+ <% end %>
75
+ </div>
@@ -12,11 +12,15 @@
12
12
 
13
13
  <div hidden data-reveal data-transition class="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg border border-gray-200 z-30">
14
14
  <div class="rounded-md bg-white shadow-xs py-1">
15
- <%= button_to t('spina.blocks.delete'),
16
- spina.blocks_admin_block_path(@block),
17
- method: :delete,
18
- class: "block w-full text-left px-4 py-2 text-sm leading-5 font-medium text-red-500 cursor-pointer bg-white hover:bg-red-100/50 hover:text-red-500",
19
- form: {data: {controller: "confirm", confirm_message: t('spina.blocks.delete_confirmation')}} %>
15
+ <% if @block.deletable? %>
16
+ <%= button_to t('spina.blocks.delete'),
17
+ spina.blocks_admin_block_path(@block),
18
+ method: :delete,
19
+ class: "block w-full text-left px-4 py-2 text-sm leading-5 font-medium text-red-500 cursor-pointer bg-white hover:bg-red-100/50 hover:text-red-500",
20
+ form: {data: {controller: "confirm", confirm_message: t('spina.blocks.delete_confirmation')}} %>
21
+ <% else %>
22
+ <span class="block px-4 py-2 text-sm leading-5 text-gray-400"><%= t('spina.blocks.cannot_be_deleted') %></span>
23
+ <% end %>
20
24
  </div>
21
25
  </div>
22
26
  </div>
@@ -1,4 +1,16 @@
1
1
  <div class="max-w-5xl grid grid-cols-1 md:grid-cols-3 gap-3 md:gap-6">
2
+ <% if @block.key.present? %>
3
+ <div class="col-span-1 mt-6 md:mt-0">
4
+ <label class="block text-sm font-medium leading-5 text-gray-700">
5
+ <%= Spina::Blocks::Block.human_attribute_name(:key) %>
6
+ </label>
7
+ </div>
8
+
9
+ <div class="col-span-2 mt-1">
10
+ <span class="text-sm text-gray-500 font-mono"><%= @block.key %></span>
11
+ </div>
12
+ <% end %>
13
+
2
14
  <div class="col-span-1 mt-6 md:mt-0">
3
15
  <label class="block text-sm font-medium leading-5 text-gray-700">
4
16
  <%= Spina::Blocks::Block.human_attribute_name(:block_template) %>
@@ -1,7 +1,9 @@
1
1
  <%= render Spina::UserInterface::HeaderComponent.new do |header| %>
2
2
  <% header.with_actions do %>
3
- <% block_templates = current_theme.try(:block_templates) || [] %>
4
- <% if block_templates.any? %>
3
+ <% creatable_templates = (current_theme.try(:block_templates) || []).reject { |bt|
4
+ ((current_theme.try(:custom_blocks) || []).map { |cb| cb[:block_template].to_s }.uniq).include?(bt[:name].to_s)
5
+ } %>
6
+ <% if creatable_templates.any? %>
5
7
  <%= link_to spina.new_blocks_admin_block_path,
6
8
  class: "btn btn-primary",
7
9
  data: {turbo_frame: "modal"} do %>
@@ -18,7 +20,10 @@
18
20
  <button type="button"
19
21
  class="inline-flex items-center px-3 py-1.5 rounded-md text-sm font-medium bg-white border border-gray-300 text-gray-700 shadow-xs hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
20
22
  data-action="reveal#toggle">
21
- <% if @current_block_template.present? %>
23
+ <% if @current_filter == "layout" %>
24
+ <%= heroicon('lock-closed', style: :mini, class: 'h-4 w-4 mr-1.5 -ml-0.5 opacity-75') %>
25
+ <%= t('spina.blocks.layout_blocks') %>
26
+ <% elsif @current_block_template.present? %>
22
27
  <% current_bt = @block_templates.find { |bt| bt[:name].to_s == @current_block_template } %>
23
28
  <%= current_bt ? current_bt[:title] : @current_block_template %>
24
29
  <% else %>
@@ -30,18 +35,33 @@
30
35
 
31
36
  <div hidden data-reveal data-transition class="origin-top-left absolute left-0 mt-2 w-56 rounded-md shadow-lg border border-gray-200 z-30">
32
37
  <div class="rounded-md bg-white shadow-xs py-1">
38
+ <%# "All" filter %>
39
+ <% all_active = @current_block_template.nil? && @current_filter.nil? %>
33
40
  <%= link_to spina.blocks_admin_blocks_path,
34
- class: "flex items-center px-4 py-2 text-sm font-medium leading-5 #{@current_block_template.nil? ? 'text-blue-600 bg-blue-50' : 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'}",
41
+ class: "flex items-center px-4 py-2 text-sm font-medium leading-5 #{all_active ? 'text-blue-600 bg-blue-50' : 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'}",
35
42
  data: {action: "reveal#hide"} do %>
36
43
  <%= heroicon('squares-2x2', class: 'h-4 w-4 mr-2 opacity-75') %>
37
44
  <%= t('spina.blocks.all') %>
38
- <% if @current_block_template.nil? %>
45
+ <% if all_active %>
46
+ <%= heroicon('check', style: :mini, class: 'w-4 h-4 ml-auto opacity-75') %>
47
+ <% end %>
48
+ <% end %>
49
+
50
+ <%# "Layout" filter %>
51
+ <% layout_active = @current_filter == "layout" %>
52
+ <%= link_to spina.blocks_admin_blocks_path(filter: "layout"),
53
+ class: "flex items-center px-4 py-2 text-sm font-medium leading-5 #{layout_active ? 'text-blue-600 bg-blue-50' : 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'}",
54
+ data: {action: "reveal#hide"} do %>
55
+ <%= heroicon('lock-closed', style: :mini, class: 'h-4 w-4 mr-2 opacity-75') %>
56
+ <%= t('spina.blocks.layout_blocks') %>
57
+ <% if layout_active %>
39
58
  <%= heroicon('check', style: :mini, class: 'w-4 h-4 ml-auto opacity-75') %>
40
59
  <% end %>
41
60
  <% end %>
42
61
 
43
62
  <div class="border-t border-gray-100 my-1"></div>
44
63
 
64
+ <%# Template filters %>
45
65
  <% @block_templates.each do |bt| %>
46
66
  <% active = @current_block_template == bt[:name].to_s %>
47
67
  <%= link_to spina.blocks_admin_blocks_path(block_template: bt[:name]),
@@ -84,6 +104,11 @@
84
104
  </div>
85
105
  <% end %>
86
106
  </div>
107
+ <% unless block.deletable? %>
108
+ <div class="pr-4 shrink-0">
109
+ <span class="text-sm font-medium text-blue-500"><%= t('spina.blocks.layout_block') %></span>
110
+ </div>
111
+ <% end %>
87
112
  </li>
88
113
  <% end %>
89
114
  </ul>
@@ -70,14 +70,20 @@
70
70
  </div>
71
71
  <% end %>
72
72
  </div>
73
- <div class="pr-4">
74
- <%= button_to spina.blocks_admin_page_page_block_path(page_id: @page.id, id: page_block.id),
75
- method: :delete,
76
- class: "text-gray-300 hover:text-red-500 transition-colors",
77
- form: {data: {controller: "confirm", confirm_message: t('spina.page_blocks.remove_confirmation')}} do %>
78
- <%= heroicon('x-mark', style: :mini, class: 'w-5 h-5') %>
79
- <% end %>
80
- </div>
73
+ <% if page_block.block.deletable? %>
74
+ <div class="pr-4">
75
+ <%= button_to spina.blocks_admin_page_page_block_path(page_id: @page.id, id: page_block.id),
76
+ method: :delete,
77
+ class: "text-gray-300 hover:text-red-500 transition-colors",
78
+ form: {data: {controller: "confirm", confirm_message: t('spina.page_blocks.remove_confirmation')}} do %>
79
+ <%= heroicon('x-mark', style: :mini, class: 'w-5 h-5') %>
80
+ <% end %>
81
+ </div>
82
+ <% else %>
83
+ <div class="pr-4">
84
+ <span class="text-sm font-medium text-blue-500"><%= t('spina.blocks.layout_block') %></span>
85
+ </div>
86
+ <% end %>
81
87
  </li>
82
88
  <% end %>
83
89
  </ul>
@@ -1,4 +1,16 @@
1
1
  en:
2
+ activerecord:
3
+ attributes:
4
+ spina/blocks/block:
5
+ key: "Key"
6
+ errors:
7
+ models:
8
+ spina/blocks/block:
9
+ attributes:
10
+ base:
11
+ undeletable: "This block is a layout block and cannot be deleted."
12
+ key:
13
+ immutable: "cannot be changed after creation"
2
14
  spina:
3
15
  blocks:
4
16
  title: "Blocks"
@@ -10,6 +22,7 @@ en:
10
22
  deleted: "Block deleted"
11
23
  delete: "Delete block"
12
24
  delete_confirmation: "Are you sure you want to delete this block?"
25
+ cannot_be_deleted: "Block can't be deleted"
13
26
  couldnt_be_saved: "Block couldn't be saved"
14
27
  sorting_saved: "Sorting saved"
15
28
  content: "Content"
@@ -24,9 +37,17 @@ en:
24
37
  inactive: "Inactive"
25
38
  uncategorized: "Uncategorized"
26
39
  active_description: "Inactive blocks are hidden from pages."
40
+ layout_block: "Layout"
41
+ layout_blocks: "Layout blocks"
42
+ custom_block_not_found: "Custom block not found. Run rake spina:bootstrap to create it."
27
43
  block_categories:
28
44
  saved: "Category saved"
29
45
  couldnt_be_saved: "Category couldn't be saved"
46
+ content_blocks:
47
+ add_block: "Add block"
48
+ remove: "Remove"
49
+ empty: 'No content blocks yet. Click "Add block" to get started.'
50
+ no_template_found: "Unknown block type: %{type}"
30
51
  page_blocks:
31
52
  title: "Page Blocks"
32
53
  add_block: "Add block"
@@ -34,6 +55,7 @@ en:
34
55
  added: "Block added to page"
35
56
  couldnt_be_added: "Block couldn't be added"
36
57
  removed: "Block removed from page"
58
+ cannot_remove_layout_block: "This layout block cannot be removed from the page."
37
59
  sorting_saved: "Sorting saved"
38
60
  remove_confirmation: "Remove this block from the page?"
39
61
  no_blocks_yet: "No blocks on this page yet. Add blocks from the library to build your page."
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddCustomBlockFieldsToSpinaBlocksBlocks < ActiveRecord::Migration[7.0]
4
+ def change
5
+ unless connection.column_exists?(:spina_blocks_blocks, :deletable)
6
+ add_column(:spina_blocks_blocks, :deletable, :boolean, default: true, null: false)
7
+ end
8
+
9
+ unless connection.column_exists?(:spina_blocks_blocks, :key)
10
+ add_column(:spina_blocks_blocks, :key, :string)
11
+ add_index(:spina_blocks_blocks, :key, unique: true, where: "key IS NOT NULL")
12
+ end
13
+ end
14
+ end
@@ -12,7 +12,7 @@ module Spina
12
12
  end
13
13
 
14
14
  ::Spina::Theme.class_eval do
15
- attr_accessor :block_templates, :block_categories
15
+ attr_accessor :block_templates, :block_categories, :custom_blocks, :content_block_templates
16
16
 
17
17
  unless method_defined?(:initialize_without_blocks)
18
18
  alias_method :initialize_without_blocks, :initialize
@@ -21,6 +21,8 @@ module Spina
21
21
  initialize_without_blocks
22
22
  @block_templates = []
23
23
  @block_categories = []
24
+ @custom_blocks = []
25
+ @content_block_templates = []
24
26
  end
25
27
  end
26
28
  end
@@ -31,11 +33,19 @@ module Spina
31
33
  pin_all_from Spina::Blocks::Engine.root.join("app/assets/javascripts/spina/controllers"),
32
34
  under: "controllers",
33
35
  to: "spina/controllers"
36
+ pin_all_from Spina::Blocks::Engine.root.join("app/assets/javascripts/spina/utils"),
37
+ under: "spina/utils",
38
+ to: "spina/utils"
34
39
  end
35
40
  end
36
41
 
37
42
  initializer "spina.blocks.assets.precompile" do |app|
38
- app.config.assets.precompile += ["spina/controllers/block_collection_controller.js"] if defined?(Sprockets)
43
+ app.config.assets.precompile += %w[
44
+ spina/controllers/block_collection_controller.js
45
+ spina/controllers/content_blocks_controller.js
46
+ spina/controllers/nested_content_blocks_controller.js
47
+ spina/utils/dropdown_position.js
48
+ ] if defined?(Sprockets)
39
49
  end
40
50
 
41
51
  initializer "spina.blocks.register_parts" do
@@ -43,6 +53,9 @@ module Spina
43
53
  ::Spina::Part.register(
44
54
  Spina::Parts::BlockReference,
45
55
  Spina::Parts::BlockCollection,
56
+ Spina::Parts::ContentBlocks,
57
+ Spina::Parts::ContentBlock,
58
+ Spina::Parts::NestedContentBlocks,
46
59
  )
47
60
  end
48
61
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Spina
4
4
  module Blocks
5
- VERSION = "0.3.1"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spina-blocks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Kanashchuk
@@ -90,6 +90,9 @@ files:
90
90
  - README.md
91
91
  - Rakefile
92
92
  - app/assets/javascripts/spina/controllers/block_collection_controller.js
93
+ - app/assets/javascripts/spina/controllers/content_blocks_controller.js
94
+ - app/assets/javascripts/spina/controllers/nested_content_blocks_controller.js
95
+ - app/assets/javascripts/spina/utils/dropdown_position.js
93
96
  - app/controllers/spina/blocks/admin/blocks_controller.rb
94
97
  - app/controllers/spina/blocks/admin/categories_controller.rb
95
98
  - app/controllers/spina/blocks/admin/page_blocks_controller.rb
@@ -104,9 +107,15 @@ files:
104
107
  - app/models/spina/blocks/search_extension.rb
105
108
  - app/models/spina/parts/block_collection.rb
106
109
  - app/models/spina/parts/block_reference.rb
110
+ - app/models/spina/parts/content_block.rb
111
+ - app/models/spina/parts/content_blocks.rb
112
+ - app/models/spina/parts/nested_content_blocks.rb
107
113
  - app/views/spina/admin/hooks/blocks/_website_secondary_navigation.html.erb
108
114
  - app/views/spina/admin/parts/block_collections/_form.html.erb
109
115
  - app/views/spina/admin/parts/block_references/_form.html.erb
116
+ - app/views/spina/admin/parts/content_blocks/_fields.html.erb
117
+ - app/views/spina/admin/parts/content_blocks/_form.html.erb
118
+ - app/views/spina/admin/parts/nested_content_blocks/_form.html.erb
110
119
  - app/views/spina/admin/pro/search/results/_block.html.erb
111
120
  - app/views/spina/blocks/admin/blocks/_button_block_content.html.erb
112
121
  - app/views/spina/blocks/admin/blocks/_button_block_settings.html.erb
@@ -128,6 +137,7 @@ files:
128
137
  - db/migrate/2_create_spina_blocks_blocks.rb
129
138
  - db/migrate/3_create_spina_blocks_page_blocks.rb
130
139
  - db/migrate/5_rename_title_to_name_in_spina_blocks_blocks.rb
140
+ - db/migrate/6_add_custom_block_fields_to_spina_blocks_blocks.rb
131
141
  - lib/generators/spina/blocks/install_generator.rb
132
142
  - lib/spina-blocks.rb
133
143
  - lib/spina/blocks/engine.rb