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
@@ -17,30 +17,45 @@ 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])
34
+ @modal = params[:modal]
32
35
  end
33
36
 
34
37
  def create
35
38
  @block = Spina::Blocks::Block.new(block_params)
36
39
  if @block.save
37
- redirect_to(spina.edit_blocks_admin_block_url(@block))
40
+ if params[:modal]
41
+ redirect_to(spina.edit_modal_blocks_admin_block_url(@block))
42
+ else
43
+ redirect_to(spina.edit_blocks_admin_block_url(@block))
44
+ end
38
45
  else
39
- @block_templates = current_theme.try(:block_templates) || []
40
- render(turbo_stream: turbo_stream.update(
41
- helpers.dom_id(@block, :new_block_form),
42
- partial: "new_block_form",
43
- ))
46
+ @block_templates = creatable_block_templates
47
+ if params[:modal]
48
+ @modal = true
49
+ render(turbo_stream: turbo_stream.update(
50
+ helpers.dom_id(@block, :new_block_modal_form),
51
+ partial: "new_block_modal_form",
52
+ ))
53
+ else
54
+ render(turbo_stream: turbo_stream.update(
55
+ helpers.dom_id(@block, :new_block_form),
56
+ partial: "new_block_form",
57
+ ))
58
+ end
44
59
  end
45
60
  end
46
61
 
@@ -79,6 +94,19 @@ module Spina
79
94
  end
80
95
  end
81
96
 
97
+ def blocks_data
98
+ blocks = Spina::Blocks::Block.active.sorted
99
+ template_titles = build_template_titles
100
+ render(json: blocks.map do |b|
101
+ {
102
+ id: b.id,
103
+ name: b.name,
104
+ templateName: b.block_template.to_s,
105
+ templateTitle: template_titles[b.block_template.to_s] || b.block_template.to_s.titleize,
106
+ }
107
+ end)
108
+ end
109
+
82
110
  def sort
83
111
  params[:ids].each.with_index do |id, index|
84
112
  Spina::Blocks::Block.where(id: id).update_all(position: index + 1)
@@ -89,6 +117,12 @@ module Spina
89
117
  end
90
118
 
91
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
+
92
126
  flash[:info] = I18n.t("spina.blocks.deleted")
93
127
  @block.destroy
94
128
  redirect_to(spina.blocks_admin_blocks_url)
@@ -108,6 +142,24 @@ module Spina
108
142
  @tabs = ["block_content", "block_settings"]
109
143
  end
110
144
 
145
+ def build_template_titles
146
+ titles = {}
147
+ (current_theme.try(:block_templates) || []).each do |bt|
148
+ titles[bt[:name].to_s] = bt[:title].to_s
149
+ end
150
+ titles
151
+ end
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
+
111
163
  def block_params
112
164
  params.require(:block).permit!
113
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)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spina
4
+ module Parts
5
+ module BlockFilterable
6
+ extend ActiveSupport::Concern
7
+
8
+ def available_blocks
9
+ scope = ::Spina::Blocks::Block.active.sorted
10
+ opts = options.is_a?(Hash) ? options.with_indifferent_access : nil
11
+ if opts&.dig(:block_template).present?
12
+ scope = scope.where(block_template: opts[:block_template])
13
+ end
14
+ scope
15
+ end
16
+ end
17
+ end
18
+ end
@@ -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
@@ -3,6 +3,8 @@
3
3
  module Spina
4
4
  module Parts
5
5
  class BlockCollection < Base
6
+ include BlockFilterable
7
+
6
8
  attr_json :block_ids, :integer, array: true, default: -> { [] }
7
9
 
8
10
  attr_accessor :options
@@ -3,12 +3,32 @@
3
3
  module Spina
4
4
  module Parts
5
5
  class BlockReference < Base
6
+ include BlockFilterable
7
+
6
8
  attr_json :block_id, :integer, default: nil
7
9
 
8
10
  attr_accessor :options
9
11
 
10
12
  def content
11
- ::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)
12
32
  end
13
33
  end
14
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
@@ -1,5 +1,5 @@
1
1
  <%
2
- blocks = Spina::Blocks::Block.active.sorted
2
+ blocks = f.object.available_blocks
3
3
  selected_ids = f.object.block_ids || []
4
4
  field_name = "#{f.object_name}[block_ids][]"
5
5
 
@@ -27,6 +27,8 @@
27
27
  data-block-collection-blocks-value="<%= blocks_json.to_json %>"
28
28
  data-block-collection-selected-ids-value="<%= selected_ids.to_json %>"
29
29
  data-block-collection-edit-url-value="<%= spina.edit_modal_blocks_admin_block_path("__ID__") %>"
30
+ data-block-collection-new-url-value="<%= spina.new_blocks_admin_block_path(modal: 1) %>"
31
+ data-block-collection-blocks-data-url-value="<%= spina.blocks_data_blocks_admin_blocks_path(format: :json) %>"
30
32
  data-field-name="<%= field_name %>">
31
33
 
32
34
  <label class="block text-sm leading-5 font-medium text-gray-700"><%= f.object.title %></label>
@@ -34,11 +36,26 @@
34
36
  <div class="text-gray-400 text-sm mb-2"><%= f.object.hint %></div>
35
37
  <% end %>
36
38
 
39
+ <%# Search input + dropdown %>
40
+ <div class="relative mb-3">
41
+ <input type="text"
42
+ data-block-collection-target="searchInput"
43
+ data-action="input->block-collection#onSearchInput focus->block-collection#onSearchFocus"
44
+ placeholder="Search or add block..."
45
+ autocomplete="off"
46
+ class="block w-full rounded-md border-gray-300 shadow-sm text-sm focus:border-spina focus:ring-spina" />
47
+
48
+ <div data-block-collection-target="dropdown"
49
+ style="display:none"
50
+ class="absolute z-10 w-full bg-white border border-gray-200 rounded-md shadow-lg py-1 max-h-60 overflow-y-auto">
51
+ </div>
52
+ </div>
53
+
37
54
  <%# Empty state message %>
38
55
  <div data-block-collection-target="emptyMessage"
39
56
  class="text-sm text-gray-400 italic py-3"
40
57
  style="<%= selected_ids.any? ? 'display:none' : '' %>">
41
- No blocks selected. Click &ldquo;Add block&rdquo; to get started.
58
+ No blocks selected. Use the search field above to add blocks.
42
59
  </div>
43
60
 
44
61
  <%# Sortable list of selected blocks (rendered by Stimulus) %>
@@ -47,24 +64,6 @@
47
64
  <%# Hidden fields container (rendered by Stimulus) %>
48
65
  <div data-block-collection-target="hiddenFields"></div>
49
66
 
50
- <%# Add block button + dropdown %>
51
- <div class="relative inline-block">
52
- <button type="button"
53
- data-action="block-collection#toggleDropdown"
54
- data-block-collection-target="addButton"
55
- class="inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-spina">
56
- <svg class="w-4 h-4 mr-1.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
57
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
58
- </svg>
59
- Add block
60
- </button>
61
-
62
- <div data-block-collection-target="dropdown"
63
- style="display:none"
64
- class="absolute z-10 mt-1 w-64 bg-white border border-gray-200 rounded-md shadow-lg py-1 max-h-60 overflow-y-auto">
65
- </div>
66
- </div>
67
-
68
67
  <%# ===== Templates for Stimulus controller ===== %>
69
68
 
70
69
  <%# Selected block list item %>
@@ -110,6 +109,19 @@
110
109
 
111
110
  <%# Dropdown empty state %>
112
111
  <template data-block-collection-target="dropdownEmptyTemplate">
113
- <div class="px-3 py-2 text-sm text-gray-400 italic">All blocks added</div>
112
+ <div class="px-3 py-2 text-sm text-gray-400 italic">No blocks found</div>
113
+ </template>
114
+
115
+ <%# Dropdown "New block" link %>
116
+ <template data-block-collection-target="newBlockTemplate">
117
+ <div class="border-t border-gray-200 mt-1 pt-1">
118
+ <a class="w-full text-left px-3 py-2 text-sm text-spina-primary hover:bg-gray-100 flex items-center font-medium"
119
+ href="" data-turbo-frame="modal" data-role="new-block-link">
120
+ <svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
121
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
122
+ </svg>
123
+ <%= t('spina.blocks.new') %>
124
+ </a>
125
+ </div>
114
126
  </template>
115
127
  </div>
@@ -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 = Spina::Blocks::Block.active.sorted %>
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>