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,237 @@
1
+ <% cache cache_key, expires_in: 1.hour do %>
2
+ <% cards_data = cards %>
3
+ <% if cards_data.empty? %>
4
+ <% return %>
5
+ <% end %>
6
+
7
+ <% if can_edit_cms? && field %>
8
+ <%= content_tag(:div, class: 'cms-inline-field relative', data: data_attributes) do %>
9
+ <div class="grid md:grid-cols-<%= grid_cols %> gap-<%= gap %> <%= container_class %>"<% if scroll_animate %> data-controller="scroll-animate" data-scroll-animate-stagger-value="<%= stagger_delay %>"<% end %>>
10
+ <% cards_data.each do |card| %>
11
+ <%
12
+ # Parse card data (handle both string and symbol keys)
13
+ icon = card['icon'] || card[:icon]
14
+ icon_color = card['icon_color'] || card[:icon_color]
15
+ bg_color = card['bg_color'] || card[:bg_color]
16
+ heading = card['heading'] || card[:heading]
17
+ text = card['text'] || card[:text]
18
+ alignment = text_align || card['alignment'] || card[:alignment] || 'center'
19
+ use_image = card['use_image'] == true || card['use_image'] == 'true'
20
+ image_id = card['image_id'] || card[:image_id]
21
+ card_image = nil
22
+
23
+ # Get image attachment if use_image is true and image_id is present
24
+ if use_image && image_id.present? && field
25
+ card_image = field.card_image(image_id)
26
+ end
27
+
28
+ # Determine styling classes
29
+ text_align_class = case alignment
30
+ when 'left' then 'text-left'
31
+ when 'right' then 'text-right'
32
+ else 'text-center'
33
+ end
34
+
35
+ # Handle gradient backgrounds (skip if ignore_card_bg is true)
36
+ has_gradient = !ignore_card_bg && bg_color&.include?('gradient')
37
+ bg_gradient_class = if ignore_card_bg
38
+ nil
39
+ else
40
+ case bg_color
41
+ when 'gradient-red'
42
+ 'bg-gradient-to-br from-[#b82025] to-[#a01c20] text-white'
43
+ when 'gradient-blue'
44
+ 'bg-gradient-to-br from-[#2563eb] to-[#1e40af] text-white'
45
+ else
46
+ nil
47
+ end
48
+ end
49
+
50
+ # Regular background color (skip if ignore_card_bg is true)
51
+ bg_style = nil
52
+ is_dark_bg = false
53
+ if !ignore_card_bg && !has_gradient && bg_color&.start_with?('#')
54
+ bg_style = "background-color: #{bg_color};"
55
+ # Detect actual brightness so light colors (e.g. #dbeafe) get dark text
56
+ hex_match = bg_color.match(/#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i)
57
+ if hex_match
58
+ r, g, b = hex_match[1].to_i(16), hex_match[2].to_i(16), hex_match[3].to_i(16)
59
+ brightness = (r * 299 + g * 587 + b * 114) / 1000
60
+ is_dark_bg = brightness < 128
61
+ end
62
+ end
63
+
64
+ # Determine if we need light text (for dark backgrounds or gradients)
65
+ use_light_text = !ignore_card_bg && (has_gradient || is_dark_bg)
66
+ %>
67
+
68
+ <div class="<%= card_class %> <%= bg_gradient_class %> <%= text_align_class %>"
69
+ <% if bg_style %>style="<%= bg_style %>"<% end %>>
70
+
71
+ <%# Render image or icon if present %>
72
+ <% if use_image && card_image %>
73
+ <%# Display uploaded image - resize to 512x512 for retina displays, CSS will scale down %>
74
+ <div class="w-<%= icon_size %> h-<%= icon_size %>
75
+ <%= alignment == 'center' ? 'mx-auto' : '' %>
76
+ mb-<%= alignment == 'center' ? '4' : '6' %>
77
+ flex items-center justify-center">
78
+ <%= image_tag card_image.variant(resize_to_limit: [512, 512]),
79
+ class: "w-full h-full object-contain",
80
+ alt: heading.presence || 'Card image' %>
81
+ </div>
82
+ <% elsif icon.present? %>
83
+ <% if icon_bg_gradient %>
84
+ <%# Red gradient icon box (like landing page service cards) %>
85
+ <div class="w-<%= icon_size %> h-<%= icon_size %>
86
+ rounded-xl
87
+ flex items-center justify-center
88
+ <%= alignment == 'center' ? 'mx-auto' : '' %>
89
+ mb-6"
90
+ style="background: linear-gradient(135deg, #b82025, #d92028);">
91
+ <%= helpers.render_card_icon(icon, 'white', true) %>
92
+ </div>
93
+ <% else %>
94
+ <div class="w-<%= icon_size %> h-<%= icon_size %>
95
+ <%= use_light_text ? 'bg-white/20' : (bg_color&.start_with?('#') ? '' : "bg-#{bg_color}") %>
96
+ rounded-<%= icon_shape %>
97
+ flex items-center justify-center
98
+ <%= alignment == 'center' ? 'mx-auto' : '' %>
99
+ mb-<%= alignment == 'center' ? '4' : '6' %>">
100
+ <%= helpers.render_card_icon(icon, icon_color, use_light_text) %>
101
+ </div>
102
+ <% end %>
103
+ <% end %>
104
+
105
+ <%# Render heading %>
106
+ <% if heading.present? %>
107
+ <h3 class="text-<%= alignment == 'center' ? 'xl' : '2xl' %> font-bold mb-<%= alignment == 'center' ? '2' : '4' %>
108
+ <%= use_light_text ? 'text-white' : 'text-slate-800' %>">
109
+ <%= heading %>
110
+ </h3>
111
+ <% end %>
112
+
113
+ <%# Render text/description %>
114
+ <% if text.present? %>
115
+ <p class="leading-relaxed <%= use_light_text ? 'text-white opacity-90' : 'text-slate-500' %>">
116
+ <%= text %>
117
+ </p>
118
+ <% end %>
119
+ </div>
120
+ <% end %>
121
+ <% end %>
122
+ <% else %>
123
+ <div class="grid md:grid-cols-<%= grid_cols %> gap-<%= gap %> <%= container_class %>"<% if scroll_animate %> data-controller="scroll-animate" data-scroll-animate-stagger-value="<%= stagger_delay %>"<% end %>>
124
+ <% cards_data.each do |card| %>
125
+ <%
126
+ # Parse card data (handle both string and symbol keys)
127
+ icon = card['icon'] || card[:icon]
128
+ icon_color = card['icon_color'] || card[:icon_color]
129
+ bg_color = card['bg_color'] || card[:bg_color]
130
+ heading = card['heading'] || card[:heading]
131
+ text = card['text'] || card[:text]
132
+ alignment = text_align || card['alignment'] || card[:alignment] || 'center'
133
+ use_image = card['use_image'] == true || card['use_image'] == 'true'
134
+ image_id = card['image_id'] || card[:image_id]
135
+ card_image = nil
136
+
137
+ # Get image attachment if use_image is true and image_id is present
138
+ if use_image && image_id.present? && field
139
+ card_image = field.card_image(image_id)
140
+ end
141
+
142
+ # Determine styling classes
143
+ text_align_class = case alignment
144
+ when 'left' then 'text-left'
145
+ when 'right' then 'text-right'
146
+ else 'text-center'
147
+ end
148
+
149
+ # Handle gradient backgrounds (skip if ignore_card_bg is true)
150
+ has_gradient = !ignore_card_bg && bg_color&.include?('gradient')
151
+ bg_gradient_class = if ignore_card_bg
152
+ nil
153
+ else
154
+ case bg_color
155
+ when 'gradient-red'
156
+ 'bg-gradient-to-br from-[#b82025] to-[#a01c20] text-white'
157
+ when 'gradient-blue'
158
+ 'bg-gradient-to-br from-blue-600 to-blue-800 text-white'
159
+ else
160
+ nil
161
+ end
162
+ end
163
+
164
+ # Regular background color (skip if ignore_card_bg is true)
165
+ bg_style = nil
166
+ is_dark_bg = false
167
+ if !ignore_card_bg && !has_gradient && bg_color&.start_with?('#')
168
+ bg_style = "background-color: #{bg_color};"
169
+ # Detect actual brightness so light colors (e.g. #dbeafe) get dark text
170
+ hex_match = bg_color.match(/#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i)
171
+ if hex_match
172
+ r, g, b = hex_match[1].to_i(16), hex_match[2].to_i(16), hex_match[3].to_i(16)
173
+ brightness = (r * 299 + g * 587 + b * 114) / 1000
174
+ is_dark_bg = brightness < 128
175
+ end
176
+ end
177
+
178
+ # Determine if we need light text (for dark backgrounds or gradients)
179
+ use_light_text = !ignore_card_bg && (has_gradient || is_dark_bg)
180
+ %>
181
+
182
+ <div class="<%= card_class %> <%= bg_gradient_class %> <%= text_align_class %>"
183
+ <% if bg_style %>style="<%= bg_style %>"<% end %>>
184
+
185
+ <%# Render image or icon if present %>
186
+ <% if use_image && card_image %>
187
+ <%# Display uploaded image - resize to 512x512 for retina displays, CSS will scale down %>
188
+ <div class="w-<%= icon_size %> h-<%= icon_size %>
189
+ <%= alignment == 'center' ? 'mx-auto' : '' %>
190
+ mb-<%= alignment == 'center' ? '4' : '6' %>
191
+ flex items-center justify-center">
192
+ <%= image_tag card_image.variant(resize_to_limit: [512, 512]),
193
+ class: "w-full h-full object-contain",
194
+ alt: heading.presence || 'Card image' %>
195
+ </div>
196
+ <% elsif icon.present? %>
197
+ <% if icon_bg_gradient %>
198
+ <%# Red gradient icon box (like landing page service cards) %>
199
+ <div class="w-<%= icon_size %> h-<%= icon_size %>
200
+ rounded-xl
201
+ flex items-center justify-center
202
+ <%= alignment == 'center' ? 'mx-auto' : '' %>
203
+ mb-6"
204
+ style="background: linear-gradient(135deg, #b82025, #a01c20);">
205
+ <%= helpers.render_card_icon(icon, 'white', true) %>
206
+ </div>
207
+ <% else %>
208
+ <div class="w-<%= icon_size %> h-<%= icon_size %>
209
+ <%= use_light_text ? 'bg-white/20' : (bg_color&.start_with?('#') ? '' : "bg-#{bg_color}") %>
210
+ rounded-<%= icon_shape %>
211
+ flex items-center justify-center
212
+ <%= alignment == 'center' ? 'mx-auto' : '' %>
213
+ mb-<%= alignment == 'center' ? '4' : '6' %>">
214
+ <%= helpers.render_card_icon(icon, icon_color, use_light_text) %>
215
+ </div>
216
+ <% end %>
217
+ <% end %>
218
+
219
+ <%# Render heading %>
220
+ <% if heading.present? %>
221
+ <h3 class="text-<%= alignment == 'center' ? 'xl' : '2xl' %> font-bold mb-<%= alignment == 'center' ? '2' : '4' %>
222
+ <%= use_light_text ? 'text-white' : 'text-slate-800' %>">
223
+ <%= heading %>
224
+ </h3>
225
+ <% end %>
226
+
227
+ <%# Render text/description %>
228
+ <% if text.present? %>
229
+ <p class="leading-relaxed <%= use_light_text ? 'text-white opacity-90' : 'text-slate-500' %>">
230
+ <%= text %>
231
+ </p>
232
+ <% end %>
233
+ </div>
234
+ <% end %>
235
+ </div>
236
+ <% end %>
237
+ <% end %>
@@ -0,0 +1,71 @@
1
+ module LeanCms
2
+ class CardsSectionComponent < BaseComponent
3
+ attr_reader :section, :grid_cols, :gap, :container_class, :card_class, :icon_size,
4
+ :icon_shape, :icon_bg_gradient, :text_align, :ignore_card_bg,
5
+ :scroll_animate, :stagger_delay
6
+
7
+ def initialize(section:, page: nil, grid_cols: 3, gap: 8, container_class: '',
8
+ card_class: 'bg-white rounded-xl p-8 shadow-sm hover:shadow-md transition-shadow',
9
+ icon_size: 12, icon_shape: 'lg', icon_bg_gradient: false,
10
+ text_align: nil, ignore_card_bg: false, scroll_animate: false,
11
+ stagger_delay: 150, **options)
12
+ super(page: page)
13
+ @section = section
14
+ @grid_cols = grid_cols
15
+ @gap = gap
16
+ @container_class = container_class
17
+ @card_class = card_class
18
+ @icon_size = icon_size
19
+ @icon_shape = icon_shape
20
+ @icon_bg_gradient = icon_bg_gradient
21
+ @text_align = text_align
22
+ @ignore_card_bg = ignore_card_bg
23
+ @scroll_animate = scroll_animate
24
+ @stagger_delay = stagger_delay
25
+ @options = options
26
+ end
27
+
28
+ private
29
+
30
+ def cache_key
31
+ super("#{section}_cards")
32
+ end
33
+
34
+ def cards
35
+ if page.is_a?(LeanCms::Page) && page.page_contents.loaded?
36
+ content_record = page.page_contents.find { |pc| pc.section == section.to_s && pc.key == 'cards' }
37
+ return [] unless content_record&.cards?
38
+ content_record.display_value
39
+ else
40
+ page_key = page.is_a?(LeanCms::Page) ? page.slug : page.to_s
41
+ Rails.cache.fetch("page_cards/#{page_key}/#{section}", expires_in: 1.hour) do
42
+ content_record = if page.is_a?(LeanCms::Page)
43
+ LeanCms::PageContent.find_by(page_id: page.id, section: section, key: 'cards')
44
+ else
45
+ LeanCms::PageContent.find_by("page = ? AND section = ? AND key = ?", page.to_s, section.to_s, 'cards')
46
+ end
47
+ return [] unless content_record&.cards?
48
+ content_record.display_value
49
+ end
50
+ end
51
+ end
52
+
53
+ def field
54
+ @field ||= find_field(section, 'cards')
55
+ end
56
+
57
+ def data_attributes
58
+ return {} unless can_edit_cms? && field
59
+
60
+ {
61
+ controller: 'inline-edit',
62
+ inline_edit_field_id_value: field.id,
63
+ inline_edit_type_value: 'cards',
64
+ inline_edit_inline_value: false,
65
+ inline_edit_page_value: page_slug,
66
+ inline_edit_section_value: section,
67
+ inline_edit_key_value: 'cards'
68
+ }
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,15 @@
1
+ <% if field&.rich_text? %>
2
+ <%# Rich text content %>
3
+ <% if can_edit_cms? && field %>
4
+ <%= content_tag(:div, raw(value), html_options.merge(class: css_classes, data: data_attributes)) %>
5
+ <% else %>
6
+ <%= raw(value) %>
7
+ <% end %>
8
+ <% else %>
9
+ <%# Regular text content %>
10
+ <% if can_edit_cms? && field %>
11
+ <%= content_tag(tag, value, html_options.merge(class: css_classes, data: data_attributes)) %>
12
+ <% else %>
13
+ <%= content_tag(tag, value, html_options) %>
14
+ <% end %>
15
+ <% end %>
@@ -0,0 +1,53 @@
1
+ module LeanCms
2
+ class EditableContentComponent < BaseComponent
3
+ attr_reader :section, :key, :tag, :default, :html_options
4
+
5
+ def initialize(section:, key:, tag: :span, default: nil, page: nil, **html_options)
6
+ super(page: page)
7
+ @section = section
8
+ @key = key
9
+ @tag = tag
10
+ @default = default
11
+ @html_options = html_options
12
+ end
13
+
14
+ private
15
+
16
+ attr_reader :field
17
+
18
+ def field
19
+ @field ||= find_field(section, key)
20
+ end
21
+
22
+ def value
23
+ if field&.rich_text?
24
+ helpers.page_content_html(page, section, key, default: default)
25
+ else
26
+ field_value(section, key, default: default)
27
+ end
28
+ end
29
+
30
+ def inline_editable?
31
+ field&.text? || field&.url? || field&.color?
32
+ end
33
+
34
+ def data_attributes
35
+ return {} unless can_edit_cms? && field
36
+
37
+ {
38
+ controller: 'inline-edit',
39
+ inline_edit_field_id_value: field.id,
40
+ inline_edit_type_value: field.content_type,
41
+ inline_edit_inline_value: inline_editable?,
42
+ inline_edit_page_value: page_slug,
43
+ inline_edit_section_value: section,
44
+ inline_edit_key_value: key
45
+ }
46
+ end
47
+
48
+ def css_classes
49
+ base_classes = html_options[:class] || ''
50
+ can_edit_cms? && field ? "#{base_classes} cms-inline-field".strip : base_classes
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,18 @@
1
+ <% cache cache_key, expires_in: 1.hour do %>
2
+ <% if can_edit_cms? %>
3
+ <div class="cms-editable-section"
4
+ data-cms-section="<%= page_slug %>/<%= section %>"
5
+ data-controller="cms-sticky-overlay"
6
+ data-action="mouseenter->cms-sticky-overlay#mouseEnter mouseleave->cms-sticky-overlay#mouseLeave">
7
+ <%= content %>
8
+ <div class="cms-edit-overlay" data-cms-sticky-overlay-target="overlay">
9
+ <div class="cms-edit-controls">
10
+ <span class="cms-section-title"><%= full_title %></span>
11
+ <%= link_to 'Edit', edit_url, target: '_blank', class: 'cms-edit-button', data: { turbo: false } %>
12
+ </div>
13
+ </div>
14
+ </div>
15
+ <% else %>
16
+ <%= content %>
17
+ <% end %>
18
+ <% end %>
@@ -0,0 +1,35 @@
1
+ module LeanCms
2
+ class SectionComponent < BaseComponent
3
+ attr_reader :section, :title
4
+
5
+ def initialize(section:, title: nil, page: nil, **options)
6
+ super(page: page)
7
+ @section = section
8
+ @title = title
9
+ @options = options
10
+ end
11
+
12
+ private
13
+
14
+ def cache_key
15
+ super(section)
16
+ end
17
+
18
+ def display_title
19
+ return title if title.present?
20
+ section.humanize
21
+ end
22
+
23
+ def page_title
24
+ page.is_a?(LeanCms::Page) ? page.title : page_slug.titleize
25
+ end
26
+
27
+ def full_title
28
+ "#{page_title} - #{display_title}"
29
+ end
30
+
31
+ def edit_url
32
+ @view_context.lean_cms_edit_page_content_path(page: page_slug, section: section)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,60 @@
1
+ module LeanCms
2
+ module Authentication
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ before_action :resume_session
7
+ before_action :require_authentication
8
+ helper_method :authenticated?, :current_user
9
+ end
10
+
11
+ class_methods do
12
+ def allow_unauthenticated_access(**options)
13
+ skip_before_action :require_authentication, **options
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def authenticated?
20
+ LeanCms::Current.session.present?
21
+ end
22
+
23
+ def current_user
24
+ LeanCms::Current.user
25
+ end
26
+
27
+ def require_authentication
28
+ authenticated? || request_authentication
29
+ end
30
+
31
+ def resume_session
32
+ LeanCms::Current.session ||= find_session_by_cookie
33
+ end
34
+
35
+ def find_session_by_cookie
36
+ LeanCms::Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
37
+ end
38
+
39
+ def request_authentication
40
+ session[:return_to_after_authenticating] = request.url
41
+ redirect_to lean_cms_new_session_path
42
+ end
43
+
44
+ def after_authentication_url
45
+ session.delete(:return_to_after_authenticating) || root_url
46
+ end
47
+
48
+ def start_new_session_for(user)
49
+ LeanCms::Session.create!(user: user, user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
50
+ LeanCms::Current.session = session
51
+ cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
52
+ end
53
+ end
54
+
55
+ def terminate_session
56
+ LeanCms::Current.session.destroy
57
+ cookies.delete(:session_id)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,60 @@
1
+ module LeanCms
2
+ module Authorization
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ before_action :require_cms_access
7
+ helper_method :current_user
8
+ end
9
+
10
+ private
11
+
12
+ # Base access check - user must have at least one CMS permission
13
+ def require_cms_access
14
+ unless LeanCms::Current.user&.has_any_cms_permission?
15
+ redirect_to root_path, alert: "You do not have access to the CMS."
16
+ end
17
+ end
18
+
19
+ # Specific permission checks for controllers that need them
20
+ def require_page_editing
21
+ unless LeanCms::Current.user&.can_edit_pages?
22
+ redirect_to lean_cms_root_path, alert: "You do not have permission to edit pages."
23
+ end
24
+ end
25
+
26
+ def require_blog_editing
27
+ unless LeanCms::Current.user&.can_edit_blog?
28
+ redirect_to lean_cms_root_path, alert: "You do not have permission to edit blog posts."
29
+ end
30
+ end
31
+
32
+ def require_user_management
33
+ unless LeanCms::Current.user&.can_manage_users?
34
+ redirect_to lean_cms_root_path, alert: "You do not have permission to manage users."
35
+ end
36
+ end
37
+
38
+ def require_settings_access
39
+ unless LeanCms::Current.user&.can_access_settings?
40
+ redirect_to lean_cms_root_path, alert: "You do not have permission to access settings."
41
+ end
42
+ end
43
+
44
+ # Check if current user can edit a specific record
45
+ def can_edit?(record)
46
+ return true if LeanCms::Current.user&.is_super_admin?
47
+ return true if record.respond_to?(:author) && record.author == LeanCms::Current.user
48
+ false
49
+ end
50
+
51
+ def set_paper_trail_whodunnit
52
+ PaperTrail.request.whodunnit = LeanCms::Current.user&.id
53
+ end
54
+
55
+ # Helper method for views
56
+ def current_user
57
+ LeanCms::Current.user
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,16 @@
1
+ module LeanCms
2
+ class ActivityController < ApplicationController
3
+ skip_before_action :check_content_lock
4
+ before_action :require_settings_access
5
+
6
+ def index
7
+ @versions = PaperTrail::Version
8
+ .order(created_at: :desc)
9
+ .page(params[:page])
10
+ .per(20)
11
+
12
+ @versions = @versions.where(item_type: params[:item_type]) if params[:item_type].present?
13
+ @versions = @versions.where(whodunnit: params[:whodunnit]) if params[:whodunnit].present?
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,48 @@
1
+ module LeanCms
2
+ class ApplicationController < ::ApplicationController
3
+ # Pundit is included here so the gem's admin controllers (UsersController
4
+ # most prominently) can call `authorize` / `policy_scope` without
5
+ # requiring the host's ApplicationController to include it. Hosts that
6
+ # want Pundit available in their own non-CMS controllers should still
7
+ # `include Pundit::Authorization` in their own ApplicationController.
8
+ include Pundit::Authorization
9
+
10
+ include LeanCms::Authorization
11
+
12
+ layout 'lean_cms/application'
13
+
14
+ before_action :set_paper_trail_whodunnit
15
+ before_action :check_content_lock, only: [:create, :update]
16
+
17
+ private
18
+
19
+ def can_edit?(resource)
20
+ return true if current_user&.is_super_admin?
21
+ return true if resource.respond_to?(:author) && resource.author_id == current_user&.id
22
+ false
23
+ end
24
+ helper_method :can_edit?
25
+
26
+ def content_locked?
27
+ LeanCms::Setting.content_locked?
28
+ end
29
+ helper_method :content_locked?
30
+
31
+ def content_lock_info
32
+ LeanCms::Setting.content_lock_info
33
+ end
34
+ helper_method :content_lock_info
35
+
36
+ def check_content_lock
37
+ return unless content_locked?
38
+
39
+ lock_info = content_lock_info
40
+ message = "Content editing is temporarily locked: #{lock_info[:reason]}"
41
+
42
+ respond_to do |format|
43
+ format.html { redirect_back fallback_location: lean_cms_dashboard_path, alert: message }
44
+ format.json { render json: { error: message, locked: true }, status: :locked }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,13 @@
1
+ module LeanCms
2
+ class DashboardController < LeanCms::ApplicationController
3
+ skip_before_action :check_content_lock
4
+
5
+ def index
6
+ @recent_posts = LeanCms::Post.recent.limit(5)
7
+ @recent_submissions = LeanCms::FormSubmission.recent.limit(10)
8
+ @unread_submissions_count = LeanCms::FormSubmission.unread.count
9
+ @draft_posts_count = LeanCms::Post.draft.count
10
+ @published_posts_count = LeanCms::Post.published.count
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,37 @@
1
+ module LeanCms
2
+ class FormSubmissionsController < LeanCms::ApplicationController
3
+ skip_before_action :check_content_lock
4
+ before_action :set_form_submission, only: [:show, :mark_as_read, :mark_as_replied, :destroy]
5
+
6
+ def index
7
+ @form_submissions = LeanCms::FormSubmission.recent
8
+ @form_submissions = @form_submissions.where(form_type: params[:form_type]) if params[:form_type].present?
9
+ @form_submissions = @form_submissions.where(status: params[:status]) if params[:status].present?
10
+ end
11
+
12
+ def show
13
+ @form_submission.mark_as_read! if @form_submission.unread?
14
+ end
15
+
16
+ def mark_as_read
17
+ @form_submission.mark_as_read!
18
+ redirect_to lean_cms_form_submissions_path, notice: 'Submission marked as read.'
19
+ end
20
+
21
+ def mark_as_replied
22
+ @form_submission.mark_as_replied!
23
+ redirect_to lean_cms_form_submissions_path, notice: 'Submission marked as replied.'
24
+ end
25
+
26
+ def destroy
27
+ @form_submission.destroy
28
+ redirect_to lean_cms_form_submissions_path, notice: 'Submission deleted successfully.'
29
+ end
30
+
31
+ private
32
+
33
+ def set_form_submission
34
+ @form_submission = LeanCms::FormSubmission.find(params[:id])
35
+ end
36
+ end
37
+ end