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,78 @@
1
+ module LeanCms
2
+ class PostsController < LeanCms::ApplicationController
3
+ before_action :require_blog_editing
4
+ before_action :set_post, only: [:show, :edit, :update, :destroy]
5
+ before_action :authorize_edit, only: [:edit, :update, :destroy]
6
+ before_action :check_content_lock, only: [:destroy]
7
+
8
+ def index
9
+ @posts = LeanCms::Post.includes(:author, :last_edited_by)
10
+ .order(created_at: :desc)
11
+
12
+ # Filter by content_type if provided
13
+ if params[:content_type].present?
14
+ case params[:content_type]
15
+ when 'blog'
16
+ @posts = @posts.blog_posts
17
+ when 'portfolio'
18
+ @posts = @posts.portfolio_items
19
+ end
20
+ end
21
+ end
22
+
23
+ def show
24
+ end
25
+
26
+ def new
27
+ @post = LeanCms::Post.new
28
+ end
29
+
30
+ def create
31
+ @post = LeanCms::Post.new(post_params)
32
+ @post.author = current_user
33
+ @post.last_edited_by = current_user
34
+
35
+ if @post.save
36
+ redirect_to lean_cms_posts_path, notice: 'Post created successfully.'
37
+ else
38
+ render :new, status: :unprocessable_entity
39
+ end
40
+ end
41
+
42
+ def edit
43
+ end
44
+
45
+ def update
46
+ @post.last_edited_by = current_user
47
+
48
+ if @post.update(post_params)
49
+ redirect_to lean_cms_posts_path, notice: 'Post updated successfully.'
50
+ else
51
+ render :edit, status: :unprocessable_entity
52
+ end
53
+ end
54
+
55
+ def destroy
56
+ @post.destroy
57
+ redirect_to lean_cms_posts_path, notice: 'Post deleted successfully.'
58
+ end
59
+
60
+ private
61
+
62
+ def set_post
63
+ @post = LeanCms::Post.find(params[:id])
64
+ end
65
+
66
+ def authorize_edit
67
+ unless can_edit?(@post)
68
+ redirect_to lean_cms_posts_path, alert: 'You are not authorized to edit this post.'
69
+ end
70
+ end
71
+
72
+ def post_params
73
+ params.require(:lean_cms_post).permit(
74
+ :title, :slug, :excerpt, :body, :status, :published_at, :content_type, :featured_image
75
+ )
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,50 @@
1
+ module LeanCms
2
+ class SessionsController < ::ApplicationController
3
+ allow_unauthenticated_access only: %i[ new create ]
4
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to lean_cms_new_session_path, alert: "Try again later." }
5
+
6
+ layout "lean_cms/auth"
7
+
8
+ def new
9
+ redirect_to after_authentication_url if authenticated?
10
+ end
11
+
12
+ def create
13
+ user_class = LeanCms.user_class.constantize
14
+
15
+ if user = user_class.authenticate_by(params.permit(:email_address, :password))
16
+ unless user.active?
17
+ redirect_to lean_cms_new_session_path, alert: "Your account has been deactivated. Please contact an administrator."
18
+ return
19
+ end
20
+
21
+ start_new_session_for user
22
+ user.record_login!
23
+
24
+ if user.must_change_password?
25
+ magic_link = LeanCms::MagicLink.create_for_password_reset(user)
26
+ redirect_to lean_cms_password_setup_path(token: magic_link.token), notice: "Please set a new password."
27
+ else
28
+ redirect_to after_authentication_url
29
+ end
30
+ else
31
+ redirect_to lean_cms_new_session_path, alert: "Try another email address or password."
32
+ end
33
+ end
34
+
35
+ def destroy
36
+ terminate_session
37
+ redirect_to lean_cms_new_session_path, status: :see_other
38
+ end
39
+
40
+ private
41
+
42
+ def after_authentication_url
43
+ if LeanCms::Current.user&.has_any_cms_permission?
44
+ lean_cms_root_path
45
+ else
46
+ session.delete(:return_to_after_authenticating) || "/"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,124 @@
1
+ module LeanCms
2
+ class SettingsController < ApplicationController
3
+ skip_before_action :check_content_lock
4
+ before_action :require_settings_access
5
+
6
+ def edit
7
+ # Load current settings (default to enabled for backward compatibility)
8
+ @in_context_editing_enabled = LeanCms::Setting.get('in_context_editing', 'true') == 'true'
9
+ @show_blog_enabled = LeanCms::Setting.get('show_blog', 'true') == 'true'
10
+ @show_portfolio_enabled = LeanCms::Setting.get('show_portfolio', 'true') == 'true'
11
+
12
+ # Load counts for display
13
+ @blog_count = LeanCms::Post.published.blog_posts.count
14
+ @portfolio_count = LeanCms::Post.published.portfolio_items.count
15
+
16
+ # Blog and Portfolio hero text
17
+ @blog_title = LeanCms::Setting.get('blog_title', 'Our Blog')
18
+ @blog_subtitle = LeanCms::Setting.get('blog_subtitle', 'Insights, updates, and stories from Custom Assembly Services')
19
+ @portfolio_title = LeanCms::Setting.get('portfolio_title', 'Our Portfolio')
20
+ @portfolio_subtitle = LeanCms::Setting.get('portfolio_subtitle', 'Showcasing our industrial assembly and installation projects')
21
+
22
+ # Privacy & Compliance
23
+ @cookie_consent_enabled = LeanCms::Setting.get('cookie_consent_enabled', 'false') == 'true'
24
+ @cookie_consent_message = LeanCms::Setting.get('cookie_consent_message', 'We use cookies to improve your experience and analyze site traffic. You can choose which cookies to allow.')
25
+ @cookie_consent_forced_on = LeanCms::Setting.enabled?('google_analytics_enabled')
26
+
27
+ # Analytics
28
+ @google_analytics_enabled = LeanCms::Setting.enabled?('google_analytics_enabled')
29
+ @google_analytics_id = LeanCms::Setting.get('google_analytics_id', '')
30
+ end
31
+
32
+ def update
33
+ # Site favicon (uploaded override for the public site)
34
+ LeanCms::Setting.remove_site_favicon! if params[:remove_site_favicon] == "1"
35
+ LeanCms::Setting.update_site_favicon!(params[:site_favicon]) if params[:site_favicon].present?
36
+
37
+ # Update in-context editing setting
38
+ LeanCms::Setting.set('in_context_editing', params[:in_context_editing] == '1' ? 'true' : 'false')
39
+
40
+ # Update show blog and portfolio settings
41
+ LeanCms::Setting.set('show_blog', params[:show_blog] == '1' ? 'true' : 'false')
42
+ LeanCms::Setting.set('show_portfolio', params[:show_portfolio] == '1' ? 'true' : 'false')
43
+
44
+ # Update blog and portfolio hero text
45
+ LeanCms::Setting.set('blog_title', params[:blog_title].to_s) if params.key?(:blog_title)
46
+ LeanCms::Setting.set('blog_subtitle', params[:blog_subtitle].to_s) if params.key?(:blog_subtitle)
47
+ LeanCms::Setting.set('portfolio_title', params[:portfolio_title].to_s) if params.key?(:portfolio_title)
48
+ LeanCms::Setting.set('portfolio_subtitle', params[:portfolio_subtitle].to_s) if params.key?(:portfolio_subtitle)
49
+
50
+ # Update site address (as structured JSON)
51
+ address_data = {
52
+ 'street1' => params[:site_street1].to_s,
53
+ 'street2' => params[:site_street2].to_s,
54
+ 'city' => params[:site_city].to_s,
55
+ 'state' => params[:site_state].to_s.upcase,
56
+ 'zip' => params[:site_zip].to_s
57
+ }
58
+ LeanCms::Setting.set_json('site_address', address_data)
59
+
60
+ # Update phone and email
61
+ LeanCms::Setting.set('site_phone', params[:site_phone]) if params[:site_phone]
62
+ LeanCms::Setting.set('site_email', params[:site_email]) if params[:site_email]
63
+
64
+ # Update business hours (as JSON)
65
+ if params[:business_hours_labels] || params[:business_hours_note]
66
+ hours_data = {
67
+ 'hours' => build_hours_array,
68
+ 'note' => params[:business_hours_note].to_s
69
+ }
70
+ LeanCms::Setting.set_json('business_hours', hours_data)
71
+ end
72
+
73
+ # Update Google Analytics (when enabled, force cookie consent on)
74
+ LeanCms::Setting.set('google_analytics_enabled', params[:google_analytics_enabled] == '1' ? 'true' : 'false')
75
+ LeanCms::Setting.set('google_analytics_id', params[:google_analytics_id].to_s.strip) if params.key?(:google_analytics_id)
76
+ LeanCms::Setting.set('cookie_consent_enabled', 'true') if LeanCms::Setting.enabled?('google_analytics_enabled')
77
+
78
+ # Update cookie consent (cannot disable if GA is enabled)
79
+ if params.key?(:cookie_consent_enabled)
80
+ forced_on = LeanCms::Setting.enabled?('google_analytics_enabled')
81
+ value = params[:cookie_consent_enabled] == '1' ? 'true' : 'false'
82
+ LeanCms::Setting.set('cookie_consent_enabled', forced_on ? 'true' : value)
83
+ end
84
+ LeanCms::Setting.set('cookie_consent_message', params[:cookie_consent_message].to_s) if params.key?(:cookie_consent_message)
85
+
86
+ redirect_to lean_cms_settings_path, notice: 'Settings updated successfully.'
87
+ end
88
+
89
+ def lock
90
+ reason = params[:reason].presence || 'Content sync in progress'
91
+ LeanCms::Setting.lock_content!(reason)
92
+ redirect_to lean_cms_settings_path, notice: "Content editing locked: #{reason}"
93
+ end
94
+
95
+ def unlock
96
+ LeanCms::Setting.unlock_content!
97
+ redirect_to lean_cms_settings_path, notice: 'Content editing unlocked. Editors can now make changes.'
98
+ end
99
+
100
+ # AJAX endpoint to toggle override settings
101
+ def update_override
102
+ allowed_keys = %w[contact_info_override contact_hours_override]
103
+ key = params[:key]
104
+ value = params[:value]
105
+
106
+ if allowed_keys.include?(key) && %w[true false].include?(value)
107
+ LeanCms::Setting.set(key, value)
108
+ head :ok
109
+ else
110
+ head :unprocessable_entity
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ def build_hours_array
117
+ return [] unless params[:business_hours_labels]
118
+ params[:business_hours_labels]
119
+ .zip(params[:business_hours_values] || [])
120
+ .map { |label, value| { 'label' => label.to_s, 'value' => value.to_s } }
121
+ .reject { |h| h['label'].blank? && h['value'].blank? }
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,113 @@
1
+ module LeanCms
2
+ class UsersController < ApplicationController
3
+ before_action :set_user, only: [:show, :edit, :update, :deactivate, :activate, :send_password_reset]
4
+ after_action :verify_authorized
5
+
6
+ def index
7
+ authorize User
8
+ @users = policy_scope(User).includes(:sessions).order(created_at: :desc)
9
+ end
10
+
11
+ def show
12
+ authorize @user
13
+ end
14
+
15
+ def new
16
+ @user = User.new
17
+ authorize @user
18
+ end
19
+
20
+ def create
21
+ @user = User.new(user_params)
22
+ @user.active = false # Will be activated when they set their password
23
+ @user.password = SecureRandom.hex(32) # Temporary password, will be replaced
24
+ authorize @user
25
+
26
+ if @user.save
27
+ magic_link = MagicLink.create_for_invitation(@user, created_by_ip: request.remote_ip)
28
+ UsersMailer.invitation(@user, magic_link).deliver_later
29
+ redirect_to lean_cms_users_path, notice: "User invited. They will receive an email to set their password."
30
+ else
31
+ render :new, status: :unprocessable_entity
32
+ end
33
+ end
34
+
35
+ def edit
36
+ authorize @user
37
+ end
38
+
39
+ def update
40
+ authorize @user
41
+
42
+ # Prevent non-super-admins from granting super admin or settings access
43
+ if !current_user.is_super_admin?
44
+ if params[:user][:is_super_admin] == "1" || params[:user][:is_super_admin] == true
45
+ flash[:alert] = "Only super admins can grant super admin privileges."
46
+ render :edit, status: :unprocessable_entity
47
+ return
48
+ end
49
+ if params[:user][:can_access_settings] == "1" || params[:user][:can_access_settings] == true
50
+ flash[:alert] = "Only super admins can grant settings access."
51
+ render :edit, status: :unprocessable_entity
52
+ return
53
+ end
54
+ end
55
+
56
+ if @user.update(user_params)
57
+ redirect_to lean_cms_users_path, notice: "User updated successfully."
58
+ else
59
+ render :edit, status: :unprocessable_entity
60
+ end
61
+ end
62
+
63
+ def deactivate
64
+ authorize @user
65
+
66
+ if @user == current_user
67
+ redirect_to lean_cms_users_path, alert: "You cannot deactivate your own account."
68
+ return
69
+ end
70
+
71
+ @user.deactivate!
72
+ redirect_to lean_cms_users_path, notice: "User deactivated."
73
+ end
74
+
75
+ def activate
76
+ authorize @user
77
+
78
+ # Send a password reset link when activating a previously deactivated user
79
+ magic_link = MagicLink.create_for_password_reset(@user, created_by_ip: request.remote_ip)
80
+ UsersMailer.reactivation(@user, magic_link).deliver_later
81
+ @user.activate!
82
+
83
+ redirect_to lean_cms_users_path, notice: "User activated. They will receive an email to set a new password."
84
+ end
85
+
86
+ def send_password_reset
87
+ authorize @user
88
+
89
+ magic_link = MagicLink.create_for_password_reset(@user, created_by_ip: request.remote_ip)
90
+ UsersMailer.admin_triggered_password_reset(@user, magic_link).deliver_later
91
+
92
+ redirect_to lean_cms_users_path, notice: "Password reset email sent to #{@user.email_address}."
93
+ end
94
+
95
+ private
96
+
97
+ def set_user
98
+ @user = User.find(params[:id])
99
+ end
100
+
101
+ def user_params
102
+ permitted = [:name, :email_address, :can_edit_pages, :can_edit_blog, :can_manage_users]
103
+
104
+ # Only super admins can modify these permissions
105
+ if current_user.is_super_admin?
106
+ permitted << :can_access_settings
107
+ permitted << :is_super_admin
108
+ end
109
+
110
+ params.require(:user).permit(permitted)
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,190 @@
1
+ module LeanCms
2
+ module ActivityHelper
3
+ def activity_item_label(version)
4
+ case version.item_type
5
+ when "LeanCms::Setting"
6
+ setting = parsed_setting_object(version)
7
+ "Setting: #{setting&.dig('key') || version.item_id}"
8
+ when "LeanCms::PageContent"
9
+ pc = version.item_type.constantize.unscoped.find_by(id: version.item_id)
10
+ if pc
11
+ page_slug = pc.page&.full_path || pc.page&.slug || pc.read_attribute(:page).to_s
12
+ "Page Content: #{page_slug}/#{pc.section}/#{pc.key}"
13
+ else
14
+ "Page Content ##{version.item_id}"
15
+ end
16
+ when "LeanCms::Post"
17
+ post = version.item_type.constantize.unscoped.find_by(id: version.item_id)
18
+ post ? "Post: #{post.title}" : "Post ##{version.item_id}"
19
+ when "User"
20
+ user = User.unscoped.find_by(id: version.item_id)
21
+ user ? "User: #{user.email_address}" : "User ##{version.item_id}"
22
+ when "LeanCms::FormSubmission"
23
+ fs = version.item_type.constantize.unscoped.find_by(id: version.item_id)
24
+ fs ? "Form Submission ##{version.item_id}" : "Form Submission ##{version.item_id}"
25
+ else
26
+ "#{version.item_type} ##{version.item_id}"
27
+ end
28
+ rescue
29
+ "#{version.item_type} ##{version.item_id}"
30
+ end
31
+
32
+ def activity_who(version)
33
+ return "System" if version.whodunnit.blank?
34
+ user = User.unscoped.find_by(id: version.whodunnit)
35
+ user ? user.email_address : "User ##{version.whodunnit}"
36
+ rescue
37
+ "User ##{version.whodunnit}"
38
+ end
39
+
40
+ def activity_action_badge_class(event)
41
+ case event.to_s
42
+ when "create" then "bg-green-100 text-green-800"
43
+ when "update" then "bg-blue-100 text-blue-800"
44
+ when "destroy" then "bg-red-100 text-red-800"
45
+ else "bg-gray-100 text-gray-800"
46
+ end
47
+ end
48
+
49
+ def activity_old_value(version)
50
+ return "—" if version.event == "create"
51
+ obj = parsed_version_object(version)
52
+ return "—" if obj.blank?
53
+
54
+ format_attributes_for_display(obj, version.item_type)
55
+ rescue
56
+ "—"
57
+ end
58
+
59
+ def activity_new_value(version)
60
+ return "—" if version.event == "destroy"
61
+ obj = parsed_version_object(version)
62
+ return "—" if obj.blank? && version.event != "create"
63
+
64
+ case version.item_type
65
+ when "LeanCms::Setting"
66
+ if version.event == "create"
67
+ obj["value"].to_s.truncate(80)
68
+ else
69
+ item = version.item_type.constantize.unscoped.find_by(id: version.item_id)
70
+ item&.value&.to_s&.truncate(80) || "—"
71
+ end
72
+ else
73
+ if version.event == "create"
74
+ format_attributes_for_display(obj, version.item_type)
75
+ else
76
+ item = version.item_type.constantize.unscoped.find_by(id: version.item_id)
77
+ item ? truncate_item_summary(item) : "—"
78
+ end
79
+ end
80
+ rescue
81
+ "—"
82
+ end
83
+
84
+ private
85
+
86
+ def parsed_version_object(version)
87
+ return {} if version.object.blank?
88
+ YAML.safe_load(
89
+ version.object,
90
+ permitted_classes: [Symbol, Time, ActiveSupport::TimeWithZone, ActiveSupport::TimeZone, Date, DateTime],
91
+ aliases: true
92
+ ) || {}
93
+ rescue
94
+ {}
95
+ end
96
+
97
+ def parsed_setting_object(version)
98
+ parsed_version_object(version)
99
+ end
100
+
101
+ def truncate_item_summary(item)
102
+ case item.class.name
103
+ when "LeanCms::PageContent"
104
+ page_slug = item.page&.full_path || item.page&.slug || item.read_attribute(:page).to_s
105
+ value_summary = format_display_value(item.display_value)
106
+ "#{page_slug}/#{item.section}/#{item.key}: #{value_summary}"
107
+ when "LeanCms::Post"
108
+ item.title.to_s.truncate(60)
109
+ when "LeanCms::Page"
110
+ "#{item.title} (#{item.slug})"
111
+ when "User"
112
+ item.email_address.to_s
113
+ when "LeanCms::FormSubmission"
114
+ "Submission ##{item.id}"
115
+ else
116
+ item.to_s.truncate(60)
117
+ end
118
+ rescue
119
+ "—"
120
+ end
121
+
122
+ def format_attributes_for_display(obj, item_type)
123
+ case item_type
124
+ when "LeanCms::Setting"
125
+ obj["value"].to_s.truncate(80)
126
+ when "LeanCms::PageContent"
127
+ page_slug = safe_page_slug(obj["page"])
128
+ section = obj["section"].to_s.presence || "—"
129
+ key = obj["key"].to_s.presence || "—"
130
+ value_summary = format_value_from_attributes(obj)
131
+ "#{page_slug}/#{section}/#{key}: #{value_summary}"
132
+ when "LeanCms::Post"
133
+ parts = []
134
+ parts << obj["title"].to_s if obj["title"].present?
135
+ parts << obj["excerpt"].to_s.truncate(40) if obj["excerpt"].present?
136
+ parts.any? ? parts.join(" — ").truncate(80) : "Post ##{obj["id"]}"
137
+ when "LeanCms::Page"
138
+ title = obj["title"].to_s.presence || "—"
139
+ slug = obj["slug"].to_s.presence || "—"
140
+ "#{title} (#{slug})"
141
+ when "User"
142
+ obj["email_address"].to_s.presence || obj["name"].to_s.presence || "User ##{obj["id"]}"
143
+ when "LeanCms::FormSubmission"
144
+ "Submission ##{obj["id"]}"
145
+ else
146
+ safe_attributes_summary(obj)
147
+ end
148
+ rescue
149
+ "—"
150
+ end
151
+
152
+ def safe_page_slug(page_attr)
153
+ return "—" if page_attr.blank?
154
+ return page_attr.to_s if page_attr.is_a?(String)
155
+ page_attr.respond_to?(:slug) ? page_attr.slug.to_s : page_attr.respond_to?(:full_path) ? page_attr.full_path.to_s : "—"
156
+ end
157
+
158
+ def format_value_from_attributes(obj)
159
+ val = obj["value"].to_s.presence || obj["content"].to_s.presence
160
+ return "—" if val.blank?
161
+ stripped = val.gsub(/<[^>]*>/, " ").squish
162
+ stripped.present? ? stripped.truncate(50) : "—"
163
+ end
164
+
165
+ def format_display_value(val)
166
+ return "—" if val.blank?
167
+ return val.truncate(40) if val.is_a?(String)
168
+ return "Cards (#{val.size})" if val.is_a?(Array)
169
+ return "Image attached" if val.respond_to?(:attached?) && val.attached?
170
+ return "Image attached" if val.is_a?(ActiveStorage::Attached::One)
171
+ val.to_s.truncate(40)
172
+ end
173
+
174
+ def safe_attributes_summary(obj)
175
+ skip = %w[created_at updated_at id]
176
+ filtered = obj.except(*skip).transform_values { |v| safe_attribute_value(v) }
177
+ filtered.reject! { |_, v| v.blank? }
178
+ filtered.any? ? filtered.map { |k, v| "#{k}: #{v}" }.join("; ").truncate(80) : "—"
179
+ end
180
+
181
+ def safe_attribute_value(v)
182
+ return v.to_s if v.is_a?(String) || v.is_a?(Numeric) || v == true || v == false
183
+ return v.strftime("%Y-%m-%d %H:%M") if v.respond_to?(:strftime)
184
+ return v.slug.to_s if v.respond_to?(:slug)
185
+ return v.title.to_s if v.respond_to?(:title)
186
+ return v.email_address.to_s if v.respond_to?(:email_address)
187
+ "—"
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,43 @@
1
+ module LeanCms
2
+ module ApplicationHelper
3
+ # Darken a hex color by a percentage
4
+ def darken_color(hex_color, percent)
5
+ hex_color = hex_color.gsub('#', '')
6
+ rgb = hex_color.scan(/../).map { |color| color.to_i(16) }
7
+ rgb = rgb.map { |channel| [(channel * (100 - percent) / 100).round, 0].max }
8
+ "##{rgb.map { |channel| channel.to_s(16).rjust(2, '0') }.join}"
9
+ end
10
+
11
+ # Format date for display
12
+ def format_date(date)
13
+ return unless date
14
+ date.strftime("%B %d, %Y")
15
+ end
16
+
17
+ # Format datetime for display
18
+ def format_datetime(datetime)
19
+ return unless datetime
20
+ datetime.strftime("%B %d, %Y at %I:%M %p")
21
+ end
22
+
23
+ # Status badge colors
24
+ def status_badge_class(status)
25
+ case status.to_s
26
+ when 'published'
27
+ 'bg-green-100 text-green-800'
28
+ when 'draft'
29
+ 'bg-yellow-100 text-yellow-800'
30
+ when 'new_submission'
31
+ 'bg-blue-100 text-blue-800'
32
+ when 'read'
33
+ 'bg-gray-100 text-gray-800'
34
+ when 'replied'
35
+ 'bg-green-100 text-green-800'
36
+ when 'archived'
37
+ 'bg-gray-100 text-gray-600'
38
+ else
39
+ 'bg-gray-100 text-gray-800'
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,34 @@
1
+ module LeanCms
2
+ module ContentHelper
3
+ # Render editable content section
4
+ def render_editable_section(page_key, section_key, default: nil, **options)
5
+ content = LeanCms::PageContent.for_section(page_key, section_key)
6
+
7
+ if content.persisted?
8
+ case content.content_type.to_sym
9
+ when :rich
10
+ content_tag(:div, content.rich_content, **options)
11
+ when :markdown
12
+ # TODO: Add markdown rendering with a gem like Redcarpet
13
+ content_tag(:div, simple_format(content.content), **options)
14
+ else
15
+ content_tag(:div, content.content, **options)
16
+ end
17
+ else
18
+ default
19
+ end
20
+ end
21
+
22
+ # Check if current user can edit CMS content
23
+ def can_edit_cms?
24
+ current_user&.has_any_cms_permission?
25
+ end
26
+
27
+ # Show edit link if user is CMS editor
28
+ def cms_edit_link(path, text: "Edit", css_class: "")
29
+ return unless can_edit_cms?
30
+
31
+ link_to text, path, class: "cms-edit-link #{css_class}".strip
32
+ end
33
+ end
34
+ end