spina-blocks 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +108 -8
- data/app/assets/javascripts/spina/controllers/block_collection_controller.js +5 -29
- 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/parts/block_reference.rb +19 -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_references/_form.html.erb +16 -5
- data/app/views/spina/admin/parts/content_blocks/_fields.html.erb +57 -0
- data/app/views/spina/admin/parts/content_blocks/_form.html.erb +73 -0
- data/app/views/spina/admin/parts/nested_content_blocks/_form.html.erb +75 -0
- data/app/views/spina/blocks/admin/blocks/_form.html.erb +9 -5
- data/app/views/spina/blocks/admin/blocks/_form_block_settings.html.erb +12 -0
- data/app/views/spina/blocks/admin/blocks/index.html.erb +30 -5
- data/app/views/spina/blocks/admin/page_blocks/index.html.erb +14 -8
- data/config/locales/en.yml +22 -0
- data/db/migrate/6_add_custom_block_fields_to_spina_blocks_blocks.rb +14 -0
- data/lib/spina/blocks/engine.rb +15 -2
- data/lib/spina/blocks/version.rb +1 -1
- metadata +11 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 24a864994819ca4dd7ee93b0cd6957d008d08586659ad9672654014b2add6961
|
|
4
|
+
data.tar.gz: 264aca6e16771526496af183ef5443091dc4ae70ea5f550bc0e29f0e9c5d1d5a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 297624cd733819fbdb489b8a6cfe6a5290959eac521aa11117fbf11aa4b50f999c9b4fccba3c3821e7d16a679b5023943b8bd2470f8a9f114529591a4bdfa899
|
|
7
|
+
data.tar.gz: 448f9470f24ae571bb6ac2f95aa882bca7b6504c968d5369d591b8e8a1d53711f6fc0d60becb2a793c228e0768b13ab63fc4ea3eb7add2bbf883b864d2fc42af
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
|
|
|
@@ -72,6 +72,12 @@ Spina::Theme.register do |theme|
|
|
|
72
72
|
}
|
|
73
73
|
]
|
|
74
74
|
|
|
75
|
+
# Custom blocks (layout blocks, created automatically during bootstrap)
|
|
76
|
+
theme.custom_blocks = [
|
|
77
|
+
{ name: "header", title: "Header Block", block_template: "header", category: "heroes" },
|
|
78
|
+
{ name: "footer", title: "Footer Block", block_template: "footer", category: "cta" }
|
|
79
|
+
]
|
|
80
|
+
|
|
75
81
|
# Page templates (can use BlockCollection or BlockReference parts)
|
|
76
82
|
theme.view_templates = [
|
|
77
83
|
{
|
|
@@ -149,6 +155,94 @@ Then in your template:
|
|
|
149
155
|
<% end %>
|
|
150
156
|
```
|
|
151
157
|
|
|
158
|
+
## Custom blocks (layout blocks)
|
|
159
|
+
|
|
160
|
+
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.
|
|
161
|
+
|
|
162
|
+
### Defining custom blocks
|
|
163
|
+
|
|
164
|
+
Add `custom_blocks` to your theme initializer:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
theme.custom_blocks = [
|
|
168
|
+
{ name: "header", title: "Header Block", block_template: "header", category: "heroes" },
|
|
169
|
+
{ name: "footer", title: "Footer Block", block_template: "footer", category: "cta" }
|
|
170
|
+
]
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Each custom block accepts:
|
|
174
|
+
|
|
175
|
+
| Key | Description |
|
|
176
|
+
| ---------------- | ------------------------------------------------------------------------ |
|
|
177
|
+
| `name` | Immutable machine key (used for lookups in code and templates) |
|
|
178
|
+
| `title` | (Optional) Initial display name shown in admin. Defaults to `name.titleize`. Editable by users after creation |
|
|
179
|
+
| `block_template` | Must match a template defined in `theme.block_templates` |
|
|
180
|
+
| `category` | (Optional) Name of a category from `theme.block_categories` |
|
|
181
|
+
|
|
182
|
+
### Key vs Name
|
|
183
|
+
|
|
184
|
+
Custom blocks have two identifiers:
|
|
185
|
+
|
|
186
|
+
- **`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.
|
|
187
|
+
- **`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.
|
|
188
|
+
|
|
189
|
+
Regular (non-layout) blocks do not have a `key` — it is `nil` for them.
|
|
190
|
+
|
|
191
|
+
### How it works
|
|
192
|
+
|
|
193
|
+
- **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`.
|
|
194
|
+
- **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.
|
|
195
|
+
- **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.
|
|
196
|
+
- **Template restriction**: Block templates used by custom blocks are excluded from the "New block" form, preventing users from manually creating blocks with layout templates.
|
|
197
|
+
- **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.
|
|
198
|
+
|
|
199
|
+
### Using custom blocks in templates
|
|
200
|
+
|
|
201
|
+
#### Direct rendering by key
|
|
202
|
+
|
|
203
|
+
Use `render_custom_block` to render a system block directly in any template:
|
|
204
|
+
|
|
205
|
+
```erb
|
|
206
|
+
<%# app/views/my_theme/pages/homepage.html.erb %>
|
|
207
|
+
<%= render_custom_block("header") %>
|
|
208
|
+
|
|
209
|
+
<main>
|
|
210
|
+
<%= render_blocks %>
|
|
211
|
+
</main>
|
|
212
|
+
|
|
213
|
+
<%= render_custom_block("footer") %>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
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.
|
|
217
|
+
|
|
218
|
+
#### As a pre-defined BlockReference
|
|
219
|
+
|
|
220
|
+
Use the `custom_block` option in a `BlockReference` part to hardcode which block it points to:
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
theme.parts = [
|
|
224
|
+
{ name: "header_block", title: "Header",
|
|
225
|
+
part_type: "Spina::Parts::BlockReference",
|
|
226
|
+
options: { custom_block: "header" } },
|
|
227
|
+
|
|
228
|
+
{ name: "footer_block", title: "Footer",
|
|
229
|
+
part_type: "Spina::Parts::BlockReference",
|
|
230
|
+
options: { custom_block: "footer" } }
|
|
231
|
+
]
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
When `custom_block` is set:
|
|
235
|
+
- The part's `content` method returns the block by `key` (ignoring `block_id`)
|
|
236
|
+
- In the admin form, instead of a dropdown, a clickable link to the block's edit page is shown
|
|
237
|
+
- If the custom block does not exist yet, a warning message is displayed
|
|
238
|
+
|
|
239
|
+
Then in your template:
|
|
240
|
+
|
|
241
|
+
```erb
|
|
242
|
+
<%= render_block(content(:header_block)) %>
|
|
243
|
+
<%= render_block(content(:footer_block)) %>
|
|
244
|
+
```
|
|
245
|
+
|
|
152
246
|
## Models
|
|
153
247
|
|
|
154
248
|
| Model | Description |
|
|
@@ -162,18 +256,24 @@ Then in your template:
|
|
|
162
256
|
The plugin adds:
|
|
163
257
|
|
|
164
258
|
- **Blocks** link in the Content section of the admin sidebar
|
|
165
|
-
- Block library with
|
|
259
|
+
- Block library with template filter dropdown
|
|
166
260
|
- Block editor with content fields (same as page editor)
|
|
167
261
|
- Page Blocks management page (per-page block assignment and ordering)
|
|
262
|
+
- **Layout** badge on non-deletable blocks in the block library (right-aligned)
|
|
263
|
+
- **Layout blocks** filter in the template filter dropdown
|
|
264
|
+
- `...` menu shows "Block can't be deleted" for layout blocks (like Spina pages)
|
|
265
|
+
- Block templates used by layout blocks are hidden from the "New block" form
|
|
266
|
+
- Immutable **key** shown as read-only in block settings for layout blocks
|
|
168
267
|
|
|
169
268
|
## Helper methods
|
|
170
269
|
|
|
171
|
-
| Helper | Description
|
|
172
|
-
| --------------------------------------- |
|
|
173
|
-
| `render_blocks(page)` | Render all blocks attached to a page via PageBlocks
|
|
174
|
-
| `render_block(block)` | Render a single block using its template partial
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
270
|
+
| Helper | Description |
|
|
271
|
+
| --------------------------------------- | ------------------------------------------------------ |
|
|
272
|
+
| `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 |
|
|
275
|
+
| `block_content(block, :part_name)` | Access a block's content |
|
|
276
|
+
| `block_has_content?(block, :part_name)` | Check if a block has content |
|
|
177
277
|
|
|
178
278
|
## License
|
|
179
279
|
|
|
@@ -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,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)
|
|
@@ -7,6 +7,7 @@ module Spina
|
|
|
7
7
|
|
|
8
8
|
included do
|
|
9
9
|
after_save :bootstrap_block_categories
|
|
10
|
+
after_save :bootstrap_custom_blocks
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
private
|
|
@@ -22,6 +23,37 @@ module Spina
|
|
|
22
23
|
.update(label: category[:label], position: index)
|
|
23
24
|
end
|
|
24
25
|
end
|
|
26
|
+
|
|
27
|
+
def bootstrap_custom_blocks
|
|
28
|
+
theme_config = Spina::Theme.find_by_name(theme)
|
|
29
|
+
return unless theme_config
|
|
30
|
+
return unless theme_config.respond_to?(:custom_blocks) && theme_config.custom_blocks.present?
|
|
31
|
+
|
|
32
|
+
theme_config.custom_blocks.each do |block_config|
|
|
33
|
+
category = if block_config[:category].present?
|
|
34
|
+
Spina::Blocks::Category.find_by(name: block_config[:category])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
display_name = block_config[:title].presence || block_config[:name].to_s.titleize
|
|
38
|
+
|
|
39
|
+
block = Spina::Blocks::Block
|
|
40
|
+
.where(key: block_config[:name])
|
|
41
|
+
.first_or_initialize
|
|
42
|
+
|
|
43
|
+
block.assign_attributes(
|
|
44
|
+
name: block.persisted? ? block.name : display_name,
|
|
45
|
+
block_template: block_config[:block_template],
|
|
46
|
+
category: category,
|
|
47
|
+
deletable: false,
|
|
48
|
+
active: block.persisted? ? block.active : true,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Set key only on new records (it is immutable after creation)
|
|
52
|
+
block.key = block_config[:name] unless block.persisted?
|
|
53
|
+
|
|
54
|
+
block.save!
|
|
55
|
+
end
|
|
56
|
+
end
|
|
25
57
|
end
|
|
26
58
|
end
|
|
27
59
|
end
|
|
@@ -12,15 +12,41 @@ module Spina
|
|
|
12
12
|
has_many :page_blocks, class_name: "Spina::Blocks::PageBlock", dependent: :destroy
|
|
13
13
|
has_many :pages, through: :page_blocks, class_name: "Spina::Page"
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
before_destroy :ensure_deletable
|
|
16
|
+
|
|
17
|
+
validates :name, presence: true, uniqueness: true
|
|
16
18
|
validates :block_template, presence: true
|
|
19
|
+
validates :key, uniqueness: { allow_nil: true }
|
|
20
|
+
validate :key_must_not_change, on: :update
|
|
17
21
|
|
|
18
22
|
scope :active, -> { where(active: true) }
|
|
19
23
|
scope :sorted, -> { order(:position) }
|
|
24
|
+
scope :deletable, -> { where(deletable: true) }
|
|
25
|
+
scope :undeletable, -> { where(deletable: false) }
|
|
20
26
|
|
|
21
27
|
def to_s
|
|
22
28
|
name
|
|
23
29
|
end
|
|
30
|
+
|
|
31
|
+
# Whether this block is a system (custom) block with an immutable key
|
|
32
|
+
def system?
|
|
33
|
+
key.present?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def ensure_deletable
|
|
39
|
+
unless deletable?
|
|
40
|
+
errors.add(:base, :undeletable)
|
|
41
|
+
throw(:abort)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def key_must_not_change
|
|
46
|
+
if key_was.present? && key_changed?
|
|
47
|
+
errors.add(:key, :immutable)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
24
50
|
end
|
|
25
51
|
end
|
|
26
52
|
end
|
|
@@ -10,7 +10,25 @@ module Spina
|
|
|
10
10
|
attr_accessor :options
|
|
11
11
|
|
|
12
12
|
def content
|
|
13
|
-
|
|
13
|
+
if custom_block_name.present?
|
|
14
|
+
custom_block_record
|
|
15
|
+
else
|
|
16
|
+
::Spina::Blocks::Block.active.find_by(id: block_id)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns the custom_block key from options, supporting both symbol and string keys
|
|
21
|
+
def custom_block_name
|
|
22
|
+
return if options.blank?
|
|
23
|
+
|
|
24
|
+
options[:custom_block] || options["custom_block"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Looks up the custom block record by key
|
|
28
|
+
def custom_block_record
|
|
29
|
+
return if custom_block_name.blank?
|
|
30
|
+
|
|
31
|
+
::Spina::Blocks::Block.find_by(key: custom_block_name)
|
|
14
32
|
end
|
|
15
33
|
end
|
|
16
34
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spina
|
|
4
|
+
module Parts
|
|
5
|
+
class ContentBlock < Base
|
|
6
|
+
include Partable
|
|
7
|
+
include AttrJson::NestedAttributes
|
|
8
|
+
|
|
9
|
+
attr_json :block_type, :string
|
|
10
|
+
attr_json :parts, AttrJson::Type::SpinaPartsModel.new, array: true
|
|
11
|
+
attr_json_accepts_nested_attributes_for :parts
|
|
12
|
+
|
|
13
|
+
def find_part(name)
|
|
14
|
+
(parts || []).find { |part| part.name.to_s == name.to_s }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spina
|
|
4
|
+
module Parts
|
|
5
|
+
class ContentBlocks < Base
|
|
6
|
+
include AttrJson::NestedAttributes
|
|
7
|
+
|
|
8
|
+
attr_json :content, ContentBlock.to_type, array: true
|
|
9
|
+
attr_json_accepts_nested_attributes_for :content
|
|
10
|
+
|
|
11
|
+
attr_accessor :options
|
|
12
|
+
|
|
13
|
+
# Returns available content block templates from the theme,
|
|
14
|
+
# optionally filtered by the :content_block_templates option.
|
|
15
|
+
def available_templates(theme)
|
|
16
|
+
templates = theme.content_block_templates || []
|
|
17
|
+
allowed = options&.dig(:content_block_templates)
|
|
18
|
+
return templates if allowed.blank?
|
|
19
|
+
|
|
20
|
+
allowed_names = Array(allowed).map(&:to_s)
|
|
21
|
+
templates.select { |t| allowed_names.include?(t[:name].to_s) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spina
|
|
4
|
+
module Parts
|
|
5
|
+
# Same behavior as ContentBlocks but registered as a separate Part type
|
|
6
|
+
# with its own Stimulus controller name ("nested-content-blocks").
|
|
7
|
+
# This avoids Stimulus scope conflicts when used inside Repeaters or
|
|
8
|
+
# other ContentBlocks containers.
|
|
9
|
+
#
|
|
10
|
+
# Use `options[:content_block_templates]` to limit which block types
|
|
11
|
+
# are available (prevents recursive nesting of the same block type).
|
|
12
|
+
class NestedContentBlocks < ContentBlocks
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -4,10 +4,21 @@
|
|
|
4
4
|
<div class="text-gray-400 text-sm"><%= f.object.hint %></div>
|
|
5
5
|
<% end %>
|
|
6
6
|
<div class="mt-1">
|
|
7
|
-
<%
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
<% if f.object.custom_block_name.present? %>
|
|
8
|
+
<% custom_block = f.object.custom_block_record %>
|
|
9
|
+
<% if custom_block %>
|
|
10
|
+
<%= link_to custom_block.name,
|
|
11
|
+
spina.edit_blocks_admin_block_path(custom_block),
|
|
12
|
+
class: "inline-flex items-center text-sm font-medium text-spina-primary hover:underline" %>
|
|
13
|
+
<% else %>
|
|
14
|
+
<span class="text-sm text-red-500"><%= t('spina.blocks.custom_block_not_found') %></span>
|
|
15
|
+
<% end %>
|
|
16
|
+
<% else %>
|
|
17
|
+
<% blocks = f.object.available_blocks %>
|
|
18
|
+
<%= f.select :block_id,
|
|
19
|
+
blocks.map { |b| [b.name, b.id] },
|
|
20
|
+
{include_blank: t('spina.blocks.select_block')},
|
|
21
|
+
class: "form-select text-sm w-full shadow-xs" %>
|
|
22
|
+
<% end %>
|
|
12
23
|
</div>
|
|
13
24
|
</div>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Renders form fields for a single ContentBlock item.
|
|
3
|
+
Looks up the content_block_template by block_type to determine which parts to render.
|
|
4
|
+
|
|
5
|
+
Accepts an optional `stimulus_controller` local to support nested contexts.
|
|
6
|
+
Defaults to "content-blocks".
|
|
7
|
+
%>
|
|
8
|
+
<% sc = local_assigns[:stimulus_controller] || "content-blocks" %>
|
|
9
|
+
<% template_config = current_theme.content_block_templates&.find { |t| t[:name].to_s == f.object.block_type.to_s } || {} %>
|
|
10
|
+
<% template_title = template_config[:title] || f.object.block_type&.titleize || "Block" %>
|
|
11
|
+
<% parts = template_config[:parts] || [] %>
|
|
12
|
+
|
|
13
|
+
<div class="border border-gray-200 rounded-md bg-white shadow-sm"
|
|
14
|
+
data-<%= sc %>-target="block"
|
|
15
|
+
data-block-type="<%= f.object.block_type %>">
|
|
16
|
+
|
|
17
|
+
<%= f.hidden_field :name %>
|
|
18
|
+
<%= f.hidden_field :block_type %>
|
|
19
|
+
|
|
20
|
+
<%# Header: drag handle, type label (clickable to collapse), delete with confirm %>
|
|
21
|
+
<div class="flex items-center px-4 py-3 border-b border-gray-100 bg-gray-50 rounded-t-md cursor-pointer select-none"
|
|
22
|
+
data-action="click-><%= sc %>#toggleCollapse">
|
|
23
|
+
<button type="button" data-sortable-handle
|
|
24
|
+
class="mr-3 text-gray-300 hover:text-gray-500 cursor-grab">
|
|
25
|
+
<svg class="w-4 h-4" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
|
|
26
|
+
<path d="M432 288H16c-8.8 0-16 7.2-16 16v16c0 8.8 7.2 16 16 16h416c8.8 0 16-7.2 16-16v-16c0-8.8-7.2-16-16-16zm0-112H16c-8.8 0-16 7.2-16 16v16c0 8.8 7.2 16 16 16h416c8.8 0 16-7.2 16-16v-16c0-8.8-7.2-16-16-16z"/>
|
|
27
|
+
</svg>
|
|
28
|
+
</button>
|
|
29
|
+
|
|
30
|
+
<span class="flex-1 text-sm font-medium text-gray-700"><%= template_title %></span>
|
|
31
|
+
|
|
32
|
+
<button type="button"
|
|
33
|
+
class="ml-3 text-gray-300 hover:text-red-500 transition-colors"
|
|
34
|
+
data-action="click-><%= sc %>#removeBlock:stop"
|
|
35
|
+
title="<%= t('spina.content_blocks.remove') %>">
|
|
36
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
37
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
38
|
+
</svg>
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<%# Content: form fields for this block's parts %>
|
|
43
|
+
<div class="px-4 pb-4" data-<%= sc %>-target="blockContent">
|
|
44
|
+
<% if parts.any? %>
|
|
45
|
+
<%= f.fields_for :parts, build_parts(f.object, parts) do |ff| %>
|
|
46
|
+
<%= ff.hidden_field :title %>
|
|
47
|
+
<%= ff.hidden_field :type, value: ff.object.class %>
|
|
48
|
+
<%= ff.hidden_field :name %>
|
|
49
|
+
<%= render "spina/admin/parts/#{parts_partial_namespace(ff.object.class.to_s)}/form", f: ff %>
|
|
50
|
+
<% end %>
|
|
51
|
+
<% else %>
|
|
52
|
+
<p class="text-sm text-gray-400 italic mt-4">
|
|
53
|
+
<%= t('spina.content_blocks.no_template_found', type: f.object.block_type) %>
|
|
54
|
+
</p>
|
|
55
|
+
<% end %>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Admin form for Spina::Parts::ContentBlocks.
|
|
3
|
+
Renders an ordered list of typed content blocks with add/remove/reorder.
|
|
4
|
+
%>
|
|
5
|
+
<% available_templates = f.object.available_templates(current_theme) %>
|
|
6
|
+
|
|
7
|
+
<div class="mt-6" data-controller="content-blocks">
|
|
8
|
+
<label class="block text-sm leading-5 font-medium text-gray-700"><%= f.object.title %></label>
|
|
9
|
+
<% if f.object.hint.present? %>
|
|
10
|
+
<div class="text-gray-400 text-sm mb-2"><%= f.object.hint %></div>
|
|
11
|
+
<% end %>
|
|
12
|
+
|
|
13
|
+
<%# List of existing content blocks %>
|
|
14
|
+
<div data-content-blocks-target="list" class="space-y-3 mb-4">
|
|
15
|
+
<% (f.object.content || []).each do |content_block| %>
|
|
16
|
+
<%= f.fields_for :content, [content_block], child_index: content_block.object_id do |ff| %>
|
|
17
|
+
<%= render "spina/admin/parts/content_blocks/fields", f: ff %>
|
|
18
|
+
<% end %>
|
|
19
|
+
<% end %>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<%# Empty state %>
|
|
23
|
+
<div data-content-blocks-target="emptyMessage"
|
|
24
|
+
class="text-sm text-gray-400 italic py-3 text-center"
|
|
25
|
+
style="<%= (f.object.content || []).any? ? 'display:none' : '' %>">
|
|
26
|
+
<%= t('spina.content_blocks.empty') %>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<%# Add block button with type picker dropdown %>
|
|
30
|
+
<% if available_templates.any? %>
|
|
31
|
+
<div class="relative">
|
|
32
|
+
<button type="button"
|
|
33
|
+
class="btn btn-default text-sm"
|
|
34
|
+
data-action="content-blocks#toggleTypeMenu">
|
|
35
|
+
<svg class="w-4 h-4 mr-1.5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
36
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
|
37
|
+
</svg>
|
|
38
|
+
<%= t('spina.content_blocks.add_block') %>
|
|
39
|
+
</button>
|
|
40
|
+
|
|
41
|
+
<div data-content-blocks-target="typeMenu"
|
|
42
|
+
class="absolute z-10 mt-1 w-56 bg-white border border-gray-200 rounded-md shadow-lg py-1"
|
|
43
|
+
style="display:none">
|
|
44
|
+
<% available_templates.each do |template| %>
|
|
45
|
+
<button type="button"
|
|
46
|
+
class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
|
47
|
+
data-action="content-blocks#addBlock"
|
|
48
|
+
data-block-type="<%= template[:name] %>">
|
|
49
|
+
<%= template[:title] || template[:name].titleize %>
|
|
50
|
+
</button>
|
|
51
|
+
<% end %>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
<% end %>
|
|
55
|
+
|
|
56
|
+
<%# Pre-rendered templates for each block type (used by Stimulus to clone) %>
|
|
57
|
+
<% available_templates.each do |template| %>
|
|
58
|
+
<%
|
|
59
|
+
content_block = Spina::Parts::ContentBlock.new(
|
|
60
|
+
name: f.object.name,
|
|
61
|
+
block_type: template[:name].to_s
|
|
62
|
+
)
|
|
63
|
+
child_index_placeholder = content_block.object_id.to_s
|
|
64
|
+
%>
|
|
65
|
+
<template data-content-blocks-target="blockTemplate"
|
|
66
|
+
data-block-type="<%= template[:name] %>"
|
|
67
|
+
data-child-index="<%= child_index_placeholder %>">
|
|
68
|
+
<%= f.fields_for(:content, [content_block], child_index: child_index_placeholder) do |ff| %>
|
|
69
|
+
<%= render "spina/admin/parts/content_blocks/fields", f: ff %>
|
|
70
|
+
<% end %>
|
|
71
|
+
</template>
|
|
72
|
+
<% end %>
|
|
73
|
+
</div>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Admin form for Spina::Parts::NestedContentBlocks.
|
|
3
|
+
Same as ContentBlocks but uses "nested-content-blocks" Stimulus controller
|
|
4
|
+
to avoid scope conflicts when rendered inside Repeaters or other ContentBlocks.
|
|
5
|
+
Reuses content_blocks/_fields.html.erb for individual block rendering.
|
|
6
|
+
%>
|
|
7
|
+
<% available_templates = f.object.available_templates(current_theme) %>
|
|
8
|
+
|
|
9
|
+
<div class="mt-4" data-controller="nested-content-blocks">
|
|
10
|
+
<label class="block text-sm leading-5 font-medium text-gray-700"><%= f.object.title %></label>
|
|
11
|
+
<% if f.object.hint.present? %>
|
|
12
|
+
<div class="text-gray-400 text-sm mb-2"><%= f.object.hint %></div>
|
|
13
|
+
<% end %>
|
|
14
|
+
|
|
15
|
+
<%# List of existing content blocks %>
|
|
16
|
+
<div data-nested-content-blocks-target="list" class="space-y-3 mb-4">
|
|
17
|
+
<% (f.object.content || []).each do |content_block| %>
|
|
18
|
+
<%= f.fields_for :content, [content_block], child_index: content_block.object_id do |ff| %>
|
|
19
|
+
<%= render "spina/admin/parts/content_blocks/fields", f: ff, stimulus_controller: "nested-content-blocks" %>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% end %>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<%# Empty state %>
|
|
25
|
+
<div data-nested-content-blocks-target="emptyMessage"
|
|
26
|
+
class="text-sm text-gray-400 italic py-3 text-center"
|
|
27
|
+
style="<%= (f.object.content || []).any? ? 'display:none' : '' %>">
|
|
28
|
+
<%= t('spina.content_blocks.empty') %>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<%# Add block button with type picker dropdown %>
|
|
32
|
+
<% if available_templates.any? %>
|
|
33
|
+
<div class="relative">
|
|
34
|
+
<button type="button"
|
|
35
|
+
class="btn btn-default text-sm"
|
|
36
|
+
data-action="nested-content-blocks#toggleTypeMenu">
|
|
37
|
+
<svg class="w-4 h-4 mr-1.5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
38
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
|
39
|
+
</svg>
|
|
40
|
+
<%= t('spina.content_blocks.add_block') %>
|
|
41
|
+
</button>
|
|
42
|
+
|
|
43
|
+
<div data-nested-content-blocks-target="typeMenu"
|
|
44
|
+
class="absolute z-10 mt-1 w-56 bg-white border border-gray-200 rounded-md shadow-lg py-1"
|
|
45
|
+
style="display:none">
|
|
46
|
+
<% available_templates.each do |template| %>
|
|
47
|
+
<button type="button"
|
|
48
|
+
class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
|
49
|
+
data-action="nested-content-blocks#addBlock"
|
|
50
|
+
data-block-type="<%= template[:name] %>">
|
|
51
|
+
<%= template[:title] || template[:name].titleize %>
|
|
52
|
+
</button>
|
|
53
|
+
<% end %>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
<% end %>
|
|
57
|
+
|
|
58
|
+
<%# Pre-rendered templates for each block type (used by Stimulus to clone) %>
|
|
59
|
+
<% available_templates.each do |template| %>
|
|
60
|
+
<%
|
|
61
|
+
content_block = Spina::Parts::ContentBlock.new(
|
|
62
|
+
name: f.object.name,
|
|
63
|
+
block_type: template[:name].to_s
|
|
64
|
+
)
|
|
65
|
+
child_index_placeholder = content_block.object_id.to_s
|
|
66
|
+
%>
|
|
67
|
+
<template data-nested-content-blocks-target="blockTemplate"
|
|
68
|
+
data-block-type="<%= template[:name] %>"
|
|
69
|
+
data-child-index="<%= child_index_placeholder %>">
|
|
70
|
+
<%= f.fields_for(:content, [content_block], child_index: child_index_placeholder) do |ff| %>
|
|
71
|
+
<%= render "spina/admin/parts/content_blocks/fields", f: ff, stimulus_controller: "nested-content-blocks" %>
|
|
72
|
+
<% end %>
|
|
73
|
+
</template>
|
|
74
|
+
<% end %>
|
|
75
|
+
</div>
|
|
@@ -12,11 +12,15 @@
|
|
|
12
12
|
|
|
13
13
|
<div hidden data-reveal data-transition class="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg border border-gray-200 z-30">
|
|
14
14
|
<div class="rounded-md bg-white shadow-xs py-1">
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
<% if @block.deletable? %>
|
|
16
|
+
<%= button_to t('spina.blocks.delete'),
|
|
17
|
+
spina.blocks_admin_block_path(@block),
|
|
18
|
+
method: :delete,
|
|
19
|
+
class: "block w-full text-left px-4 py-2 text-sm leading-5 font-medium text-red-500 cursor-pointer bg-white hover:bg-red-100/50 hover:text-red-500",
|
|
20
|
+
form: {data: {controller: "confirm", confirm_message: t('spina.blocks.delete_confirmation')}} %>
|
|
21
|
+
<% else %>
|
|
22
|
+
<span class="block px-4 py-2 text-sm leading-5 text-gray-400"><%= t('spina.blocks.cannot_be_deleted') %></span>
|
|
23
|
+
<% end %>
|
|
20
24
|
</div>
|
|
21
25
|
</div>
|
|
22
26
|
</div>
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
<div class="max-w-5xl grid grid-cols-1 md:grid-cols-3 gap-3 md:gap-6">
|
|
2
|
+
<% if @block.key.present? %>
|
|
3
|
+
<div class="col-span-1 mt-6 md:mt-0">
|
|
4
|
+
<label class="block text-sm font-medium leading-5 text-gray-700">
|
|
5
|
+
<%= Spina::Blocks::Block.human_attribute_name(:key) %>
|
|
6
|
+
</label>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="col-span-2 mt-1">
|
|
10
|
+
<span class="text-sm text-gray-500 font-mono"><%= @block.key %></span>
|
|
11
|
+
</div>
|
|
12
|
+
<% end %>
|
|
13
|
+
|
|
2
14
|
<div class="col-span-1 mt-6 md:mt-0">
|
|
3
15
|
<label class="block text-sm font-medium leading-5 text-gray-700">
|
|
4
16
|
<%= Spina::Blocks::Block.human_attribute_name(:block_template) %>
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
<%= render Spina::UserInterface::HeaderComponent.new do |header| %>
|
|
2
2
|
<% header.with_actions do %>
|
|
3
|
-
<%
|
|
4
|
-
|
|
3
|
+
<% creatable_templates = (current_theme.try(:block_templates) || []).reject { |bt|
|
|
4
|
+
((current_theme.try(:custom_blocks) || []).map { |cb| cb[:block_template].to_s }.uniq).include?(bt[:name].to_s)
|
|
5
|
+
} %>
|
|
6
|
+
<% if creatable_templates.any? %>
|
|
5
7
|
<%= link_to spina.new_blocks_admin_block_path,
|
|
6
8
|
class: "btn btn-primary",
|
|
7
9
|
data: {turbo_frame: "modal"} do %>
|
|
@@ -18,7 +20,10 @@
|
|
|
18
20
|
<button type="button"
|
|
19
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"
|
|
20
22
|
data-action="reveal#toggle">
|
|
21
|
-
<% if @
|
|
23
|
+
<% if @current_filter == "layout" %>
|
|
24
|
+
<%= heroicon('lock-closed', style: :mini, class: 'h-4 w-4 mr-1.5 -ml-0.5 opacity-75') %>
|
|
25
|
+
<%= t('spina.blocks.layout_blocks') %>
|
|
26
|
+
<% elsif @current_block_template.present? %>
|
|
22
27
|
<% current_bt = @block_templates.find { |bt| bt[:name].to_s == @current_block_template } %>
|
|
23
28
|
<%= current_bt ? current_bt[:title] : @current_block_template %>
|
|
24
29
|
<% else %>
|
|
@@ -30,18 +35,33 @@
|
|
|
30
35
|
|
|
31
36
|
<div hidden data-reveal data-transition class="origin-top-left absolute left-0 mt-2 w-56 rounded-md shadow-lg border border-gray-200 z-30">
|
|
32
37
|
<div class="rounded-md bg-white shadow-xs py-1">
|
|
38
|
+
<%# "All" filter %>
|
|
39
|
+
<% all_active = @current_block_template.nil? && @current_filter.nil? %>
|
|
33
40
|
<%= link_to spina.blocks_admin_blocks_path,
|
|
34
|
-
class: "flex items-center px-4 py-2 text-sm font-medium leading-5 #{
|
|
41
|
+
class: "flex items-center px-4 py-2 text-sm font-medium leading-5 #{all_active ? 'text-blue-600 bg-blue-50' : 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'}",
|
|
35
42
|
data: {action: "reveal#hide"} do %>
|
|
36
43
|
<%= heroicon('squares-2x2', class: 'h-4 w-4 mr-2 opacity-75') %>
|
|
37
44
|
<%= t('spina.blocks.all') %>
|
|
38
|
-
<% if
|
|
45
|
+
<% if all_active %>
|
|
46
|
+
<%= heroicon('check', style: :mini, class: 'w-4 h-4 ml-auto opacity-75') %>
|
|
47
|
+
<% end %>
|
|
48
|
+
<% end %>
|
|
49
|
+
|
|
50
|
+
<%# "Layout" filter %>
|
|
51
|
+
<% layout_active = @current_filter == "layout" %>
|
|
52
|
+
<%= link_to spina.blocks_admin_blocks_path(filter: "layout"),
|
|
53
|
+
class: "flex items-center px-4 py-2 text-sm font-medium leading-5 #{layout_active ? 'text-blue-600 bg-blue-50' : 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'}",
|
|
54
|
+
data: {action: "reveal#hide"} do %>
|
|
55
|
+
<%= heroicon('lock-closed', style: :mini, class: 'h-4 w-4 mr-2 opacity-75') %>
|
|
56
|
+
<%= t('spina.blocks.layout_blocks') %>
|
|
57
|
+
<% if layout_active %>
|
|
39
58
|
<%= heroicon('check', style: :mini, class: 'w-4 h-4 ml-auto opacity-75') %>
|
|
40
59
|
<% end %>
|
|
41
60
|
<% end %>
|
|
42
61
|
|
|
43
62
|
<div class="border-t border-gray-100 my-1"></div>
|
|
44
63
|
|
|
64
|
+
<%# Template filters %>
|
|
45
65
|
<% @block_templates.each do |bt| %>
|
|
46
66
|
<% active = @current_block_template == bt[:name].to_s %>
|
|
47
67
|
<%= link_to spina.blocks_admin_blocks_path(block_template: bt[:name]),
|
|
@@ -84,6 +104,11 @@
|
|
|
84
104
|
</div>
|
|
85
105
|
<% end %>
|
|
86
106
|
</div>
|
|
107
|
+
<% unless block.deletable? %>
|
|
108
|
+
<div class="pr-4 shrink-0">
|
|
109
|
+
<span class="text-sm font-medium text-blue-500"><%= t('spina.blocks.layout_block') %></span>
|
|
110
|
+
</div>
|
|
111
|
+
<% end %>
|
|
87
112
|
</li>
|
|
88
113
|
<% end %>
|
|
89
114
|
</ul>
|
|
@@ -70,14 +70,20 @@
|
|
|
70
70
|
</div>
|
|
71
71
|
<% end %>
|
|
72
72
|
</div>
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
73
|
+
<% if page_block.block.deletable? %>
|
|
74
|
+
<div class="pr-4">
|
|
75
|
+
<%= button_to spina.blocks_admin_page_page_block_path(page_id: @page.id, id: page_block.id),
|
|
76
|
+
method: :delete,
|
|
77
|
+
class: "text-gray-300 hover:text-red-500 transition-colors",
|
|
78
|
+
form: {data: {controller: "confirm", confirm_message: t('spina.page_blocks.remove_confirmation')}} do %>
|
|
79
|
+
<%= heroicon('x-mark', style: :mini, class: 'w-5 h-5') %>
|
|
80
|
+
<% end %>
|
|
81
|
+
</div>
|
|
82
|
+
<% else %>
|
|
83
|
+
<div class="pr-4">
|
|
84
|
+
<span class="text-sm font-medium text-blue-500"><%= t('spina.blocks.layout_block') %></span>
|
|
85
|
+
</div>
|
|
86
|
+
<% end %>
|
|
81
87
|
</li>
|
|
82
88
|
<% end %>
|
|
83
89
|
</ul>
|
data/config/locales/en.yml
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
en:
|
|
2
|
+
activerecord:
|
|
3
|
+
attributes:
|
|
4
|
+
spina/blocks/block:
|
|
5
|
+
key: "Key"
|
|
6
|
+
errors:
|
|
7
|
+
models:
|
|
8
|
+
spina/blocks/block:
|
|
9
|
+
attributes:
|
|
10
|
+
base:
|
|
11
|
+
undeletable: "This block is a layout block and cannot be deleted."
|
|
12
|
+
key:
|
|
13
|
+
immutable: "cannot be changed after creation"
|
|
2
14
|
spina:
|
|
3
15
|
blocks:
|
|
4
16
|
title: "Blocks"
|
|
@@ -10,6 +22,7 @@ en:
|
|
|
10
22
|
deleted: "Block deleted"
|
|
11
23
|
delete: "Delete block"
|
|
12
24
|
delete_confirmation: "Are you sure you want to delete this block?"
|
|
25
|
+
cannot_be_deleted: "Block can't be deleted"
|
|
13
26
|
couldnt_be_saved: "Block couldn't be saved"
|
|
14
27
|
sorting_saved: "Sorting saved"
|
|
15
28
|
content: "Content"
|
|
@@ -24,9 +37,17 @@ en:
|
|
|
24
37
|
inactive: "Inactive"
|
|
25
38
|
uncategorized: "Uncategorized"
|
|
26
39
|
active_description: "Inactive blocks are hidden from pages."
|
|
40
|
+
layout_block: "Layout"
|
|
41
|
+
layout_blocks: "Layout blocks"
|
|
42
|
+
custom_block_not_found: "Custom block not found. Run rake spina:bootstrap to create it."
|
|
27
43
|
block_categories:
|
|
28
44
|
saved: "Category saved"
|
|
29
45
|
couldnt_be_saved: "Category couldn't be saved"
|
|
46
|
+
content_blocks:
|
|
47
|
+
add_block: "Add block"
|
|
48
|
+
remove: "Remove"
|
|
49
|
+
empty: 'No content blocks yet. Click "Add block" to get started.'
|
|
50
|
+
no_template_found: "Unknown block type: %{type}"
|
|
30
51
|
page_blocks:
|
|
31
52
|
title: "Page Blocks"
|
|
32
53
|
add_block: "Add block"
|
|
@@ -34,6 +55,7 @@ en:
|
|
|
34
55
|
added: "Block added to page"
|
|
35
56
|
couldnt_be_added: "Block couldn't be added"
|
|
36
57
|
removed: "Block removed from page"
|
|
58
|
+
cannot_remove_layout_block: "This layout block cannot be removed from the page."
|
|
37
59
|
sorting_saved: "Sorting saved"
|
|
38
60
|
remove_confirmation: "Remove this block from the page?"
|
|
39
61
|
no_blocks_yet: "No blocks on this page yet. Add blocks from the library to build your page."
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddCustomBlockFieldsToSpinaBlocksBlocks < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
unless connection.column_exists?(:spina_blocks_blocks, :deletable)
|
|
6
|
+
add_column(:spina_blocks_blocks, :deletable, :boolean, default: true, null: false)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
unless connection.column_exists?(:spina_blocks_blocks, :key)
|
|
10
|
+
add_column(:spina_blocks_blocks, :key, :string)
|
|
11
|
+
add_index(:spina_blocks_blocks, :key, unique: true, where: "key IS NOT NULL")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/spina/blocks/engine.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Spina
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
::Spina::Theme.class_eval do
|
|
15
|
-
attr_accessor :block_templates, :block_categories
|
|
15
|
+
attr_accessor :block_templates, :block_categories, :custom_blocks, :content_block_templates
|
|
16
16
|
|
|
17
17
|
unless method_defined?(:initialize_without_blocks)
|
|
18
18
|
alias_method :initialize_without_blocks, :initialize
|
|
@@ -21,6 +21,8 @@ module Spina
|
|
|
21
21
|
initialize_without_blocks
|
|
22
22
|
@block_templates = []
|
|
23
23
|
@block_categories = []
|
|
24
|
+
@custom_blocks = []
|
|
25
|
+
@content_block_templates = []
|
|
24
26
|
end
|
|
25
27
|
end
|
|
26
28
|
end
|
|
@@ -31,11 +33,19 @@ module Spina
|
|
|
31
33
|
pin_all_from Spina::Blocks::Engine.root.join("app/assets/javascripts/spina/controllers"),
|
|
32
34
|
under: "controllers",
|
|
33
35
|
to: "spina/controllers"
|
|
36
|
+
pin_all_from Spina::Blocks::Engine.root.join("app/assets/javascripts/spina/utils"),
|
|
37
|
+
under: "spina/utils",
|
|
38
|
+
to: "spina/utils"
|
|
34
39
|
end
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
initializer "spina.blocks.assets.precompile" do |app|
|
|
38
|
-
app.config.assets.precompile += [
|
|
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
|
|
48
|
+
] if defined?(Sprockets)
|
|
39
49
|
end
|
|
40
50
|
|
|
41
51
|
initializer "spina.blocks.register_parts" do
|
|
@@ -43,6 +53,9 @@ module Spina
|
|
|
43
53
|
::Spina::Part.register(
|
|
44
54
|
Spina::Parts::BlockReference,
|
|
45
55
|
Spina::Parts::BlockCollection,
|
|
56
|
+
Spina::Parts::ContentBlocks,
|
|
57
|
+
Spina::Parts::ContentBlock,
|
|
58
|
+
Spina::Parts::NestedContentBlocks,
|
|
46
59
|
)
|
|
47
60
|
end
|
|
48
61
|
end
|
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Konstantin Kanashchuk
|
|
@@ -90,6 +90,9 @@ files:
|
|
|
90
90
|
- README.md
|
|
91
91
|
- Rakefile
|
|
92
92
|
- app/assets/javascripts/spina/controllers/block_collection_controller.js
|
|
93
|
+
- app/assets/javascripts/spina/controllers/content_blocks_controller.js
|
|
94
|
+
- app/assets/javascripts/spina/controllers/nested_content_blocks_controller.js
|
|
95
|
+
- app/assets/javascripts/spina/utils/dropdown_position.js
|
|
93
96
|
- app/controllers/spina/blocks/admin/blocks_controller.rb
|
|
94
97
|
- app/controllers/spina/blocks/admin/categories_controller.rb
|
|
95
98
|
- app/controllers/spina/blocks/admin/page_blocks_controller.rb
|
|
@@ -104,9 +107,15 @@ files:
|
|
|
104
107
|
- app/models/spina/blocks/search_extension.rb
|
|
105
108
|
- app/models/spina/parts/block_collection.rb
|
|
106
109
|
- app/models/spina/parts/block_reference.rb
|
|
110
|
+
- app/models/spina/parts/content_block.rb
|
|
111
|
+
- app/models/spina/parts/content_blocks.rb
|
|
112
|
+
- app/models/spina/parts/nested_content_blocks.rb
|
|
107
113
|
- app/views/spina/admin/hooks/blocks/_website_secondary_navigation.html.erb
|
|
108
114
|
- app/views/spina/admin/parts/block_collections/_form.html.erb
|
|
109
115
|
- app/views/spina/admin/parts/block_references/_form.html.erb
|
|
116
|
+
- app/views/spina/admin/parts/content_blocks/_fields.html.erb
|
|
117
|
+
- app/views/spina/admin/parts/content_blocks/_form.html.erb
|
|
118
|
+
- app/views/spina/admin/parts/nested_content_blocks/_form.html.erb
|
|
110
119
|
- app/views/spina/admin/pro/search/results/_block.html.erb
|
|
111
120
|
- app/views/spina/blocks/admin/blocks/_button_block_content.html.erb
|
|
112
121
|
- app/views/spina/blocks/admin/blocks/_button_block_settings.html.erb
|
|
@@ -128,6 +137,7 @@ files:
|
|
|
128
137
|
- db/migrate/2_create_spina_blocks_blocks.rb
|
|
129
138
|
- db/migrate/3_create_spina_blocks_page_blocks.rb
|
|
130
139
|
- db/migrate/5_rename_title_to_name_in_spina_blocks_blocks.rb
|
|
140
|
+
- db/migrate/6_add_custom_block_fields_to_spina_blocks_blocks.rb
|
|
131
141
|
- lib/generators/spina/blocks/install_generator.rb
|
|
132
142
|
- lib/spina-blocks.rb
|
|
133
143
|
- lib/spina/blocks/engine.rb
|