spina-blocks 0.4.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24a864994819ca4dd7ee93b0cd6957d008d08586659ad9672654014b2add6961
4
- data.tar.gz: 264aca6e16771526496af183ef5443091dc4ae70ea5f550bc0e29f0e9c5d1d5a
3
+ metadata.gz: 101db90d52972f83d5d308f7aa1cb1538b722105bba4e45676b520ba805bb438
4
+ data.tar.gz: cf2d2db9b0eacb4f1a18c2747c317a9da13003df8fa672954bcd3ce2ea75ad84
5
5
  SHA512:
6
- metadata.gz: 297624cd733819fbdb489b8a6cfe6a5290959eac521aa11117fbf11aa4b50f999c9b4fccba3c3821e7d16a679b5023943b8bd2470f8a9f114529591a4bdfa899
7
- data.tar.gz: 448f9470f24ae571bb6ac2f95aa882bca7b6504c968d5369d591b8e8a1d53711f6fc0d60becb2a793c228e0768b13ab63fc4ea3eb7add2bbf883b864d2fc42af
6
+ metadata.gz: ce7bcbb151b613732b0510c9d3f7c356ce161efa33b40df71116d5ebd96f3ab651290fc05408d3d89ad71786efb07c96b3689a40f3734ef54600ac4b530ec35a
7
+ data.tar.gz: 2c2db687f6312363ecaddf5ab424b24f1ea560d28d7a4ebaadc6817a03e90ed7501952222256139c684da693c41585184be37d53086b4f770c6cf13d1771799c
data/README.md CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  A plugin for [Spina CMS](https://www.spinacms.com) that adds reusable block components. Blocks are independent content units with their own templates and fields that can be assembled into pages.
6
6
 
7
+ ## Requirements
8
+
9
+ - Spina 2.21 or newer. Spina 2.21 builds its admin UI with Tailwind CSS 4, and the plugin's admin views use Tailwind 4 utility names. On Spina 2.20 or older, use spina-blocks 0.4.x.
10
+
7
11
  ## Installation
8
12
 
9
13
  Add to your Gemfile:
@@ -21,8 +25,16 @@ rails generate spina:blocks:install
21
25
 
22
26
  This copies the plugin's migrations into your app and runs `db:migrate`.
23
27
 
28
+ The plugin registers its views, helpers and JavaScript in `Spina.config.tailwind_content`, so `rails spina:tailwind:build` (also run by `assets:precompile`) picks up the classes it uses in Spina's admin CSS.
29
+
24
30
  ## Upgrading
25
31
 
32
+ ### To 0.5.0 (Spina 2.21)
33
+
34
+ Upgrade Spina to 2.21 together with spina-blocks 0.5.0, then rebuild the admin CSS with `rails spina:tailwind:build`. Spina 2.21 needs `tailwindcss-ruby` 4. It generates `app/assets/stylesheets/spina/application.tailwind.css` (with an `@source` line for every `tailwind_content` path) in place of the old `app/assets/config/spina/tailwind.config.js`.
35
+
36
+ ### From timestamp migrations
37
+
26
38
  If you are upgrading from a version that used timestamp migrations (`20250101000001`–`20250101000004`), the new migrations will detect the old version numbers in your `schema_migrations` table, clean them up automatically, and skip any tables that already exist. No manual intervention is needed.
27
39
 
28
40
  ## Configuration
@@ -143,6 +155,31 @@ theme.parts = [
143
155
 
144
156
  With this configuration, the "Hero Block" select will only show blocks created with the `hero` template, and "Sidebar Blocks" will only show `sidebar_widget` blocks. If `options` is omitted or does not contain `block_template`, all active blocks are shown (the default behavior).
145
157
 
158
+ #### Filling a reference in inline
159
+
160
+ A `BlockReference` normally points at a shared block. Pass `inline: true` to also let editors fill the block template's fields in on the page itself, storing that content in the page rather than in a block record:
161
+
162
+ ```ruby
163
+ theme.parts = [
164
+ { name: "testimonials", title: "Testimonials",
165
+ part_type: "Spina::Parts::BlockReference",
166
+ options: { block_template: "testimonials", inline: true } }
167
+ ]
168
+ ```
169
+
170
+ The part form then offers two modes: **Select existing** (pick a shared block) and **Fill in here** (page-local content). Both render through the same block template partial, so `render_block(content(:testimonials))` is unchanged and the output is identical either way.
171
+
172
+ Offering inline mode requires `block_template` — it is the field set editors fill in — and it is not available on parts bound to a `custom_block`. Without `inline: true` the part stays a plain block picker.
173
+
174
+ Content already stored inline keeps rendering whatever you change later — the gate applies to the editor, not to rendering:
175
+
176
+ - Remove `inline: true`: the content still shows on the site, and the page editor presents it as read-only rather than offering a block picker that couldn't override it.
177
+ - Remove `block_template` but keep `inline: true`: the content stays editable, because the form falls back to the template it was filled in against.
178
+
179
+ Either way a save preserves the stored content untouched.
180
+
181
+ `inline: true` controls what the page editor offers, not what the model accepts; it is an authoring affordance rather than an access control.
182
+
146
183
  Then in your template:
147
184
 
148
185
  ```erb
@@ -155,6 +192,34 @@ Then in your template:
155
192
  <% end %>
156
193
  ```
157
194
 
195
+ ### Yielding content into block templates
196
+
197
+ `render_block` and `render_custom_block` forward an optional content block to the partial, so block templates can expose `<%= yield %>` slots that callers fill at render time. This lets a single shared block adapt per-page without splitting it into template variants.
198
+
199
+ In the block partial:
200
+
201
+ ```erb
202
+ <%# app/views/my_theme/blocks/_funnel_form.html.erb %>
203
+ <section class="funnel-form">
204
+ <% if block_given? %>
205
+ <%= yield %>
206
+ <% end %>
207
+ <%# ...rest of the form... %>
208
+ </section>
209
+ ```
210
+
211
+ In the page that includes it:
212
+
213
+ ```erb
214
+ <%= render_block(content(:funnel_form_block)) do %>
215
+ <% if (banner = content(:discount_banner_section))&.content(:enabled) %>
216
+ <%= render "default/shared/blocks/discount_banner", block: banner %>
217
+ <% end %>
218
+ <% end %>
219
+ ```
220
+
221
+ Pages without anything to inject just call `render_block(...)` without a block — `block_given?` is `false` and the slot stays empty.
222
+
158
223
  ## Custom blocks (layout blocks)
159
224
 
160
225
  Custom blocks are non-deletable blocks that are automatically created during `rake spina:bootstrap`. They work similarly to Spina's `custom_pages` — once defined in the theme configuration, they are created on bootstrap and cannot be deleted through the admin interface. In the admin UI they are labeled as **Layout** blocks.
@@ -270,8 +335,8 @@ The plugin adds:
270
335
  | Helper | Description |
271
336
  | --------------------------------------- | ------------------------------------------------------ |
272
337
  | `render_blocks(page)` | Render all blocks attached to a page via PageBlocks |
273
- | `render_block(block)` | Render a single block using its template partial |
274
- | `render_custom_block(key)` | Render a custom (layout) block by its immutable key |
338
+ | `render_block(block, &content_block)` | Render a single block using its template partial; an optional content block is forwarded to the partial via `yield` |
339
+ | `render_custom_block(key, &content_block)` | Render a custom (layout) block by its immutable key; forwards a content block to the partial like `render_block` |
275
340
  | `block_content(block, :part_name)` | Access a block's content |
276
341
  | `block_has_content?(block, :part_name)` | Check if a block has content |
277
342
 
@@ -0,0 +1,55 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Toggles a BlockReference part between "reference" (select a shared block)
4
+ // and "inline" (fill the block template's fields in on the page itself).
5
+ //
6
+ // Styled and driven like Spina's own tabs: active/inactive class lists come
7
+ // from data attributes on the controller element. We don't reuse the `tabs`
8
+ // controller because it always activates the first button on connect, which
9
+ // would misreport the persisted mode.
10
+ export default class extends Controller {
11
+ static get targets() {
12
+ return ["button", "referencePane", "inlinePane", "modeField"];
13
+ }
14
+
15
+ connect() {
16
+ this.#apply(this.hasModeFieldTarget ? this.modeFieldTarget.value : "reference");
17
+ }
18
+
19
+ switch(event) {
20
+ this.#apply(event.currentTarget.dataset.mode);
21
+ }
22
+
23
+ #apply(mode) {
24
+ const selected = mode === "inline" ? "inline" : "reference";
25
+
26
+ if (this.hasModeFieldTarget) {
27
+ this.modeFieldTarget.value = selected;
28
+ }
29
+
30
+ this.buttonTargets.forEach((button) => {
31
+ const isActive = button.dataset.mode === selected;
32
+ button.classList.remove(...(isActive ? this.#inactiveClasses : this.#activeClasses));
33
+ button.classList.add(...(isActive ? this.#activeClasses : this.#inactiveClasses));
34
+ });
35
+
36
+ if (this.hasReferencePaneTarget) {
37
+ this.referencePaneTarget.hidden = selected !== "reference";
38
+ }
39
+ if (this.hasInlinePaneTarget) {
40
+ this.inlinePaneTarget.hidden = selected !== "inline";
41
+ }
42
+ }
43
+
44
+ get #activeClasses() {
45
+ return this.#classList("blockReferenceModeActive", "active");
46
+ }
47
+
48
+ get #inactiveClasses() {
49
+ return this.#classList("blockReferenceModeInactive", "inactive");
50
+ }
51
+
52
+ #classList(datasetKey, fallback) {
53
+ return (this.element.dataset[datasetKey] || fallback).split(" ").filter(Boolean);
54
+ }
55
+ }
@@ -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,12 +4,30 @@ 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
29
+ return inline_block if inline?
30
+
13
31
  if custom_block_name.present?
14
32
  custom_block_record
15
33
  else
@@ -17,6 +35,34 @@ module Spina
17
35
  end
18
36
  end
19
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
+
20
66
  # Returns the custom_block key from options, supporting both symbol and string keys
21
67
  def custom_block_name
22
68
  return if options.blank?
@@ -30,6 +76,50 @@ module Spina
30
76
 
31
77
  ::Spina::Blocks::Block.find_by(key: custom_block_name)
32
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?
122
+ end
33
123
  end
34
124
  end
35
125
  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,11 +1,81 @@
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
- <% if f.object.custom_block_name.present? %>
8
- <% custom_block = f.object.custom_block_record %>
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 %>
9
79
  <% if custom_block %>
10
80
  <%= link_to custom_block.name,
11
81
  spina.edit_blocks_admin_block_path(custom_block),
@@ -14,11 +84,38 @@
14
84
  <span class="text-sm text-red-500"><%= t('spina.blocks.custom_block_not_found') %></span>
15
85
  <% end %>
16
86
  <% else %>
17
- <% blocks = f.object.available_blocks %>
87
+ <% blocks = part.available_blocks %>
18
88
  <%= f.select :block_id,
19
89
  blocks.map { |b| [b.name, b.id] },
20
90
  {include_blank: t('spina.blocks.select_block')},
21
91
  class: "form-select text-sm w-full shadow-xs" %>
22
92
  <% end %>
23
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 %>
24
121
  </div>
@@ -10,7 +10,7 @@
10
10
  <% template_title = template_config[:title] || f.object.block_type&.titleize || "Block" %>
11
11
  <% parts = template_config[:parts] || [] %>
12
12
 
13
- <div class="border border-gray-200 rounded-md bg-white shadow-sm"
13
+ <div class="border border-gray-200 rounded-md bg-white shadow-xs"
14
14
  data-<%= sc %>-target="block"
15
15
  data-block-type="<%= f.object.block_type %>">
16
16
 
@@ -30,7 +30,7 @@
30
30
  <span class="flex-1 text-sm font-medium text-gray-700"><%= template_title %></span>
31
31
 
32
32
  <button type="button"
33
- class="ml-3 text-gray-300 hover:text-red-500 transition-colors"
33
+ class="ml-3 cursor-pointer text-gray-300 hover:text-red-500 transition-colors"
34
34
  data-action="click-><%= sc %>#removeBlock:stop"
35
35
  title="<%= t('spina.content_blocks.remove') %>">
36
36
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -43,7 +43,7 @@
43
43
  style="display:none">
44
44
  <% available_templates.each do |template| %>
45
45
  <button type="button"
46
- class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
46
+ class="w-full cursor-pointer text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
47
47
  data-action="content-blocks#addBlock"
48
48
  data-block-type="<%= template[:name] %>">
49
49
  <%= template[:title] || template[:name].titleize %>
@@ -45,7 +45,7 @@
45
45
  style="display:none">
46
46
  <% available_templates.each do |template| %>
47
47
  <button type="button"
48
- class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
48
+ class="w-full cursor-pointer text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
49
49
  data-action="nested-content-blocks#addBlock"
50
50
  data-block-type="<%= template[:name] %>">
51
51
  <%= template[:title] || template[:name].titleize %>
@@ -1,12 +1,12 @@
1
1
  <%= link_to spina.edit_blocks_admin_block_path(block), class: "block px-2 leading-tight", data: {selected: index.zero?, search_target: "result", action: "mouseenter->search#select", turbo_frame: "_top"} do %>
2
- <div class="flex items-center justify-between w-full px-4 py-3 rounded-md bg-gray-400 bg-opacity-0 <%= 'bg-opacity-25' if index.zero? %>" data-selected-class="bg-opacity-25">
2
+ <div class="flex items-center justify-between w-full px-4 py-3 rounded-md <%= 'bg-gray-400/25' if index.zero? %>" data-selected-class="bg-gray-400/25">
3
3
  <div>
4
4
  <div class="font-medium flex items-center">
5
5
  <%= block.name %>
6
6
  <% unless block.active? %>
7
7
  <div class="opacity-50 text-xs ml-1">(inactive)</div>
8
8
  <% end %>
9
- <div class="flex items-center ml-2 text-xs px-1.5 py-0.5 space-x-1 bg-gray-900 bg-opacity-5 rounded">
9
+ <div class="flex items-center ml-2 text-xs px-1.5 py-0.5 space-x-1 bg-gray-900/5 rounded">
10
10
  <%= heroicon('cube', style: :outline, class: 'w-3 h-3 opacity-50') %>
11
11
  <div class="opacity-75"><%= block.block_template %></div>
12
12
  </div>
@@ -18,7 +18,7 @@
18
18
  <nav class="-mb-1 md:-mb-3 mt-4">
19
19
  <div class="relative inline-block" data-controller="reveal" data-reveal-away-value>
20
20
  <button type="button"
21
- class="inline-flex items-center px-3 py-1.5 rounded-md text-sm font-medium bg-white border border-gray-300 text-gray-700 shadow-xs hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
21
+ class="inline-flex items-center cursor-pointer px-3 py-1.5 rounded-md text-sm font-medium bg-white border border-gray-300 text-gray-700 shadow-xs hover:bg-gray-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
22
22
  data-action="reveal#toggle">
23
23
  <% if @current_filter == "layout" %>
24
24
  <%= heroicon('lock-closed', style: :mini, class: 'h-4 w-4 mr-1.5 -ml-0.5 opacity-75') %>
@@ -31,6 +31,9 @@ en:
31
31
  category: "Category"
32
32
  no_category: "— No category —"
33
33
  select_block: "— Select block —"
34
+ mode_reference: "Select existing"
35
+ mode_inline: "Fill in here"
36
+ inline_not_editable: "Filled in on this page. Inline editing is switched off for this part, so the content still shows on the site but can't be edited or replaced here."
34
37
  block_type: "Block type"
35
38
  select_block_type: "— Select block type —"
36
39
  no_blocks_yet: "No blocks yet. Create your first block to get started."
@@ -40,11 +40,12 @@ module Spina
40
40
  end
41
41
 
42
42
  initializer "spina.blocks.assets.precompile" do |app|
43
- app.config.assets.precompile += %w[
44
- spina/controllers/block_collection_controller.js
45
- spina/controllers/content_blocks_controller.js
46
- spina/controllers/nested_content_blocks_controller.js
47
- spina/utils/dropdown_position.js
43
+ app.config.assets.precompile += [
44
+ "spina/controllers/block_collection_controller.js",
45
+ "spina/controllers/block_reference_mode_controller.js",
46
+ "spina/controllers/content_blocks_controller.js",
47
+ "spina/controllers/nested_content_blocks_controller.js",
48
+ "spina/utils/dropdown_position.js",
48
49
  ] if defined?(Sprockets)
49
50
  end
50
51
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Spina
4
4
  module Blocks
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spina-blocks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Kanashchuk
@@ -15,14 +15,20 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '2.0'
18
+ version: '2.21'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
22
25
  requirements:
23
26
  - - ">="
24
27
  - !ruby/object:Gem::Version
25
- version: '2.0'
28
+ version: '2.21'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: database_cleaner-active_record
28
34
  requirement: !ruby/object:Gem::Requirement
@@ -90,6 +96,7 @@ files:
90
96
  - README.md
91
97
  - Rakefile
92
98
  - app/assets/javascripts/spina/controllers/block_collection_controller.js
99
+ - app/assets/javascripts/spina/controllers/block_reference_mode_controller.js
93
100
  - app/assets/javascripts/spina/controllers/content_blocks_controller.js
94
101
  - app/assets/javascripts/spina/controllers/nested_content_blocks_controller.js
95
102
  - app/assets/javascripts/spina/utils/dropdown_position.js
@@ -102,6 +109,7 @@ files:
102
109
  - app/models/spina/blocks/application_record.rb
103
110
  - app/models/spina/blocks/block.rb
104
111
  - app/models/spina/blocks/category.rb
112
+ - app/models/spina/blocks/inline_block.rb
105
113
  - app/models/spina/blocks/page_block.rb
106
114
  - app/models/spina/blocks/page_extension.rb
107
115
  - app/models/spina/blocks/search_extension.rb