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
@@ -0,0 +1,367 @@
1
+ require "json"
2
+ require "zip"
3
+ require "digest"
4
+ require "tempfile"
5
+
6
+ module Plum
7
+ module SiteArchive
8
+ FORMAT = "plum-site"
9
+ VERSION = 1
10
+
11
+ class Error < StandardError; end
12
+ class InvalidArchive < Error; end
13
+
14
+ module_function
15
+
16
+ def dump(site:, path:)
17
+ Exporter.new(site).write(path)
18
+ end
19
+
20
+ def load(path:, name: nil, domain: nil)
21
+ Importer.new(path).import(name: name, domain: domain)
22
+ end
23
+
24
+ class Exporter
25
+ def initialize(site)
26
+ @site = site
27
+ end
28
+
29
+ def write(path)
30
+ destination = Pathname(path).expand_path
31
+ destination.dirname.mkpath
32
+ FileUtils.rm_f(destination)
33
+
34
+ Zip::File.open(destination, create: true) do |zip|
35
+ zip.get_output_stream("manifest.json") { |stream| stream.write(JSON.pretty_generate(manifest)) }
36
+ site.assets.with_attached_file.find_each do |asset|
37
+ next unless asset.file.attached?
38
+
39
+ zip.get_output_stream(asset_path(asset)) do |stream|
40
+ asset.file.blob.open { |file| IO.copy_stream(file, stream) }
41
+ end
42
+ end
43
+ end
44
+ destination
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :site
50
+
51
+ def manifest
52
+ {
53
+ "format" => FORMAT,
54
+ "format_version" => VERSION,
55
+ "plum_version" => Plum::VERSION,
56
+ "exported_at" => Time.current.iso8601,
57
+ "site" => record(site, %w[id name domain theme_name settings theme_settings custom_css]),
58
+ "site_setting" => site.site_setting && record(site.site_setting, site_setting_fields),
59
+ "content_types" => records(site.content_types, %w[id name handle singleton blueprint icon]),
60
+ "fieldsets" => records(site.fieldsets, %w[id name handle fields]),
61
+ "taxonomies" => records(site.taxonomies, %w[id name handle slug]),
62
+ "terms" => records(site.terms, %w[id taxonomy_id name slug position]),
63
+ "assets" => site.assets.with_attached_file.order(:id).map { |asset| asset_record(asset) },
64
+ "entries" => site.entries.order(:id).map { |entry| entry_record(entry) },
65
+ "entry_revisions" => revision_records,
66
+ "globals" => records(site.globals, %w[id name handle data]),
67
+ "nav_menus" => records(site.nav_menus, %w[id name handle]),
68
+ "nav_items" => records(site.nav_items.unscoped.where(site: site), %w[id nav_menu_id parent_id entry_id label url position]),
69
+ "form_definitions" => records(site.form_definitions, %w[id name handle fields notification_email]),
70
+ "form_submissions" => records(site.form_submissions, %w[id form_definition_id data created_at updated_at])
71
+ }.compact
72
+ end
73
+
74
+ def site_setting_fields
75
+ %w[name tagline logo favicon seo_title seo_description theme_name primary_color support_email]
76
+ end
77
+
78
+ def entry_record(entry)
79
+ record(entry, %w[id content_type_id title slug status data published_at author_name author_email author_gid locale origin_id]).merge(
80
+ "term_ids" => entry.term_ids
81
+ )
82
+ end
83
+
84
+ def revision_records
85
+ site.entries.includes(:revisions).flat_map do |entry|
86
+ entry.revisions.order(:id).map do |revision|
87
+ record(revision, %w[id entry_id editor_name editor_email editor_gid snapshot created_at updated_at])
88
+ end
89
+ end
90
+ end
91
+
92
+ def asset_record(asset)
93
+ record(asset, %w[id alt_text caption folder focal_x focal_y]).merge(
94
+ "filename" => asset.filename,
95
+ "content_type" => asset.content_type,
96
+ "byte_size" => asset.file.byte_size,
97
+ "checksum" => asset.file.blob.checksum,
98
+ "path" => asset_path(asset)
99
+ )
100
+ end
101
+
102
+ def asset_path(asset)
103
+ "assets/#{asset.id}/#{asset.filename.gsub(/[^A-Za-z0-9._-]/, "_")}"
104
+ end
105
+
106
+ def records(scope, fields)
107
+ scope.order(:id).map { |item| record(item, fields) }
108
+ end
109
+
110
+ def record(item, fields)
111
+ item.attributes.slice(*fields)
112
+ end
113
+ end
114
+
115
+ class Importer
116
+ def initialize(path)
117
+ @path = Pathname(path).expand_path
118
+ @maps = Hash.new { |hash, key| hash[key] = {} }
119
+ @uploaded_blobs = []
120
+ end
121
+
122
+ def import(name: nil, domain: nil)
123
+ raise InvalidArchive, "Archive does not exist: #{path}" unless path.file?
124
+
125
+ Zip::File.open(path) do |zip|
126
+ @zip = zip
127
+ @data = parse_manifest(zip)
128
+ validate_manifest!
129
+ ActiveRecord::Base.transaction { import_site(name:, domain:) }
130
+ end
131
+ rescue StandardError => error
132
+ cleanup_uploaded_files
133
+ raise unless error.is_a?(Zip::Error) || error.is_a?(JSON::ParserError)
134
+
135
+ raise InvalidArchive, error.message
136
+ ensure
137
+ @zip = nil
138
+ end
139
+
140
+ private
141
+
142
+ attr_reader :path, :data, :maps, :zip
143
+
144
+ def parse_manifest(zip_file)
145
+ entry = zip_file.find_entry("manifest.json")
146
+ raise InvalidArchive, "Archive is missing manifest.json" unless entry
147
+
148
+ JSON.parse(entry.get_input_stream.read)
149
+ end
150
+
151
+ def validate_manifest!
152
+ raise InvalidArchive, "Not a Plum site archive" unless data["format"] == FORMAT
153
+ raise InvalidArchive, "Unsupported archive version #{data['format_version'].inspect}" unless data["format_version"] == VERSION
154
+ raise InvalidArchive, "Archive is missing site data" unless data["site"].is_a?(Hash)
155
+ end
156
+
157
+ def import_site(name:, domain:)
158
+ source = data.fetch("site")
159
+ @site = Site.create!(
160
+ name: name.presence || source.fetch("name"),
161
+ domain: domain.nil? ? source["domain"] : domain,
162
+ theme_name: source["theme_name"],
163
+ settings: source["settings"] || {},
164
+ theme_settings: source["theme_settings"] || {},
165
+ custom_css: source["custom_css"],
166
+ skip_defaults: true
167
+ )
168
+ maps[:sites][source["id"]] = @site.id
169
+
170
+ import_simple(:content_types, ContentType, %w[name handle singleton blueprint icon])
171
+ import_simple(:fieldsets, Fieldset, %w[name handle fields])
172
+ import_simple(:taxonomies, Taxonomy, %w[name handle slug])
173
+ import_terms
174
+ import_assets
175
+ import_entries
176
+ import_entry_links
177
+ import_globals
178
+ import_navigation
179
+ import_forms
180
+ import_site_setting
181
+ @site
182
+ end
183
+
184
+ def import_simple(key, model, fields)
185
+ Array(data[key.to_s]).each do |source|
186
+ item = model.create!(source.slice(*fields).merge("site_id" => @site.id))
187
+ maps[key][source["id"]] = item.id
188
+ end
189
+ end
190
+
191
+ def import_terms
192
+ Array(data["terms"]).each do |source|
193
+ term = Term.create!(source.slice("name", "slug", "position").merge(
194
+ "site_id" => @site.id,
195
+ "taxonomy_id" => mapped!(:taxonomies, source["taxonomy_id"])
196
+ ))
197
+ maps[:terms][source["id"]] = term.id
198
+ end
199
+ end
200
+
201
+ def import_assets
202
+ Array(data["assets"]).each do |source|
203
+ archive_entry = zip.find_entry(source.fetch("path"))
204
+ raise InvalidArchive, "Archive is missing asset #{source['path']}" unless archive_entry
205
+
206
+ asset = import_asset(source, archive_entry)
207
+ maps[:assets][source["id"]] = asset.id
208
+ end
209
+ end
210
+
211
+ def import_asset(source, archive_entry)
212
+ Tempfile.create([ "plum-asset", File.extname(source.fetch("filename")) ], binmode: true) do |file|
213
+ digest = Digest::MD5.new
214
+ bytes = 0
215
+ input = archive_entry.get_input_stream
216
+ while (chunk = input.read(64 * 1024))
217
+ file.write(chunk)
218
+ digest.update(chunk)
219
+ bytes += chunk.bytesize
220
+ end
221
+ expected_checksum = source["checksum"].to_s
222
+ actual_checksum = [ digest.digest ].pack("m0")
223
+ raise InvalidArchive, "Asset #{source['path']} has an invalid size" if source["byte_size"].present? && bytes != source["byte_size"].to_i
224
+ raise InvalidArchive, "Asset #{source['path']} failed its checksum" if expected_checksum.present? && actual_checksum != expected_checksum
225
+
226
+ file.rewind
227
+ asset = Asset.new(source.slice("alt_text", "caption", "folder", "focal_x", "focal_y").merge("site_id" => @site.id))
228
+ blob = ActiveStorage::Blob.create_and_upload!(
229
+ io: file,
230
+ filename: source.fetch("filename"),
231
+ content_type: source["content_type"]
232
+ )
233
+ @uploaded_blobs << blob
234
+ asset.file.attach(blob)
235
+ asset.save!
236
+ asset
237
+ end
238
+ end
239
+
240
+ def import_entries
241
+ Array(data["entries"]).each do |source|
242
+ entry = Entry.create!(source.slice("title", "slug", "status", "data", "published_at", "author_name", "author_email", "author_gid", "locale").merge(
243
+ "site_id" => @site.id,
244
+ "content_type_id" => mapped!(:content_types, source["content_type_id"])
245
+ ))
246
+ maps[:entries][source["id"]] = entry.id
247
+ end
248
+ end
249
+
250
+ def import_entry_links
251
+ entries_by_id = Array(data["entries"]).index_by { |item| item["id"] }
252
+ entries_by_id.each do |old_id, source|
253
+ entry = Entry.find(mapped!(:entries, old_id))
254
+ fields = entry.content_type.fields
255
+ entry.update_columns(
256
+ data: remap_field_values(source["data"] || {}, fields),
257
+ origin_id: mapped(:entries, source["origin_id"]),
258
+ updated_at: Time.current
259
+ )
260
+ entry.term_ids = Array(source["term_ids"]).filter_map { |id| mapped(:terms, id) }
261
+ end
262
+
263
+ Array(data["entry_revisions"]).each do |source|
264
+ snapshot = source["snapshot"].to_h.deep_dup
265
+ entry = Entry.find(mapped!(:entries, source["entry_id"]))
266
+ snapshot["data"] = remap_field_values(snapshot["data"] || {}, entry.content_type.fields)
267
+ snapshot["term_ids"] = Array(snapshot["term_ids"]).filter_map { |id| mapped(:terms, id) }
268
+ EntryRevision.create!(source.slice("editor_name", "editor_email", "editor_gid", "created_at", "updated_at").merge(
269
+ "site_id" => @site.id, "entry_id" => entry.id, "snapshot" => snapshot
270
+ ))
271
+ end
272
+ end
273
+
274
+ def remap_field_values(values, fields)
275
+ result = values.to_h.deep_dup
276
+ Array(fields).each do |field|
277
+ handle = field["handle"].to_s
278
+ value = result[handle]
279
+ result[handle] = case field["type"]
280
+ when "image" then mapped(:assets, value)
281
+ when "images" then Array(value).filter_map { |id| mapped(:assets, id) }
282
+ when "relationship"
283
+ field["multiple"] ? Array(value).filter_map { |id| mapped(:entries, id) } : mapped(:entries, value)
284
+ when "group" then remap_field_values(value || {}, field["fields"])
285
+ when "repeater" then Array(value).map { |row| remap_field_values(row, field["fields"]) }
286
+ when "blocks" then remap_blocks(value)
287
+ else value
288
+ end
289
+ end
290
+ result
291
+ end
292
+
293
+ def remap_blocks(value)
294
+ library = BlockLibrary.new(@site.theme)
295
+ Array(value).map do |block|
296
+ restored = block.to_h.deep_dup
297
+ definition = library.definition(restored["type"])
298
+ restored["fields"] = remap_field_values(restored["fields"] || {}, definition&.dig("fields") || [])
299
+ restored
300
+ end
301
+ end
302
+
303
+ def import_globals
304
+ Array(data["globals"]).each do |source|
305
+ Global.create!(source.slice("name", "handle", "data").merge("site_id" => @site.id))
306
+ end
307
+ end
308
+
309
+ def import_navigation
310
+ import_simple(:nav_menus, NavMenu, %w[name handle])
311
+ pending = Array(data["nav_items"]).sort_by { |item| item["parent_id"].present? ? 1 : 0 }
312
+ until pending.empty?
313
+ imported = pending.reject! do |source|
314
+ next false if source["parent_id"].present? && mapped(:nav_items, source["parent_id"]).blank?
315
+
316
+ item = NavItem.create!(source.slice("label", "url", "position").merge(
317
+ "site_id" => @site.id,
318
+ "nav_menu_id" => mapped!(:nav_menus, source["nav_menu_id"]),
319
+ "parent_id" => mapped(:nav_items, source["parent_id"]),
320
+ "entry_id" => mapped(:entries, source["entry_id"])
321
+ ))
322
+ maps[:nav_items][source["id"]] = item.id
323
+ true
324
+ end
325
+ raise InvalidArchive, "Navigation contains an invalid parent cycle" unless imported
326
+ end
327
+ end
328
+
329
+ def import_forms
330
+ import_simple(:form_definitions, FormDefinition, %w[name handle fields notification_email])
331
+ Array(data["form_submissions"]).each do |source|
332
+ submission = FormSubmission.new(source.slice("data", "created_at", "updated_at").merge(
333
+ "site_id" => @site.id,
334
+ "form_definition_id" => mapped!(:form_definitions, source["form_definition_id"])
335
+ ))
336
+ submission.save!(validate: false)
337
+ end
338
+ end
339
+
340
+ def import_site_setting
341
+ source = data["site_setting"]
342
+ return SiteSetting.instance(@site) unless source
343
+
344
+ attributes = source.except("id")
345
+ attributes["logo"] = mapped(:assets, source["logo"].to_i)&.to_s if source["logo"].present?
346
+ attributes["favicon"] = mapped(:assets, source["favicon"].to_i)&.to_s if source["favicon"].present?
347
+ SiteSetting.create!(attributes.merge("site_id" => @site.id))
348
+ end
349
+
350
+ def mapped(type, old_id)
351
+ return if old_id.blank?
352
+
353
+ maps[type][old_id] || maps[type][old_id.to_i]
354
+ end
355
+
356
+ def mapped!(type, old_id)
357
+ mapped(type, old_id) || raise(InvalidArchive, "Missing #{type.to_s.singularize} reference #{old_id.inspect}")
358
+ end
359
+
360
+ def cleanup_uploaded_files
361
+ @uploaded_blobs.each { |blob| blob.service.delete(blob.key) }
362
+ rescue StandardError
363
+ nil
364
+ end
365
+ end
366
+ end
367
+ end
@@ -78,6 +78,8 @@
78
78
  <% end %>
79
79
  <%= link_to "+ New Type", new_cp_content_type_path,
80
80
  class: "flex items-center px-4 py-2 mt-2 text-sm font-medium plum-sidebar-link" %>
81
+ <%= link_to "Fieldsets", cp_fieldsets_path,
82
+ class: "flex items-center px-4 py-2 mt-2 text-sm font-medium rounded-md plum-sidebar-link #{request.path.include?('/cp/fieldsets') ? 'active' : ''}" %>
81
83
  <%= link_to "Assets", cp_assets_path,
82
84
  class: "flex items-center px-4 py-2 mt-2 text-sm font-medium rounded-md plum-sidebar-link #{request.path.include?('/cp/assets') ? 'active' : ''}" %>
83
85
  <%= link_to "Globals", cp_globals_path,
@@ -9,12 +9,24 @@
9
9
  </div>
10
10
  <% end %>
11
11
 
12
+ <div data-controller="plum--focal-point">
12
13
  <% if asset.persisted? && plum_asset_image_url(asset).present? %>
13
14
  <div class="overflow-hidden rounded-lg border border-gray-200 bg-gray-100">
14
- <%= image_tag plum_asset_image_url(asset), alt: asset.alt_text.to_s, class: "h-44 w-full object-cover" %>
15
+ <%= image_tag plum_asset_image_url(asset), alt: asset.alt_text.to_s, class: "h-44 w-full object-cover", data: { "plum--focal-point-target": "preview" } %>
15
16
  </div>
16
17
  <% end %>
17
18
 
19
+ <div class="mt-4 grid gap-4 sm:grid-cols-2">
20
+ <label class="text-sm font-medium text-gray-700">Horizontal focal point
21
+ <%= f.range_field :focal_x, min: 0, max: 100, data: { "plum--focal-point-target": "x", action: "input->plum--focal-point#refresh" }, class: "mt-2 block w-full" %>
22
+ </label>
23
+ <label class="text-sm font-medium text-gray-700">Vertical focal point
24
+ <%= f.range_field :focal_y, min: 0, max: 100, data: { "plum--focal-point-target": "y", action: "input->plum--focal-point#refresh" }, class: "mt-2 block w-full" %>
25
+ </label>
26
+ </div>
27
+ <p class="mt-2 text-xs text-gray-500">Crop focus: <span data-plum--focal-point-target="label"></span></p>
28
+ </div>
29
+
18
30
  <div>
19
31
  <%= f.label :file, asset.persisted? ? "Replace image" : "Image", class: "block text-sm font-medium text-gray-700" %>
20
32
  <%= f.file_field :file, accept: "image/*", class: "mt-1 block w-full text-sm text-gray-700" %>
@@ -46,34 +46,28 @@
46
46
  </div>
47
47
  </div>
48
48
 
49
- <div class="bg-white shadow rounded-lg p-6" data-controller="plum--blueprint">
49
+ <div class="bg-white shadow rounded-lg p-6" data-controller="plum--blueprint"
50
+ data-plum--blueprint-types-value="<%= Plum::FieldTypeRegistry.as_json.to_json %>"
51
+ data-plum--blueprint-nested-types-value="<%= Plum::FieldTypeRegistry.as_json.select { |type| Plum::FieldTypeRegistry::NESTED_FIELD_TYPES.include?(type[:handle]) }.to_json %>"
52
+ <% if content_type.persisted? %>data-plum--blueprint-apply-fieldset-url-value="<%= apply_fieldset_cp_content_type_path(content_type) %>"<% end %>>
50
53
  <h3 class="text-lg font-medium text-gray-900 mb-4">Blueprint</h3>
51
- <p class="text-sm text-gray-500 mb-4">Define the fields for this content type using JSON format.</p>
54
+ <p class="text-sm text-gray-500 mb-4">Choose fields and configure how editors enter content.</p>
52
55
 
53
56
  <%= f.hidden_field :blueprint, value: (content_type.blueprint || { "fields" => [] }).to_json, data: { "plum--blueprint-target": "input" } %>
54
57
 
55
58
  <div class="space-y-4" data-plum--blueprint-target="fields">
56
59
  <% (content_type.fields || []).each_with_index do |field, index| %>
57
- <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">
60
+ <div class="plum-blueprint-field" data-plum--blueprint-target="field">
61
+ <div class="plum-blueprint-field-header col-span-full">
62
+ <p class="plum-blueprint-field-title" data-field-summary><%= field["label"].presence || field["handle"].presence || "Untitled field" %><span class="plum-blueprint-field-type"><%= field["type"].presence || "text" %></span></p>
63
+ <button type="button" data-action="plum--blueprint#removeField" class="text-sm font-medium text-red-600 hover:text-red-700">Remove</button>
64
+ </div>
58
65
  <input type="text" value="<%= field['handle'] %>" placeholder="handle" data-field="handle"
59
66
  data-action="input->plum--blueprint#inputChanged"
60
67
  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">
61
68
  <select data-field="type" data-action="change->plum--blueprint#inputChanged"
62
69
  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" <%= 'selected' if field['type'] == 'text' %>>Text</option>
64
- <option value="textarea" <%= 'selected' if field['type'] == 'textarea' %>>Textarea</option>
65
- <option value="rich_text" <%= 'selected' if field['type'] == 'rich_text' %>>Rich Text</option>
66
- <option value="number" <%= 'selected' if field['type'] == 'number' %>>Number</option>
67
- <option value="boolean" <%= 'selected' if field['type'] == 'boolean' %>>Boolean</option>
68
- <option value="date" <%= 'selected' if field['type'] == 'date' %>>Date</option>
69
- <option value="select" <%= 'selected' if field['type'] == 'select' %>>Select</option>
70
- <option value="checkboxes" <%= 'selected' if field['type'] == 'checkboxes' %>>Checkboxes</option>
71
- <option value="color" <%= 'selected' if field['type'] == 'color' %>>Color</option>
72
- <option value="url" <%= 'selected' if field['type'] == 'url' %>>URL</option>
73
- <option value="taxonomy" <%= 'selected' if field['type'] == 'taxonomy' %>>Taxonomy</option>
74
- <option value="image" <%= 'selected' if field['type'] == 'image' %>>Image</option>
75
- <option value="relationship" <%= 'selected' if field['type'] == 'relationship' %>>Relationship</option>
76
- <option value="blocks" <%= 'selected' if field['type'] == 'blocks' %>>Blocks</option>
70
+ <%= options_for_select(Plum::FieldTypeRegistry.options, field["type"]) %>
77
71
  </select>
78
72
  <input type="text" value="<%= field['label'] %>" placeholder="Label" data-field="label"
79
73
  data-action="input->plum--blueprint#inputChanged"
@@ -81,17 +75,72 @@
81
75
  <input type="text" value="<%= field['content_type'] %>" placeholder="Related type handle" data-field="content_type" data-field-config="relationship"
82
76
  data-action="input->plum--blueprint#inputChanged"
83
77
  class="<%= 'hidden' unless field['type'] == 'relationship' %> 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">
78
+ <label class="<%= 'hidden' unless field['type'] == 'relationship' %> 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" <%= "checked" if ActiveModel::Type::Boolean.new.cast(field['multiple']) %>> Allow multiple entries</label>
84
79
  <input type="text" value="<%= field['taxonomy'] %>" placeholder="Taxonomy handle" data-field="taxonomy" data-field-config="taxonomy"
85
80
  data-action="input->plum--blueprint#inputChanged"
86
81
  class="<%= 'hidden' unless field['type'] == 'taxonomy' %> 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">
87
- <input type="text" value="<%= Array(field['options']).join(', ') %>" placeholder="Option 1, Option 2, Option 3" data-field="options" data-field-config="select checkboxes"
82
+ <textarea rows="4" placeholder="Published | published&#10;Draft | draft" data-field="options" data-field-config="select radio button_group checkboxes"
88
83
  data-action="input->plum--blueprint#inputChanged"
89
- class="<%= 'hidden' unless %w[select checkboxes].include?(field['type']) %> 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">
90
- <button type="button" data-action="plum--blueprint#removeField" class="justify-self-start text-red-500 hover:text-red-700">
91
- <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
92
- <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" />
93
- </svg>
94
- </button>
84
+ class="<%= 'hidden' unless %w[select radio button_group checkboxes].include?(field['type']) %> 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"><%= Plum::FieldOptions.editor_value(field['options']) %></textarea>
85
+ <div class="<%= 'hidden' unless %w[group repeater].include?(field['type']) %> col-span-full space-y-3 rounded-md border border-gray-200 bg-white p-3" data-field-config="group repeater" data-nested-editor>
86
+ <div class="flex items-center justify-between">
87
+ <p class="text-sm font-medium text-gray-700">Nested fields</p>
88
+ <button type="button" data-action="plum--blueprint#addNestedField" class="text-sm font-medium text-purple-600 hover:text-purple-700">+ Add nested field</button>
89
+ </div>
90
+ <div class="space-y-3" data-nested-fields>
91
+ <% Array(field["fields"]).each do |nested| %>
92
+ <div class="grid gap-2 rounded-md border border-gray-200 bg-gray-50 p-3 sm:grid-cols-3" data-nested-field>
93
+ <input type="text" value="<%= nested['handle'] %>" 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">
94
+ <select data-nested="type" data-action="change->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm"><%= options_for_select(Plum::FieldTypeRegistry.options.select { |_, handle| Plum::FieldTypeRegistry::NESTED_FIELD_TYPES.include?(handle) }, nested["type"]) %></select>
95
+ <input type="text" value="<%= nested['label'] %>" placeholder="Label" data-nested="label" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
96
+ <input type="text" value="<%= nested['instructions'] %>" placeholder="Instructions" data-nested="instructions" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
97
+ <input type="text" value="<%= nested['placeholder'] %>" placeholder="Placeholder" data-nested="placeholder" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
98
+ <input type="text" value="<%= nested['default'] %>" 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">
99
+ <label class="flex items-center gap-2 text-sm text-gray-700"><input type="checkbox" data-nested="required" data-action="change->plum--blueprint#inputChanged" <%= "checked" if ActiveModel::Type::Boolean.new.cast(nested['required']) %>> Required</label>
100
+ <div class="flex gap-3">
101
+ <button type="button" data-direction="-1" data-action="plum--blueprint#moveNestedField" class="text-sm text-gray-600">↑ Up</button>
102
+ <button type="button" data-direction="1" data-action="plum--blueprint#moveNestedField" class="text-sm text-gray-600">↓ Down</button>
103
+ <button type="button" data-action="plum--blueprint#removeNestedField" class="text-sm font-medium text-red-600">Remove</button>
104
+ </div>
105
+ </div>
106
+ <% end %>
107
+ </div>
108
+ </div>
109
+ <div class="<%= 'hidden' unless field['type'] == 'number' %> col-span-full grid gap-3 rounded-md border border-gray-200 bg-white p-3 sm:grid-cols-4" data-field-config="number">
110
+ <%= select_tag nil, options_for_select([["Decimal", "decimal"], ["Integer", "integer"]], field["number_kind"]), data: { field: "number_kind", action: "change->plum--blueprint#inputChanged" }, class: "rounded-md border border-gray-300 px-3 py-2 text-sm" %>
111
+ <input type="number" value="<%= field['min'] %>" placeholder="Minimum" data-field="min" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
112
+ <input type="number" value="<%= field['max'] %>" placeholder="Maximum" data-field="max" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
113
+ <input type="number" value="<%= field['step'] %>" placeholder="Step" data-field="step" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
114
+ <input type="text" value="<%= field['unit'] %>" 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">
115
+ </div>
116
+ <div class="<%= 'hidden' unless field['type'] == 'date' %> col-span-full grid gap-3 rounded-md border border-gray-200 bg-white p-3 sm:grid-cols-3" data-field-config="date">
117
+ <%= select_tag nil, options_for_select([["Date", "date"], ["Time", "time"], ["Date and time", "datetime"]], field["date_mode"]), data: { field: "date_mode", action: "change->plum--blueprint#inputChanged" }, class: "rounded-md border border-gray-300 px-3 py-2 text-sm" %>
118
+ <input type="text" value="<%= field['min'] %>" 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">
119
+ <input type="text" value="<%= field['max'] %>" 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">
120
+ </div>
121
+ <div class="<%= 'hidden' unless %w[list repeater images].include?(field['type']) || (field['type'] == 'relationship' && field['multiple']) %> 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>
122
+ <input type="number" min="0" value="<%= field['min_items'] %>" 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">
123
+ <input type="number" min="0" value="<%= field['max_items'] %>" 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">
124
+ <label class="<%= 'hidden' unless field['type'] == 'list' %> 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" <%= "checked" if ActiveModel::Type::Boolean.new.cast(field['unique']) %>> Unique values</label>
125
+ </div>
126
+ <div class="col-span-full grid gap-3 border-t border-gray-200 pt-3 sm:grid-cols-3">
127
+ <input type="text" value="<%= field['instructions'] %>" placeholder="Instructions" data-field="instructions" data-action="input->plum--blueprint#inputChanged"
128
+ class="px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm">
129
+ <input type="text" value="<%= field['placeholder'] %>" placeholder="Placeholder" data-field="placeholder" data-action="input->plum--blueprint#inputChanged"
130
+ class="px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm">
131
+ <input type="text" value="<%= field['default'] %>" placeholder="Default value" data-field="default" data-action="input->plum--blueprint#inputChanged"
132
+ class="px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm">
133
+ <label class="flex items-center gap-2 text-sm text-gray-700">
134
+ <input type="checkbox" data-field="required" data-action="change->plum--blueprint#inputChanged" <%= "checked" if ActiveModel::Type::Boolean.new.cast(field['required']) %>>
135
+ Required
136
+ </label>
137
+ </div>
138
+ <div class="col-span-full grid gap-3 rounded-md border border-gray-200 bg-white p-3 sm:grid-cols-4">
139
+ <%= select_tag nil, options_for_select([["Full width", 12], ["Three quarters", 9], ["Two thirds", 8], ["Half width", 6], ["One third", 4], ["Quarter width", 3]], field["width"].presence || 12), data: { field: "width", action: "change->plum--blueprint#inputChanged" }, class: "rounded-md border border-gray-300 px-3 py-2 text-sm" %>
140
+ <input type="text" value="<%= field.dig('condition', 'field') %>" 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">
141
+ <%= select_tag nil, options_for_select([["Equals", "equals"], ["Does not equal", "not_equals"], ["Contains", "contains"], ["Is empty", "empty"], ["Is not empty", "not_empty"]], field.dig("condition", "operator")), include_blank: "No condition", data: { field: "condition_operator", action: "change->plum--blueprint#inputChanged" }, class: "rounded-md border border-gray-300 px-3 py-2 text-sm" %>
142
+ <input type="text" value="<%= field.dig('condition', 'value') %>" 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">
143
+ </div>
95
144
  </div>
96
145
  <% end %>
97
146
  </div>
@@ -103,6 +152,14 @@
103
152
  </svg>
104
153
  Add Field
105
154
  </button>
155
+ <% if content_type.persisted? && @fieldsets.any? %>
156
+ <div class="mt-4 flex flex-wrap items-center gap-3 border-t border-gray-200 pt-4">
157
+ <%= select_tag nil, options_from_collection_for_select(@fieldsets, :id, :name), prompt: "Choose saved fieldset", data: { fieldset_picker: true }, class: "rounded-md border border-gray-300 px-3 py-2 text-sm" %>
158
+ <button type="button" data-action="plum--blueprint#applyFieldset" class="rounded-md border border-purple-200 bg-purple-50 px-3 py-2 text-sm font-semibold text-purple-700 hover:bg-purple-100">Insert fieldset</button>
159
+ <span class="hidden text-sm" data-fieldset-status></span>
160
+ </div>
161
+ <% end %>
162
+ <p class="mt-3 text-xs text-gray-500"><%= link_to "Manage reusable fieldsets", cp_fieldsets_path, class: "text-purple-600 hover:text-purple-700" %></p>
106
163
  </div>
107
164
 
108
165
  <div class="flex justify-end space-x-3">
@@ -0,0 +1,5 @@
1
+ <%= text_field_tag "entry[data][#{field_handle}]", field_value,
2
+ id: field_id,
3
+ placeholder: field["placeholder"],
4
+ required: field["required"],
5
+ class: "mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-purple-500 focus:ring-purple-500" %>