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
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
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# Spina Blocks
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> **Warning**: This plugin is not production-ready and may cause issues. Use at your own risk.
|
|
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
|
|
@@ -72,6 +84,12 @@ Spina::Theme.register do |theme|
|
|
|
72
84
|
}
|
|
73
85
|
]
|
|
74
86
|
|
|
87
|
+
# Custom blocks (layout blocks, created automatically during bootstrap)
|
|
88
|
+
theme.custom_blocks = [
|
|
89
|
+
{ name: "header", title: "Header Block", block_template: "header", category: "heroes" },
|
|
90
|
+
{ name: "footer", title: "Footer Block", block_template: "footer", category: "cta" }
|
|
91
|
+
]
|
|
92
|
+
|
|
75
93
|
# Page templates (can use BlockCollection or BlockReference parts)
|
|
76
94
|
theme.view_templates = [
|
|
77
95
|
{
|
|
@@ -137,6 +155,31 @@ theme.parts = [
|
|
|
137
155
|
|
|
138
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).
|
|
139
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
|
+
|
|
140
183
|
Then in your template:
|
|
141
184
|
|
|
142
185
|
```erb
|
|
@@ -149,6 +192,122 @@ Then in your template:
|
|
|
149
192
|
<% end %>
|
|
150
193
|
```
|
|
151
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
|
+
|
|
223
|
+
## Custom blocks (layout blocks)
|
|
224
|
+
|
|
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.
|
|
226
|
+
|
|
227
|
+
### Defining custom blocks
|
|
228
|
+
|
|
229
|
+
Add `custom_blocks` to your theme initializer:
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
theme.custom_blocks = [
|
|
233
|
+
{ name: "header", title: "Header Block", block_template: "header", category: "heroes" },
|
|
234
|
+
{ name: "footer", title: "Footer Block", block_template: "footer", category: "cta" }
|
|
235
|
+
]
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Each custom block accepts:
|
|
239
|
+
|
|
240
|
+
| Key | Description |
|
|
241
|
+
| ---------------- | ------------------------------------------------------------------------ |
|
|
242
|
+
| `name` | Immutable machine key (used for lookups in code and templates) |
|
|
243
|
+
| `title` | (Optional) Initial display name shown in admin. Defaults to `name.titleize`. Editable by users after creation |
|
|
244
|
+
| `block_template` | Must match a template defined in `theme.block_templates` |
|
|
245
|
+
| `category` | (Optional) Name of a category from `theme.block_categories` |
|
|
246
|
+
|
|
247
|
+
### Key vs Name
|
|
248
|
+
|
|
249
|
+
Custom blocks have two identifiers:
|
|
250
|
+
|
|
251
|
+
- **`key`** — an immutable machine identifier (set from `name` in the config). Used by `render_custom_block`, `BlockReference` with `custom_block` option, and for bootstrap lookups. Cannot be changed after creation. Shown as read-only in the block settings tab.
|
|
252
|
+
- **`name`** — the display name shown in the admin UI. Initially set from `title` (or `name.titleize` if `title` is omitted). Users can freely rename it without breaking any template references.
|
|
253
|
+
|
|
254
|
+
Regular (non-layout) blocks do not have a `key` — it is `nil` for them.
|
|
255
|
+
|
|
256
|
+
### How it works
|
|
257
|
+
|
|
258
|
+
- **Bootstrap**: Custom blocks are created (or updated) automatically whenever `Spina::Account` is saved — this includes `rake spina:bootstrap` and admin account edits. They are created with `deletable: false` and an immutable `key`.
|
|
259
|
+
- **Idempotent**: Running bootstrap multiple times will not duplicate blocks. Existing blocks are found by `key` and updated. The display `name` is only set on initial creation — subsequent bootstraps preserve user edits to the display name.
|
|
260
|
+
- **Deletion protection**: Layout blocks cannot be deleted through the admin UI or programmatically via `destroy`. In the block editor, the `...` menu shows "Block can't be deleted" (matching Spina's page behavior). The controller also rejects delete requests.
|
|
261
|
+
- **Template restriction**: Block templates used by custom blocks are excluded from the "New block" form, preventing users from manually creating blocks with layout templates.
|
|
262
|
+
- **Admin UI**: Layout blocks are marked with a **Layout** badge (right-aligned) in the block library. A **Layout blocks** filter is available in the template filter dropdown. On page block lists, the remove button is replaced with a "Layout" label. The `key` is displayed as read-only in the block settings tab.
|
|
263
|
+
|
|
264
|
+
### Using custom blocks in templates
|
|
265
|
+
|
|
266
|
+
#### Direct rendering by key
|
|
267
|
+
|
|
268
|
+
Use `render_custom_block` to render a system block directly in any template:
|
|
269
|
+
|
|
270
|
+
```erb
|
|
271
|
+
<%# app/views/my_theme/pages/homepage.html.erb %>
|
|
272
|
+
<%= render_custom_block("header") %>
|
|
273
|
+
|
|
274
|
+
<main>
|
|
275
|
+
<%= render_blocks %>
|
|
276
|
+
</main>
|
|
277
|
+
|
|
278
|
+
<%= render_custom_block("footer") %>
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
This looks up the block by its immutable `key` and renders it using the block's template partial, just like `render_block`. Renaming the block's display name in the admin will not affect this lookup.
|
|
282
|
+
|
|
283
|
+
#### As a pre-defined BlockReference
|
|
284
|
+
|
|
285
|
+
Use the `custom_block` option in a `BlockReference` part to hardcode which block it points to:
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
theme.parts = [
|
|
289
|
+
{ name: "header_block", title: "Header",
|
|
290
|
+
part_type: "Spina::Parts::BlockReference",
|
|
291
|
+
options: { custom_block: "header" } },
|
|
292
|
+
|
|
293
|
+
{ name: "footer_block", title: "Footer",
|
|
294
|
+
part_type: "Spina::Parts::BlockReference",
|
|
295
|
+
options: { custom_block: "footer" } }
|
|
296
|
+
]
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
When `custom_block` is set:
|
|
300
|
+
- The part's `content` method returns the block by `key` (ignoring `block_id`)
|
|
301
|
+
- In the admin form, instead of a dropdown, a clickable link to the block's edit page is shown
|
|
302
|
+
- If the custom block does not exist yet, a warning message is displayed
|
|
303
|
+
|
|
304
|
+
Then in your template:
|
|
305
|
+
|
|
306
|
+
```erb
|
|
307
|
+
<%= render_block(content(:header_block)) %>
|
|
308
|
+
<%= render_block(content(:footer_block)) %>
|
|
309
|
+
```
|
|
310
|
+
|
|
152
311
|
## Models
|
|
153
312
|
|
|
154
313
|
| Model | Description |
|
|
@@ -162,18 +321,24 @@ Then in your template:
|
|
|
162
321
|
The plugin adds:
|
|
163
322
|
|
|
164
323
|
- **Blocks** link in the Content section of the admin sidebar
|
|
165
|
-
- Block library with
|
|
324
|
+
- Block library with template filter dropdown
|
|
166
325
|
- Block editor with content fields (same as page editor)
|
|
167
326
|
- Page Blocks management page (per-page block assignment and ordering)
|
|
327
|
+
- **Layout** badge on non-deletable blocks in the block library (right-aligned)
|
|
328
|
+
- **Layout blocks** filter in the template filter dropdown
|
|
329
|
+
- `...` menu shows "Block can't be deleted" for layout blocks (like Spina pages)
|
|
330
|
+
- Block templates used by layout blocks are hidden from the "New block" form
|
|
331
|
+
- Immutable **key** shown as read-only in block settings for layout blocks
|
|
168
332
|
|
|
169
333
|
## Helper methods
|
|
170
334
|
|
|
171
|
-
| Helper | Description
|
|
172
|
-
| --------------------------------------- |
|
|
173
|
-
| `render_blocks(page)` | Render all blocks attached to a page via PageBlocks
|
|
174
|
-
| `render_block(block)`
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
335
|
+
| Helper | Description |
|
|
336
|
+
| --------------------------------------- | ------------------------------------------------------ |
|
|
337
|
+
| `render_blocks(page)` | Render all blocks attached to a page via PageBlocks |
|
|
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` |
|
|
340
|
+
| `block_content(block, :part_name)` | Access a block's content |
|
|
341
|
+
| `block_has_content?(block, :part_name)` | Check if a block has content |
|
|
177
342
|
|
|
178
343
|
## License
|
|
179
344
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
2
|
import Sortable from "libraries/sortablejs";
|
|
3
|
+
import { positionDropdown } from "spina/utils/dropdown_position";
|
|
3
4
|
|
|
4
5
|
export default class extends Controller {
|
|
5
6
|
#abortController = null;
|
|
@@ -209,7 +210,7 @@ export default class extends Controller {
|
|
|
209
210
|
|
|
210
211
|
// Reposition if dropdown is currently visible
|
|
211
212
|
if (this.dropdownTarget.style.display !== "none") {
|
|
212
|
-
|
|
213
|
+
positionDropdown(this.dropdownTarget);
|
|
213
214
|
}
|
|
214
215
|
}
|
|
215
216
|
|
|
@@ -255,12 +256,12 @@ export default class extends Controller {
|
|
|
255
256
|
openDropdown() {
|
|
256
257
|
if (this.dropdownTarget.style.display === "block") {
|
|
257
258
|
// Already open, just reposition
|
|
258
|
-
|
|
259
|
+
positionDropdown(this.dropdownTarget);
|
|
259
260
|
return;
|
|
260
261
|
}
|
|
261
262
|
|
|
262
263
|
this.dropdownTarget.style.display = "block";
|
|
263
|
-
|
|
264
|
+
positionDropdown(this.dropdownTarget);
|
|
264
265
|
|
|
265
266
|
this.#abortController = new AbortController();
|
|
266
267
|
const { signal } = this.#abortController;
|
|
@@ -273,7 +274,7 @@ export default class extends Controller {
|
|
|
273
274
|
let scrollTimeout;
|
|
274
275
|
const debouncedPosition = () => {
|
|
275
276
|
clearTimeout(scrollTimeout);
|
|
276
|
-
scrollTimeout = setTimeout(() =>
|
|
277
|
+
scrollTimeout = setTimeout(() => positionDropdown(this.dropdownTarget), 100);
|
|
277
278
|
};
|
|
278
279
|
window.addEventListener("scroll", debouncedPosition, {
|
|
279
280
|
capture: true,
|
|
@@ -297,31 +298,6 @@ export default class extends Controller {
|
|
|
297
298
|
|
|
298
299
|
// --- Private ---
|
|
299
300
|
|
|
300
|
-
#positionDropdown() {
|
|
301
|
-
const dropdown = this.dropdownTarget;
|
|
302
|
-
const container = dropdown.parentElement;
|
|
303
|
-
const containerRect = container.getBoundingClientRect();
|
|
304
|
-
const dropdownHeight = dropdown.offsetHeight;
|
|
305
|
-
const viewportHeight = window.innerHeight;
|
|
306
|
-
|
|
307
|
-
const spaceBelow = viewportHeight - containerRect.bottom;
|
|
308
|
-
const spaceAbove = containerRect.top;
|
|
309
|
-
|
|
310
|
-
if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) {
|
|
311
|
-
// Not enough space below and more space above, show above
|
|
312
|
-
dropdown.style.bottom = "100%";
|
|
313
|
-
dropdown.style.top = "auto";
|
|
314
|
-
dropdown.style.marginBottom = "4px";
|
|
315
|
-
dropdown.style.marginTop = "0";
|
|
316
|
-
} else {
|
|
317
|
-
// Default: show below
|
|
318
|
-
dropdown.style.bottom = "auto";
|
|
319
|
-
dropdown.style.top = "100%";
|
|
320
|
-
dropdown.style.marginTop = "4px";
|
|
321
|
-
dropdown.style.marginBottom = "0";
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
301
|
#observeModal() {
|
|
326
302
|
const modalFrame = document.querySelector("turbo-frame[id='modal']");
|
|
327
303
|
if (!modalFrame || !this.hasBlocksDataUrlValue || !this.blocksDataUrlValue)
|
|
@@ -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,118 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import Sortable from "libraries/sortablejs"
|
|
3
|
+
import { positionDropdown } from "spina/utils/dropdown_position"
|
|
4
|
+
|
|
5
|
+
// Manages a list of typed content blocks with add, remove, reorder, and collapse.
|
|
6
|
+
//
|
|
7
|
+
// Each block type has a pre-rendered <template> element. When the user adds a
|
|
8
|
+
// block, the template is cloned and the placeholder child_index is replaced
|
|
9
|
+
// with a unique timestamp so Rails form params are unique.
|
|
10
|
+
export default class extends Controller {
|
|
11
|
+
static targets = [
|
|
12
|
+
"list",
|
|
13
|
+
"block",
|
|
14
|
+
"blockContent",
|
|
15
|
+
"emptyMessage",
|
|
16
|
+
"typeMenu",
|
|
17
|
+
"blockTemplate"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
#abortController = null
|
|
21
|
+
|
|
22
|
+
connect() {
|
|
23
|
+
this.sortable = Sortable.create(this.listTarget, {
|
|
24
|
+
handle: "[data-sortable-handle]",
|
|
25
|
+
animation: 150
|
|
26
|
+
})
|
|
27
|
+
this._updateEmptyMessage()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
disconnect() {
|
|
31
|
+
if (this.sortable) this.sortable.destroy()
|
|
32
|
+
this.#abortController?.abort()
|
|
33
|
+
this.#abortController = null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// --- Actions ---
|
|
37
|
+
|
|
38
|
+
addBlock(event) {
|
|
39
|
+
event.preventDefault()
|
|
40
|
+
const blockType = event.currentTarget.dataset.blockType
|
|
41
|
+
const template = this.blockTemplateTargets.find(
|
|
42
|
+
t => t.dataset.blockType === blockType
|
|
43
|
+
)
|
|
44
|
+
if (!template) return
|
|
45
|
+
|
|
46
|
+
const childIndex = template.dataset.childIndex
|
|
47
|
+
const uniqueId = new Date().getTime().toString()
|
|
48
|
+
const regex = new RegExp(childIndex, "g")
|
|
49
|
+
|
|
50
|
+
// Clone template content and replace placeholder child_index
|
|
51
|
+
const html = template.innerHTML.replace(regex, uniqueId)
|
|
52
|
+
this.listTarget.insertAdjacentHTML("beforeend", html)
|
|
53
|
+
|
|
54
|
+
this._closeTypeMenu()
|
|
55
|
+
this._updateEmptyMessage()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
removeBlock(event) {
|
|
59
|
+
event.preventDefault()
|
|
60
|
+
if (!confirm("Are you sure you want to remove this block?")) return
|
|
61
|
+
|
|
62
|
+
const block = event.currentTarget.closest(`[data-${this.identifier}-target='block']`)
|
|
63
|
+
if (block) {
|
|
64
|
+
block.remove()
|
|
65
|
+
this._updateEmptyMessage()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
toggleTypeMenu(event) {
|
|
70
|
+
event.preventDefault()
|
|
71
|
+
const menu = this.typeMenuTarget
|
|
72
|
+
const isOpen = menu.style.display !== "none"
|
|
73
|
+
|
|
74
|
+
if (isOpen) {
|
|
75
|
+
this._closeTypeMenu()
|
|
76
|
+
} else {
|
|
77
|
+
menu.style.display = "block"
|
|
78
|
+
positionDropdown(menu)
|
|
79
|
+
this.#abortController = new AbortController()
|
|
80
|
+
const { signal } = this.#abortController
|
|
81
|
+
|
|
82
|
+
// Close on outside click (delayed to avoid catching the current click)
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
document.addEventListener("click", (e) => {
|
|
85
|
+
if (!menu.contains(e.target) && !event.currentTarget.contains(e.target)) {
|
|
86
|
+
this._closeTypeMenu()
|
|
87
|
+
}
|
|
88
|
+
}, { capture: true, once: true, signal })
|
|
89
|
+
}, 0)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
toggleCollapse(event) {
|
|
94
|
+
event.preventDefault()
|
|
95
|
+
const block = event.currentTarget.closest(`[data-${this.identifier}-target='block']`)
|
|
96
|
+
if (!block) return
|
|
97
|
+
|
|
98
|
+
const content = block.querySelector(`[data-${this.identifier}-target='blockContent']`)
|
|
99
|
+
if (content) {
|
|
100
|
+
content.style.display = content.style.display === "none" ? "" : "none"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Private ---
|
|
105
|
+
|
|
106
|
+
_closeTypeMenu() {
|
|
107
|
+
this.typeMenuTarget.style.display = "none"
|
|
108
|
+
this.#abortController?.abort()
|
|
109
|
+
this.#abortController = null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_updateEmptyMessage() {
|
|
113
|
+
if (this.hasEmptyMessageTarget) {
|
|
114
|
+
const hasBlocks = this.listTarget.children.length > 0
|
|
115
|
+
this.emptyMessageTarget.style.display = hasBlocks ? "none" : ""
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Re-exports the content_blocks_controller under the name "nested-content-blocks".
|
|
2
|
+
// This provides Stimulus scope isolation when ContentBlocks are used inside
|
|
3
|
+
// Repeaters or other ContentBlocks containers.
|
|
4
|
+
export { default } from "controllers/content_blocks_controller"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Positions an absolutely-positioned dropdown element above or below its
|
|
2
|
+
// container based on available viewport space.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// import { positionDropdown } from "spina/utils/dropdown_position"
|
|
6
|
+
// positionDropdown(this.dropdownTarget)
|
|
7
|
+
//
|
|
8
|
+
export function positionDropdown(dropdown) {
|
|
9
|
+
const container = dropdown.parentElement
|
|
10
|
+
const containerRect = container.getBoundingClientRect()
|
|
11
|
+
const dropdownHeight = dropdown.offsetHeight
|
|
12
|
+
const viewportHeight = window.innerHeight
|
|
13
|
+
|
|
14
|
+
const spaceBelow = viewportHeight - containerRect.bottom
|
|
15
|
+
const spaceAbove = containerRect.top
|
|
16
|
+
|
|
17
|
+
if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) {
|
|
18
|
+
// Not enough space below and more space above — show above
|
|
19
|
+
dropdown.style.bottom = "100%"
|
|
20
|
+
dropdown.style.top = "auto"
|
|
21
|
+
dropdown.style.marginBottom = "4px"
|
|
22
|
+
dropdown.style.marginTop = "0"
|
|
23
|
+
} else {
|
|
24
|
+
// Default: show below
|
|
25
|
+
dropdown.style.bottom = "auto"
|
|
26
|
+
dropdown.style.top = "100%"
|
|
27
|
+
dropdown.style.marginTop = "4px"
|
|
28
|
+
dropdown.style.marginBottom = "0"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -17,17 +17,19 @@ module Spina
|
|
|
17
17
|
|
|
18
18
|
@block_templates = current_theme.try(:block_templates) || []
|
|
19
19
|
|
|
20
|
-
if params[:
|
|
20
|
+
if params[:filter] == "layout"
|
|
21
|
+
@current_filter = "layout"
|
|
22
|
+
@blocks = Spina::Blocks::Block.undeletable.sorted
|
|
23
|
+
elsif params[:block_template].present?
|
|
21
24
|
@current_block_template = params[:block_template]
|
|
22
25
|
@blocks = Spina::Blocks::Block.where(block_template: @current_block_template).sorted
|
|
23
26
|
else
|
|
24
|
-
@current_block_template = nil
|
|
25
27
|
@blocks = Spina::Blocks::Block.sorted
|
|
26
28
|
end
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
def new
|
|
30
|
-
@block_templates =
|
|
32
|
+
@block_templates = creatable_block_templates
|
|
31
33
|
@block = Spina::Blocks::Block.new(block_template: params[:block_template])
|
|
32
34
|
@modal = params[:modal]
|
|
33
35
|
end
|
|
@@ -41,7 +43,7 @@ module Spina
|
|
|
41
43
|
redirect_to(spina.edit_blocks_admin_block_url(@block))
|
|
42
44
|
end
|
|
43
45
|
else
|
|
44
|
-
@block_templates =
|
|
46
|
+
@block_templates = creatable_block_templates
|
|
45
47
|
if params[:modal]
|
|
46
48
|
@modal = true
|
|
47
49
|
render(turbo_stream: turbo_stream.update(
|
|
@@ -115,6 +117,12 @@ module Spina
|
|
|
115
117
|
end
|
|
116
118
|
|
|
117
119
|
def destroy
|
|
120
|
+
unless @block.deletable?
|
|
121
|
+
flash[:error] = I18n.t("spina.blocks.cannot_be_deleted")
|
|
122
|
+
redirect_to(spina.edit_blocks_admin_block_url(@block))
|
|
123
|
+
return
|
|
124
|
+
end
|
|
125
|
+
|
|
118
126
|
flash[:info] = I18n.t("spina.blocks.deleted")
|
|
119
127
|
@block.destroy
|
|
120
128
|
redirect_to(spina.blocks_admin_blocks_url)
|
|
@@ -142,6 +150,16 @@ module Spina
|
|
|
142
150
|
titles
|
|
143
151
|
end
|
|
144
152
|
|
|
153
|
+
# Returns block templates available for manual block creation,
|
|
154
|
+
# excluding templates reserved for layout (custom) blocks.
|
|
155
|
+
def creatable_block_templates
|
|
156
|
+
all_templates = current_theme.try(:block_templates) || []
|
|
157
|
+
layout_template_names = (current_theme.try(:custom_blocks) || [])
|
|
158
|
+
.map { |cb| cb[:block_template].to_s }.uniq
|
|
159
|
+
|
|
160
|
+
all_templates.reject { |bt| layout_template_names.include?(bt[:name].to_s) }
|
|
161
|
+
end
|
|
162
|
+
|
|
145
163
|
def block_params
|
|
146
164
|
params.require(:block).permit!
|
|
147
165
|
end
|
|
@@ -32,6 +32,13 @@ module Spina
|
|
|
32
32
|
|
|
33
33
|
def destroy
|
|
34
34
|
@page_block = @page.page_blocks.find(params[:id])
|
|
35
|
+
|
|
36
|
+
unless @page_block.block.deletable?
|
|
37
|
+
flash[:error] = I18n.t("spina.page_blocks.cannot_remove_layout_block")
|
|
38
|
+
redirect_to(spina.blocks_admin_page_page_blocks_url(page_id: @page.id))
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
35
42
|
@page_block.destroy
|
|
36
43
|
flash[:info] = I18n.t("spina.page_blocks.removed")
|
|
37
44
|
redirect_to(spina.blocks_admin_page_page_blocks_url(page_id: @page.id))
|
|
@@ -17,9 +17,13 @@ module Spina
|
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
# Render a single block using its block_template partial
|
|
21
|
-
#
|
|
22
|
-
|
|
20
|
+
# Render a single block using its block_template partial.
|
|
21
|
+
# An optional content block is forwarded to the partial, so block
|
|
22
|
+
# templates can expose `<%= yield %>` slots for callers to inject markup.
|
|
23
|
+
# Usage:
|
|
24
|
+
# <%= render_block(some_block) %>
|
|
25
|
+
# <%= render_block(some_block) do %>...<% end %>
|
|
26
|
+
def render_block(block, &content_block)
|
|
23
27
|
return unless block&.active?
|
|
24
28
|
|
|
25
29
|
current_spina_theme = Spina::Current.theme || current_theme
|
|
@@ -28,7 +32,10 @@ module Spina
|
|
|
28
32
|
partial_path = "#{theme_name}/blocks/#{block.block_template}"
|
|
29
33
|
|
|
30
34
|
if lookup_context.exists?(partial_path, [], true)
|
|
31
|
-
render(
|
|
35
|
+
# Use the string-form `render(string, locals, &block)` so passing a
|
|
36
|
+
# content block doesn't trigger `render`'s implicit layout branch
|
|
37
|
+
# (which would treat `:partial` as the wrapped content instead).
|
|
38
|
+
render(partial_path, { block: block }, &content_block)
|
|
32
39
|
else
|
|
33
40
|
render_block_fallback(block)
|
|
34
41
|
end
|
|
@@ -45,6 +52,15 @@ module Spina
|
|
|
45
52
|
block.has_content?(part_name)
|
|
46
53
|
end
|
|
47
54
|
|
|
55
|
+
# Render a custom (system) block by its unique key
|
|
56
|
+
# Usage:
|
|
57
|
+
# <%= render_custom_block("header") %>
|
|
58
|
+
# <%= render_custom_block("header") do %>...<% end %>
|
|
59
|
+
def render_custom_block(key, &content_block)
|
|
60
|
+
block = Spina::Blocks::Block.find_by(key: key)
|
|
61
|
+
render_block(block, &content_block) if block
|
|
62
|
+
end
|
|
63
|
+
|
|
48
64
|
private
|
|
49
65
|
|
|
50
66
|
def render_block_fallback(block)
|