spina-streamfield 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 01ee088f4f8e8934b19134e09c9e1e26c1d6e4547cd580a58ff82b2fe08eeae2
4
+ data.tar.gz: d0fbe28fe4ae347d8b7e49c77a188ceb0f395a07d89c4ac8b5a8f8d6d84aa8eb
5
+ SHA512:
6
+ metadata.gz: 6b7c40f5ef6061702dda7babc0ca0257706b74001524732331aa60ba1b8c9263e34e83a6ae63fd78c3faef2018630c48a5c9bf5073ea7a8a306c983c6b2d8040
7
+ data.tar.gz: 62dd082829b3bec16f996e6360ee941d85d9f7af0f4b71600d4bf974b091a14ea7749ee835badc394584d6bbaf29be5cbffdfe55f2ab09e93727b1672808ea82
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Winston Kotzan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # Spina::Streamfield
2
+
3
+ A StreamField component for Spina
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ # Gemfile
9
+ gem 'Spina' # prerequisite
10
+ gem 'spina-streamfield'
11
+ ```
12
+ ## What is this thing?
13
+
14
+ It's basically a Ruby version of the StreamField component in the Python Wagtail CMS, but for Spina CMS! It provides an open-ended page component where the admin content creator can mixmatch any other Spina component in a sort of list, even custom components that you build in your app.
15
+
16
+ ![streamfield_demo.gif](docs/streamfield_demo.gif)
17
+
18
+ It was created because I disliked the way Trix stored media like images and YouTube links in the RichText field. Links were hard coded, making them difficult to mass edit or customize how they are displayed.
19
+
20
+ Here's how StreamField works in Wagtail. This one is very similar in concept:
21
+
22
+ https://youtu.be/9YLBJC1rPnk?si=EliQ_gNI89cXhinQ
23
+
24
+ ## Usage
25
+
26
+ Base components that are registered are MultiLine, RichText, and Image. To make other components available in the
27
+ Streamfield:
28
+ ```ruby
29
+ # config/initializers/spina.rb
30
+
31
+ # Custom YouTube component in the hosting program
32
+ Spina::Part.register(Spina::Parts::YoutubeVideo)
33
+
34
+ # Register custom StreamField block types.
35
+ # Runs after class reload (which resets @component_types to defaults), so additions are re-applied on each reload.
36
+ Spina::Parts::StreamField.register_component("YouTube Video", Spina::Parts::YoutubeVideo)
37
+ # Spina::Parts::StreamField.unregister_component("Image") # example: remove a default
38
+
39
+
40
+ # config/initializers/themes/default.rb
41
+ theme.parts = [
42
+ {name: "streamfield", title: "Article Body", part_type: "Spina::Parts::StreamField"},
43
+ ]
44
+
45
+ theme.view_templates = [
46
+ {name: "page_parts_demo", title: "Page Parts Demo", parts: %w[streamfield]}
47
+ ]
48
+ ```
49
+
50
+ In your display template:
51
+ ```erb
52
+ <%= # app/views/defaults/pages/page_parts_demo.html.erb %>
53
+
54
+ <% content(:streamfield)&.each do |item| %>
55
+ <% if item.part_type == "Spina::Parts::MultiLine" %>
56
+ <%= markdown(item.content_part.content) %>
57
+ <% elsif item.part_type == "Spina::Parts::YoutubeVideo" %>
58
+ <figure class="py-2">
59
+ <%= render(partial: 'default/pages/youtube_video', locals: { youtube_part: item.content_part }) %>
60
+ </figure>
61
+ <% elsif item.part_type == "Spina::Parts::Image" %>
62
+ <figure class="py-2">
63
+ <%= content.image_tag(item.content_part, {}, { style: "max-width: 600px;" }) %>
64
+ <% if item.content_part.alt.present? %>
65
+ <figcaption><%= item.content_part.alt %></figcaption>
66
+ <% end %>
67
+ </figure>
68
+ <% end %>
69
+ <% end %>
70
+ ```
71
+
72
+ Notice that in the example above I have a Markdown helper that allows me to use Markdown in the MultiLine component:
73
+ ```ruby
74
+ module ApplicationHelper
75
+ def markdown(text_page_part)
76
+ Kramdown::Document.new(text_page_part.to_s).to_html.html_safe
77
+ end
78
+ end
79
+ ```
80
+
81
+ ## Contributing
82
+
83
+ Open a PR or Issue if you want, or not
84
+
85
+ ## License
86
+
87
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,79 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static get targets() {
5
+ return ["content", "typeMenu", "addButton"]
6
+ }
7
+
8
+ toggleTypeMenu(event) {
9
+ event.stopPropagation()
10
+ this.typeMenuTarget.classList.toggle("hidden")
11
+ }
12
+
13
+ closeTypeMenu() {
14
+ this.typeMenuTarget.classList.add("hidden")
15
+ }
16
+
17
+ addFields(event) {
18
+ this.typeMenuTarget.classList.add("hidden")
19
+
20
+ let button = event.currentTarget
21
+ let childIndex = button.dataset.childIndex
22
+ let time = new Date().getTime()
23
+ let regex = new RegExp(childIndex, "g")
24
+ let html = button.dataset.fields.replace(regex, time)
25
+
26
+ const parser = new DOMParser()
27
+ const docFields = parser.parseFromString(html, "text/html")
28
+ this.contentTarget.appendChild(docFields.body.firstChild)
29
+ }
30
+
31
+ removeFields(event) {
32
+ this._pendingDeleteId = event.currentTarget.dataset.id
33
+ this._showConfirmDialog()
34
+ }
35
+
36
+ moveUp(event) {
37
+ const pane = document.getElementById(event.currentTarget.dataset.id)
38
+ const prev = pane?.previousElementSibling
39
+ if (prev) this.contentTarget.insertBefore(pane, prev)
40
+ }
41
+
42
+ moveDown(event) {
43
+ const pane = document.getElementById(event.currentTarget.dataset.id)
44
+ const next = pane?.nextElementSibling
45
+ if (next) this.contentTarget.insertBefore(next, pane)
46
+ }
47
+
48
+ _showConfirmDialog() {
49
+ if (document.getElementById("stream-field-confirm-dialog")) return
50
+
51
+ const dialog = document.createElement("div")
52
+ dialog.id = "stream-field-confirm-dialog"
53
+ dialog.innerHTML = `
54
+ <div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
55
+ <div class="bg-white rounded-lg shadow-xl p-6 max-w-sm w-full mx-4">
56
+ <p class="text-gray-700 text-sm mb-4">Are you sure you want to delete this section?</p>
57
+ <div class="flex gap-3 justify-end">
58
+ <button id="stream-field-confirm-cancel" class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md">Cancel</button>
59
+ <button id="stream-field-confirm-yes" class="px-4 py-2 text-sm text-white bg-red-600 hover:bg-red-700 rounded-md">Yes</button>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ `
64
+ document.body.appendChild(dialog)
65
+
66
+ document.getElementById("stream-field-confirm-yes").addEventListener("click", () => this._doDelete())
67
+ document.getElementById("stream-field-confirm-cancel").addEventListener("click", () => this._hideConfirmDialog())
68
+ }
69
+
70
+ _doDelete() {
71
+ document.getElementById(this._pendingDeleteId)?.remove()
72
+ this._hideConfirmDialog()
73
+ }
74
+
75
+ _hideConfirmDialog() {
76
+ document.getElementById("stream-field-confirm-dialog")?.remove()
77
+ this._pendingDeleteId = null
78
+ }
79
+ }
@@ -0,0 +1,36 @@
1
+ module Spina
2
+ module Parts
3
+ class StreamField < Base
4
+ include AttrJson::NestedAttributes
5
+
6
+ attr_json :content, StreamFieldContent.to_type, array: true
7
+ attr_json_accepts_nested_attributes_for :content
8
+
9
+ @component_types = {
10
+ "MultiLine" => "Spina::Parts::MultiLine",
11
+ "Image" => "Spina::Parts::Image",
12
+ "Rich Text" => "Spina::Parts::Text"
13
+ }
14
+
15
+ class << self
16
+ def component_types
17
+ @component_types.dup.freeze
18
+ end
19
+
20
+ # label - human-readable string shown in the "Add Block" menu
21
+ # klass - part class or fully-qualified string name
22
+ def register_component(label, klass)
23
+ class_name = klass.is_a?(Class) ? klass.name : klass.to_s
24
+ raise ArgumentError, "label must be a non-empty String" if label.blank?
25
+ raise ArgumentError, "klass must resolve to a class name" if class_name.blank?
26
+ @component_types[label] = class_name
27
+ end
28
+
29
+ def unregister_component(label)
30
+ @component_types.delete(label)
31
+ end
32
+ end
33
+
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,22 @@
1
+ module Spina
2
+ module Parts
3
+ class StreamFieldContent < Base
4
+ include AttrJson::NestedAttributes
5
+
6
+ attr_json :part_type, :string, default: "Spina::Parts::MultiLine"
7
+ attr_json :parts, AttrJson::Type::SpinaPartsModel.new, array: true
8
+ attr_json_accepts_nested_attributes_for :parts
9
+
10
+ def content_part
11
+ parts&.first
12
+ end
13
+
14
+ def label
15
+ type_label = StreamField.component_types.key(part_type) ||
16
+ part_type.to_s.demodulize
17
+ content_label = content_part&.label.to_s
18
+ content_label.present? ? content_label : type_label
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,36 @@
1
+ <div id="pane_<%= f.object.object_id %>" class="mt-6">
2
+ <%# TODO this HR styling is not working because of tailwind preflight overriding the utility classes %>
3
+ <hr class="border-t-2 border-solid border-red-900 my-4">
4
+ <div class="flex gap-3">
5
+ <div class="flex-1 min-w-0">
6
+ <div class="flex items-center justify-between mb-1">
7
+ <h4 class="text-sm font-semibold text-gray-700">
8
+ <%= Spina::Parts::StreamField.component_types.key(f.object.part_type) || f.object.part_type.to_s.demodulize %>
9
+ </h4>
10
+ <%= button_tag "Delete", type: :button, class: "btn btn-default bg-transparent hover:bg-white hover:text-red-500 h-8 px-3", data: {action: "stream-field#removeFields", id: "pane_#{f.object.object_id}"} %>
11
+ </div>
12
+
13
+ <%= f.hidden_field :part_type %>
14
+
15
+ <%
16
+ part_type_class = f.object.part_type.presence || "Spina::Parts::Markdown"
17
+ content_part = f.object.parts&.first || part_type_class.constantize.new(name: "content")
18
+ %>
19
+
20
+ <%= f.fields_for :parts, [content_part] do |ff| %>
21
+ <%= ff.hidden_field :type, value: ff.object.class %>
22
+ <%= ff.hidden_field :name %>
23
+ <%= render "spina/admin/parts/#{parts_partial_namespace(ff.object.class.to_s)}/form", f: ff %>
24
+ <% end %>
25
+ </div>
26
+
27
+ <div class="flex flex-col justify-center gap-1 shrink-0">
28
+ <%= button_tag type: :button, class: "btn btn-default bg-transparent hover:bg-white h-8 px-2", title: "Move up", data: {action: "stream-field#moveUp", id: "pane_#{f.object.object_id}"} do %>
29
+ <%= heroicon('chevron-up', style: :mini, class: "w-4 h-4") %>
30
+ <% end %>
31
+ <%= button_tag type: :button, class: "btn btn-default bg-transparent hover:bg-white h-8 px-2", title: "Move down", data: {action: "stream-field#moveDown", id: "pane_#{f.object.object_id}"} do %>
32
+ <%= heroicon('chevron-down', style: :mini, class: "w-4 h-4") %>
33
+ <% end %>
34
+ </div>
35
+ </div>
36
+ </div>
@@ -0,0 +1,46 @@
1
+ <div class="mt-6" data-controller="stream-field">
2
+ <label class="block text-sm leading-5 font-medium text-gray-700"><%= f.object.title %></label>
3
+ <div class="text-gray-400 text-sm"><%= f.object.hint %></div>
4
+
5
+ <%
6
+ # Pre-render a fields template for each allowed part type.
7
+ # The JS uses these to inject new items when the user picks a type.
8
+ type_templates = Spina::Parts::StreamField.component_types.map do |type_label, part_class|
9
+ template_obj = Spina::Parts::StreamFieldContent.new(name: f.object.name, part_type: part_class)
10
+ html = f.fields_for(:content, [template_obj], child_index: template_obj.object_id) do |builder|
11
+ render("spina/admin/parts/stream_fields/fields", f: builder)
12
+ end.gsub("\n", "")
13
+ { label: type_label, part_class: part_class, html: html, child_index: template_obj.object_id }
14
+ end
15
+ %>
16
+
17
+ <div class="ml-3" data-stream-field-target="content">
18
+ <%= f.fields_for :content do |ff| %>
19
+ <%= render 'spina/admin/parts/stream_fields/fields', f: ff %>
20
+ <% end %>
21
+ </div>
22
+
23
+ <%# Type-picker dropdown — click "Add Block" to reveal the type menu %>
24
+ <div class="relative mt-2" data-action="click@window->stream-field#closeTypeMenu">
25
+ <button type="button" class="text-gray-400 pl-2 hover:text-gray-900 rounded-md truncate text-sm font-medium flex items-center h-10" data-action="stream-field#toggleTypeMenu" data-stream-field-target="addButton">
26
+ <%= heroicon('plus', style: :mini, class: "w-4 h-4 mr-2 ml-1") %>
27
+ Add Block
28
+ </button>
29
+
30
+ <div class="hidden absolute left-0 z-10 mt-1 w-48 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5" data-stream-field-target="typeMenu">
31
+ <div class="py-1" role="menu">
32
+ <% type_templates.each do |tmpl| %>
33
+ <button type="button"
34
+ class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
35
+ role="menuitem"
36
+ data-action="stream-field#addFields"
37
+ data-fields="<%= tmpl[:html] %>"
38
+ data-child-index="<%= tmpl[:child_index] %>"
39
+ data-type-label="<%= tmpl[:label] %>">
40
+ <%= tmpl[:label] %>
41
+ </button>
42
+ <% end %>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </div>
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spina
4
+ module Streamfield
5
+ class Engine < ::Rails::Engine
6
+ config.before_initialize do
7
+ ::Spina.config.tailwind_content.concat(
8
+ Spina::Streamfield::Engine.root.glob("app/views/**/*.*").map(&:to_s)
9
+ )
10
+ end
11
+
12
+ initializer "spina.streamfield.importmap" do
13
+ ::Spina.config.importmap.draw do
14
+ pin_all_from Spina::Streamfield::Engine.root.join("app/assets/javascripts/spina/controllers"),
15
+ under: "controllers",
16
+ to: "spina/controllers"
17
+ end
18
+ end
19
+
20
+ config.to_prepare do
21
+ ::Spina::Part.register(Spina::Parts::StreamField)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spina
4
+ module Streamfield
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spina"
4
+ require_relative "streamfield/version"
5
+ require_relative "streamfield/engine"
6
+
7
+ module Spina
8
+ module Streamfield
9
+ end
10
+ end
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spina-streamfield
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Winston Kotzan
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: spina
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 2.1.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 2.1.0
27
+ description: Adds a Streamfield component to Spina
28
+ email:
29
+ - contact@ustreasuryyieldcurve.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - LICENSE.txt
35
+ - README.md
36
+ - app/assets/javascripts/spina/controllers/stream_field_controller.js
37
+ - app/models/spina/parts/stream_field.rb
38
+ - app/models/spina/parts/stream_field_content.rb
39
+ - app/views/spina/admin/parts/stream_fields/_fields.html.erb
40
+ - app/views/spina/admin/parts/stream_fields/_form.html.erb
41
+ - lib/spina/streamfield.rb
42
+ - lib/spina/streamfield/engine.rb
43
+ - lib/spina/streamfield/version.rb
44
+ homepage: https://github.com/wakproductions/spina-streamfield
45
+ licenses:
46
+ - MIT
47
+ metadata:
48
+ homepage_uri: https://github.com/wakproductions/spina-streamfield
49
+ source_code_uri: https://github.com/wakproductions/spina-streamfield
50
+ post_install_message:
51
+ rdoc_options: []
52
+ require_paths:
53
+ - lib
54
+ required_ruby_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: 3.1.0
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubygems_version: 3.3.26
66
+ signing_key:
67
+ specification_version: 4
68
+ summary: Adds a Streamfield component to Spina
69
+ test_files: []