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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +235 -0
- data/LICENSE +21 -0
- data/README.md +107 -0
- data/app/assets/images/lean_cms/sloth-404.png +0 -0
- data/app/assets/images/lean_cms/sloth-500.png +0 -0
- data/app/assets/images/lean_cms/sloth-favicon-16.png +0 -0
- data/app/assets/images/lean_cms/sloth-favicon-32.png +0 -0
- data/app/assets/images/lean_cms/sloth-favicon-64.png +0 -0
- data/app/assets/images/lean_cms/sloth-logo.png +0 -0
- data/app/assets/lean_cms/actiontext.css +440 -0
- data/app/assets/lean_cms/cms_edit_controls.css +548 -0
- data/app/assets/tailwind/lean_cms/engine.css +14 -0
- data/app/components/lean_cms/base_component.rb +61 -0
- data/app/components/lean_cms/bullets_section_component.html.erb +23 -0
- data/app/components/lean_cms/bullets_section_component.rb +54 -0
- data/app/components/lean_cms/cards_section_component.html.erb +237 -0
- data/app/components/lean_cms/cards_section_component.rb +71 -0
- data/app/components/lean_cms/editable_content_component.html.erb +15 -0
- data/app/components/lean_cms/editable_content_component.rb +53 -0
- data/app/components/lean_cms/section_component.html.erb +18 -0
- data/app/components/lean_cms/section_component.rb +35 -0
- data/app/controllers/concerns/lean_cms/authentication.rb +60 -0
- data/app/controllers/concerns/lean_cms/authorization.rb +60 -0
- data/app/controllers/lean_cms/activity_controller.rb +16 -0
- data/app/controllers/lean_cms/application_controller.rb +48 -0
- data/app/controllers/lean_cms/dashboard_controller.rb +13 -0
- data/app/controllers/lean_cms/form_submissions_controller.rb +37 -0
- data/app/controllers/lean_cms/notification_settings_controller.rb +145 -0
- data/app/controllers/lean_cms/notifications_controller.rb +26 -0
- data/app/controllers/lean_cms/page_contents_controller.rb +403 -0
- data/app/controllers/lean_cms/password_setup_controller.rb +65 -0
- data/app/controllers/lean_cms/passwords_controller.rb +42 -0
- data/app/controllers/lean_cms/posts_controller.rb +78 -0
- data/app/controllers/lean_cms/sessions_controller.rb +50 -0
- data/app/controllers/lean_cms/settings_controller.rb +124 -0
- data/app/controllers/lean_cms/users_controller.rb +113 -0
- data/app/helpers/lean_cms/activity_helper.rb +190 -0
- data/app/helpers/lean_cms/application_helper.rb +43 -0
- data/app/helpers/lean_cms/content_helper.rb +34 -0
- data/app/helpers/lean_cms/page_content_helper.rb +359 -0
- data/app/javascript/controllers/cards_editor_controller.js +317 -0
- data/app/javascript/controllers/cms_sticky_overlay_controller.js +59 -0
- data/app/javascript/controllers/field_editor_form_controller.js +68 -0
- data/app/javascript/controllers/field_editor_modal_controller.js +79 -0
- data/app/javascript/controllers/inline_edit_controller.js +414 -0
- data/app/javascript/controllers/inline_edit_toggle_controller.js +81 -0
- data/app/javascript/controllers/notifications_controller.js +19 -0
- data/app/javascript/controllers/settings_inline_edit_sync_controller.js +38 -0
- data/app/javascript/controllers/settings_override_controller.js +45 -0
- data/app/mailers/lean_cms/application_mailer.rb +6 -0
- data/app/mailers/lean_cms/passwords_mailer.rb +8 -0
- data/app/mailers/lean_cms/users_mailer.rb +39 -0
- data/app/models/lean_cms/current.rb +6 -0
- data/app/models/lean_cms/form_submission.rb +45 -0
- data/app/models/lean_cms/magic_link.rb +76 -0
- data/app/models/lean_cms/meta_tag.rb +30 -0
- data/app/models/lean_cms/notification_setting.rb +69 -0
- data/app/models/lean_cms/page.rb +23 -0
- data/app/models/lean_cms/page_content.rb +245 -0
- data/app/models/lean_cms/post.rb +65 -0
- data/app/models/lean_cms/session.rb +7 -0
- data/app/models/lean_cms/setting.rb +156 -0
- data/app/policies/lean_cms/application_policy.rb +35 -0
- data/app/policies/lean_cms/page_content_policy.rb +31 -0
- data/app/policies/lean_cms/post_policy.rb +37 -0
- data/app/policies/lean_cms/setting_policy.rb +17 -0
- data/app/views/layouts/lean_cms/application.html.erb +114 -0
- data/app/views/layouts/lean_cms/auth.html.erb +200 -0
- data/app/views/lean_cms/activity/index.html.erb +79 -0
- data/app/views/lean_cms/dashboard/index.html.erb +180 -0
- data/app/views/lean_cms/form_submissions/index.html.erb +104 -0
- data/app/views/lean_cms/form_submissions/show.html.erb +157 -0
- data/app/views/lean_cms/notification_settings/edit.html.erb +192 -0
- data/app/views/lean_cms/notifications/index.html.erb +72 -0
- data/app/views/lean_cms/notifications/show.html.erb +39 -0
- data/app/views/lean_cms/page_contents/_field_editor.html.erb +174 -0
- data/app/views/lean_cms/page_contents/edit.html.erb +428 -0
- data/app/views/lean_cms/page_contents/index.html.erb +113 -0
- data/app/views/lean_cms/password_setup/show.html.erb +35 -0
- data/app/views/lean_cms/passwords/edit.html.erb +26 -0
- data/app/views/lean_cms/passwords/new.html.erb +21 -0
- data/app/views/lean_cms/passwords_mailer/reset.html.erb +6 -0
- data/app/views/lean_cms/passwords_mailer/reset.text.erb +4 -0
- data/app/views/lean_cms/posts/_form.html.erb +118 -0
- data/app/views/lean_cms/posts/edit.html.erb +31 -0
- data/app/views/lean_cms/posts/index.html.erb +100 -0
- data/app/views/lean_cms/posts/new.html.erb +16 -0
- data/app/views/lean_cms/sessions/new.html.erb +28 -0
- data/app/views/lean_cms/settings/edit.html.erb +384 -0
- data/app/views/lean_cms/shared/_admin_bar.html.erb +85 -0
- data/app/views/lean_cms/shared/_header.html.erb +86 -0
- data/app/views/lean_cms/shared/_notifications_bell.html.erb +84 -0
- data/app/views/lean_cms/shared/_sidebar.html.erb +102 -0
- data/app/views/lean_cms/users/_form.html.erb +105 -0
- data/app/views/lean_cms/users/edit.html.erb +8 -0
- data/app/views/lean_cms/users/index.html.erb +99 -0
- data/app/views/lean_cms/users/new.html.erb +8 -0
- data/app/views/lean_cms/users_mailer/admin_triggered_password_reset.html.erb +13 -0
- data/app/views/lean_cms/users_mailer/admin_triggered_password_reset.text.erb +11 -0
- data/app/views/lean_cms/users_mailer/invitation.html.erb +13 -0
- data/app/views/lean_cms/users_mailer/invitation.text.erb +11 -0
- data/app/views/lean_cms/users_mailer/reactivation.html.erb +13 -0
- data/app/views/lean_cms/users_mailer/reactivation.text.erb +11 -0
- data/config/importmap.rb +8 -0
- data/config/routes.rb +78 -0
- data/db/migrate/20251112034030_create_lean_cms_tables.rb +131 -0
- data/db/migrate/20260513000001_create_lean_cms_auth_tables.rb +31 -0
- data/db/migrate/20260514000001_create_paper_trail_versions.rb +16 -0
- data/db/migrate/20260514000002_create_action_text_tables.rb +18 -0
- data/db/migrate/20260514000003_create_active_storage_tables.rb +45 -0
- data/db/migrate/20260514000004_create_noticed_tables.rb +27 -0
- data/lib/generators/lean_cms/demo/demo_generator.rb +54 -0
- data/lib/generators/lean_cms/demo/templates/lean_cms_structure.yml +129 -0
- data/lib/generators/lean_cms/demo/templates/pages_controller.rb +30 -0
- data/lib/generators/lean_cms/demo/templates/views/pages/about.html.erb +40 -0
- data/lib/generators/lean_cms/demo/templates/views/pages/contact.html.erb +55 -0
- data/lib/generators/lean_cms/demo/templates/views/pages/home.html.erb +31 -0
- data/lib/generators/lean_cms/install/install_generator.rb +317 -0
- data/lib/generators/lean_cms/install/templates/add_lean_cms_columns_to_users.rb.tt +7 -0
- data/lib/generators/lean_cms/install/templates/lean_cms.rb +11 -0
- data/lib/generators/lean_cms/install/templates/lean_cms_structure.yml +29 -0
- data/lib/lean_cms/configuration.rb +32 -0
- data/lib/lean_cms/engine.rb +93 -0
- data/lib/lean_cms/loader.rb +217 -0
- data/lib/lean_cms/sync_helper.rb +182 -0
- data/lib/lean_cms/version.rb +3 -0
- data/lib/lean_cms.rb +26 -0
- data/lib/tasks/lean_cms.rake +390 -0
- 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,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,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
|