plum-cms 0.1.2 → 0.2.1

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/README.md +81 -3
  4. data/app/assets/builds/tailwind.css +1 -1
  5. data/app/assets/stylesheets/plum/control_panel.css +1 -1
  6. data/app/assets/tailwind/application.css +42 -0
  7. data/app/controllers/plum/api/v1/entries_controller.rb +48 -0
  8. data/app/controllers/plum/cp/assets_controller.rb +4 -1
  9. data/app/controllers/plum/cp/content_types_controller.rb +25 -2
  10. data/app/controllers/plum/cp/entries_controller.rb +119 -9
  11. data/app/controllers/plum/cp/entry_revisions_controller.rb +27 -0
  12. data/app/controllers/plum/cp/fieldsets_controller.rb +37 -0
  13. data/app/controllers/plum/cp/site_settings_controller.rb +20 -1
  14. data/app/controllers/plum/pages_controller.rb +26 -1
  15. data/app/javascript/controllers/plum/asset_collection_controller.js +18 -0
  16. data/app/javascript/controllers/plum/blueprint_controller.js +202 -25
  17. data/app/javascript/controllers/plum/conditional_fields_controller.js +43 -0
  18. data/app/javascript/controllers/plum/focal_point_controller.js +16 -0
  19. data/app/javascript/controllers/plum/structured_field_controller.js +133 -0
  20. data/app/models/plum/asset.rb +5 -1
  21. data/app/models/plum/content_type.rb +63 -1
  22. data/app/models/plum/entry.rb +225 -1
  23. data/app/models/plum/entry_revision.rb +14 -0
  24. data/app/models/plum/fieldset.rb +17 -0
  25. data/app/models/plum/site.rb +15 -0
  26. data/app/models/plum/user.rb +1 -0
  27. data/app/services/plum/entry_serializer.rb +36 -0
  28. data/app/services/plum/field_expander.rb +12 -1
  29. data/app/services/plum/field_options.rb +21 -0
  30. data/app/services/plum/field_type_registry.rb +88 -0
  31. data/app/services/plum/liquid_context.rb +7 -1
  32. data/app/services/plum/site_archive.rb +367 -0
  33. data/app/views/layouts/plum/cp.html.erb +2 -0
  34. data/app/views/plum/cp/assets/_form.html.erb +13 -1
  35. data/app/views/plum/cp/content_types/_form.html.erb +81 -24
  36. data/app/views/plum/cp/custom_fields/_input.html.erb +5 -0
  37. data/app/views/plum/cp/entries/_form.html.erb +108 -31
  38. data/app/views/plum/cp/entries/edit.html.erb +16 -0
  39. data/app/views/plum/cp/entry_revisions/index.html.erb +25 -0
  40. data/app/views/plum/cp/fieldsets/index.html.erb +30 -0
  41. data/app/views/plum/cp/site_settings/edit.html.erb +12 -0
  42. data/config/plum_routes.rb +15 -0
  43. data/db/engine_migrate/20260807130000_create_plum_entry_revisions.rb +16 -0
  44. data/db/engine_migrate/20260807140000_create_plum_fieldsets.rb +13 -0
  45. data/db/engine_migrate/20260807150000_add_focal_point_to_plum_assets.rb +6 -0
  46. data/db/engine_migrate/20260807160000_add_localization_to_plum_entries.rb +9 -0
  47. data/docs/blueprint-fields.md +55 -0
  48. data/docs/extensions.md +32 -0
  49. data/docs/portability.md +40 -0
  50. data/docs/product-principles.md +73 -0
  51. data/docs/roadmap.md +100 -0
  52. data/docs/site/homepage.md +104 -0
  53. data/docs/site/information-architecture.md +176 -0
  54. data/docs/statamic-parity.md +28 -0
  55. data/docs/vision.md +82 -0
  56. data/lib/plum/engine.rb +3 -1
  57. data/lib/plum/version.rb +1 -1
  58. data/lib/plum.rb +4 -0
  59. data/lib/tasks/plum_portability.rake +37 -0
  60. data/lib/tasks/plum_styles.rake +12 -0
  61. metadata +36 -8
@@ -2,6 +2,7 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
4
  static targets = ["fields", "input"]
5
+ static values = { types: Array, nestedTypes: Array, applyFieldsetUrl: String }
5
6
 
6
7
  connect() {
7
8
  this.syncFieldConfigVisibility()
@@ -12,26 +13,17 @@ export default class extends Controller {
12
13
  event.preventDefault()
13
14
 
14
15
  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
+ <div class="plum-blueprint-field" data-plum--blueprint-target="field">
17
+ <div class="plum-blueprint-field-header col-span-full">
18
+ <p class="plum-blueprint-field-title" data-field-summary>Untitled field<span class="plum-blueprint-field-type">text</span></p>
19
+ <button type="button" data-action="plum--blueprint#removeField" class="text-sm font-medium text-red-600 hover:text-red-700">Remove</button>
20
+ </div>
16
21
  <input type="text" placeholder="handle" data-field="handle"
17
22
  data-action="input->plum--blueprint#inputChanged"
18
23
  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
24
  <select data-field="type" data-action="change->plum--blueprint#inputChanged"
20
25
  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>
26
+ ${this.typesValue.map(type => `<option value="${type.handle}">${type.label}</option>`).join("")}
35
27
  </select>
36
28
  <input type="text" placeholder="Label" data-field="label"
37
29
  data-action="input->plum--blueprint#inputChanged"
@@ -39,17 +31,27 @@ export default class extends Controller {
39
31
  <input type="text" placeholder="Related type handle" data-field="content_type" data-field-config="relationship"
40
32
  data-action="input->plum--blueprint#inputChanged"
41
33
  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">
34
+ <label class="hidden flex items-center gap-2 text-sm text-gray-700" data-field-config="relationship"><input type="checkbox" data-field="multiple" data-action="change->plum--blueprint#inputChanged"> Allow multiple entries</label>
42
35
  <input type="text" placeholder="Taxonomy handle" data-field="taxonomy" data-field-config="taxonomy"
43
36
  data-action="input->plum--blueprint#inputChanged"
44
37
  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"
38
+ <textarea rows="4" placeholder="Published | published&#10;Draft | draft" data-field="options" data-field-config="select radio button_group checkboxes"
46
39
  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>
40
+ class="hidden col-span-full 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"></textarea>
41
+ ${this.nestedEditorHtml()}
42
+ ${this.typeConstraintsHtml()}
43
+ <div class="col-span-full grid gap-3 border-t border-gray-200 pt-3 sm:grid-cols-3">
44
+ <input type="text" placeholder="Instructions" data-field="instructions" data-action="input->plum--blueprint#inputChanged"
45
+ class="px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm">
46
+ <input type="text" placeholder="Placeholder" data-field="placeholder" data-action="input->plum--blueprint#inputChanged"
47
+ class="px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm">
48
+ <input type="text" placeholder="Default value" data-field="default" data-action="input->plum--blueprint#inputChanged"
49
+ class="px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm">
50
+ <label class="flex items-center gap-2 text-sm text-gray-700">
51
+ <input type="checkbox" data-field="required" data-action="change->plum--blueprint#inputChanged"> Required
52
+ </label>
53
+ </div>
54
+ ${this.presentationHtml()}
53
55
  </div>
54
56
  `
55
57
 
@@ -65,6 +67,63 @@ export default class extends Controller {
65
67
  this.updateHiddenField()
66
68
  }
67
69
 
70
+ async applyFieldset(event) {
71
+ event.preventDefault()
72
+ const picker = this.element.querySelector("[data-fieldset-picker]")
73
+ const status = this.element.querySelector("[data-fieldset-status]")
74
+ if (!picker?.value || !this.hasApplyFieldsetUrlValue) return
75
+
76
+ event.currentTarget.disabled = true
77
+ if (status) {
78
+ status.textContent = "Inserting…"
79
+ status.className = "text-sm text-gray-600"
80
+ }
81
+ try {
82
+ const response = await fetch(this.applyFieldsetUrlValue, {
83
+ method: "POST",
84
+ headers: {
85
+ Accept: "application/json",
86
+ "Content-Type": "application/json",
87
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content || ""
88
+ },
89
+ body: JSON.stringify({ fieldset_id: picker.value })
90
+ })
91
+ const payload = await response.json()
92
+ if (!response.ok) throw new Error(payload.error || "Could not insert fieldset")
93
+ window.location.assign(payload.redirect_url)
94
+ } catch (error) {
95
+ event.currentTarget.disabled = false
96
+ if (status) {
97
+ status.textContent = error.message
98
+ status.className = "text-sm text-red-600"
99
+ }
100
+ }
101
+ }
102
+
103
+ addNestedField(event) {
104
+ event.preventDefault()
105
+ const editor = event.currentTarget.closest("[data-nested-editor]")
106
+ editor.querySelector("[data-nested-fields]").insertAdjacentHTML("beforeend", this.nestedFieldHtml())
107
+ this.updateHiddenField()
108
+ }
109
+
110
+ removeNestedField(event) {
111
+ event.preventDefault()
112
+ event.currentTarget.closest("[data-nested-field]").remove()
113
+ this.updateHiddenField()
114
+ }
115
+
116
+ moveNestedField(event) {
117
+ event.preventDefault()
118
+ const row = event.currentTarget.closest("[data-nested-field]")
119
+ const direction = Number(event.currentTarget.dataset.direction)
120
+ const sibling = direction < 0 ? row.previousElementSibling : row.nextElementSibling
121
+ if (!sibling) return
122
+ if (direction < 0) row.parentElement.insertBefore(row, sibling)
123
+ else row.parentElement.insertBefore(sibling, row)
124
+ this.updateHiddenField()
125
+ }
126
+
68
127
  updateHiddenField() {
69
128
  const fields = []
70
129
  const fieldElements = this.fieldsTarget.querySelectorAll('[data-plum--blueprint-target="field"]')
@@ -78,6 +137,21 @@ export default class extends Controller {
78
137
  if (handle) {
79
138
  const field = { handle, type, label }
80
139
 
140
+ ;["instructions", "placeholder", "default", "number_kind", "step", "unit", "date_mode", "min_items", "max_items", "width"].forEach(key => {
141
+ const value = fieldEl.querySelector(`[data-field="${key}"]`)?.value?.trim()
142
+ if (value) field[key] = value
143
+ })
144
+ if (fieldEl.querySelector('[data-field="required"]')?.checked) field.required = true
145
+ if (type === "relationship" && fieldEl.querySelector('[data-field="multiple"]')?.checked) field.multiple = true
146
+ if (type === "list" && fieldEl.querySelector('[data-field="unique"]')?.checked) field.unique = true
147
+ if (type === "number" || type === "date") {
148
+ const constraints = fieldEl.querySelector(`[data-field-config="${type}"]`)
149
+ ;["min", "max"].forEach(key => {
150
+ const value = constraints?.querySelector(`[data-field="${key}"]`)?.value.trim()
151
+ if (value) field[key] = value
152
+ })
153
+ }
154
+
81
155
  if (type === "relationship" && contentType) {
82
156
  field.content_type = contentType
83
157
  }
@@ -88,8 +162,24 @@ export default class extends Controller {
88
162
  }
89
163
 
90
164
  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)
165
+ if (["select", "radio", "button_group", "checkboxes"].includes(type) && optionsVal) {
166
+ field.options = optionsVal.split("\n").map(line => {
167
+ const [label, ...valueParts] = line.split("|")
168
+ const value = valueParts.join("|").trim()
169
+ return value ? { label: label.trim(), value } : label.trim()
170
+ }).filter(option => typeof option === "string" ? option : option.label && option.value)
171
+ }
172
+
173
+ const conditionField = fieldEl.querySelector('[data-field="condition_field"]')?.value.trim()
174
+ const conditionOperator = fieldEl.querySelector('[data-field="condition_operator"]')?.value
175
+ const conditionValue = fieldEl.querySelector('[data-field="condition_value"]')?.value.trim()
176
+ if (conditionField && conditionOperator) {
177
+ field.condition = { field: conditionField, operator: conditionOperator }
178
+ if (conditionValue) field.condition.value = conditionValue
179
+ }
180
+
181
+ if (type === "group" || type === "repeater") {
182
+ field.fields = this.readNestedFields(fieldEl)
93
183
  }
94
184
 
95
185
  fields.push(field)
@@ -99,6 +189,82 @@ export default class extends Controller {
99
189
  this.inputTarget.value = JSON.stringify({ fields })
100
190
  }
101
191
 
192
+ readNestedFields(fieldEl) {
193
+ return Array.from(fieldEl.querySelectorAll("[data-nested-field]")).filter(row => {
194
+ return row.querySelector('[data-nested="handle"]')?.value.trim()
195
+ }).map(row => {
196
+ const handle = row.querySelector('[data-nested="handle"]').value.trim()
197
+ const field = {
198
+ handle,
199
+ type: row.querySelector('[data-nested="type"]').value,
200
+ label: row.querySelector('[data-nested="label"]').value.trim() || handle.replaceAll("_", " ")
201
+ }
202
+ ;["instructions", "placeholder", "default"].forEach(key => {
203
+ const value = row.querySelector(`[data-nested="${key}"]`)?.value.trim()
204
+ if (value) field[key] = value
205
+ })
206
+ if (row.querySelector('[data-nested="required"]')?.checked) field.required = true
207
+ return field
208
+ })
209
+ }
210
+
211
+ nestedEditorHtml() {
212
+ return `<div class="hidden col-span-full space-y-3 rounded-md border border-gray-200 bg-white p-3" data-field-config="group repeater" data-nested-editor>
213
+ <div class="flex items-center justify-between"><p class="text-sm font-medium text-gray-700">Nested fields</p>
214
+ <button type="button" data-action="plum--blueprint#addNestedField" class="text-sm font-medium text-purple-600 hover:text-purple-700">+ Add nested field</button></div>
215
+ <div class="space-y-3" data-nested-fields></div>
216
+ </div>`
217
+ }
218
+
219
+ typeConstraintsHtml() {
220
+ return `<div class="hidden col-span-full grid gap-3 rounded-md border border-gray-200 bg-white p-3 sm:grid-cols-4" data-field-config="number">
221
+ <select data-field="number_kind" data-action="change->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm"><option value="decimal">Decimal</option><option value="integer">Integer</option></select>
222
+ <input type="number" placeholder="Minimum" data-field="min" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
223
+ <input type="number" placeholder="Maximum" data-field="max" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
224
+ <input type="number" placeholder="Step" data-field="step" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
225
+ <input type="text" placeholder="Unit (optional)" data-field="unit" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
226
+ </div>
227
+ <div class="hidden col-span-full grid gap-3 rounded-md border border-gray-200 bg-white p-3 sm:grid-cols-3" data-field-config="date">
228
+ <select data-field="date_mode" data-action="change->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm"><option value="date">Date</option><option value="time">Time</option><option value="datetime">Date and time</option></select>
229
+ <input type="text" placeholder="Earliest value" data-field="min" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
230
+ <input type="text" placeholder="Latest value" data-field="max" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
231
+ </div>
232
+ <div class="hidden col-span-full grid gap-3 rounded-md border border-gray-200 bg-white p-3 sm:grid-cols-3" data-field-config="list repeater images relationship" data-multiple-only>
233
+ <input type="number" min="0" placeholder="Minimum items" data-field="min_items" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
234
+ <input type="number" min="0" placeholder="Maximum items" data-field="max_items" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
235
+ <label class="flex items-center gap-2 text-sm text-gray-700" data-field-config="list"><input type="checkbox" data-field="unique" data-action="change->plum--blueprint#inputChanged"> Unique values</label>
236
+ </div>`
237
+ }
238
+
239
+ presentationHtml() {
240
+ return `<div class="col-span-full grid gap-3 rounded-md border border-gray-200 bg-white p-3 sm:grid-cols-4">
241
+ <select data-field="width" data-action="change->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
242
+ <option value="12">Full width</option><option value="9">Three quarters</option><option value="8">Two thirds</option><option value="6">Half width</option><option value="4">One third</option><option value="3">Quarter width</option>
243
+ </select>
244
+ <input type="text" placeholder="Show when field…" data-field="condition_field" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 font-mono text-sm">
245
+ <select data-field="condition_operator" data-action="change->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
246
+ <option value="">No condition</option><option value="equals">Equals</option><option value="not_equals">Does not equal</option><option value="contains">Contains</option><option value="empty">Is empty</option><option value="not_empty">Is not empty</option>
247
+ </select>
248
+ <input type="text" placeholder="Value" data-field="condition_value" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
249
+ </div>`
250
+ }
251
+
252
+ nestedFieldHtml() {
253
+ const options = this.nestedTypesValue.map(type => `<option value="${type.handle}">${type.label}</option>`).join("")
254
+ return `<div class="grid gap-2 rounded-md border border-gray-200 bg-gray-50 p-3 sm:grid-cols-3" data-nested-field>
255
+ <input type="text" placeholder="handle" data-nested="handle" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 font-mono text-sm">
256
+ <select data-nested="type" data-action="change->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">${options}</select>
257
+ <input type="text" placeholder="Label" data-nested="label" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
258
+ <input type="text" placeholder="Instructions" data-nested="instructions" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
259
+ <input type="text" placeholder="Placeholder" data-nested="placeholder" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
260
+ <input type="text" placeholder="Default value" data-nested="default" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
261
+ <label class="flex items-center gap-2 text-sm text-gray-700"><input type="checkbox" data-nested="required" data-action="change->plum--blueprint#inputChanged"> Required</label>
262
+ <div class="flex gap-3"><button type="button" data-direction="-1" data-action="plum--blueprint#moveNestedField" class="text-sm text-gray-600">↑ Up</button>
263
+ <button type="button" data-direction="1" data-action="plum--blueprint#moveNestedField" class="text-sm text-gray-600">↓ Down</button>
264
+ <button type="button" data-action="plum--blueprint#removeNestedField" class="text-sm font-medium text-red-600">Remove</button></div>
265
+ </div>`
266
+ }
267
+
102
268
  inputChanged(event) {
103
269
  const fieldEl = event.target.closest('[data-plum--blueprint-target="field"]')
104
270
  if (fieldEl) this.syncFieldConfigVisibility(fieldEl)
@@ -111,9 +277,20 @@ export default class extends Controller {
111
277
 
112
278
  fieldElements.forEach(fieldEl => {
113
279
  const type = fieldEl.querySelector('[data-field="type"]')?.value
280
+ const label = fieldEl.querySelector('[data-field="label"]')?.value.trim()
281
+ const handle = fieldEl.querySelector('[data-field="handle"]')?.value.trim()
282
+ const summary = fieldEl.querySelector("[data-field-summary]")
283
+ if (summary) {
284
+ summary.replaceChildren(document.createTextNode(label || handle || "Untitled field"))
285
+ const typeLabel = document.createElement("span")
286
+ typeLabel.className = "plum-blueprint-field-type"
287
+ typeLabel.textContent = type || "text"
288
+ summary.append(typeLabel)
289
+ }
114
290
  fieldEl.querySelectorAll('[data-field-config]').forEach(configEl => {
115
291
  const configTypes = configEl.dataset.fieldConfig.split(" ")
116
- configEl.classList.toggle("hidden", !configTypes.includes(type))
292
+ const multipleAllowed = !configEl.hasAttribute("data-multiple-only") || type !== "relationship" || fieldEl.querySelector('[data-field="multiple"]')?.checked
293
+ configEl.classList.toggle("hidden", !configTypes.includes(type) || !multipleAllowed)
117
294
  })
118
295
  })
119
296
  }
@@ -0,0 +1,43 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ connect() {
5
+ this.refresh = this.refresh.bind(this)
6
+ this.element.addEventListener("input", this.refresh)
7
+ this.element.addEventListener("change", this.refresh)
8
+ this.refresh()
9
+ }
10
+
11
+ disconnect() {
12
+ this.element.removeEventListener("input", this.refresh)
13
+ this.element.removeEventListener("change", this.refresh)
14
+ }
15
+
16
+ refresh() {
17
+ this.element.querySelectorAll("[data-field-condition]").forEach(field => {
18
+ const condition = JSON.parse(field.dataset.fieldCondition || "{}")
19
+ const values = this.valuesFor(condition.field)
20
+ const expected = String(condition.value ?? "")
21
+ let visible = true
22
+
23
+ if (condition.operator === "equals") visible = values.includes(expected)
24
+ if (condition.operator === "not_equals") visible = !values.includes(expected)
25
+ if (condition.operator === "contains") visible = values.some(value => value.includes(expected))
26
+ if (condition.operator === "empty") visible = values.length === 0 || values.every(value => !value)
27
+ if (condition.operator === "not_empty") visible = values.some(Boolean)
28
+
29
+ field.classList.toggle("hidden", !visible)
30
+ field.querySelectorAll("input, select, textarea, button").forEach(input => {
31
+ if (input.type !== "hidden") input.disabled = !visible
32
+ })
33
+ })
34
+ }
35
+
36
+ valuesFor(handle) {
37
+ if (!handle) return []
38
+ const selector = `[name="entry[data][${CSS.escape(handle)}]"], [name="entry[data][${CSS.escape(handle)}][]"]`
39
+ return Array.from(this.element.querySelectorAll(selector)).filter(input => {
40
+ return !["checkbox", "radio"].includes(input.type) || input.checked
41
+ }).map(input => input.value)
42
+ }
43
+ }
@@ -0,0 +1,16 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["preview", "x", "y", "label"]
5
+
6
+ connect() {
7
+ this.refresh()
8
+ }
9
+
10
+ refresh() {
11
+ const x = this.xTarget.value
12
+ const y = this.yTarget.value
13
+ if (this.hasPreviewTarget) this.previewTarget.style.objectPosition = `${x}% ${y}%`
14
+ if (this.hasLabelTarget) this.labelTarget.textContent = `${x}% × ${y}%`
15
+ }
16
+ }
@@ -0,0 +1,133 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["input", "rows", "empty", "add"]
5
+ static values = { type: String, fields: Array, minItems: Number, maxItems: Number }
6
+
7
+ connect() {
8
+ this.value = this.read()
9
+ this.render()
10
+ }
11
+
12
+ add(event) {
13
+ event.preventDefault()
14
+ if (this.hasMaxItemsValue && this.value.length >= this.maxItemsValue) return
15
+ this.value.push(this.typeValue === "list" ? "" : {})
16
+ this.render()
17
+ }
18
+
19
+ remove(event) {
20
+ event.preventDefault()
21
+ if (this.hasMinItemsValue && this.value.length <= this.minItemsValue) return
22
+ this.value.splice(Number(event.currentTarget.dataset.index), 1)
23
+ this.render()
24
+ }
25
+
26
+ move(event) {
27
+ event.preventDefault()
28
+ const index = Number(event.currentTarget.dataset.index)
29
+ const target = index + Number(event.currentTarget.dataset.direction)
30
+ if (target < 0 || target >= this.value.length) return
31
+ const [row] = this.value.splice(index, 1)
32
+ this.value.splice(target, 0, row)
33
+ this.render()
34
+ }
35
+
36
+ changed(event) {
37
+ const index = Number(event.currentTarget.dataset.index)
38
+ const field = event.currentTarget.dataset.field
39
+ const value = event.currentTarget.type === "checkbox" ? event.currentTarget.checked : event.currentTarget.value
40
+ if (this.typeValue === "list") this.value[index] = value
41
+ else this.value[index][field] = value
42
+ this.serialize()
43
+ }
44
+
45
+ read() {
46
+ try {
47
+ const parsed = JSON.parse(this.inputTarget.value || (this.typeValue === "group" ? "{}" : "[]"))
48
+ if (this.typeValue === "group") return [parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}]
49
+ return Array.isArray(parsed) ? parsed : []
50
+ } catch (_error) {
51
+ return this.typeValue === "group" ? [{}] : []
52
+ }
53
+ }
54
+
55
+ serialize() {
56
+ this.inputTarget.value = JSON.stringify(this.typeValue === "group" ? (this.value[0] || {}) : this.value)
57
+ }
58
+
59
+ render() {
60
+ this.rowsTarget.innerHTML = ""
61
+ this.value.forEach((row, index) => this.rowsTarget.appendChild(this.row(row, index)))
62
+ if (this.hasEmptyTarget) this.emptyTarget.classList.toggle("hidden", this.value.length > 0)
63
+ if (this.hasAddTarget) this.addTarget.disabled = this.hasMaxItemsValue && this.value.length >= this.maxItemsValue
64
+ this.serialize()
65
+ }
66
+
67
+ row(row, index) {
68
+ const wrapper = document.createElement("div")
69
+ wrapper.className = "space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-4"
70
+
71
+ const fields = this.typeValue === "list" ? [{ handle: "value", label: "Value", type: "text" }] : this.fieldsValue
72
+ fields.forEach(field => wrapper.appendChild(this.control(field, row, index)))
73
+
74
+ if (this.typeValue !== "group") {
75
+ const controls = document.createElement("div")
76
+ controls.className = "flex items-center gap-3"
77
+ controls.appendChild(this.actionButton("↑ Up", "move", index, { direction: -1, disabled: index === 0 }))
78
+ controls.appendChild(this.actionButton("↓ Down", "move", index, { direction: 1, disabled: index === this.value.length - 1 }))
79
+ const remove = document.createElement("button")
80
+ remove.type = "button"
81
+ remove.textContent = "Remove"
82
+ remove.dataset.index = index
83
+ remove.dataset.action = "plum--structured-field#remove"
84
+ remove.disabled = this.hasMinItemsValue && this.value.length <= this.minItemsValue
85
+ remove.className = "text-sm font-medium text-red-600 hover:text-red-700 disabled:opacity-30"
86
+ controls.appendChild(remove)
87
+ wrapper.appendChild(controls)
88
+ }
89
+ return wrapper
90
+ }
91
+
92
+ actionButton(label, action, index, options = {}) {
93
+ const button = document.createElement("button")
94
+ button.type = "button"
95
+ button.textContent = label
96
+ button.dataset.index = index
97
+ button.dataset.action = `plum--structured-field#${action}`
98
+ if (options.direction) button.dataset.direction = options.direction
99
+ button.disabled = options.disabled || false
100
+ button.className = "text-sm text-gray-600 hover:text-gray-900 disabled:opacity-30"
101
+ return button
102
+ }
103
+
104
+ control(field, row, index) {
105
+ const label = document.createElement("label")
106
+ label.className = "block space-y-1"
107
+ const caption = document.createElement("span")
108
+ caption.className = "block text-xs font-medium text-gray-600"
109
+ caption.textContent = field.label || field.handle
110
+ label.appendChild(caption)
111
+ if (field.instructions) {
112
+ const instructions = document.createElement("span")
113
+ instructions.className = "block text-xs text-gray-500"
114
+ instructions.textContent = field.instructions
115
+ label.appendChild(instructions)
116
+ }
117
+
118
+ const input = field.type === "textarea" ? document.createElement("textarea") : document.createElement("input")
119
+ if (field.type !== "textarea") input.type = field.type === "boolean" ? "checkbox" : (field.type === "number" ? "number" : field.type === "date" ? "date" : "text")
120
+ const stored = this.typeValue === "list" ? row : row?.[field.handle]
121
+ const value = stored === undefined || stored === null ? field.default : stored
122
+ if (input.type === "checkbox") input.checked = value === true || value === "true"
123
+ else input.value = value ?? ""
124
+ if (input.type !== "checkbox") input.className = "block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm"
125
+ if (field.placeholder) input.placeholder = field.placeholder
126
+ input.required = field.required === true || field.required === "true"
127
+ input.dataset.index = index
128
+ input.dataset.field = field.handle
129
+ input.dataset.action = "input->plum--structured-field#changed change->plum--structured-field#changed"
130
+ label.appendChild(input)
131
+ return label
132
+ }
133
+ }
@@ -5,6 +5,7 @@ module Plum
5
5
  has_one_attached :file
6
6
 
7
7
  validates :file, presence: true
8
+ validates :focal_x, :focal_y, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
8
9
  validate :file_must_be_image
9
10
 
10
11
  before_validation :normalize_folder
@@ -49,7 +50,10 @@ module Plum
49
50
  "filename" => filename,
50
51
  "content_type" => content_type.to_s,
51
52
  "byte_size" => file.attached? ? file.byte_size : nil,
52
- "folder" => folder.to_s
53
+ "folder" => folder.to_s,
54
+ "focal_x" => focal_x,
55
+ "focal_y" => focal_y,
56
+ "object_position" => "#{focal_x}% #{focal_y}%"
53
57
  }
54
58
  end
55
59
 
@@ -2,13 +2,14 @@ module Plum
2
2
  class ContentType < ApplicationRecord
3
3
  include SiteScoped
4
4
 
5
- FIELD_TYPES = %w[text textarea rich_text image boolean select date relationship blocks].freeze
5
+ FIELD_TYPES = FieldTypeRegistry.handles.freeze
6
6
 
7
7
  has_many :entries, dependent: :destroy
8
8
 
9
9
  validates :name, presence: true
10
10
  validates :handle, presence: true, uniqueness: { scope: :site_id }, format: { with: /\A[a-z][a-z0-9_]*\z/ }
11
11
  validate :route_prefix_format
12
+ validate :blueprint_fields_are_valid
12
13
 
13
14
  before_validation :generate_handle, on: :create
14
15
 
@@ -31,5 +32,66 @@ module Plum
31
32
 
32
33
  errors.add(:blueprint, "route prefix must contain lowercase letters, numbers, and hyphens")
33
34
  end
35
+
36
+ def blueprint_fields_are_valid
37
+ handles = fields.map { |field| field["handle"].to_s }
38
+ fields.each do |field|
39
+ handle = field["handle"].to_s
40
+ type = field["type"].to_s
41
+ errors.add(:blueprint, "field handle #{handle.inspect} is invalid") unless handle.match?(/\A[a-z][a-z0-9_]*\z/)
42
+ errors.add(:blueprint, "field #{handle.presence || '(unnamed)'} has unknown type #{type.inspect}") unless FieldTypeRegistry.include?(type)
43
+ validate_nested_fields(field, handle) if %w[group repeater].include?(type)
44
+ validate_collection_configuration(field, handle) if %w[list repeater images].include?(type) || (type == "relationship" && ActiveModel::Type::Boolean.new.cast(field["multiple"]))
45
+ validate_number_configuration(field, handle) if type == "number"
46
+ validate_presentation_configuration(field, handle, handles)
47
+ end
48
+ end
49
+
50
+ def validate_presentation_configuration(field, handle, handles)
51
+ width = Integer(field["width"], exception: false) if field["width"].present?
52
+ errors.add(:blueprint, "field #{handle} width must be between 1 and 12") if field["width"].present? && (!width || !width.between?(1, 12))
53
+
54
+ condition = field["condition"]
55
+ return if condition.blank?
56
+
57
+ source = condition["field"].to_s
58
+ operator = condition["operator"].to_s
59
+ errors.add(:blueprint, "field #{handle} condition references an unknown field") unless handles.include?(source) && source != handle
60
+ unless %w[equals not_equals contains empty not_empty].include?(operator)
61
+ errors.add(:blueprint, "field #{handle} condition has an invalid operator")
62
+ end
63
+ end
64
+
65
+ def validate_collection_configuration(field, handle)
66
+ minimum = Integer(field["min_items"], exception: false) if field["min_items"].present?
67
+ maximum = Integer(field["max_items"], exception: false) if field["max_items"].present?
68
+ errors.add(:blueprint, "field #{handle} minimum items must be zero or greater") if field["min_items"].present? && (!minimum || minimum.negative?)
69
+ errors.add(:blueprint, "field #{handle} maximum items must be zero or greater") if field["max_items"].present? && (!maximum || maximum.negative?)
70
+ errors.add(:blueprint, "field #{handle} maximum items must be at least its minimum") if minimum && maximum && maximum < minimum
71
+ end
72
+
73
+ def validate_number_configuration(field, handle)
74
+ minimum = Float(field["min"], exception: false) if field["min"].present?
75
+ maximum = Float(field["max"], exception: false) if field["max"].present?
76
+ step = Float(field["step"], exception: false) if field["step"].present?
77
+ errors.add(:blueprint, "field #{handle} minimum must be numeric") if field["min"].present? && !minimum
78
+ errors.add(:blueprint, "field #{handle} maximum must be numeric") if field["max"].present? && !maximum
79
+ errors.add(:blueprint, "field #{handle} step must be greater than zero") if field["step"].present? && (!step || !step.positive?)
80
+ errors.add(:blueprint, "field #{handle} maximum must be at least its minimum") if minimum && maximum && maximum < minimum
81
+ end
82
+
83
+ def validate_nested_fields(field, parent_handle)
84
+ nested_fields = Array(field["fields"])
85
+ errors.add(:blueprint, "field #{parent_handle} must define nested fields") if nested_fields.empty?
86
+
87
+ nested_fields.each do |nested|
88
+ nested_handle = nested["handle"].to_s
89
+ nested_type = nested["type"].to_s
90
+ errors.add(:blueprint, "field #{parent_handle} has invalid nested handle #{nested_handle.inspect}") unless nested_handle.match?(/\A[a-z][a-z0-9_]*\z/)
91
+ unless FieldTypeRegistry::NESTED_FIELD_TYPES.include?(nested_type)
92
+ errors.add(:blueprint, "field #{parent_handle}.#{nested_handle.presence || '(unnamed)'} has unsupported nested type #{nested_type.inspect}")
93
+ end
94
+ end
95
+ end
34
96
  end
35
97
  end