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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/README.md +81 -3
- data/app/assets/builds/tailwind.css +1 -1
- data/app/assets/stylesheets/plum/control_panel.css +1 -1
- data/app/assets/tailwind/application.css +42 -0
- data/app/controllers/plum/api/v1/entries_controller.rb +48 -0
- data/app/controllers/plum/cp/assets_controller.rb +4 -1
- data/app/controllers/plum/cp/content_types_controller.rb +25 -2
- data/app/controllers/plum/cp/entries_controller.rb +119 -9
- data/app/controllers/plum/cp/entry_revisions_controller.rb +27 -0
- data/app/controllers/plum/cp/fieldsets_controller.rb +37 -0
- data/app/controllers/plum/cp/site_settings_controller.rb +20 -1
- data/app/controllers/plum/pages_controller.rb +26 -1
- data/app/javascript/controllers/plum/asset_collection_controller.js +18 -0
- data/app/javascript/controllers/plum/blueprint_controller.js +202 -25
- data/app/javascript/controllers/plum/conditional_fields_controller.js +43 -0
- data/app/javascript/controllers/plum/focal_point_controller.js +16 -0
- data/app/javascript/controllers/plum/structured_field_controller.js +133 -0
- data/app/models/plum/asset.rb +5 -1
- data/app/models/plum/content_type.rb +63 -1
- data/app/models/plum/entry.rb +225 -1
- data/app/models/plum/entry_revision.rb +14 -0
- data/app/models/plum/fieldset.rb +17 -0
- data/app/models/plum/site.rb +15 -0
- data/app/models/plum/user.rb +1 -0
- data/app/services/plum/entry_serializer.rb +36 -0
- data/app/services/plum/field_expander.rb +12 -1
- data/app/services/plum/field_options.rb +21 -0
- data/app/services/plum/field_type_registry.rb +88 -0
- data/app/services/plum/liquid_context.rb +7 -1
- data/app/services/plum/site_archive.rb +367 -0
- data/app/views/layouts/plum/cp.html.erb +2 -0
- data/app/views/plum/cp/assets/_form.html.erb +13 -1
- data/app/views/plum/cp/content_types/_form.html.erb +81 -24
- data/app/views/plum/cp/custom_fields/_input.html.erb +5 -0
- data/app/views/plum/cp/entries/_form.html.erb +108 -31
- data/app/views/plum/cp/entries/edit.html.erb +16 -0
- data/app/views/plum/cp/entry_revisions/index.html.erb +25 -0
- data/app/views/plum/cp/fieldsets/index.html.erb +30 -0
- data/app/views/plum/cp/site_settings/edit.html.erb +12 -0
- data/config/plum_routes.rb +15 -0
- data/db/engine_migrate/20260807130000_create_plum_entry_revisions.rb +16 -0
- data/db/engine_migrate/20260807140000_create_plum_fieldsets.rb +13 -0
- data/db/engine_migrate/20260807150000_add_focal_point_to_plum_assets.rb +6 -0
- data/db/engine_migrate/20260807160000_add_localization_to_plum_entries.rb +9 -0
- data/docs/blueprint-fields.md +55 -0
- data/docs/extensions.md +32 -0
- data/docs/portability.md +40 -0
- data/docs/product-principles.md +73 -0
- data/docs/roadmap.md +100 -0
- data/docs/site/homepage.md +104 -0
- data/docs/site/information-architecture.md +176 -0
- data/docs/statamic-parity.md +28 -0
- data/docs/vision.md +82 -0
- data/lib/plum/engine.rb +3 -1
- data/lib/plum/version.rb +1 -1
- data/lib/plum.rb +4 -0
- data/lib/tasks/plum_portability.rake +37 -0
- data/lib/tasks/plum_styles.rake +12 -0
- 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">
|
|
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="
|
|
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
|
-
|
|
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
|
-
<
|
|
82
|
+
<textarea rows="4" placeholder="Published | published 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
|
-
<
|
|
91
|
-
<
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
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" %>
|