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.
- checksums.yaml +4 -4
- data/README.md +173 -8
- data/app/assets/javascripts/spina/controllers/block_collection_controller.js +5 -29
- data/app/assets/javascripts/spina/controllers/block_reference_mode_controller.js +55 -0
- 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 +22 -4
- 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/spina/blocks/account_extension.rb +32 -0
- data/app/models/spina/blocks/block.rb +27 -1
- data/app/models/spina/blocks/inline_block.rb +63 -0
- data/app/models/spina/parts/block_reference.rb +109 -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 +4 -4
- data/app/views/spina/admin/parts/block_references/_form.html.erb +118 -10
- 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/admin/pro/search/results/_block.html.erb +2 -2
- 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/index.html.erb +31 -6
- data/app/views/spina/blocks/admin/page_blocks/index.html.erb +14 -8
- data/config/locales/en.yml +25 -0
- data/db/migrate/6_add_custom_block_fields_to_spina_blocks_blocks.rb +14 -0
- data/lib/spina/blocks/engine.rb +16 -2
- data/lib/spina/blocks/version.rb +1 -1
- 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
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
<%= f.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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>
|