plum-cms 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +66 -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/views/layouts/plum/cp.html.erb +2 -0
  33. data/app/views/plum/cp/assets/_form.html.erb +13 -1
  34. data/app/views/plum/cp/content_types/_form.html.erb +81 -24
  35. data/app/views/plum/cp/custom_fields/_input.html.erb +5 -0
  36. data/app/views/plum/cp/entries/_form.html.erb +108 -31
  37. data/app/views/plum/cp/entries/edit.html.erb +16 -0
  38. data/app/views/plum/cp/entry_revisions/index.html.erb +25 -0
  39. data/app/views/plum/cp/fieldsets/index.html.erb +30 -0
  40. data/app/views/plum/cp/site_settings/edit.html.erb +12 -0
  41. data/config/plum_routes.rb +15 -0
  42. data/db/engine_migrate/20260807130000_create_plum_entry_revisions.rb +16 -0
  43. data/db/engine_migrate/20260807140000_create_plum_fieldsets.rb +13 -0
  44. data/db/engine_migrate/20260807150000_add_focal_point_to_plum_assets.rb +6 -0
  45. data/db/engine_migrate/20260807160000_add_localization_to_plum_entries.rb +9 -0
  46. data/docs/blueprint-fields.md +55 -0
  47. data/docs/extensions.md +32 -0
  48. data/docs/product-principles.md +73 -0
  49. data/docs/roadmap.md +98 -0
  50. data/docs/site/homepage.md +104 -0
  51. data/docs/site/information-architecture.md +176 -0
  52. data/docs/statamic-parity.md +28 -0
  53. data/docs/vision.md +82 -0
  54. data/lib/plum/version.rb +1 -1
  55. data/lib/plum.rb +4 -0
  56. data/lib/tasks/plum_styles.rake +12 -0
  57. metadata +31 -6
@@ -4,9 +4,12 @@ module Plum
4
4
 
5
5
  belongs_to :content_type
6
6
  belongs_to :author, class_name: "Plum::User", optional: true
7
+ belongs_to :origin, class_name: "Plum::Entry", optional: true
7
8
 
8
9
  has_many :entry_terms, dependent: :destroy
9
10
  has_many :terms, through: :entry_terms
11
+ has_many :revisions, class_name: "Plum::EntryRevision", dependent: :destroy
12
+ has_many :translations, class_name: "Plum::Entry", foreign_key: :origin_id, dependent: :destroy
10
13
 
11
14
  # The page served at "/" — Plum resolves the homepage by this slug
12
15
  # (convention over configuration). Its slug is locked and it can't be
@@ -17,11 +20,16 @@ module Plum
17
20
  enum :status, { draft: 0, published: 1, scheduled: 2 }
18
21
 
19
22
  validates :title, presence: true
20
- validates :slug, presence: true, uniqueness: { scope: :site_id }, format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ }
23
+ validates :slug, presence: true, uniqueness: { scope: [ :site_id, :locale ] }, format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ }
24
+ validates :locale, presence: true, format: { with: /\A[a-z]{2}(?:-[A-Z]{2})?\z/ }
25
+ validate :origin_matches_site_and_content_type
21
26
  validates :status, presence: true
22
27
  validate :homepage_slug_is_unchanged
28
+ validate :required_blueprint_fields_are_present
29
+ validate :blueprint_field_values_are_valid
23
30
 
24
31
  before_validation :generate_slug
32
+ before_validation :set_default_locale, on: :create
25
33
  before_validation :set_published_at, if: :published?
26
34
  before_destroy :prevent_homepage_destroy
27
35
 
@@ -41,8 +49,66 @@ module Plum
41
49
  slug == HOMEPAGE_SLUG
42
50
  end
43
51
 
52
+ def record_revision!(editor: nil)
53
+ identity = revision_editor_identity(editor)
54
+ attributes = {
55
+ site: site,
56
+ editor_name: identity[:name],
57
+ editor_email: identity[:email],
58
+ editor_gid: identity[:gid],
59
+ snapshot: {
60
+ "title" => title,
61
+ "slug" => slug,
62
+ "status" => status,
63
+ "published_at" => published_at&.iso8601,
64
+ "data" => data.to_h.deep_dup,
65
+ "term_ids" => term_ids
66
+ }
67
+ }
68
+ attributes[:editor] = editor if editor.is_a?(Plum::User)
69
+ revisions.create!(attributes)
70
+ end
71
+
72
+ def restore_revision!(revision, editor: nil)
73
+ raise ArgumentError, "Revision does not belong to this entry" unless revision.entry_id == id && revision.site_id == site_id
74
+
75
+ snapshot = revision.snapshot.to_h
76
+ transaction do
77
+ update!(snapshot.slice("title", "slug", "status", "published_at", "data"))
78
+ self.term_ids = site.terms.where(id: Array(snapshot["term_ids"])).pluck(:id)
79
+ record_revision!(editor: editor)
80
+ end
81
+ end
82
+
83
+ def translation_group
84
+ source = origin || self
85
+ [ source, *source.translations ].uniq.sort_by(&:locale)
86
+ end
87
+
44
88
  private
45
89
 
90
+ def set_default_locale
91
+ self.locale = site&.default_locale if locale.blank?
92
+ end
93
+
94
+ def origin_matches_site_and_content_type
95
+ return unless origin
96
+
97
+ errors.add(:origin, "must belong to the same site") if origin.site_id != site_id
98
+ errors.add(:origin, "must use the same content type") if origin.content_type_id != content_type_id
99
+ errors.add(:origin, "cannot itself be a translation") if origin.origin_id.present?
100
+ end
101
+
102
+ def revision_editor_identity(editor)
103
+ return {} unless editor
104
+
105
+ {
106
+ name: editor.respond_to?(:name) ? editor.name : nil,
107
+ email: editor.respond_to?(:email) ? editor.email : nil,
108
+ gid: editor.respond_to?(:to_global_id) ? editor.to_global_id.to_s : nil
109
+ }
110
+ end
111
+
46
112
  def generate_slug
47
113
  self.slug = title&.parameterize if slug.blank?
48
114
  end
@@ -53,6 +119,164 @@ module Plum
53
119
  errors.add(:slug, "can't be changed — this is the homepage")
54
120
  end
55
121
 
122
+ def required_blueprint_fields_are_present
123
+ content_type&.fields.to_a.each do |field|
124
+ next if field["type"] == "section"
125
+ next unless blueprint_field_visible?(field)
126
+
127
+ handle = field["handle"].to_s
128
+ value = if field["type"] == "taxonomy"
129
+ terms.joins(:taxonomy).where(plum_taxonomies: { handle: field["taxonomy"].to_s }).exists?
130
+ else
131
+ data&.dig(handle)
132
+ end
133
+ value = parsed_structured_validation_value(value) if %w[group repeater].include?(field["type"])
134
+
135
+ validate_required_value(field, value, field["label"].presence || handle.titleize)
136
+ end
137
+ end
138
+
139
+ def parsed_structured_validation_value(value)
140
+ return value unless value.is_a?(String)
141
+
142
+ JSON.parse(value)
143
+ rescue JSON::ParserError
144
+ value
145
+ end
146
+
147
+ def validate_required_value(field, value, path)
148
+ if ActiveModel::Type::Boolean.new.cast(field["required"]) && value != false && value.blank?
149
+ errors.add(:data, "#{path} is required")
150
+ return
151
+ end
152
+
153
+ case field["type"]
154
+ when "group"
155
+ values = value.respond_to?(:to_h) ? value.to_h : {}
156
+ validate_required_nested_fields(field, values, path)
157
+ when "repeater"
158
+ Array(value).each_with_index do |row, index|
159
+ values = row.respond_to?(:to_h) ? row.to_h : {}
160
+ validate_required_nested_fields(field, values, "#{path} row #{index + 1}")
161
+ end
162
+ end
163
+ end
164
+
165
+ def validate_required_nested_fields(field, values, path)
166
+ Array(field["fields"]).each do |nested_field|
167
+ handle = nested_field["handle"].to_s
168
+ validate_required_value(nested_field, values[handle], "#{path} #{nested_field['label'].presence || handle.titleize}")
169
+ end
170
+ end
171
+
172
+ def blueprint_field_values_are_valid
173
+ content_type&.fields.to_a.each do |field|
174
+ next if field["type"] == "section"
175
+ next unless blueprint_field_visible?(field)
176
+
177
+ value = data&.dig(field["handle"].to_s)
178
+ value = parsed_structured_validation_value(value) if %w[list repeater].include?(field["type"])
179
+ label = field["label"].presence || field["handle"].to_s.titleize
180
+
181
+ case field["type"]
182
+ when "list", "repeater", "images"
183
+ validate_collection_constraints(field, Array(value), label)
184
+ when "relationship"
185
+ validate_collection_constraints(field, Array(value), label) if ActiveModel::Type::Boolean.new.cast(field["multiple"])
186
+ when "number"
187
+ validate_number_constraints(field, value, label)
188
+ when "date"
189
+ validate_date_constraints(field, value, label)
190
+ when "select", "radio", "button_group", "checkboxes"
191
+ validate_option_values(field, value, label)
192
+ else
193
+ validate_custom_field(definition: FieldTypeRegistry.find(field["type"]), field: field, value: value, label: label)
194
+ end
195
+ end
196
+ end
197
+
198
+ def validate_custom_field(definition:, field:, value:, label:)
199
+ return unless definition&.validator
200
+
201
+ messages = definition.validator.call(value: value, field: field, entry: self)
202
+ Array(messages).select(&:present?).each { |message| errors.add(:data, "#{label} #{message}") }
203
+ end
204
+
205
+ def blueprint_field_visible?(field)
206
+ condition = field["condition"]
207
+ return true if condition.blank?
208
+
209
+ values = Array(data&.dig(condition["field"].to_s)).map(&:to_s)
210
+ expected = condition["value"].to_s
211
+ case condition["operator"]
212
+ when "equals" then values.include?(expected)
213
+ when "not_equals" then !values.include?(expected)
214
+ when "contains" then values.any? { |value| value.include?(expected) }
215
+ when "empty" then values.empty? || values.all?(&:blank?)
216
+ when "not_empty" then values.any?(&:present?)
217
+ else true
218
+ end
219
+ end
220
+
221
+ def validate_option_values(field, value, label)
222
+ return if value.blank?
223
+
224
+ allowed = FieldOptions.pairs(field["options"]).map(&:last)
225
+ invalid = Array(value).map(&:to_s) - allowed
226
+ errors.add(:data, "#{label} contains an invalid option") if invalid.any?
227
+ end
228
+
229
+ def validate_collection_constraints(field, values, label)
230
+ minimum = field["min_items"].presence&.to_i
231
+ maximum = field["max_items"].presence&.to_i
232
+ errors.add(:data, "#{label} must have at least #{minimum} items") if minimum && values.length < minimum
233
+ errors.add(:data, "#{label} must have no more than #{maximum} items") if maximum && values.length > maximum
234
+ if field["type"] == "list" && ActiveModel::Type::Boolean.new.cast(field["unique"])
235
+ normalized = values.map { |value| value.to_s.strip.downcase }.reject(&:blank?)
236
+ errors.add(:data, "#{label} values must be unique") if normalized.uniq.length != normalized.length
237
+ end
238
+ end
239
+
240
+ def validate_number_constraints(field, value, label)
241
+ return if value.blank?
242
+
243
+ number = BigDecimal(value.to_s)
244
+ if field["number_kind"] == "integer" && number.frac.nonzero?
245
+ errors.add(:data, "#{label} must be a whole number")
246
+ end
247
+ errors.add(:data, "#{label} must be at least #{field['min']}") if field["min"].present? && number < BigDecimal(field["min"].to_s)
248
+ errors.add(:data, "#{label} must be no more than #{field['max']}") if field["max"].present? && number > BigDecimal(field["max"].to_s)
249
+ if field["step"].present? && BigDecimal(field["step"].to_s).positive?
250
+ base = field["min"].present? ? BigDecimal(field["min"].to_s) : 0
251
+ errors.add(:data, "#{label} does not match the required step") unless ((number - base) % BigDecimal(field["step"].to_s)).zero?
252
+ end
253
+ rescue ArgumentError
254
+ errors.add(:data, "#{label} must be a number")
255
+ end
256
+
257
+ def validate_date_constraints(field, value, label)
258
+ return if value.blank?
259
+
260
+ parsed = parsed_temporal_value(value, field["date_mode"])
261
+ minimum = parsed_temporal_value(field["min"], field["date_mode"]) if field["min"].present?
262
+ maximum = parsed_temporal_value(field["max"], field["date_mode"]) if field["max"].present?
263
+ errors.add(:data, "#{label} must be on or after #{field['min']}") if minimum && parsed < minimum
264
+ errors.add(:data, "#{label} must be on or before #{field['max']}") if maximum && parsed > maximum
265
+ rescue ArgumentError
266
+ errors.add(:data, "#{label} is not a valid #{field['date_mode'].presence || 'date'}")
267
+ end
268
+
269
+ def parsed_temporal_value(value, mode)
270
+ case mode
271
+ when "time"
272
+ Time.strptime(value.to_s, "%H:%M")
273
+ when "datetime"
274
+ Time.zone.parse(value.to_s) || raise(ArgumentError)
275
+ else
276
+ Date.iso8601(value.to_s)
277
+ end
278
+ end
279
+
56
280
  def prevent_homepage_destroy
57
281
  return unless homepage?
58
282
 
@@ -0,0 +1,14 @@
1
+ module Plum
2
+ class EntryRevision < ApplicationRecord
3
+ include SiteScoped
4
+
5
+ belongs_to :entry
6
+ belongs_to :editor, class_name: "Plum::User", optional: true
7
+
8
+ validates :snapshot, presence: true
9
+
10
+ def editor_label
11
+ editor_name.presence || editor_email.presence || editor&.email.presence || "Unknown editor"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ module Plum
2
+ class Fieldset < ApplicationRecord
3
+ include SiteScoped
4
+
5
+ validates :name, presence: true
6
+ validates :handle, presence: true, uniqueness: { scope: :site_id }, format: { with: /\A[a-z][a-z0-9_]*\z/ }
7
+ validates :fields, presence: true
8
+
9
+ before_validation :generate_handle, on: :create
10
+
11
+ private
12
+
13
+ def generate_handle
14
+ self.handle = name&.parameterize(separator: "_") if handle.blank?
15
+ end
16
+ end
17
+ end
@@ -13,6 +13,7 @@ module Plum
13
13
  has_many :form_submissions, dependent: :destroy
14
14
  has_many :taxonomies, dependent: :destroy
15
15
  has_many :terms, dependent: :destroy
16
+ has_many :fieldsets, dependent: :destroy
16
17
 
17
18
  attribute :skip_defaults, :boolean, default: false
18
19
 
@@ -51,6 +52,20 @@ module Plum
51
52
  super || {}
52
53
  end
53
54
 
55
+ def locales
56
+ configured = Array(settings.to_h["locales"]).map(&:to_s).select { |locale| locale.match?(/\A[a-z]{2}(?:-[A-Z]{2})?\z/) }
57
+ configured.presence || [ "en" ]
58
+ end
59
+
60
+ def default_locale
61
+ configured = settings.to_h["default_locale"].to_s
62
+ locales.include?(configured) ? configured : locales.first
63
+ end
64
+
65
+ def localized?
66
+ locales.many?
67
+ end
68
+
54
69
  private
55
70
 
56
71
  def self.owner_default_name(owner)
@@ -3,6 +3,7 @@ module Plum
3
3
  has_secure_password
4
4
 
5
5
  has_many :entries, foreign_key: :author_id, dependent: :nullify
6
+ has_many :entry_revisions, foreign_key: :editor_id, dependent: :nullify
6
7
 
7
8
  enum :role, { viewer: 0, editor: 1, admin: 2 }
8
9
 
@@ -0,0 +1,36 @@
1
+ module Plum
2
+ class EntrySerializer
3
+ def initialize(site:)
4
+ @site = site
5
+ @expander = FieldExpander.new(site: site, url_builder: method(:entry_path))
6
+ end
7
+
8
+ def as_json(entry)
9
+ {
10
+ "id" => entry.id,
11
+ "title" => entry.title,
12
+ "slug" => entry.slug,
13
+ "locale" => entry.locale,
14
+ "url" => entry_path(entry),
15
+ "status" => entry.status,
16
+ "published_at" => entry.published_at&.iso8601,
17
+ "updated_at" => entry.updated_at&.iso8601,
18
+ "collection" => {
19
+ "handle" => entry.content_type.handle,
20
+ "title" => entry.content_type.name
21
+ },
22
+ "data" => @expander.expand(values: entry.data, fields: entry.content_type.fields),
23
+ "terms" => entry.terms.group_by { |term| term.taxonomy.handle }.transform_values do |terms|
24
+ terms.map { |term| { "name" => term.name, "slug" => term.slug } }
25
+ end
26
+ }
27
+ end
28
+
29
+ private
30
+
31
+ def entry_path(entry)
32
+ locale_prefix = entry.locale == @site.default_locale ? nil : entry.locale
33
+ "/#{[ locale_prefix, entry.content_type.route_prefix, entry.slug ].compact.join('/')}"
34
+ end
35
+ end
36
+ end
@@ -24,8 +24,19 @@ module Plum
24
24
  case field["type"]
25
25
  when "image"
26
26
  data[handle] = image_asset_context(data[handle])
27
+ when "images"
28
+ data[handle] = Array(data[handle]).filter_map { |value| image_asset_context(value) }
27
29
  when "relationship"
28
- data[handle] = relationship_entry_context(data[handle], relationship_depth: relationship_depth)
30
+ data[handle] = if ActiveModel::Type::Boolean.new.cast(field["multiple"])
31
+ Array(data[handle]).filter_map { |value| relationship_entry_context(value, relationship_depth: relationship_depth) }
32
+ else
33
+ relationship_entry_context(data[handle], relationship_depth: relationship_depth)
34
+ end
35
+ else
36
+ definition = FieldTypeRegistry.find(field["type"])
37
+ if definition&.expander
38
+ data[handle] = definition.expander.call(value: data[handle], field: field, site: site, expander: self)
39
+ end
29
40
  end
30
41
  end
31
42
 
@@ -0,0 +1,21 @@
1
+ module Plum
2
+ module FieldOptions
3
+ module_function
4
+
5
+ def pairs(options)
6
+ Array(options).filter_map do |option|
7
+ if option.is_a?(Hash)
8
+ value = option["value"] || option[:value]
9
+ label = option["label"] || option[:label] || value
10
+ [ label.to_s, value.to_s ] if value.present?
11
+ elsif option.present?
12
+ [ option.to_s, option.to_s ]
13
+ end
14
+ end
15
+ end
16
+
17
+ def editor_value(options)
18
+ pairs(options).map { |label, value| label == value ? label : "#{label} | #{value}" }.join("\n")
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,88 @@
1
+ module Plum
2
+ class FieldTypeRegistry
3
+ Definition = Struct.new(
4
+ :handle, :label, :configuration, :partial, :normalizer, :validator, :expander,
5
+ keyword_init: true
6
+ )
7
+
8
+ COMMON_CONFIGURATION = %w[label instructions required default placeholder].freeze
9
+ NESTED_FIELD_TYPES = %w[text textarea number boolean date url].freeze
10
+
11
+ DEFINITIONS = [
12
+ Definition.new(handle: "text", label: "Text", configuration: []),
13
+ Definition.new(handle: "textarea", label: "Textarea", configuration: []),
14
+ Definition.new(handle: "rich_text", label: "Rich Text", configuration: []),
15
+ Definition.new(handle: "number", label: "Number", configuration: %w[number_kind min max step unit]),
16
+ Definition.new(handle: "boolean", label: "Boolean", configuration: []),
17
+ Definition.new(handle: "date", label: "Date / Time", configuration: %w[date_mode min max]),
18
+ Definition.new(handle: "select", label: "Select", configuration: %w[options]),
19
+ Definition.new(handle: "radio", label: "Radio", configuration: %w[options]),
20
+ Definition.new(handle: "button_group", label: "Button Group", configuration: %w[options]),
21
+ Definition.new(handle: "checkboxes", label: "Checkboxes", configuration: %w[options]),
22
+ Definition.new(handle: "color", label: "Color", configuration: []),
23
+ Definition.new(handle: "url", label: "URL", configuration: []),
24
+ Definition.new(handle: "taxonomy", label: "Taxonomy", configuration: %w[taxonomy]),
25
+ Definition.new(handle: "image", label: "Image", configuration: []),
26
+ Definition.new(handle: "images", label: "Images", configuration: %w[min_items max_items]),
27
+ Definition.new(handle: "relationship", label: "Relationship", configuration: %w[content_type multiple min_items max_items]),
28
+ Definition.new(handle: "blocks", label: "Blocks", configuration: %w[blocks]),
29
+ Definition.new(handle: "list", label: "List", configuration: %w[min_items max_items unique]),
30
+ Definition.new(handle: "group", label: "Group", configuration: %w[fields]),
31
+ Definition.new(handle: "repeater", label: "Repeater", configuration: %w[fields min_items max_items]),
32
+ Definition.new(handle: "section", label: "Section", configuration: [])
33
+ ].freeze
34
+
35
+ class << self
36
+ def all
37
+ DEFINITIONS + custom_definitions.values
38
+ end
39
+
40
+ def handles
41
+ all.map(&:handle)
42
+ end
43
+
44
+ def find(handle)
45
+ all.find { |definition| definition.handle == handle.to_s }
46
+ end
47
+
48
+ def include?(handle)
49
+ find(handle).present?
50
+ end
51
+
52
+ def options
53
+ all.map { |definition| [ definition.label, definition.handle ] }
54
+ end
55
+
56
+ def as_json
57
+ all.map { |definition| { handle: definition.handle, label: definition.label } }
58
+ end
59
+
60
+ def register(handle:, label:, configuration: [], partial: nil, normalizer: nil, validator: nil, expander: nil)
61
+ normalized_handle = handle.to_s
62
+ raise ArgumentError, "Field type handle is invalid" unless normalized_handle.match?(/\A[a-z][a-z0-9_]*\z/)
63
+ raise ArgumentError, "Field type #{normalized_handle} is already registered" if include?(normalized_handle)
64
+ raise ArgumentError, "Custom field types require an editor partial" if partial.blank?
65
+
66
+ custom_definitions[normalized_handle] = Definition.new(
67
+ handle: normalized_handle,
68
+ label: label.to_s.presence || normalized_handle.titleize,
69
+ configuration: Array(configuration).map(&:to_s),
70
+ partial: partial.to_s,
71
+ normalizer: normalizer,
72
+ validator: validator,
73
+ expander: expander
74
+ ).freeze
75
+ end
76
+
77
+ def reset_custom!
78
+ @custom_definitions = {}
79
+ end
80
+
81
+ private
82
+
83
+ def custom_definitions
84
+ @custom_definitions ||= {}
85
+ end
86
+ end
87
+ end
88
+ end
@@ -306,6 +306,7 @@ module Plum
306
306
 
307
307
  def live_entries_by_type
308
308
  @live_entries_by_type ||= Entry.for_site(site).live
309
+ .where(locale: current_locale)
309
310
  .includes(:content_type, terms: :taxonomy)
310
311
  .order(published_at: :desc, created_at: :desc)
311
312
  .group_by(&:content_type_id)
@@ -320,10 +321,15 @@ module Plum
320
321
  end
321
322
 
322
323
  def public_entry_path(entry)
323
- path = [ entry.content_type.route_prefix, entry.slug ].compact.join("/")
324
+ locale_prefix = entry.locale == site.default_locale ? nil : entry.locale
325
+ path = [ locale_prefix, entry.content_type.route_prefix, entry.slug ].compact.join("/")
324
326
  "#{controller.request.script_name.to_s.chomp("/")}/#{path}"
325
327
  end
326
328
 
329
+ def current_locale
330
+ entry&.locale.presence || controller.params[:locale].presence || site.default_locale
331
+ end
332
+
327
333
  def public_form_path(form)
328
334
  "#{controller.request.script_name.to_s.chomp("/")}/forms/#{form.handle}"
329
335
  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" %>