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.
- checksums.yaml +4 -4
- data/README.md +126 -8
- data/app/assets/javascripts/spina/controllers/block_collection_controller.js +155 -42
- data/app/assets/javascripts/spina/controllers/content_blocks_controller.js +118 -0
- data/app/assets/javascripts/spina/controllers/nested_content_blocks_controller.js +4 -0
- data/app/assets/javascripts/spina/utils/dropdown_position.js +30 -0
- data/app/controllers/spina/blocks/admin/blocks_controller.rb +61 -9
- data/app/controllers/spina/blocks/admin/page_blocks_controller.rb +7 -0
- data/app/helpers/spina/blocks/blocks_helper.rb +20 -4
- data/app/models/concerns/spina/parts/block_filterable.rb +18 -0
- data/app/models/spina/blocks/account_extension.rb +32 -0
- data/app/models/spina/blocks/block.rb +27 -1
- data/app/models/spina/parts/block_collection.rb +2 -0
- data/app/models/spina/parts/block_reference.rb +21 -1
- data/app/models/spina/parts/content_block.rb +18 -0
- data/app/models/spina/parts/content_blocks.rb +25 -0
- data/app/models/spina/parts/nested_content_blocks.rb +15 -0
- data/app/views/spina/admin/parts/block_collections/_form.html.erb +33 -21
- data/app/views/spina/admin/parts/block_references/_form.html.erb +16 -5
- data/app/views/spina/admin/parts/content_blocks/_fields.html.erb +57 -0
- data/app/views/spina/admin/parts/content_blocks/_form.html.erb +73 -0
- data/app/views/spina/admin/parts/nested_content_blocks/_form.html.erb +75 -0
- data/app/views/spina/blocks/admin/blocks/_form.html.erb +9 -5
- data/app/views/spina/blocks/admin/blocks/_form_block_settings.html.erb +12 -0
- data/app/views/spina/blocks/admin/blocks/_new_block_modal_form.html.erb +42 -0
- data/app/views/spina/blocks/admin/blocks/index.html.erb +30 -5
- data/app/views/spina/blocks/admin/blocks/new.html.erb +5 -1
- data/app/views/spina/blocks/admin/page_blocks/index.html.erb +14 -8
- data/config/locales/en.yml +22 -0
- data/config/routes.rb +1 -0
- data/db/migrate/6_add_custom_block_fields_to_spina_blocks_blocks.rb +14 -0
- data/lib/spina/blocks/engine.rb +15 -2
- data/lib/spina/blocks/version.rb +1 -1
- 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[:
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
#
|
|
22
|
-
|
|
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(
|
|
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
|
-
|
|
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,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
|
-
|
|
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 =
|
|
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.
|
|
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">
|
|
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
|
-
<%
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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>
|