lean_cms 0.2.12

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 (130) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +235 -0
  3. data/LICENSE +21 -0
  4. data/README.md +107 -0
  5. data/app/assets/images/lean_cms/sloth-404.png +0 -0
  6. data/app/assets/images/lean_cms/sloth-500.png +0 -0
  7. data/app/assets/images/lean_cms/sloth-favicon-16.png +0 -0
  8. data/app/assets/images/lean_cms/sloth-favicon-32.png +0 -0
  9. data/app/assets/images/lean_cms/sloth-favicon-64.png +0 -0
  10. data/app/assets/images/lean_cms/sloth-logo.png +0 -0
  11. data/app/assets/lean_cms/actiontext.css +440 -0
  12. data/app/assets/lean_cms/cms_edit_controls.css +548 -0
  13. data/app/assets/tailwind/lean_cms/engine.css +14 -0
  14. data/app/components/lean_cms/base_component.rb +61 -0
  15. data/app/components/lean_cms/bullets_section_component.html.erb +23 -0
  16. data/app/components/lean_cms/bullets_section_component.rb +54 -0
  17. data/app/components/lean_cms/cards_section_component.html.erb +237 -0
  18. data/app/components/lean_cms/cards_section_component.rb +71 -0
  19. data/app/components/lean_cms/editable_content_component.html.erb +15 -0
  20. data/app/components/lean_cms/editable_content_component.rb +53 -0
  21. data/app/components/lean_cms/section_component.html.erb +18 -0
  22. data/app/components/lean_cms/section_component.rb +35 -0
  23. data/app/controllers/concerns/lean_cms/authentication.rb +60 -0
  24. data/app/controllers/concerns/lean_cms/authorization.rb +60 -0
  25. data/app/controllers/lean_cms/activity_controller.rb +16 -0
  26. data/app/controllers/lean_cms/application_controller.rb +48 -0
  27. data/app/controllers/lean_cms/dashboard_controller.rb +13 -0
  28. data/app/controllers/lean_cms/form_submissions_controller.rb +37 -0
  29. data/app/controllers/lean_cms/notification_settings_controller.rb +145 -0
  30. data/app/controllers/lean_cms/notifications_controller.rb +26 -0
  31. data/app/controllers/lean_cms/page_contents_controller.rb +403 -0
  32. data/app/controllers/lean_cms/password_setup_controller.rb +65 -0
  33. data/app/controllers/lean_cms/passwords_controller.rb +42 -0
  34. data/app/controllers/lean_cms/posts_controller.rb +78 -0
  35. data/app/controllers/lean_cms/sessions_controller.rb +50 -0
  36. data/app/controllers/lean_cms/settings_controller.rb +124 -0
  37. data/app/controllers/lean_cms/users_controller.rb +113 -0
  38. data/app/helpers/lean_cms/activity_helper.rb +190 -0
  39. data/app/helpers/lean_cms/application_helper.rb +43 -0
  40. data/app/helpers/lean_cms/content_helper.rb +34 -0
  41. data/app/helpers/lean_cms/page_content_helper.rb +359 -0
  42. data/app/javascript/controllers/cards_editor_controller.js +317 -0
  43. data/app/javascript/controllers/cms_sticky_overlay_controller.js +59 -0
  44. data/app/javascript/controllers/field_editor_form_controller.js +68 -0
  45. data/app/javascript/controllers/field_editor_modal_controller.js +79 -0
  46. data/app/javascript/controllers/inline_edit_controller.js +414 -0
  47. data/app/javascript/controllers/inline_edit_toggle_controller.js +81 -0
  48. data/app/javascript/controllers/notifications_controller.js +19 -0
  49. data/app/javascript/controllers/settings_inline_edit_sync_controller.js +38 -0
  50. data/app/javascript/controllers/settings_override_controller.js +45 -0
  51. data/app/mailers/lean_cms/application_mailer.rb +6 -0
  52. data/app/mailers/lean_cms/passwords_mailer.rb +8 -0
  53. data/app/mailers/lean_cms/users_mailer.rb +39 -0
  54. data/app/models/lean_cms/current.rb +6 -0
  55. data/app/models/lean_cms/form_submission.rb +45 -0
  56. data/app/models/lean_cms/magic_link.rb +76 -0
  57. data/app/models/lean_cms/meta_tag.rb +30 -0
  58. data/app/models/lean_cms/notification_setting.rb +69 -0
  59. data/app/models/lean_cms/page.rb +23 -0
  60. data/app/models/lean_cms/page_content.rb +245 -0
  61. data/app/models/lean_cms/post.rb +65 -0
  62. data/app/models/lean_cms/session.rb +7 -0
  63. data/app/models/lean_cms/setting.rb +156 -0
  64. data/app/policies/lean_cms/application_policy.rb +35 -0
  65. data/app/policies/lean_cms/page_content_policy.rb +31 -0
  66. data/app/policies/lean_cms/post_policy.rb +37 -0
  67. data/app/policies/lean_cms/setting_policy.rb +17 -0
  68. data/app/views/layouts/lean_cms/application.html.erb +114 -0
  69. data/app/views/layouts/lean_cms/auth.html.erb +200 -0
  70. data/app/views/lean_cms/activity/index.html.erb +79 -0
  71. data/app/views/lean_cms/dashboard/index.html.erb +180 -0
  72. data/app/views/lean_cms/form_submissions/index.html.erb +104 -0
  73. data/app/views/lean_cms/form_submissions/show.html.erb +157 -0
  74. data/app/views/lean_cms/notification_settings/edit.html.erb +192 -0
  75. data/app/views/lean_cms/notifications/index.html.erb +72 -0
  76. data/app/views/lean_cms/notifications/show.html.erb +39 -0
  77. data/app/views/lean_cms/page_contents/_field_editor.html.erb +174 -0
  78. data/app/views/lean_cms/page_contents/edit.html.erb +428 -0
  79. data/app/views/lean_cms/page_contents/index.html.erb +113 -0
  80. data/app/views/lean_cms/password_setup/show.html.erb +35 -0
  81. data/app/views/lean_cms/passwords/edit.html.erb +26 -0
  82. data/app/views/lean_cms/passwords/new.html.erb +21 -0
  83. data/app/views/lean_cms/passwords_mailer/reset.html.erb +6 -0
  84. data/app/views/lean_cms/passwords_mailer/reset.text.erb +4 -0
  85. data/app/views/lean_cms/posts/_form.html.erb +118 -0
  86. data/app/views/lean_cms/posts/edit.html.erb +31 -0
  87. data/app/views/lean_cms/posts/index.html.erb +100 -0
  88. data/app/views/lean_cms/posts/new.html.erb +16 -0
  89. data/app/views/lean_cms/sessions/new.html.erb +28 -0
  90. data/app/views/lean_cms/settings/edit.html.erb +384 -0
  91. data/app/views/lean_cms/shared/_admin_bar.html.erb +85 -0
  92. data/app/views/lean_cms/shared/_header.html.erb +86 -0
  93. data/app/views/lean_cms/shared/_notifications_bell.html.erb +84 -0
  94. data/app/views/lean_cms/shared/_sidebar.html.erb +102 -0
  95. data/app/views/lean_cms/users/_form.html.erb +105 -0
  96. data/app/views/lean_cms/users/edit.html.erb +8 -0
  97. data/app/views/lean_cms/users/index.html.erb +99 -0
  98. data/app/views/lean_cms/users/new.html.erb +8 -0
  99. data/app/views/lean_cms/users_mailer/admin_triggered_password_reset.html.erb +13 -0
  100. data/app/views/lean_cms/users_mailer/admin_triggered_password_reset.text.erb +11 -0
  101. data/app/views/lean_cms/users_mailer/invitation.html.erb +13 -0
  102. data/app/views/lean_cms/users_mailer/invitation.text.erb +11 -0
  103. data/app/views/lean_cms/users_mailer/reactivation.html.erb +13 -0
  104. data/app/views/lean_cms/users_mailer/reactivation.text.erb +11 -0
  105. data/config/importmap.rb +8 -0
  106. data/config/routes.rb +78 -0
  107. data/db/migrate/20251112034030_create_lean_cms_tables.rb +131 -0
  108. data/db/migrate/20260513000001_create_lean_cms_auth_tables.rb +31 -0
  109. data/db/migrate/20260514000001_create_paper_trail_versions.rb +16 -0
  110. data/db/migrate/20260514000002_create_action_text_tables.rb +18 -0
  111. data/db/migrate/20260514000003_create_active_storage_tables.rb +45 -0
  112. data/db/migrate/20260514000004_create_noticed_tables.rb +27 -0
  113. data/lib/generators/lean_cms/demo/demo_generator.rb +54 -0
  114. data/lib/generators/lean_cms/demo/templates/lean_cms_structure.yml +129 -0
  115. data/lib/generators/lean_cms/demo/templates/pages_controller.rb +30 -0
  116. data/lib/generators/lean_cms/demo/templates/views/pages/about.html.erb +40 -0
  117. data/lib/generators/lean_cms/demo/templates/views/pages/contact.html.erb +55 -0
  118. data/lib/generators/lean_cms/demo/templates/views/pages/home.html.erb +31 -0
  119. data/lib/generators/lean_cms/install/install_generator.rb +317 -0
  120. data/lib/generators/lean_cms/install/templates/add_lean_cms_columns_to_users.rb.tt +7 -0
  121. data/lib/generators/lean_cms/install/templates/lean_cms.rb +11 -0
  122. data/lib/generators/lean_cms/install/templates/lean_cms_structure.yml +29 -0
  123. data/lib/lean_cms/configuration.rb +32 -0
  124. data/lib/lean_cms/engine.rb +93 -0
  125. data/lib/lean_cms/loader.rb +217 -0
  126. data/lib/lean_cms/sync_helper.rb +182 -0
  127. data/lib/lean_cms/version.rb +3 -0
  128. data/lib/lean_cms.rb +26 -0
  129. data/lib/tasks/lean_cms.rake +390 -0
  130. metadata +313 -0
@@ -0,0 +1,19 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ connect() {
5
+ // Subscribe to Turbo Streams for real-time updates
6
+ if (typeof Turbo !== 'undefined') {
7
+ this.subscribeToNotifications()
8
+ }
9
+ }
10
+
11
+ subscribeToNotifications() {
12
+ const userId = this.element.dataset.userId || document.body.dataset.userId
13
+ if (!userId) return
14
+
15
+ // Subscribe to notification channel for this user
16
+ // This would require ActionCable setup - for now, we'll rely on page refreshes
17
+ // In a full implementation, you'd set up ActionCable here
18
+ }
19
+ }
@@ -0,0 +1,38 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["checkbox"]
5
+
6
+ connect() {
7
+ // Sync the checkbox state with localStorage on load
8
+ const isEnabled = localStorage.getItem('cms-inline-editing-enabled')
9
+ if (isEnabled !== null) {
10
+ this.checkboxTarget.checked = isEnabled === 'true'
11
+ }
12
+ }
13
+
14
+ toggle(event) {
15
+ const enabled = event.target.checked
16
+
17
+ // Update localStorage to match the settings page toggle
18
+ localStorage.setItem('cms-inline-editing-enabled', enabled.toString())
19
+
20
+ // Show feedback
21
+ this.showFeedback(enabled ? 'Inline editing will be enabled after save' : 'Inline editing will be disabled after save')
22
+ }
23
+
24
+ showFeedback(message) {
25
+ const feedback = document.createElement('div')
26
+ feedback.textContent = message
27
+ feedback.className = 'fixed top-4 right-4 bg-gray-800 text-white px-4 py-2 rounded shadow-lg z-[70] transition-opacity'
28
+ feedback.style.opacity = '0'
29
+
30
+ document.body.appendChild(feedback)
31
+
32
+ setTimeout(() => { feedback.style.opacity = '1' }, 10)
33
+ setTimeout(() => {
34
+ feedback.style.opacity = '0'
35
+ setTimeout(() => { feedback.remove() }, 300)
36
+ }, 3000)
37
+ }
38
+ }
@@ -0,0 +1,45 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Handles the override toggle for settings-based page content
4
+ // Shows/hides the edit form and saves the override preference
5
+ export default class extends Controller {
6
+ static targets = ["checkbox", "form"]
7
+ static values = { section: String }
8
+
9
+ connect() {
10
+ this.updateFormVisibility()
11
+ }
12
+
13
+ toggle(event) {
14
+ const isChecked = event.target.checked
15
+ const settingsKey = this.sectionValue === 'info' ? 'contact_info_override' : 'contact_hours_override'
16
+
17
+ // Save the setting via AJAX
18
+ fetch('/lean-cms/settings/update_override', {
19
+ method: 'PATCH',
20
+ headers: {
21
+ 'Content-Type': 'application/json',
22
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
23
+ },
24
+ body: JSON.stringify({
25
+ key: settingsKey,
26
+ value: isChecked ? 'true' : 'false'
27
+ })
28
+ }).then(response => {
29
+ if (response.ok) {
30
+ this.updateFormVisibility()
31
+ }
32
+ })
33
+ }
34
+
35
+ updateFormVisibility() {
36
+ if (this.hasFormTarget) {
37
+ const isChecked = this.checkboxTarget.checked
38
+ if (isChecked) {
39
+ this.formTarget.classList.remove('hidden')
40
+ } else {
41
+ this.formTarget.classList.add('hidden')
42
+ }
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,6 @@
1
+ module LeanCms
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: -> { LeanCms.mailer_from }
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,8 @@
1
+ module LeanCms
2
+ class PasswordsMailer < LeanCms::ApplicationMailer
3
+ def reset(user)
4
+ @user = user
5
+ mail subject: "Reset your password", to: user.email_address
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,39 @@
1
+ module LeanCms
2
+ class UsersMailer < LeanCms::ApplicationMailer
3
+ def invitation(user, magic_link)
4
+ @user = user
5
+ @magic_link = magic_link
6
+ @setup_url = lean_cms_password_setup_url(token: magic_link.token)
7
+ @site_name = LeanCms.site_name
8
+
9
+ mail(
10
+ to: user.email_address,
11
+ subject: "You've been invited to #{@site_name} CMS"
12
+ )
13
+ end
14
+
15
+ def reactivation(user, magic_link)
16
+ @user = user
17
+ @magic_link = magic_link
18
+ @setup_url = lean_cms_password_setup_url(token: magic_link.token)
19
+ @site_name = LeanCms.site_name
20
+
21
+ mail(
22
+ to: user.email_address,
23
+ subject: "Your #{@site_name} CMS account has been reactivated"
24
+ )
25
+ end
26
+
27
+ def admin_triggered_password_reset(user, magic_link)
28
+ @user = user
29
+ @magic_link = magic_link
30
+ @setup_url = lean_cms_password_setup_url(token: magic_link.token)
31
+ @site_name = LeanCms.site_name
32
+
33
+ mail(
34
+ to: user.email_address,
35
+ subject: "Password reset requested for #{@site_name} CMS"
36
+ )
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,6 @@
1
+ module LeanCms
2
+ class Current < ActiveSupport::CurrentAttributes
3
+ attribute :session
4
+ delegate :user, to: :session, allow_nil: true
5
+ end
6
+ end
@@ -0,0 +1,45 @@
1
+ module LeanCms
2
+ class FormSubmission < ApplicationRecord
3
+ self.table_name = 'lean_cms_form_submissions'
4
+
5
+ has_paper_trail
6
+
7
+ validates :form_type, presence: true
8
+ validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
9
+
10
+ enum :status, { new_submission: 0, read: 1, replied: 2, archived: 3 }
11
+
12
+ scope :recent, -> { order(created_at: :desc) }
13
+ scope :unread, -> { where(status: :new_submission) }
14
+ scope :quote_requests, -> { where(form_type: 'quote_request') }
15
+
16
+ # Mark as read
17
+ def mark_as_read!
18
+ update(status: :read)
19
+ end
20
+
21
+ # Mark as replied
22
+ def mark_as_replied!
23
+ update(status: :replied)
24
+ end
25
+
26
+ # Get all form data as hash
27
+ def form_data
28
+ {
29
+ name: name,
30
+ email: email,
31
+ phone: phone,
32
+ company_name: company_name,
33
+ city: city,
34
+ state: state,
35
+ zip: zip,
36
+ message: message
37
+ }.merge(additional_data || {})
38
+ end
39
+
40
+ # Check if unread
41
+ def unread?
42
+ status == 'new_submission'
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,76 @@
1
+ module LeanCms
2
+ class MagicLink < ApplicationRecord
3
+ self.table_name = "lean_cms_magic_links"
4
+
5
+ belongs_to :user, class_name: "User"
6
+
7
+ PURPOSES = %w[invitation password_reset].freeze
8
+ EXPIRATION_TIMES = {
9
+ "invitation" => 24.hours,
10
+ "password_reset" => 2.hours
11
+ }.freeze
12
+
13
+ validates :token, presence: true, uniqueness: true
14
+ validates :purpose, presence: true, inclusion: { in: PURPOSES }
15
+ validates :expires_at, presence: true
16
+
17
+ before_validation :generate_token, on: :create
18
+ before_validation :set_expiration, on: :create
19
+
20
+ scope :valid, -> { where(used_at: nil).where("expires_at > ?", Time.current) }
21
+ scope :expired, -> { where("expires_at <= ?", Time.current) }
22
+ scope :for_purpose, ->(purpose) { where(purpose: purpose) }
23
+
24
+ def self.create_for_invitation(user, created_by_ip: nil)
25
+ create!(
26
+ user: user,
27
+ purpose: "invitation",
28
+ created_by_ip: created_by_ip
29
+ )
30
+ end
31
+
32
+ def self.create_for_password_reset(user, created_by_ip: nil)
33
+ where(user: user).for_purpose("password_reset").valid.update_all(used_at: Time.current)
34
+
35
+ create!(
36
+ user: user,
37
+ purpose: "password_reset",
38
+ created_by_ip: created_by_ip
39
+ )
40
+ end
41
+
42
+ def expired?
43
+ expires_at <= Time.current
44
+ end
45
+
46
+ def used?
47
+ used_at.present?
48
+ end
49
+
50
+ def valid_for_use?
51
+ !expired? && !used?
52
+ end
53
+
54
+ def mark_as_used!(ip_address = nil)
55
+ update!(used_at: Time.current, used_from_ip: ip_address)
56
+ end
57
+
58
+ def invitation?
59
+ purpose == "invitation"
60
+ end
61
+
62
+ def password_reset?
63
+ purpose == "password_reset"
64
+ end
65
+
66
+ private
67
+
68
+ def generate_token
69
+ self.token ||= SecureRandom.urlsafe_base64(32)
70
+ end
71
+
72
+ def set_expiration
73
+ self.expires_at ||= EXPIRATION_TIMES[purpose].from_now
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,30 @@
1
+ module LeanCms
2
+ class MetaTag < ApplicationRecord
3
+ self.table_name = 'lean_cms_meta_tags'
4
+
5
+ belongs_to :taggable, polymorphic: true
6
+
7
+ validates :title, length: { maximum: 60 }, allow_blank: true
8
+ validates :description, length: { maximum: 160 }, allow_blank: true
9
+
10
+ # Get title with fallback
11
+ def title_with_fallback(fallback = nil)
12
+ title.presence || fallback
13
+ end
14
+
15
+ # Get description with fallback
16
+ def description_with_fallback(fallback = nil)
17
+ description.presence || fallback
18
+ end
19
+
20
+ # Check if has open graph image
21
+ def has_og_image?
22
+ og_image_url.present?
23
+ end
24
+
25
+ # Check if has structured data
26
+ def has_structured_data?
27
+ structured_data.present? && structured_data.is_a?(Hash)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,69 @@
1
+ module LeanCms
2
+ class NotificationSetting < ApplicationRecord
3
+ self.table_name = 'lean_cms_notification_settings'
4
+
5
+ # Encrypt sensitive credentials using Rails built-in encryption
6
+ encrypts :sendgrid_api_key
7
+ encrypts :mailgun_api_key
8
+ encrypts :twilio_account_sid
9
+ encrypts :twilio_auth_token
10
+
11
+ # Singleton pattern - only one settings record
12
+ def self.instance
13
+ first_or_create! do |setting|
14
+ setting.email_provider = 'none'
15
+ setting.email_enabled = false
16
+ setting.sms_enabled = false
17
+ setting.in_app_enabled = true
18
+ setting.notification_emails = '[]'
19
+ setting.notification_phones = '[]'
20
+ end
21
+ end
22
+
23
+ def notification_email_list
24
+ JSON.parse(notification_emails || '[]')
25
+ end
26
+
27
+ def notification_email_list=(emails)
28
+ self.notification_emails = emails.is_a?(Array) ? emails.to_json : emails
29
+ end
30
+
31
+ def notification_phone_list
32
+ JSON.parse(notification_phones || '[]')
33
+ end
34
+
35
+ def notification_phone_list=(phones)
36
+ self.notification_phones = phones.is_a?(Array) ? phones.to_json : phones
37
+ end
38
+
39
+ # Validation
40
+ validates :email_provider, inclusion: { in: %w[sendgrid mailgun none] }, allow_nil: true
41
+ validate :email_provider_required_if_enabled
42
+ validate :credentials_required_if_enabled
43
+
44
+ private
45
+
46
+ def email_provider_required_if_enabled
47
+ if email_enabled? && email_provider == 'none'
48
+ errors.add(:email_provider, 'must be selected when email notifications are enabled')
49
+ end
50
+ end
51
+
52
+ def credentials_required_if_enabled
53
+ if email_enabled? && email_provider == 'sendgrid' && sendgrid_api_key.blank?
54
+ errors.add(:sendgrid_api_key, 'is required when Sendgrid is enabled')
55
+ end
56
+
57
+ if email_enabled? && email_provider == 'mailgun'
58
+ errors.add(:mailgun_api_key, 'is required when Mailgun is enabled') if mailgun_api_key.blank?
59
+ errors.add(:mailgun_domain, 'is required when Mailgun is enabled') if mailgun_domain.blank?
60
+ end
61
+
62
+ if sms_enabled?
63
+ errors.add(:twilio_account_sid, 'is required when SMS is enabled') if twilio_account_sid.blank?
64
+ errors.add(:twilio_auth_token, 'is required when SMS is enabled') if twilio_auth_token.blank?
65
+ errors.add(:twilio_from_number, 'is required when SMS is enabled') if twilio_from_number.blank?
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,23 @@
1
+ module LeanCms
2
+ class Page < ApplicationRecord
3
+ self.table_name = 'lean_cms_pages'
4
+
5
+ has_many :page_contents, class_name: 'LeanCms::PageContent', dependent: :destroy
6
+ belongs_to :parent, class_name: 'Page', foreign_key: 'parent_slug',
7
+ primary_key: 'slug', optional: true
8
+ has_many :children, class_name: 'Page', foreign_key: 'parent_slug',
9
+ primary_key: 'slug'
10
+
11
+ validates :slug, presence: true,
12
+ uniqueness: { scope: :parent_slug }
13
+ validates :title, presence: true
14
+
15
+ scope :published, -> { where(published: true) }
16
+ scope :root_pages, -> { where(parent_slug: nil) }
17
+ scope :ordered, -> { order(:position, :title) }
18
+
19
+ def full_path
20
+ parent_slug ? "#{parent_slug}/#{slug}" : slug
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,245 @@
1
+ module LeanCms
2
+ class PageContent < ApplicationRecord
3
+ self.table_name = 'lean_cms_page_contents'
4
+
5
+ has_paper_trail
6
+
7
+ belongs_to :page, class_name: 'LeanCms::Page', optional: true, touch: true
8
+ belongs_to :last_edited_by, class_name: 'User', optional: true
9
+
10
+ # Look up a content record by its (page-slug, section, key) triple, or
11
+ # build a new one — bypassing the :page association setter so the string
12
+ # `page` column gets the slug instead of an AssociationTypeMismatch.
13
+ # `belongs_to :page` shadows the `page` varchar column for mass-assignment;
14
+ # this helper is the seam every internal write goes through during the
15
+ # in-progress migration to the normalized LeanCms::Page model.
16
+ def self.find_or_initialize_content(page:, section:, key:)
17
+ # Look up against the `page` varchar column explicitly. `where(page: ...)`
18
+ # would resolve `:page` to the `belongs_to :page` association and emit
19
+ # `WHERE page_id = NULL`, missing every existing record — which made
20
+ # re-running load_structure crash on the SQLite unique index.
21
+ where("page = ? AND section = ? AND key = ?", page.to_s, section.to_s, key.to_s).first ||
22
+ new(section: section, key: key).tap { |record| record[:page] = page }
23
+ end
24
+
25
+ # Validate the slug column directly (read_attribute) rather than `:page`,
26
+ # which `belongs_to :page` shadows — a `presence: true` check on the
27
+ # association would require a loaded LeanCms::Page record, not a slug.
28
+ validates :section, :key, presence: true
29
+ # Built-in uniqueness with scope: [:page, :section] resolves `:page` to
30
+ # the `page_id` association FK (NULL on fresh installs until the legacy
31
+ # string slug → LeanCms::Page normalization completes), which collapses
32
+ # the scope to just `:section` and triggers spurious collisions for
33
+ # repeated keys like `heading` across pages. We check the slug column
34
+ # directly instead.
35
+ validate :key_unique_within_page_section
36
+ validates :content_type, presence: true
37
+ validate :validate_max_length
38
+ validate :page_slug_present
39
+
40
+ def page_slug
41
+ read_attribute(:page)
42
+ end
43
+
44
+ # Content types: text, rich_text, image, boolean, url, color, dropdown, cards, bullets
45
+ enum :content_type, {
46
+ text: 0,
47
+ rich_text: 1,
48
+ image: 2,
49
+ boolean: 3,
50
+ url: 4,
51
+ color: 5,
52
+ dropdown: 6,
53
+ cards: 7,
54
+ bullets: 8
55
+ }
56
+
57
+ # Rich text for rich_text content type
58
+ has_rich_text :rich_content
59
+
60
+ # Image attachment for image content type
61
+ has_one_attached :image_file
62
+
63
+ # Multiple image attachments for cards content type
64
+ has_many_attached :card_images
65
+
66
+ # Scopes
67
+ scope :for_page, ->(page) {
68
+ if page.is_a?(LeanCms::Page)
69
+ where(page_id: page.id)
70
+ else
71
+ where("page = ?", page.to_s)
72
+ end
73
+ }
74
+ scope :for_section, ->(page, section) {
75
+ if page.is_a?(LeanCms::Page)
76
+ where(page_id: page.id, section: section)
77
+ else
78
+ where("page = ?", page.to_s).where(section: section)
79
+ end
80
+ }
81
+ scope :ordered, -> { order(:position, :key) }
82
+
83
+ # Class methods to fetch content
84
+ class << self
85
+ # Get all content for a page grouped by section
86
+ def page_structure(page)
87
+ for_page(page).ordered.group_by(&:section).transform_values do |contents|
88
+ contents.index_by(&:key).transform_values(&:display_value)
89
+ end
90
+ end
91
+
92
+ # Get all content for a specific section as a hash
93
+ def section_content(page, section)
94
+ for_section(page, section).ordered.index_by(&:key).transform_values(&:display_value)
95
+ end
96
+
97
+ # Get a single field value
98
+ def field_value(page, section, key, default: nil)
99
+ if page.is_a?(LeanCms::Page)
100
+ find_by(page_id: page.id, section: section, key: key)&.display_value || default
101
+ else
102
+ find_by("page = ? AND section = ? AND key = ?", page.to_s, section.to_s, key.to_s)&.display_value || default
103
+ end
104
+ end
105
+ end
106
+
107
+ # Get the display value based on content type
108
+ def display_value
109
+ case content_type.to_sym
110
+ when :text
111
+ value.presence || content
112
+ when :rich_text
113
+ rich_content.present? ? rich_content.to_s : value.presence || content
114
+ when :image
115
+ image_file.attached? ? image_file : (value.presence || content)
116
+ when :boolean
117
+ # Store as string "true"/"false", return as boolean
118
+ value == "true" || value == true || value == "1"
119
+ when :url
120
+ value.presence || content
121
+ when :color
122
+ value.presence || content
123
+ when :dropdown
124
+ value.presence || content
125
+ when :cards
126
+ # Cards are stored as JSON in the value field
127
+ parse_cards_json
128
+ when :bullets
129
+ # Bullets are stored as JSON array
130
+ parse_bullets_json
131
+ else
132
+ value.presence || content
133
+ end
134
+ end
135
+
136
+ # Parse cards JSON data
137
+ def parse_cards_json
138
+ return [] unless cards?
139
+
140
+ cards_data = if value.present?
141
+ JSON.parse(value) rescue []
142
+ elsif content.present?
143
+ JSON.parse(content) rescue []
144
+ else
145
+ []
146
+ end
147
+
148
+ # Return cards data as-is, image attachments will be looked up separately when needed
149
+ cards_data.map { |card| card.with_indifferent_access }
150
+ end
151
+
152
+ # Get image attachment for a specific card by image_id
153
+ def card_image(image_id)
154
+ return nil unless image_id.present? && card_images.attached?
155
+ card_images.find { |img| img.blob.id.to_s == image_id.to_s }
156
+ end
157
+
158
+ # Parse bullets JSON data (similar to cards)
159
+ def parse_bullets_json
160
+ return [] unless bullets?
161
+
162
+ if value.present?
163
+ JSON.parse(value) rescue []
164
+ elsif content.present?
165
+ JSON.parse(content) rescue []
166
+ else
167
+ []
168
+ end
169
+ end
170
+
171
+ # Set the value based on content type
172
+ def set_value(new_value)
173
+ case content_type.to_sym
174
+ when :boolean
175
+ self.value = new_value.to_s
176
+ when :rich_text
177
+ self.rich_content = new_value
178
+ self.value = new_value.to_s if new_value.present?
179
+ when :cards
180
+ # Store cards as JSON
181
+ self.value = new_value.is_a?(String) ? new_value : new_value.to_json
182
+ else
183
+ self.value = new_value.to_s
184
+ end
185
+ end
186
+
187
+ # Get parsed options for dropdown fields
188
+ def parsed_options
189
+ return [] unless dropdown?
190
+
191
+ if options.is_a?(Array)
192
+ options
193
+ elsif options.is_a?(Hash)
194
+ options['options'] || []
195
+ else
196
+ []
197
+ end
198
+ end
199
+
200
+ # Get max_length from options (for text fields)
201
+ def max_length
202
+ return nil unless options.is_a?(Hash)
203
+ options['max_length']&.to_i
204
+ end
205
+
206
+ private
207
+
208
+ # Custom presence check that reads the `page` varchar column directly
209
+ # rather than going through the `belongs_to :page` association.
210
+ def page_slug_present
211
+ errors.add(:page, "can't be blank") if page_slug.blank?
212
+ end
213
+
214
+ # Custom uniqueness check scoped on the slug column, not the
215
+ # association FK. See validate :key_unique_within_page_section above.
216
+ def key_unique_within_page_section
217
+ return if page_slug.blank? || section.blank? || key.blank?
218
+
219
+ scope = self.class
220
+ .where(page: page_slug, section: section, key: key)
221
+ scope = scope.where.not(id: id) if persisted?
222
+ errors.add(:key, "has already been taken") if scope.exists?
223
+ end
224
+
225
+ # Validate value doesn't exceed max_length
226
+ def validate_max_length
227
+ return unless max_length.present? && max_length > 0
228
+ return unless text? || rich_text?
229
+
230
+ content_value = value.presence || content
231
+ return if content_value.blank?
232
+
233
+ # Strip HTML tags for rich_text to get actual character count
234
+ plain_text = if rich_text?
235
+ ActionController::Base.helpers.strip_tags(content_value.to_s)
236
+ else
237
+ content_value.to_s
238
+ end
239
+
240
+ if plain_text.length > max_length
241
+ errors.add(:value, "exceeds maximum length of #{max_length} characters (currently #{plain_text.length})")
242
+ end
243
+ end
244
+ end
245
+ end