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
@@ -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
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spina
4
+ module Blocks
5
+ # Duck-typed stand-in for a Spina::Blocks::Block whose content lives inline
6
+ # in the page (a RepeaterContent) rather than in a persisted block record.
7
+ #
8
+ # It exposes just enough of the Block interface for `render_block` and block
9
+ # template partials to work unchanged: `active?`, `block_template`, and
10
+ # `content` / `has_content?` delegating to the inline RepeaterContent.
11
+ class InlineBlock
12
+ attr_reader :block_template
13
+
14
+ def initialize(repeater_content, block_template)
15
+ @repeater_content = repeater_content
16
+ @block_template = block_template.to_s
17
+ end
18
+
19
+ def active?
20
+ true
21
+ end
22
+
23
+ # Mirrors Spina::Partable#content: named lookup returns the part's content,
24
+ # no argument returns a ContentPresenter.
25
+ def content(name = nil)
26
+ repeater_content.content(name)
27
+ end
28
+
29
+ def has_content?(name)
30
+ repeater_content.has_content?(name)
31
+ end
32
+
33
+ # Kept in step with the content so callers can hand a view context down
34
+ # explicitly. Without it ContentPresenter has to fall back on
35
+ # Spina::Current.page, which isn't set everywhere blocks are rendered.
36
+ def view_context
37
+ repeater_content.view_context
38
+ end
39
+
40
+ def view_context=(value)
41
+ repeater_content.view_context = value
42
+ # Partable memoises its ContentPresenter, capturing the view context
43
+ # resolved on first use. Drop the memo so a later assignment still takes
44
+ # effect instead of silently doing nothing.
45
+ repeater_content.instance_variable_set(:@content_presenter, nil)
46
+ end
47
+
48
+ # Surfaced by render_block's fallback when a block template partial is
49
+ # missing — naming the template is what makes that visible to an editor.
50
+ def name
51
+ block_template
52
+ end
53
+
54
+ def to_s
55
+ block_template
56
+ end
57
+
58
+ private
59
+
60
+ attr_reader :repeater_content
61
+ end
62
+ end
63
+ end
@@ -4,13 +4,121 @@ module Spina
4
4
  module Parts
5
5
  class BlockReference < Base
6
6
  include BlockFilterable
7
+ include AttrJson::NestedAttributes
8
+
9
+ BOOLEAN_TYPE = ActiveModel::Type::Boolean.new.freeze
7
10
 
8
11
  attr_json :block_id, :integer, default: nil
12
+ # "reference" (default): points at a shared block via block_id / custom_block.
13
+ # "inline": content is filled in and stored on the page itself.
14
+ attr_json :mode, :string, default: nil
15
+ # Inline content, used when mode == "inline". Same shape as a Section's
16
+ # content: a RepeaterContent holding the block template's parts.
17
+ attr_json :inline_content, RepeaterContent.to_type, default: nil
18
+ attr_json_accepts_nested_attributes_for :inline_content
19
+ # The block template inline content was filled in against, persisted with
20
+ # the content itself. Part options are a theme-side concern and are only
21
+ # attached when Spina builds parts for the admin form — parts deserialized
22
+ # from stored page JSON (i.e. when rendering the site) carry none — so
23
+ # inline content has to be self-describing to render.
24
+ attr_json :inline_block_template, :string, default: nil
9
25
 
10
26
  attr_accessor :options
11
27
 
12
28
  def content
13
- ::Spina::Blocks::Block.active.find_by(id: block_id)
29
+ return inline_block if inline?
30
+
31
+ if custom_block_name.present?
32
+ custom_block_record
33
+ else
34
+ ::Spina::Blocks::Block.active.find_by(id: block_id)
35
+ end
36
+ end
37
+
38
+ # Whether this reference is filled inline (page-local) instead of pointing
39
+ # at a shared block. Absent/blank mode is treated as "reference" so existing
40
+ # parts stay backward compatible.
41
+ def inline?
42
+ mode.to_s == "inline"
43
+ end
44
+
45
+ # Whether the theme lets this part be filled in inline. Off unless opted in,
46
+ # so a reference keeps meaning "pick a shared block" by default.
47
+ #
48
+ # This gates the editor only. Content already stored inline keeps rendering
49
+ # if the option is later removed, rather than vanishing from the site.
50
+ def inline_allowed?
51
+ return false if options.blank?
52
+
53
+ value = options.key?(:inline) ? options[:inline] : options["inline"]
54
+ BOOLEAN_TYPE.cast(value) || false
55
+ end
56
+
57
+ # Optional block template restriction from the part's theme options. Filters
58
+ # the block picker and, when present, is what inline mode fills in against.
59
+ # Only available where options are attached (the admin form).
60
+ def block_template_name
61
+ return if options.blank?
62
+
63
+ options[:block_template] || options["block_template"]
64
+ end
65
+
66
+ # Returns the custom_block key from options, supporting both symbol and string keys
67
+ def custom_block_name
68
+ return if options.blank?
69
+
70
+ options[:custom_block] || options["custom_block"]
71
+ end
72
+
73
+ # Looks up the custom block record by key
74
+ def custom_block_record
75
+ return if custom_block_name.blank?
76
+
77
+ ::Spina::Blocks::Block.find_by(key: custom_block_name)
78
+ end
79
+
80
+ # Accepts inline content as a JSON string so the form can round-trip
81
+ # content it cannot render fields for — e.g. the block template is no
82
+ # longer registered in the theme. Without it the next save would drop
83
+ # stored content that is still rendering on the site.
84
+ def inline_content_json=(raw)
85
+ return if raw.blank?
86
+
87
+ parsed = JSON.parse(raw)
88
+ self.inline_content = parsed if parsed.is_a?(Hash)
89
+ rescue StandardError
90
+ # Best-effort restore of a value that arrives from a client-controlled
91
+ # hidden field: malformed JSON, or a payload naming a part type attr_json
92
+ # won't build, must leave the stored content alone rather than break the
93
+ # page save.
94
+ nil
95
+ end
96
+
97
+ private
98
+
99
+ # Wraps inline content so it renders through the same block template partial
100
+ # as a shared block. Returns nil when nothing has been filled in, so callers
101
+ # using `render_block(content)` render nothing (matching an empty reference).
102
+ def inline_block
103
+ template = inline_block_template.presence || block_template_name.presence
104
+ # Without a template there is no partial to render; bail out rather than
105
+ # let render_block fall through to its empty placeholder wrapper.
106
+ return if template.blank?
107
+ return if inline_content.blank?
108
+ return if Array(inline_content.parts).none? { |part| part_filled?(part) }
109
+
110
+ ::Spina::Blocks::InlineBlock.new(inline_content, template)
111
+ end
112
+
113
+ # Defers to each part's own `present?`, which Image and Attachment override
114
+ # to check for an attached blob. The one case `present?` gets wrong here is
115
+ # a boolean-shaped part deliberately set to false — empty by Rails' rules,
116
+ # filled by the editor's.
117
+ def part_filled?(part)
118
+ value = part.content
119
+ return true if value == false
120
+
121
+ value.present?
14
122
  end
15
123
  end
16
124
  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
@@ -43,7 +43,7 @@
43
43
  data-action="input->block-collection#onSearchInput focus->block-collection#onSearchFocus"
44
44
  placeholder="Search or add block..."
45
45
  autocomplete="off"
46
- class="block w-full rounded-md border-gray-300 shadow-sm text-sm focus:border-spina focus:ring-spina" />
46
+ class="block w-full rounded-md border-gray-300 shadow-xs text-sm focus:border-spina focus:ring-spina" />
47
47
 
48
48
  <div data-block-collection-target="dropdown"
49
49
  style="display:none"
@@ -68,7 +68,7 @@
68
68
 
69
69
  <%# Selected block list item %>
70
70
  <template data-block-collection-target="listItemTemplate">
71
- <div class="flex items-center bg-white border border-gray-200 rounded-md px-3 py-2 shadow-sm group" data-block-id="">
71
+ <div class="flex items-center bg-white border border-gray-200 rounded-md px-3 py-2 shadow-xs group" data-block-id="">
72
72
  <button type="button" data-sortable-handle class="mr-2 text-gray-300 hover:text-gray-500 cursor-grab" title="Drag to reorder">
73
73
  <svg class="w-4 h-4" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
74
74
  <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"/>
@@ -84,7 +84,7 @@
84
84
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
85
85
  </svg>
86
86
  </a>
87
- <button type="button" class="ml-2 text-gray-300 hover:text-red-500 transition-colors"
87
+ <button type="button" class="ml-2 cursor-pointer text-gray-300 hover:text-red-500 transition-colors"
88
88
  data-action="block-collection#remove" data-block-id="" title="Remove">
89
89
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
90
90
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
@@ -101,7 +101,7 @@
101
101
  <%# Dropdown option (available block) %>
102
102
  <template data-block-collection-target="dropdownOptionTemplate">
103
103
  <button type="button"
104
- class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center"
104
+ class="w-full cursor-pointer text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center"
105
105
  data-action="block-collection#add" data-block-id="">
106
106
  <span data-role="title"></span>
107
107
  </button>
@@ -1,13 +1,121 @@
1
- <div class="mt-6">
2
- <label class="block text-sm leading-5 font-medium text-gray-700"><%= f.object.title %></label>
3
- <% if f.object.hint.present? %>
4
- <div class="text-gray-400 text-sm"><%= f.object.hint %></div>
1
+ <%
2
+ part = f.object
3
+
4
+ # Prefer the template the theme binds this part to; fall back to the one the
5
+ # stored content was filled in against, so dropping the option from the theme
6
+ # leaves existing inline content editable instead of silently unrenderable.
7
+ template_name = part.block_template_name.presence || part.inline_block_template.presence
8
+
9
+ # Inline mode is opt-in per part, needs a known field set, and never applies to
10
+ # a fixed system block.
11
+ inline_parts = []
12
+ if part.inline_allowed? && part.custom_block_name.blank? && template_name.present?
13
+ block_template = current_theme.block_templates.find { |bt| bt[:name].to_s == template_name.to_s }
14
+ inline_parts = block_template&.dig(:parts) || []
15
+ end
16
+
17
+ allow_inline = inline_parts.any?
18
+ current_mode = part.inline? ? "inline" : "reference"
19
+
20
+ # Content filled in inline while the part no longer offers inline editing —
21
+ # the theme dropped `inline: true`, or its block template is gone. The content
22
+ # still renders, so offering the block picker here would be a no-op: `content`
23
+ # returns the inline content regardless of what's selected.
24
+ inline_not_editable = part.inline? && !allow_inline
25
+
26
+ # Mirrors Spina::Pages::TabButtonComponent and the tabs controller's class
27
+ # lists, so the toggle reads as a Spina tab bar. Applied server-side too, so
28
+ # the current mode is correct before (and without) Stimulus.
29
+ tab_button_classes = "block px-3 leading-relaxed py-1 rounded-md hover:text-gray-800 " \
30
+ "font-medium text-sm flex items-center whitespace-nowrap"
31
+ active_classes = "cursor-default text-gray-900 bg-spina-dark/10"
32
+ inactive_classes = "cursor-pointer bg-transparent text-gray-400 border-transparent"
33
+ %>
34
+
35
+ <div class="mt-6"
36
+ data-controller="block-reference-mode"
37
+ data-block-reference-mode-active="<%= active_classes %>"
38
+ data-block-reference-mode-inactive="<%= inactive_classes %>">
39
+ <label class="block text-sm leading-5 font-medium text-gray-700"><%= part.title %></label>
40
+ <% if part.hint.present? %>
41
+ <div class="text-gray-400 text-sm"><%= part.hint %></div>
5
42
  <% end %>
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" %>
43
+
44
+ <% if allow_inline %>
45
+ <%= f.hidden_field :mode, value: current_mode, data: { block_reference_mode_target: "modeField" } %>
46
+
47
+ <div class="mt-2 inline-flex w-auto rounded-md bg-white">
48
+ <% { "reference" => t("spina.blocks.mode_reference"), "inline" => t("spina.blocks.mode_inline") }.each do |mode_value, label| %>
49
+ <button type="button"
50
+ class="<%= tab_button_classes %> <%= mode_value == current_mode ? active_classes : inactive_classes %>"
51
+ data-mode="<%= mode_value %>"
52
+ data-block-reference-mode-target="button"
53
+ data-action="block-reference-mode#switch">
54
+ <%= label %>
55
+ </button>
56
+ <% end %>
57
+ </div>
58
+ <% else %>
59
+ <%= f.hidden_field :mode %>
60
+
61
+ <% if part.inline_content.present? %>
62
+ <%# No field set to render (template gone from the theme), but the content
63
+ is still stored and may still be rendering — round-trip it verbatim so
64
+ saving this page doesn't drop it. %>
65
+ <%= f.hidden_field :inline_block_template %>
66
+ <%= f.hidden_field :inline_content_json, value: part.inline_content.as_json.to_json %>
67
+ <% end %>
68
+ <% end %>
69
+
70
+ <%# ---- Reference pane: pick a shared block (or show a fixed custom block) ----
71
+ Always rendered, so switching modes stays client-side. %>
72
+ <div class="<%= allow_inline ? "mt-4" : "mt-1" %>"
73
+ data-block-reference-mode-target="referencePane"
74
+ <%= "hidden" if allow_inline && current_mode != "reference" %>>
75
+ <% if inline_not_editable %>
76
+ <span class="text-sm text-gray-400"><%= t("spina.blocks.inline_not_editable") %></span>
77
+ <% elsif part.custom_block_name.present? %>
78
+ <% custom_block = part.custom_block_record %>
79
+ <% if custom_block %>
80
+ <%= link_to custom_block.name,
81
+ spina.edit_blocks_admin_block_path(custom_block),
82
+ class: "inline-flex items-center text-sm font-medium text-spina-primary hover:underline" %>
83
+ <% else %>
84
+ <span class="text-sm text-red-500"><%= t('spina.blocks.custom_block_not_found') %></span>
85
+ <% end %>
86
+ <% else %>
87
+ <% blocks = part.available_blocks %>
88
+ <%= f.select :block_id,
89
+ blocks.map { |b| [b.name, b.id] },
90
+ {include_blank: t('spina.blocks.select_block')},
91
+ class: "form-select text-sm w-full shadow-xs" %>
92
+ <% end %>
12
93
  </div>
94
+
95
+ <%# ---- Inline pane: fill the block template's fields on the page itself ---- %>
96
+ <% if allow_inline %>
97
+ <%
98
+ inline_rc = part.inline_content ||
99
+ Spina::Parts::RepeaterContent.new(name: part.name, title: part.title)
100
+ %>
101
+ <div class="mt-4"
102
+ data-block-reference-mode-target="inlinePane"
103
+ <%= "hidden" if current_mode != "inline" %>>
104
+ <%# Persisted with the content so rendering doesn't depend on theme options. %>
105
+ <%= f.hidden_field :inline_block_template, value: template_name %>
106
+
107
+ <%= f.fields_for :inline_content, inline_rc do |ff| %>
108
+ <%= ff.hidden_field :name, value: part.name %>
109
+ <%= ff.hidden_field :title, value: part.title %>
110
+
111
+ <%= ff.fields_for :parts, build_parts(ff.object, inline_parts) do |fff| %>
112
+ <%= fff.hidden_field :type, value: fff.object.class.name %>
113
+ <%= fff.hidden_field :name %>
114
+ <%= fff.hidden_field :title %>
115
+
116
+ <%= render "spina/admin/parts/#{parts_partial_namespace(fff.object.class.to_s)}/form", f: fff %>
117
+ <% end %>
118
+ <% end %>
119
+ </div>
120
+ <% end %>
13
121
  </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-xs"
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 cursor-pointer 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 cursor-pointer 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>