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 +4 -4
- data/README.md +67 -2
- data/app/assets/javascripts/spina/controllers/block_reference_mode_controller.js +55 -0
- data/app/models/spina/blocks/inline_block.rb +63 -0
- data/app/models/spina/parts/block_reference.rb +90 -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 +105 -8
- data/app/views/spina/admin/parts/content_blocks/_fields.html.erb +2 -2
- data/app/views/spina/admin/parts/content_blocks/_form.html.erb +1 -1
- data/app/views/spina/admin/parts/nested_content_blocks/_form.html.erb +1 -1
- data/app/views/spina/admin/pro/search/results/_block.html.erb +2 -2
- data/app/views/spina/blocks/admin/blocks/index.html.erb +1 -1
- data/config/locales/en.yml +3 -0
- data/lib/spina/blocks/engine.rb +6 -5
- data/lib/spina/blocks/version.rb +1 -1
- metadata +11 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 101db90d52972f83d5d308f7aa1cb1538b722105bba4e45676b520ba805bb438
|
|
4
|
+
data.tar.gz: cf2d2db9b0eacb4f1a18c2747c317a9da13003df8fa672954bcd3ce2ea75ad84
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)`
|
|
274
|
-
| `render_custom_block(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-
|
|
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,11 +1,81 @@
|
|
|
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
|
-
|
|
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 =
|
|
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-
|
|
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
|
|
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
|
|
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-
|
|
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') %>
|
data/config/locales/en.yml
CHANGED
|
@@ -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."
|
data/lib/spina/blocks/engine.rb
CHANGED
|
@@ -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 +=
|
|
44
|
-
spina/controllers/block_collection_controller.js
|
|
45
|
-
spina/controllers/
|
|
46
|
-
spina/controllers/
|
|
47
|
-
spina/
|
|
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
|
|
data/lib/spina/blocks/version.rb
CHANGED
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
|
+
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.
|
|
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.
|
|
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
|