plum-cms 0.1.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.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +252 -0
  5. data/app/assets/builds/tailwind.css +2 -0
  6. data/app/assets/images/plum-mark.svg +7 -0
  7. data/app/assets/images/table-needs-logo.svg +83 -0
  8. data/app/assets/javascripts/plum/application.js +15 -0
  9. data/app/assets/stylesheets/application.css +10 -0
  10. data/app/assets/stylesheets/plum/control_panel.css +2 -0
  11. data/app/assets/tailwind/application.css +47 -0
  12. data/app/controllers/plum/application_controller.rb +34 -0
  13. data/app/controllers/plum/cp/assets_controller.rb +84 -0
  14. data/app/controllers/plum/cp/base_controller.rb +45 -0
  15. data/app/controllers/plum/cp/content_types_controller.rb +59 -0
  16. data/app/controllers/plum/cp/dashboard_controller.rb +10 -0
  17. data/app/controllers/plum/cp/entries_controller.rb +240 -0
  18. data/app/controllers/plum/cp/form_definitions_controller.rb +105 -0
  19. data/app/controllers/plum/cp/form_submissions_controller.rb +26 -0
  20. data/app/controllers/plum/cp/globals_controller.rb +84 -0
  21. data/app/controllers/plum/cp/nav_items_controller.rb +68 -0
  22. data/app/controllers/plum/cp/nav_menus_controller.rb +56 -0
  23. data/app/controllers/plum/cp/site_settings_controller.rb +87 -0
  24. data/app/controllers/plum/cp/taxonomies_controller.rb +56 -0
  25. data/app/controllers/plum/cp/terms_controller.rb +55 -0
  26. data/app/controllers/plum/cp/theme_previews_controller.rb +27 -0
  27. data/app/controllers/plum/cp/themes_controller.rb +98 -0
  28. data/app/controllers/plum/form_submissions_controller.rb +44 -0
  29. data/app/controllers/plum/pages_controller.rb +161 -0
  30. data/app/controllers/plum/sessions_controller.rb +25 -0
  31. data/app/controllers/plum/theme_assets_controller.rb +14 -0
  32. data/app/helpers/application_helper.rb +2 -0
  33. data/app/helpers/markdown_helper.rb +21 -0
  34. data/app/helpers/plum/cp/assets_helper.rb +21 -0
  35. data/app/helpers/plum/cp/theme_settings_helper.rb +26 -0
  36. data/app/javascript/application.js +18 -0
  37. data/app/javascript/controllers/application.js +9 -0
  38. data/app/javascript/controllers/index.js +4 -0
  39. data/app/javascript/controllers/plum/blocks_editor_controller.js +275 -0
  40. data/app/javascript/controllers/plum/blueprint_controller.js +120 -0
  41. data/app/javascript/controllers/plum/form_fields_controller.js +84 -0
  42. data/app/javascript/controllers/plum/image_picker_controller.js +143 -0
  43. data/app/javascript/controllers/plum/theme_settings_controller.js +17 -0
  44. data/app/models/plum/application_record.rb +5 -0
  45. data/app/models/plum/asset.rb +69 -0
  46. data/app/models/plum/content_type.rb +35 -0
  47. data/app/models/plum/entry.rb +67 -0
  48. data/app/models/plum/entry_term.rb +8 -0
  49. data/app/models/plum/form_definition.rb +68 -0
  50. data/app/models/plum/form_submission.rb +76 -0
  51. data/app/models/plum/global.rb +24 -0
  52. data/app/models/plum/nav_item.rb +58 -0
  53. data/app/models/plum/nav_menu.rb +24 -0
  54. data/app/models/plum/site.rb +112 -0
  55. data/app/models/plum/site_scoped.rb +17 -0
  56. data/app/models/plum/site_setting.rb +35 -0
  57. data/app/models/plum/taxonomy.rb +24 -0
  58. data/app/models/plum/term.rb +23 -0
  59. data/app/models/plum/user.rb +16 -0
  60. data/app/services/plum/base_blocks.rb +161 -0
  61. data/app/services/plum/block_editor_config.rb +65 -0
  62. data/app/services/plum/block_library.rb +31 -0
  63. data/app/services/plum/builder_renderer.rb +63 -0
  64. data/app/services/plum/field_expander.rb +78 -0
  65. data/app/services/plum/form_renderer.rb +110 -0
  66. data/app/services/plum/liquid_context.rb +335 -0
  67. data/app/services/plum/liquid_filters.rb +58 -0
  68. data/app/services/plum/liquid_renderer.rb +49 -0
  69. data/app/services/plum/theme.rb +160 -0
  70. data/app/services/plum/theme_asset_path.rb +31 -0
  71. data/app/services/plum/theme_package_installer.rb +287 -0
  72. data/app/services/plum/theme_registry.rb +63 -0
  73. data/app/services/plum/theme_resolver.rb +55 -0
  74. data/app/services/plum/theme_settings_params.rb +54 -0
  75. data/app/themes/bagel-boy/assets/screenshot.svg +92 -0
  76. data/app/themes/bagel-boy/assets/theme.css +184 -0
  77. data/app/themes/bagel-boy/blocks/hours.liquid +4 -0
  78. data/app/themes/bagel-boy/blocks/menu_item.liquid +7 -0
  79. data/app/themes/bagel-boy/layouts/base.liquid +64 -0
  80. data/app/themes/bagel-boy/templates/collections/posts.liquid +32 -0
  81. data/app/themes/bagel-boy/templates/entries/_default.liquid +13 -0
  82. data/app/themes/bagel-boy/templates/entries/landing.liquid +4 -0
  83. data/app/themes/bagel-boy/templates/entries/pages.liquid +18 -0
  84. data/app/themes/bagel-boy/templates/entries/posts.liquid +8 -0
  85. data/app/themes/bagel-boy/templates/index.liquid +10 -0
  86. data/app/themes/bagel-boy/templates/search.liquid +28 -0
  87. data/app/themes/bagel-boy/theme.yml +47 -0
  88. data/app/themes/bagel-shop/assets/screenshot.svg +28 -0
  89. data/app/themes/bagel-shop/assets/theme.css +7 -0
  90. data/app/themes/bagel-shop/layouts/base.liquid +507 -0
  91. data/app/themes/bagel-shop/templates/entries/_default.liquid +15 -0
  92. data/app/themes/bagel-shop/templates/entries/pages.liquid +12 -0
  93. data/app/themes/bagel-shop/templates/entries/posts.liquid +19 -0
  94. data/app/themes/bagel-shop/templates/index.liquid +55 -0
  95. data/app/themes/bagel-shop/theme.yml +30 -0
  96. data/app/themes/default/assets/screenshot.svg +24 -0
  97. data/app/themes/default/assets/theme.css +89 -0
  98. data/app/themes/default/layouts/base.liquid +117 -0
  99. data/app/themes/default/templates/collections/_default.liquid +16 -0
  100. data/app/themes/default/templates/collections/posts.liquid +27 -0
  101. data/app/themes/default/templates/entries/_default.liquid +26 -0
  102. data/app/themes/default/templates/entries/landing.liquid +5 -0
  103. data/app/themes/default/templates/entries/pages.liquid +17 -0
  104. data/app/themes/default/templates/entries/posts.liquid +20 -0
  105. data/app/themes/default/templates/index.liquid +22 -0
  106. data/app/themes/default/templates/search.liquid +28 -0
  107. data/app/themes/default/templates/taxonomies/_default.liquid +25 -0
  108. data/app/themes/default/templates/taxonomies/_default_index.liquid +14 -0
  109. data/app/themes/default/theme.yml +23 -0
  110. data/app/views/layouts/plum/cp.html.erb +138 -0
  111. data/app/views/layouts/plum/session.html.erb +43 -0
  112. data/app/views/plum/cp/assets/_form.html.erb +44 -0
  113. data/app/views/plum/cp/assets/edit.html.erb +14 -0
  114. data/app/views/plum/cp/assets/index.html.erb +52 -0
  115. data/app/views/plum/cp/assets/new.html.erb +9 -0
  116. data/app/views/plum/cp/content_types/_form.html.erb +113 -0
  117. data/app/views/plum/cp/content_types/edit.html.erb +8 -0
  118. data/app/views/plum/cp/content_types/index.html.erb +48 -0
  119. data/app/views/plum/cp/content_types/new.html.erb +5 -0
  120. data/app/views/plum/cp/content_types/show.html.erb +59 -0
  121. data/app/views/plum/cp/dashboard/show.html.erb +50 -0
  122. data/app/views/plum/cp/entries/_form.html.erb +196 -0
  123. data/app/views/plum/cp/entries/edit.html.erb +9 -0
  124. data/app/views/plum/cp/entries/index.html.erb +51 -0
  125. data/app/views/plum/cp/entries/new.html.erb +5 -0
  126. data/app/views/plum/cp/form_definitions/_form.html.erb +90 -0
  127. data/app/views/plum/cp/form_definitions/edit.html.erb +11 -0
  128. data/app/views/plum/cp/form_definitions/index.html.erb +43 -0
  129. data/app/views/plum/cp/form_definitions/new.html.erb +5 -0
  130. data/app/views/plum/cp/form_definitions/show.html.erb +70 -0
  131. data/app/views/plum/cp/form_submissions/show.html.erb +20 -0
  132. data/app/views/plum/cp/globals/_form.html.erb +44 -0
  133. data/app/views/plum/cp/globals/edit.html.erb +11 -0
  134. data/app/views/plum/cp/globals/index.html.erb +41 -0
  135. data/app/views/plum/cp/globals/new.html.erb +5 -0
  136. data/app/views/plum/cp/globals/show.html.erb +14 -0
  137. data/app/views/plum/cp/nav_items/_form.html.erb +56 -0
  138. data/app/views/plum/cp/nav_items/edit.html.erb +11 -0
  139. data/app/views/plum/cp/nav_items/new.html.erb +6 -0
  140. data/app/views/plum/cp/nav_menus/_form.html.erb +33 -0
  141. data/app/views/plum/cp/nav_menus/_nav_item.html.erb +24 -0
  142. data/app/views/plum/cp/nav_menus/edit.html.erb +11 -0
  143. data/app/views/plum/cp/nav_menus/index.html.erb +41 -0
  144. data/app/views/plum/cp/nav_menus/new.html.erb +5 -0
  145. data/app/views/plum/cp/nav_menus/show.html.erb +24 -0
  146. data/app/views/plum/cp/shared/_icon.html.erb +39 -0
  147. data/app/views/plum/cp/shared/_image_picker.html.erb +38 -0
  148. data/app/views/plum/cp/site_settings/edit.html.erb +145 -0
  149. data/app/views/plum/cp/site_settings/show.html.erb +62 -0
  150. data/app/views/plum/cp/taxonomies/_form.html.erb +38 -0
  151. data/app/views/plum/cp/taxonomies/edit.html.erb +2 -0
  152. data/app/views/plum/cp/taxonomies/index.html.erb +38 -0
  153. data/app/views/plum/cp/taxonomies/new.html.erb +2 -0
  154. data/app/views/plum/cp/taxonomies/show.html.erb +46 -0
  155. data/app/views/plum/cp/terms/_form.html.erb +30 -0
  156. data/app/views/plum/cp/terms/edit.html.erb +2 -0
  157. data/app/views/plum/cp/terms/new.html.erb +2 -0
  158. data/app/views/plum/cp/themes/index.html.erb +160 -0
  159. data/app/views/plum/form_mailer/submission_notification.html.erb +13 -0
  160. data/app/views/plum/form_mailer/submission_notification.text.erb +7 -0
  161. data/app/views/plum/sessions/new.html.erb +20 -0
  162. data/compat/plum-cms.rb +1 -0
  163. data/config/locales/en.yml +31 -0
  164. data/config/plum_importmap.rb +4 -0
  165. data/config/plum_routes.rb +34 -0
  166. data/db/engine_migrate/20260101000001_create_plum_tables.rb +154 -0
  167. data/lib/generators/plum/install/install_generator.rb +54 -0
  168. data/lib/generators/plum/install/templates/plum_initializer.rb +37 -0
  169. data/lib/plum/configuration.rb +88 -0
  170. data/lib/plum/content_source.rb +45 -0
  171. data/lib/plum/content_source_context.rb +30 -0
  172. data/lib/plum/content_source_registry.rb +120 -0
  173. data/lib/plum/engine.rb +30 -0
  174. data/lib/plum/liquid_tags/form_tag.rb +17 -0
  175. data/lib/plum/version.rb +7 -0
  176. data/lib/plum.rb +11 -0
  177. data/vendor/javascript/lexxy.js +11859 -0
  178. metadata +405 -0
@@ -0,0 +1,2 @@
1
+ module ApplicationHelper
2
+ end
@@ -0,0 +1,21 @@
1
+ module MarkdownHelper
2
+ def markdown(text)
3
+ return "" if text.blank?
4
+
5
+ renderer = Redcarpet::Render::HTML.new(
6
+ hard_wrap: true,
7
+ link_attributes: { target: "_blank", rel: "noopener" }
8
+ )
9
+
10
+ markdown = Redcarpet::Markdown.new(renderer,
11
+ autolink: true,
12
+ tables: true,
13
+ fenced_code_blocks: true,
14
+ strikethrough: true,
15
+ superscript: true,
16
+ no_intra_emphasis: true
17
+ )
18
+
19
+ markdown.render(text).html_safe
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ module Plum
2
+ module Cp
3
+ module AssetsHelper
4
+ def plum_asset_label(asset)
5
+ [ asset.filename, asset.alt_text.presence ].compact.join(" - ")
6
+ end
7
+
8
+ def plum_asset_image_url(asset)
9
+ return unless asset&.file&.attached?
10
+
11
+ asset.url
12
+ end
13
+
14
+ def plum_asset_file_size(asset)
15
+ return "0 B" unless asset.file.attached?
16
+
17
+ number_to_human_size(asset.file.byte_size)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ module Plum
2
+ module Cp
3
+ module ThemeSettingsHelper
4
+ def theme_setting_label(field)
5
+ field["label"].presence || field["handle"].to_s.humanize
6
+ end
7
+
8
+ def theme_setting_options(field)
9
+ Array(field["options"]).filter_map do |option|
10
+ if option.is_a?(Hash)
11
+ normalized = option.deep_stringify_keys
12
+ value = normalized["value"].to_s
13
+ next if value.blank?
14
+
15
+ [ normalized["label"].presence || value.humanize, value ]
16
+ else
17
+ value = option.to_s
18
+ next if value.blank?
19
+
20
+ [ value.humanize, value ]
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,18 @@
1
+ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
2
+ import "@hotwired/turbo-rails"
3
+ import "controllers"
4
+ import "lexxy"
5
+
6
+ document.addEventListener("turbo:submit-start", (event) => {
7
+ event.target.querySelectorAll("lexxy-editor[data-hidden-field]").forEach((editor) => {
8
+ const hidden = document.getElementById(editor.dataset.hiddenField)
9
+ if (hidden) hidden.value = editor.value
10
+ })
11
+ })
12
+
13
+ document.addEventListener("submit", (event) => {
14
+ event.target.querySelectorAll("lexxy-editor[data-hidden-field]").forEach((editor) => {
15
+ const hidden = document.getElementById(editor.dataset.hiddenField)
16
+ if (hidden) hidden.value = editor.value
17
+ })
18
+ })
@@ -0,0 +1,9 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+
3
+ const application = Application.start()
4
+
5
+ // Configure Stimulus development experience
6
+ application.debug = false
7
+ window.Stimulus = application
8
+
9
+ export { application }
@@ -0,0 +1,4 @@
1
+ // Import and register all your controllers from the importmap via controllers/**/*_controller
2
+ import { application } from "controllers/application"
3
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
4
+ eagerLoadControllersFrom("controllers", application)
@@ -0,0 +1,275 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Plum's own simple block editor (no GrapesJS).
4
+ //
5
+ // A calm vertical list of block "cards" — add, reorder, remove — each showing
6
+ // its theme-declared fields as plain inputs. The block library comes from the
7
+ // active theme via Plum::BlockEditorConfig (configValue); the editor never reads
8
+ // a theme directly. The stored value is the canonical ordered array of
9
+ // { id, type, fields }, persisted as JSON in the hidden input.
10
+ export default class extends Controller {
11
+ static targets = ["input", "list", "picker", "empty"]
12
+ static values = { config: Object, assetsUrl: String, uploadUrl: String }
13
+
14
+ connect() {
15
+ this.defs = this.configValue.blocks || []
16
+ this.defsByType = Object.fromEntries(this.defs.map((d) => [d.id, d]))
17
+ this.blocks = this.readInitial()
18
+ this.render()
19
+ }
20
+
21
+ readInitial() {
22
+ try {
23
+ const parsed = JSON.parse(this.inputTarget.value || "[]")
24
+ return Array.isArray(parsed) ? parsed : []
25
+ } catch (_e) {
26
+ return []
27
+ }
28
+ }
29
+
30
+ add(event) {
31
+ event.preventDefault()
32
+ const type = this.hasPickerTarget ? this.pickerTarget.value : this.defs[0]?.id
33
+ if (!type || !this.defsByType[type]) return
34
+ this.blocks.push({ id: this.uuid(), type, fields: {} })
35
+ this.render()
36
+ }
37
+
38
+ remove(event) {
39
+ event.preventDefault()
40
+ const index = Number(event.currentTarget.dataset.index)
41
+ this.blocks.splice(index, 1)
42
+ this.render()
43
+ }
44
+
45
+ move(event) {
46
+ event.preventDefault()
47
+ const index = Number(event.currentTarget.dataset.index)
48
+ const target = index + Number(event.currentTarget.dataset.delta)
49
+ if (target < 0 || target >= this.blocks.length) return
50
+ const [item] = this.blocks.splice(index, 1)
51
+ this.blocks.splice(target, 0, item)
52
+ this.render()
53
+ }
54
+
55
+ fieldChanged(event) {
56
+ const index = Number(event.currentTarget.dataset.index)
57
+ const handle = event.currentTarget.dataset.field
58
+ const block = this.blocks[index]
59
+ if (!block) return
60
+ block.fields = block.fields || {}
61
+ block.fields[handle] = event.currentTarget.type === "checkbox"
62
+ ? event.currentTarget.checked
63
+ : event.currentTarget.value
64
+ this.serialize()
65
+ }
66
+
67
+ serialize() {
68
+ this.inputTarget.value = JSON.stringify(this.blocks)
69
+ }
70
+
71
+ render() {
72
+ this.serialize()
73
+ this.listTarget.innerHTML = ""
74
+ this.blocks.forEach((block, index) => this.listTarget.appendChild(this.card(block, index)))
75
+ if (this.hasEmptyTarget) this.emptyTarget.classList.toggle("hidden", this.blocks.length > 0)
76
+ }
77
+
78
+ card(block, index) {
79
+ const def = this.defsByType[block.type] || { label: block.type, fields: [] }
80
+ const card = document.createElement("div")
81
+ card.className = "rounded-lg border border-gray-200 bg-white shadow-sm"
82
+
83
+ const header = document.createElement("div")
84
+ header.className = "flex items-center justify-between border-b border-gray-100 px-4 py-2.5"
85
+ const title = document.createElement("span")
86
+ title.className = "text-sm font-semibold text-gray-800"
87
+ title.textContent = def.label || block.type
88
+ header.appendChild(title)
89
+
90
+ const controls = document.createElement("div")
91
+ controls.className = "flex items-center gap-1"
92
+ controls.appendChild(this.iconButton("↑", "move", { index, delta: -1 }, index === 0))
93
+ controls.appendChild(this.iconButton("↓", "move", { index, delta: 1 }, index === this.blocks.length - 1))
94
+ controls.appendChild(this.iconButton("Remove", "remove", { index }, false, "text-red-600 hover:text-red-700"))
95
+ header.appendChild(controls)
96
+ card.appendChild(header)
97
+
98
+ const body = document.createElement("div")
99
+ body.className = "space-y-3 px-4 py-3"
100
+ if ((def.fields || []).length === 0) {
101
+ const none = document.createElement("p")
102
+ none.className = "text-sm text-gray-400"
103
+ none.textContent = "This block has no editable fields."
104
+ body.appendChild(none)
105
+ }
106
+ ;(def.fields || []).forEach((f) => body.appendChild(this.field(f, block, index)))
107
+ card.appendChild(body)
108
+ return card
109
+ }
110
+
111
+ field(fieldDef, block, index) {
112
+ const stored = block.fields ? block.fields[fieldDef.handle] : undefined
113
+ const value = stored === undefined || stored === null ? "" : stored
114
+
115
+ if (fieldDef.type === "image" && this.hasAssetsUrlValue) {
116
+ return this.imagePickerField(fieldDef, index, value)
117
+ }
118
+
119
+ const wrap = document.createElement("label")
120
+ wrap.className = "block space-y-1"
121
+ const label = document.createElement("span")
122
+ label.className = "block text-xs font-medium text-gray-600"
123
+ label.textContent = fieldDef.label || fieldDef.handle
124
+ wrap.appendChild(label)
125
+
126
+ const cls = "block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-purple-500 focus:outline-none focus:ring-purple-500"
127
+
128
+ let input
129
+ switch (fieldDef.type) {
130
+ case "textarea":
131
+ case "rich_text":
132
+ input = document.createElement("textarea")
133
+ input.rows = 4
134
+ input.className = cls
135
+ input.value = value
136
+ break
137
+ case "boolean":
138
+ input = document.createElement("input")
139
+ input.type = "checkbox"
140
+ input.checked = value === true || value === "true"
141
+ break
142
+ case "select":
143
+ input = document.createElement("select")
144
+ input.className = cls
145
+ ;(fieldDef.options || []).forEach((o) => {
146
+ const opt = document.createElement("option")
147
+ const v = typeof o === "object" ? o.value : o
148
+ opt.value = v
149
+ opt.textContent = typeof o === "object" ? o.label : o
150
+ if (String(v) === String(value)) opt.selected = true
151
+ input.appendChild(opt)
152
+ })
153
+ break
154
+ case "date":
155
+ input = document.createElement("input")
156
+ input.type = "date"
157
+ input.className = cls
158
+ input.value = value
159
+ break
160
+ default:
161
+ input = document.createElement("input")
162
+ input.type = "text"
163
+ input.className = cls
164
+ input.value = value
165
+ }
166
+
167
+ input.dataset.index = index
168
+ input.dataset.field = fieldDef.handle
169
+ input.dataset.action = "input->plum--blocks-editor#fieldChanged change->plum--blocks-editor#fieldChanged"
170
+
171
+ wrap.appendChild(input)
172
+ return wrap
173
+ }
174
+
175
+ // Builds a nested plum--image-picker for an image block field. The picker's
176
+ // hidden input doubles as this block editor's field input (it carries the
177
+ // data-index/data-field/data-action so selecting an asset updates the block).
178
+ imagePickerField(fieldDef, index, value) {
179
+ // NOTE: data-* attributes with dashes/colons (Stimulus target/value names)
180
+ // must be set via setAttribute — assigning through element.dataset[...] with
181
+ // such keys throws a SyntaxError.
182
+ const wrap = document.createElement("div")
183
+ wrap.className = "space-y-1"
184
+ wrap.setAttribute("data-controller", "plum--image-picker")
185
+ wrap.setAttribute("data-plum--image-picker-assets-url-value", this.assetsUrlValue)
186
+ wrap.setAttribute("data-plum--image-picker-upload-url-value", this.uploadUrlValue)
187
+
188
+ const label = document.createElement("span")
189
+ label.className = "block text-xs font-medium text-gray-600"
190
+ label.textContent = fieldDef.label || fieldDef.handle
191
+ wrap.appendChild(label)
192
+
193
+ const input = document.createElement("input")
194
+ input.type = "hidden"
195
+ input.value = value
196
+ input.setAttribute("data-plum--image-picker-target", "input")
197
+ // The image picker (a separate, nested controller) updates this hidden input
198
+ // and dispatches input/change. We listen DIRECTLY rather than via a Stimulus
199
+ // data-action so the update is captured reliably regardless of cross-controller
200
+ // routing. The closure captures the current index/handle (recreated on render).
201
+ const persist = () => {
202
+ const block = this.blocks[index]
203
+ if (!block) return
204
+ block.fields = block.fields || {}
205
+ block.fields[fieldDef.handle] = input.value
206
+ this.serialize()
207
+ }
208
+ input.addEventListener("input", persist)
209
+ input.addEventListener("change", persist)
210
+ wrap.appendChild(input)
211
+
212
+ const preview = document.createElement("div")
213
+ preview.setAttribute("data-plum--image-picker-target", "preview")
214
+ wrap.appendChild(preview)
215
+
216
+ const controls = document.createElement("div")
217
+ controls.className = "flex items-center gap-2"
218
+ controls.appendChild(this.pickerButton("Choose image", "toggle", "rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"))
219
+ controls.appendChild(this.pickerButton("Remove", "clear", "text-sm text-gray-500 hover:text-gray-700"))
220
+ wrap.appendChild(controls)
221
+
222
+ const panel = document.createElement("div")
223
+ panel.className = "hidden rounded-lg border border-gray-200 bg-gray-50 p-3"
224
+ panel.setAttribute("data-plum--image-picker-target", "panel")
225
+
226
+ const status = document.createElement("p")
227
+ status.className = "mb-2 text-xs font-medium text-gray-500"
228
+ status.setAttribute("data-plum--image-picker-target", "status")
229
+ panel.appendChild(status)
230
+
231
+ const grid = document.createElement("div")
232
+ grid.className = "grid grid-cols-3 gap-2 sm:grid-cols-4"
233
+ grid.setAttribute("data-plum--image-picker-target", "grid")
234
+ panel.appendChild(grid)
235
+
236
+ const uploadWrap = document.createElement("div")
237
+ uploadWrap.className = "mt-3 border-t border-gray-200 pt-3"
238
+ const file = document.createElement("input")
239
+ file.type = "file"
240
+ file.accept = "image/*"
241
+ file.className = "block w-full text-sm text-gray-700"
242
+ file.setAttribute("data-plum--image-picker-target", "file")
243
+ file.setAttribute("data-action", "change->plum--image-picker#upload")
244
+ uploadWrap.appendChild(file)
245
+ panel.appendChild(uploadWrap)
246
+
247
+ wrap.appendChild(panel)
248
+ return wrap
249
+ }
250
+
251
+ pickerButton(text, action, className) {
252
+ const btn = document.createElement("button")
253
+ btn.type = "button"
254
+ btn.className = className
255
+ btn.textContent = text
256
+ btn.dataset.action = `plum--image-picker#${action}`
257
+ return btn
258
+ }
259
+
260
+ iconButton(text, action, data, disabled, extra = "") {
261
+ const btn = document.createElement("button")
262
+ btn.type = "button"
263
+ btn.disabled = disabled
264
+ btn.className = `rounded px-2 py-1 text-xs font-medium text-gray-500 hover:bg-gray-100 disabled:opacity-30 ${extra}`.trim()
265
+ btn.textContent = text
266
+ Object.entries(data).forEach(([k, v]) => { btn.dataset[k] = v })
267
+ btn.dataset.action = `plum--blocks-editor#${action}`
268
+ return btn
269
+ }
270
+
271
+ uuid() {
272
+ if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID()
273
+ return `b-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
274
+ }
275
+ }
@@ -0,0 +1,120 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["fields", "input"]
5
+
6
+ connect() {
7
+ this.syncFieldConfigVisibility()
8
+ this.updateHiddenField()
9
+ }
10
+
11
+ addField(event) {
12
+ event.preventDefault()
13
+
14
+ const fieldHtml = `
15
+ <div class="grid grid-cols-1 gap-4 p-4 bg-gray-50 rounded-lg sm:grid-cols-2 lg:grid-cols-5" data-plum--blueprint-target="field">
16
+ <input type="text" placeholder="handle" data-field="handle"
17
+ data-action="input->plum--blueprint#inputChanged"
18
+ class="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500 font-mono text-sm">
19
+ <select data-field="type" data-action="change->plum--blueprint#inputChanged"
20
+ class="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500 text-sm">
21
+ <option value="text">Text</option>
22
+ <option value="textarea">Textarea</option>
23
+ <option value="rich_text">Rich Text</option>
24
+ <option value="number">Number</option>
25
+ <option value="boolean">Boolean</option>
26
+ <option value="date">Date</option>
27
+ <option value="select">Select</option>
28
+ <option value="checkboxes">Checkboxes</option>
29
+ <option value="color">Color</option>
30
+ <option value="url">URL</option>
31
+ <option value="taxonomy">Taxonomy</option>
32
+ <option value="image">Image</option>
33
+ <option value="relationship">Relationship</option>
34
+ <option value="blocks">Blocks</option>
35
+ </select>
36
+ <input type="text" placeholder="Label" data-field="label"
37
+ data-action="input->plum--blueprint#inputChanged"
38
+ class="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500 text-sm">
39
+ <input type="text" placeholder="Related type handle" data-field="content_type" data-field-config="relationship"
40
+ data-action="input->plum--blueprint#inputChanged"
41
+ class="hidden px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500 font-mono text-sm">
42
+ <input type="text" placeholder="Taxonomy handle" data-field="taxonomy" data-field-config="taxonomy"
43
+ data-action="input->plum--blueprint#inputChanged"
44
+ class="hidden px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500 font-mono text-sm">
45
+ <input type="text" placeholder="Option 1, Option 2, Option 3" data-field="options" data-field-config="select checkboxes"
46
+ data-action="input->plum--blueprint#inputChanged"
47
+ class="hidden px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500 text-sm">
48
+ <button type="button" data-action="plum--blueprint#removeField" class="justify-self-start text-red-500 hover:text-red-700">
49
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
50
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
51
+ </svg>
52
+ </button>
53
+ </div>
54
+ `
55
+
56
+ this.fieldsTarget.insertAdjacentHTML('beforeend', fieldHtml)
57
+ this.syncFieldConfigVisibility()
58
+ this.updateHiddenField()
59
+ }
60
+
61
+ removeField(event) {
62
+ event.preventDefault()
63
+ const fieldDiv = event.currentTarget.closest('[data-plum--blueprint-target="field"]')
64
+ fieldDiv.remove()
65
+ this.updateHiddenField()
66
+ }
67
+
68
+ updateHiddenField() {
69
+ const fields = []
70
+ const fieldElements = this.fieldsTarget.querySelectorAll('[data-plum--blueprint-target="field"]')
71
+
72
+ fieldElements.forEach(fieldEl => {
73
+ const handle = fieldEl.querySelector('[data-field="handle"]')?.value
74
+ const type = fieldEl.querySelector('[data-field="type"]')?.value
75
+ const label = fieldEl.querySelector('[data-field="label"]')?.value
76
+ const contentType = fieldEl.querySelector('[data-field="content_type"]')?.value?.trim()
77
+
78
+ if (handle) {
79
+ const field = { handle, type, label }
80
+
81
+ if (type === "relationship" && contentType) {
82
+ field.content_type = contentType
83
+ }
84
+
85
+ const taxonomyVal = fieldEl.querySelector('[data-field="taxonomy"]')?.value?.trim()
86
+ if (type === "taxonomy" && taxonomyVal) {
87
+ field.taxonomy = taxonomyVal
88
+ }
89
+
90
+ const optionsVal = fieldEl.querySelector('[data-field="options"]')?.value?.trim()
91
+ if ((type === "select" || type === "checkboxes") && optionsVal) {
92
+ field.options = optionsVal.split(",").map(o => o.trim()).filter(Boolean)
93
+ }
94
+
95
+ fields.push(field)
96
+ }
97
+ })
98
+
99
+ this.inputTarget.value = JSON.stringify({ fields })
100
+ }
101
+
102
+ inputChanged(event) {
103
+ const fieldEl = event.target.closest('[data-plum--blueprint-target="field"]')
104
+ if (fieldEl) this.syncFieldConfigVisibility(fieldEl)
105
+
106
+ this.updateHiddenField()
107
+ }
108
+
109
+ syncFieldConfigVisibility(fieldEl = null) {
110
+ const fieldElements = fieldEl ? [fieldEl] : this.fieldsTarget.querySelectorAll('[data-plum--blueprint-target="field"]')
111
+
112
+ fieldElements.forEach(fieldEl => {
113
+ const type = fieldEl.querySelector('[data-field="type"]')?.value
114
+ fieldEl.querySelectorAll('[data-field-config]').forEach(configEl => {
115
+ const configTypes = configEl.dataset.fieldConfig.split(" ")
116
+ configEl.classList.toggle("hidden", !configTypes.includes(type))
117
+ })
118
+ })
119
+ }
120
+ }
@@ -0,0 +1,84 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["fields", "input"]
5
+
6
+ connect() {
7
+ this.updateHiddenField()
8
+ }
9
+
10
+ addField(event) {
11
+ event.preventDefault()
12
+
13
+ this.fieldsTarget.insertAdjacentHTML("beforeend", this.fieldHtml())
14
+ this.updateHiddenField()
15
+ }
16
+
17
+ removeField(event) {
18
+ event.preventDefault()
19
+ event.currentTarget.closest("[data-plum--form-fields-target='field']").remove()
20
+ this.updateHiddenField()
21
+ }
22
+
23
+ inputChanged() {
24
+ this.updateHiddenField()
25
+ }
26
+
27
+ updateHiddenField() {
28
+ const fields = []
29
+ const fieldElements = this.fieldsTarget.querySelectorAll("[data-plum--form-fields-target='field']")
30
+
31
+ fieldElements.forEach((fieldEl) => {
32
+ const handle = fieldEl.querySelector("[data-field='handle']")?.value
33
+ const type = fieldEl.querySelector("[data-field='type']")?.value
34
+ const label = fieldEl.querySelector("[data-field='label']")?.value
35
+ const required = fieldEl.querySelector("[data-field='required']")?.checked
36
+ const options = fieldEl.querySelector("[data-field='options']")?.value
37
+
38
+ if (handle) {
39
+ fields.push({
40
+ handle,
41
+ type,
42
+ label,
43
+ required,
44
+ options: options ? options.split(",").map((option) => option.trim()).filter(Boolean) : []
45
+ })
46
+ }
47
+ })
48
+
49
+ this.inputTarget.value = JSON.stringify({ fields })
50
+ }
51
+
52
+ fieldHtml() {
53
+ return `
54
+ <div class="grid grid-cols-1 gap-4 p-4 bg-gray-50 rounded-lg sm:grid-cols-2" data-plum--form-fields-target="field">
55
+ <input type="text" placeholder="handle" data-field="handle"
56
+ data-action="input->plum--form-fields#inputChanged"
57
+ class="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500 font-mono text-sm">
58
+ <input type="text" placeholder="Label" data-field="label"
59
+ data-action="input->plum--form-fields#inputChanged"
60
+ class="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500 text-sm">
61
+ <select data-field="type" data-action="change->plum--form-fields#inputChanged"
62
+ class="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500 text-sm">
63
+ <option value="text">Text</option>
64
+ <option value="email">Email</option>
65
+ <option value="textarea">Textarea</option>
66
+ <option value="select">Select</option>
67
+ <option value="checkbox">Checkbox</option>
68
+ </select>
69
+ <input type="text" placeholder="Options, comma-separated" data-field="options"
70
+ data-action="input->plum--form-fields#inputChanged"
71
+ class="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500 text-sm">
72
+ <label class="flex items-center gap-2 text-sm text-gray-700">
73
+ <input type="checkbox" data-field="required" data-action="change->plum--form-fields#inputChanged"
74
+ class="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500">
75
+ Required
76
+ </label>
77
+ <button type="button" data-action="plum--form-fields#removeField"
78
+ class="justify-self-start text-sm font-medium text-red-700 hover:text-red-700">
79
+ Remove
80
+ </button>
81
+ </div>
82
+ `
83
+ }
84
+ }