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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +252 -0
- data/app/assets/builds/tailwind.css +2 -0
- data/app/assets/images/plum-mark.svg +7 -0
- data/app/assets/images/table-needs-logo.svg +83 -0
- data/app/assets/javascripts/plum/application.js +15 -0
- data/app/assets/stylesheets/application.css +10 -0
- data/app/assets/stylesheets/plum/control_panel.css +2 -0
- data/app/assets/tailwind/application.css +47 -0
- data/app/controllers/plum/application_controller.rb +34 -0
- data/app/controllers/plum/cp/assets_controller.rb +84 -0
- data/app/controllers/plum/cp/base_controller.rb +45 -0
- data/app/controllers/plum/cp/content_types_controller.rb +59 -0
- data/app/controllers/plum/cp/dashboard_controller.rb +10 -0
- data/app/controllers/plum/cp/entries_controller.rb +240 -0
- data/app/controllers/plum/cp/form_definitions_controller.rb +105 -0
- data/app/controllers/plum/cp/form_submissions_controller.rb +26 -0
- data/app/controllers/plum/cp/globals_controller.rb +84 -0
- data/app/controllers/plum/cp/nav_items_controller.rb +68 -0
- data/app/controllers/plum/cp/nav_menus_controller.rb +56 -0
- data/app/controllers/plum/cp/site_settings_controller.rb +87 -0
- data/app/controllers/plum/cp/taxonomies_controller.rb +56 -0
- data/app/controllers/plum/cp/terms_controller.rb +55 -0
- data/app/controllers/plum/cp/theme_previews_controller.rb +27 -0
- data/app/controllers/plum/cp/themes_controller.rb +98 -0
- data/app/controllers/plum/form_submissions_controller.rb +44 -0
- data/app/controllers/plum/pages_controller.rb +161 -0
- data/app/controllers/plum/sessions_controller.rb +25 -0
- data/app/controllers/plum/theme_assets_controller.rb +14 -0
- data/app/helpers/application_helper.rb +2 -0
- data/app/helpers/markdown_helper.rb +21 -0
- data/app/helpers/plum/cp/assets_helper.rb +21 -0
- data/app/helpers/plum/cp/theme_settings_helper.rb +26 -0
- data/app/javascript/application.js +18 -0
- data/app/javascript/controllers/application.js +9 -0
- data/app/javascript/controllers/index.js +4 -0
- data/app/javascript/controllers/plum/blocks_editor_controller.js +275 -0
- data/app/javascript/controllers/plum/blueprint_controller.js +120 -0
- data/app/javascript/controllers/plum/form_fields_controller.js +84 -0
- data/app/javascript/controllers/plum/image_picker_controller.js +143 -0
- data/app/javascript/controllers/plum/theme_settings_controller.js +17 -0
- data/app/models/plum/application_record.rb +5 -0
- data/app/models/plum/asset.rb +69 -0
- data/app/models/plum/content_type.rb +35 -0
- data/app/models/plum/entry.rb +67 -0
- data/app/models/plum/entry_term.rb +8 -0
- data/app/models/plum/form_definition.rb +68 -0
- data/app/models/plum/form_submission.rb +76 -0
- data/app/models/plum/global.rb +24 -0
- data/app/models/plum/nav_item.rb +58 -0
- data/app/models/plum/nav_menu.rb +24 -0
- data/app/models/plum/site.rb +112 -0
- data/app/models/plum/site_scoped.rb +17 -0
- data/app/models/plum/site_setting.rb +35 -0
- data/app/models/plum/taxonomy.rb +24 -0
- data/app/models/plum/term.rb +23 -0
- data/app/models/plum/user.rb +16 -0
- data/app/services/plum/base_blocks.rb +161 -0
- data/app/services/plum/block_editor_config.rb +65 -0
- data/app/services/plum/block_library.rb +31 -0
- data/app/services/plum/builder_renderer.rb +63 -0
- data/app/services/plum/field_expander.rb +78 -0
- data/app/services/plum/form_renderer.rb +110 -0
- data/app/services/plum/liquid_context.rb +335 -0
- data/app/services/plum/liquid_filters.rb +58 -0
- data/app/services/plum/liquid_renderer.rb +49 -0
- data/app/services/plum/theme.rb +160 -0
- data/app/services/plum/theme_asset_path.rb +31 -0
- data/app/services/plum/theme_package_installer.rb +287 -0
- data/app/services/plum/theme_registry.rb +63 -0
- data/app/services/plum/theme_resolver.rb +55 -0
- data/app/services/plum/theme_settings_params.rb +54 -0
- data/app/themes/bagel-boy/assets/screenshot.svg +92 -0
- data/app/themes/bagel-boy/assets/theme.css +184 -0
- data/app/themes/bagel-boy/blocks/hours.liquid +4 -0
- data/app/themes/bagel-boy/blocks/menu_item.liquid +7 -0
- data/app/themes/bagel-boy/layouts/base.liquid +64 -0
- data/app/themes/bagel-boy/templates/collections/posts.liquid +32 -0
- data/app/themes/bagel-boy/templates/entries/_default.liquid +13 -0
- data/app/themes/bagel-boy/templates/entries/landing.liquid +4 -0
- data/app/themes/bagel-boy/templates/entries/pages.liquid +18 -0
- data/app/themes/bagel-boy/templates/entries/posts.liquid +8 -0
- data/app/themes/bagel-boy/templates/index.liquid +10 -0
- data/app/themes/bagel-boy/templates/search.liquid +28 -0
- data/app/themes/bagel-boy/theme.yml +47 -0
- data/app/themes/bagel-shop/assets/screenshot.svg +28 -0
- data/app/themes/bagel-shop/assets/theme.css +7 -0
- data/app/themes/bagel-shop/layouts/base.liquid +507 -0
- data/app/themes/bagel-shop/templates/entries/_default.liquid +15 -0
- data/app/themes/bagel-shop/templates/entries/pages.liquid +12 -0
- data/app/themes/bagel-shop/templates/entries/posts.liquid +19 -0
- data/app/themes/bagel-shop/templates/index.liquid +55 -0
- data/app/themes/bagel-shop/theme.yml +30 -0
- data/app/themes/default/assets/screenshot.svg +24 -0
- data/app/themes/default/assets/theme.css +89 -0
- data/app/themes/default/layouts/base.liquid +117 -0
- data/app/themes/default/templates/collections/_default.liquid +16 -0
- data/app/themes/default/templates/collections/posts.liquid +27 -0
- data/app/themes/default/templates/entries/_default.liquid +26 -0
- data/app/themes/default/templates/entries/landing.liquid +5 -0
- data/app/themes/default/templates/entries/pages.liquid +17 -0
- data/app/themes/default/templates/entries/posts.liquid +20 -0
- data/app/themes/default/templates/index.liquid +22 -0
- data/app/themes/default/templates/search.liquid +28 -0
- data/app/themes/default/templates/taxonomies/_default.liquid +25 -0
- data/app/themes/default/templates/taxonomies/_default_index.liquid +14 -0
- data/app/themes/default/theme.yml +23 -0
- data/app/views/layouts/plum/cp.html.erb +138 -0
- data/app/views/layouts/plum/session.html.erb +43 -0
- data/app/views/plum/cp/assets/_form.html.erb +44 -0
- data/app/views/plum/cp/assets/edit.html.erb +14 -0
- data/app/views/plum/cp/assets/index.html.erb +52 -0
- data/app/views/plum/cp/assets/new.html.erb +9 -0
- data/app/views/plum/cp/content_types/_form.html.erb +113 -0
- data/app/views/plum/cp/content_types/edit.html.erb +8 -0
- data/app/views/plum/cp/content_types/index.html.erb +48 -0
- data/app/views/plum/cp/content_types/new.html.erb +5 -0
- data/app/views/plum/cp/content_types/show.html.erb +59 -0
- data/app/views/plum/cp/dashboard/show.html.erb +50 -0
- data/app/views/plum/cp/entries/_form.html.erb +196 -0
- data/app/views/plum/cp/entries/edit.html.erb +9 -0
- data/app/views/plum/cp/entries/index.html.erb +51 -0
- data/app/views/plum/cp/entries/new.html.erb +5 -0
- data/app/views/plum/cp/form_definitions/_form.html.erb +90 -0
- data/app/views/plum/cp/form_definitions/edit.html.erb +11 -0
- data/app/views/plum/cp/form_definitions/index.html.erb +43 -0
- data/app/views/plum/cp/form_definitions/new.html.erb +5 -0
- data/app/views/plum/cp/form_definitions/show.html.erb +70 -0
- data/app/views/plum/cp/form_submissions/show.html.erb +20 -0
- data/app/views/plum/cp/globals/_form.html.erb +44 -0
- data/app/views/plum/cp/globals/edit.html.erb +11 -0
- data/app/views/plum/cp/globals/index.html.erb +41 -0
- data/app/views/plum/cp/globals/new.html.erb +5 -0
- data/app/views/plum/cp/globals/show.html.erb +14 -0
- data/app/views/plum/cp/nav_items/_form.html.erb +56 -0
- data/app/views/plum/cp/nav_items/edit.html.erb +11 -0
- data/app/views/plum/cp/nav_items/new.html.erb +6 -0
- data/app/views/plum/cp/nav_menus/_form.html.erb +33 -0
- data/app/views/plum/cp/nav_menus/_nav_item.html.erb +24 -0
- data/app/views/plum/cp/nav_menus/edit.html.erb +11 -0
- data/app/views/plum/cp/nav_menus/index.html.erb +41 -0
- data/app/views/plum/cp/nav_menus/new.html.erb +5 -0
- data/app/views/plum/cp/nav_menus/show.html.erb +24 -0
- data/app/views/plum/cp/shared/_icon.html.erb +39 -0
- data/app/views/plum/cp/shared/_image_picker.html.erb +38 -0
- data/app/views/plum/cp/site_settings/edit.html.erb +145 -0
- data/app/views/plum/cp/site_settings/show.html.erb +62 -0
- data/app/views/plum/cp/taxonomies/_form.html.erb +38 -0
- data/app/views/plum/cp/taxonomies/edit.html.erb +2 -0
- data/app/views/plum/cp/taxonomies/index.html.erb +38 -0
- data/app/views/plum/cp/taxonomies/new.html.erb +2 -0
- data/app/views/plum/cp/taxonomies/show.html.erb +46 -0
- data/app/views/plum/cp/terms/_form.html.erb +30 -0
- data/app/views/plum/cp/terms/edit.html.erb +2 -0
- data/app/views/plum/cp/terms/new.html.erb +2 -0
- data/app/views/plum/cp/themes/index.html.erb +160 -0
- data/app/views/plum/form_mailer/submission_notification.html.erb +13 -0
- data/app/views/plum/form_mailer/submission_notification.text.erb +7 -0
- data/app/views/plum/sessions/new.html.erb +20 -0
- data/compat/plum-cms.rb +1 -0
- data/config/locales/en.yml +31 -0
- data/config/plum_importmap.rb +4 -0
- data/config/plum_routes.rb +34 -0
- data/db/engine_migrate/20260101000001_create_plum_tables.rb +154 -0
- data/lib/generators/plum/install/install_generator.rb +54 -0
- data/lib/generators/plum/install/templates/plum_initializer.rb +37 -0
- data/lib/plum/configuration.rb +88 -0
- data/lib/plum/content_source.rb +45 -0
- data/lib/plum/content_source_context.rb +30 -0
- data/lib/plum/content_source_registry.rb +120 -0
- data/lib/plum/engine.rb +30 -0
- data/lib/plum/liquid_tags/form_tag.rb +17 -0
- data/lib/plum/version.rb +7 -0
- data/lib/plum.rb +11 -0
- data/vendor/javascript/lexxy.js +11859 -0
- metadata +405 -0
|
@@ -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,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
|
+
}
|