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,359 @@
1
+ module LeanCms
2
+ module PageContentHelper
3
+ # Get a single field value from page content
4
+ # Usage: page_content('home', 'hero', 'heading') or page_content(@page, 'hero', 'heading')
5
+ def page_content(page, section, key, default: nil)
6
+ # Use preloaded content if available (eliminates N+1)
7
+ if page.is_a?(LeanCms::Page) && page.page_contents.loaded?
8
+ field = page.page_contents.find { |pc| pc.section == section.to_s && pc.key == key.to_s }
9
+ return field&.display_value || default
10
+ end
11
+
12
+ # Fall back to cached query
13
+ page_key = page.is_a?(LeanCms::Page) ? page.slug : page.to_s
14
+ Rails.cache.fetch("page_content/#{page_key}/#{section}/#{key}", expires_in: 1.hour) do
15
+ LeanCms::PageContent.field_value(page, section, key, default: default)
16
+ end
17
+ end
18
+
19
+ # Get all content for a section as a hash
20
+ # Usage: page_section('home', 'hero') => { 'heading' => 'Welcome', 'body' => '...' }
21
+ def page_section(page, section)
22
+ page_key = page.is_a?(LeanCms::Page) ? page.slug : page.to_s
23
+ Rails.cache.fetch("page_section/#{page_key}/#{section}", expires_in: 1.hour) do
24
+ LeanCms::PageContent.section_content(page, section)
25
+ end
26
+ end
27
+
28
+ # Get all content for a page grouped by section
29
+ # Usage: page_structure('home') => { 'hero' => { 'heading' => '...', 'body' => '...' }, 'features' => {...} }
30
+ def page_structure(page)
31
+ page_key = page.is_a?(LeanCms::Page) ? page.slug : page.to_s
32
+ Rails.cache.fetch("page_structure/#{page_key}", expires_in: 1.hour) do
33
+ LeanCms::PageContent.page_structure(page)
34
+ end
35
+ end
36
+
37
+ # Check if a boolean field is true
38
+ # Usage: page_content?('home', 'features', 'show_banner')
39
+ def page_content?(page, section, key, default: false)
40
+ value = page_content(page, section, key, default: default)
41
+ # Handle string booleans
42
+ return true if value == true || value == "true" || value == "1"
43
+ return false if value == false || value == "false" || value == "0"
44
+ !!value
45
+ end
46
+
47
+ # Render rich text content safely
48
+ # Usage: page_content_html('home', 'hero', 'body')
49
+ def page_content_html(page, section, key, default: nil)
50
+ content = page_content(page, section, key, default: default)
51
+ return content if content.is_a?(ActionText::RichText)
52
+ return content if content.respond_to?(:to_trix_html)
53
+ sanitize(content.to_s)
54
+ end
55
+
56
+ # Get image URL for an image field
57
+ # Usage: page_content_image_url('home', 'hero', 'background')
58
+ def page_content_image_url(page, section, key, variant: nil)
59
+ content_record = if page.is_a?(LeanCms::Page)
60
+ LeanCms::PageContent.find_by(page_id: page.id, section: section, key: key)
61
+ else
62
+ LeanCms::PageContent.find_by("page = ? AND section = ? AND key = ?", page.to_s, section.to_s, key.to_s)
63
+ end
64
+ return nil unless content_record
65
+
66
+ if content_record.image_file.attached?
67
+ variant ? content_record.image_file.variant(variant) : content_record.image_file
68
+ else
69
+ content_record.value
70
+ end
71
+ end
72
+
73
+ # Get cards for a section
74
+ # Usage: page_cards('about', 'certifications_standards')
75
+ def page_cards(page, section)
76
+ # Use preloaded content if available
77
+ if page.is_a?(LeanCms::Page) && page.page_contents.loaded?
78
+ content_record = page.page_contents.find { |pc| pc.section == section.to_s && pc.key == 'cards' }
79
+ return [] unless content_record&.cards?
80
+ return content_record.display_value
81
+ end
82
+
83
+ # Fall back to cached query
84
+ page_key = page.is_a?(LeanCms::Page) ? page.slug : page.to_s
85
+ Rails.cache.fetch("page_cards/#{page_key}/#{section}", expires_in: 1.hour) do
86
+ content_record = if page.is_a?(LeanCms::Page)
87
+ LeanCms::PageContent.find_by(page_id: page.id, section: section, key: 'cards')
88
+ else
89
+ LeanCms::PageContent.find_by("page = ? AND section = ? AND key = ?", page.to_s, section.to_s, 'cards')
90
+ end
91
+ return [] unless content_record&.cards?
92
+ content_record.display_value
93
+ end
94
+ end
95
+
96
+ # Get bullets for a section
97
+ # Usage: page_bullets('contact', 'why_partner')
98
+ def page_bullets(page, section)
99
+ # Use preloaded content if available
100
+ if page.is_a?(LeanCms::Page) && page.page_contents.loaded?
101
+ content_record = page.page_contents.find { |pc| pc.section == section.to_s && pc.key == 'bullets' }
102
+ return [] unless content_record&.bullets?
103
+ return content_record.display_value
104
+ end
105
+
106
+ # Fall back to cached query
107
+ page_key = page.is_a?(LeanCms::Page) ? page.slug : page.to_s
108
+ Rails.cache.fetch("page_bullets/#{page_key}/#{section}", expires_in: 1.hour) do
109
+ content_record = if page.is_a?(LeanCms::Page)
110
+ LeanCms::PageContent.find_by(page_id: page.id, section: section, key: 'bullets')
111
+ else
112
+ LeanCms::PageContent.find_by("page = ? AND section = ? AND key = ?", page.to_s, section.to_s, 'bullets')
113
+ end
114
+ return [] unless content_record&.bullets?
115
+ content_record.display_value
116
+ end
117
+ end
118
+
119
+ # Render a CMS section with built-in caching and edit controls
120
+ # Usage: <%= cms_section('hero', title: 'Hero Section') do %>
121
+ # <section>...</section>
122
+ # <% end %>
123
+ def cms_section(section, title: nil, page: nil, &block)
124
+ page ||= @page
125
+ render LeanCms::SectionComponent.new(page: page, section: section, title: title), &block
126
+ end
127
+
128
+ # Render a CMS section with built-in caching and edit controls
129
+ # Usage: <%= cms_section('hero', title: 'Hero Section') do %>
130
+ # <section>...</section>
131
+ # <% end %>
132
+ def cms_section(section, title: nil, page: nil, &block)
133
+ page ||= @page
134
+ render LeanCms::SectionComponent.new(page: page, section: section, title: title), &block
135
+ end
136
+
137
+ # Render cards section with component (new API)
138
+ # Usage: <%= cards_section('services_preview', grid_cols: 3) %>
139
+ def cards_section(section, page: nil, **options)
140
+ page ||= @page
141
+ render LeanCms::CardsSectionComponent.new(page: page, section: section, **options)
142
+ end
143
+
144
+ # Render bullets section with component (new API)
145
+ # Usage: <%= bullets_section('why_partner') %>
146
+ def bullets_section(section, page: nil, **options)
147
+ page ||= @page
148
+ render LeanCms::BulletsSectionComponent.new(page: page, section: section, **options)
149
+ end
150
+
151
+ # Render the Lean CMS admin bar (fixed top strip with Inline Editing
152
+ # toggle, Help, Admin Dashboard, Sign Out). Returns an empty string for
153
+ # signed-out visitors and users without CMS permissions, so it's safe to
154
+ # call unconditionally from your public layout.
155
+ #
156
+ # Usage in your host application.html.erb:
157
+ #
158
+ # <body class="<%= 'pt-10' if current_user&.has_any_cms_permission? %>">
159
+ # <%= cms_admin_bar %>
160
+ # …your header / content…
161
+ # </body>
162
+ def cms_admin_bar
163
+ render "lean_cms/shared/admin_bar"
164
+ end
165
+
166
+ # Render the Google Analytics gtag.js snippet using the measurement ID
167
+ # stored in `LeanCms::Setting.get("google_analytics_id")`. Returns an
168
+ # empty string when the setting is blank — safe to call unconditionally
169
+ # from your layout's <head>.
170
+ #
171
+ # Admins set the ID via /lean-cms/settings without touching code.
172
+ # Example value: "G-XXXXXXXXXX".
173
+ #
174
+ # Usage in your host application.html.erb:
175
+ # <head>
176
+ # …
177
+ # <%= cms_google_analytics_tag %>
178
+ # </head>
179
+ def cms_google_analytics_tag
180
+ id = LeanCms::Setting.get("google_analytics_id")
181
+ return "".html_safe if id.blank?
182
+
183
+ # JSON-encode the ID so a hostile-looking setting value can't break out
184
+ # of the <script>. Setting values are admin-only, but defensive is cheap.
185
+ escaped_id = id.to_s.to_json
186
+
187
+ content_tag(:script, "", async: true,
188
+ src: "https://www.googletagmanager.com/gtag/js?id=#{ERB::Util.url_encode(id)}") +
189
+ content_tag(:script, raw(<<~JS))
190
+ window.dataLayer = window.dataLayer || [];
191
+ function gtag(){dataLayer.push(arguments);}
192
+ gtag('js', new Date());
193
+ gtag('config', #{escaped_id});
194
+ JS
195
+ end
196
+
197
+ # Render cards section with partial (legacy method for backward compatibility)
198
+ # Usage: render_cards_section('about', 'certifications_standards')
199
+ def render_cards_section(page, section, **options)
200
+ cards = page_cards(page, section)
201
+ return '' if cards.empty?
202
+
203
+ # Get the field record for edit controls
204
+ field = if page.is_a?(LeanCms::Page)
205
+ LeanCms::PageContent.find_by(page_id: page.id, section: section, key: 'cards')
206
+ else
207
+ LeanCms::PageContent.find_by("page = ? AND section = ? AND key = ?", page.to_s, section.to_s, 'cards')
208
+ end
209
+
210
+ # Check if user can edit
211
+ can_edit = authenticated? && current_user&.has_any_cms_permission? &&
212
+ LeanCms::Setting.get('in_context_editing', 'true') == 'true'
213
+
214
+ render partial: 'shared/cards_section', locals: {
215
+ cards: cards,
216
+ page: page.is_a?(LeanCms::Page) ? page.slug : page.to_s,
217
+ section: section,
218
+ field: field,
219
+ can_edit: can_edit,
220
+ **options
221
+ }
222
+ end
223
+
224
+ # Render bullets section with edit controls
225
+ # Usage: render_bullets_section('contact', 'why_partner')
226
+ def render_bullets_section(page, section, **options)
227
+ bullets = page_bullets(page, section)
228
+ return '' if bullets.empty?
229
+
230
+ # Get the field record for edit controls
231
+ field = if page.is_a?(LeanCms::Page)
232
+ LeanCms::PageContent.find_by(page_id: page.id, section: section, key: 'bullets')
233
+ else
234
+ LeanCms::PageContent.find_by("page = ? AND section = ? AND key = ?", page.to_s, section.to_s, 'bullets')
235
+ end
236
+
237
+ # Check if user can edit
238
+ can_edit = authenticated? && current_user&.has_any_cms_permission? &&
239
+ LeanCms::Setting.get('in_context_editing', 'true') == 'true'
240
+
241
+ render partial: 'shared/bullets_section', locals: {
242
+ bullets: bullets,
243
+ page: page.is_a?(LeanCms::Page) ? page.slug : page.to_s,
244
+ section: section,
245
+ field: field,
246
+ can_edit: can_edit,
247
+ **options
248
+ }
249
+ end
250
+
251
+ # Wrap a content field with inline editing controls
252
+ # Usage: <%= editable_content('hero', 'heading') %> (uses implicit @page from controller)
253
+ # <%= editable_content('hero', 'heading', page: other_page) %> (override page)
254
+ # <%= editable_content('home', 'hero', 'heading') %> (legacy: page as first arg)
255
+ def editable_content(*args, default: nil, tag: :span, page: nil, **html_options)
256
+ if args.length == 3
257
+ page_arg, section, key = args
258
+ page ||= page_arg
259
+ elsif args.length == 2
260
+ section, key = args
261
+ page ||= @page
262
+ else
263
+ raise ArgumentError, "editable_content expects 2 or 3 arguments (section, key) or (page, section, key)"
264
+ end
265
+
266
+ render LeanCms::EditableContentComponent.new(
267
+ page: page, section: section, key: key, tag: tag, default: default, **html_options
268
+ )
269
+ end
270
+
271
+ # Wrap a section with CMS edit overlay (hover activates edit button linking to section editor).
272
+ # Usage: cms_editable_section(page: 'home', section: 'hero', display_title: 'Hero') do
273
+ # ... your HTML ...
274
+ # end
275
+ def cms_editable_section(page:, section:, display_title: nil, &block)
276
+ content = capture(&block)
277
+ return content unless authenticated? && current_user&.has_any_cms_permission?
278
+ return content unless LeanCms::Setting.get('in_context_editing', 'true') == 'true'
279
+
280
+ section_title = display_title || section.humanize
281
+ full_title = "#{page.to_s.titleize} - #{section_title}"
282
+ edit_url = lean_cms_edit_page_content_path(page: page, section: section)
283
+
284
+ content_tag(:div,
285
+ class: 'cms-editable-section',
286
+ data: {
287
+ cms_section: "#{page}/#{section}",
288
+ controller: 'cms-sticky-overlay',
289
+ action: 'mouseenter->cms-sticky-overlay#mouseEnter mouseleave->cms-sticky-overlay#mouseLeave'
290
+ }
291
+ ) do
292
+ concat(content)
293
+ concat(content_tag(:div, class: 'cms-edit-overlay', data: { cms_sticky_overlay_target: 'overlay' }) do
294
+ content_tag(:div, class: 'cms-edit-controls') do
295
+ concat(content_tag(:span, full_title, class: 'cms-section-title'))
296
+ concat(link_to('Edit', edit_url, target: '_blank', class: 'cms-edit-button', data: { turbo: false }))
297
+ end
298
+ end)
299
+ end
300
+ end
301
+
302
+ # Like cms_editable_section but links to the Settings page instead of the section editor.
303
+ # Usage: cms_settings_section(display_title: 'Contact Info', anchor: 'site-info') do
304
+ # ... your HTML ...
305
+ # end
306
+ def cms_settings_section(display_title:, anchor: nil, &block)
307
+ content = capture(&block)
308
+ return content unless authenticated? && current_user&.has_any_cms_permission?
309
+ return content unless LeanCms::Setting.get('in_context_editing', 'true') == 'true'
310
+
311
+ edit_url = lean_cms_settings_path
312
+ edit_url += "##{anchor}" if anchor.present?
313
+
314
+ content_tag(:div,
315
+ class: 'cms-editable-section',
316
+ data: {
317
+ cms_section: "settings/#{anchor || 'general'}",
318
+ controller: 'cms-sticky-overlay',
319
+ action: 'mouseenter->cms-sticky-overlay#mouseEnter mouseleave->cms-sticky-overlay#mouseLeave'
320
+ }
321
+ ) do
322
+ concat(content)
323
+ concat(content_tag(:div, class: 'cms-edit-overlay', data: { cms_sticky_overlay_target: 'overlay' }) do
324
+ content_tag(:div, class: 'cms-edit-controls') do
325
+ concat(content_tag(:span, "Settings - #{display_title}", class: 'cms-section-title'))
326
+ concat(link_to('Edit', edit_url, target: '_blank', class: 'cms-edit-button', data: { turbo: false }))
327
+ end
328
+ end)
329
+ end
330
+ end
331
+
332
+ # Render a responsive <picture> for an image processed by `lean_cms:optimize_images`.
333
+ #
334
+ # The optimizer produces `<name>-<width>.webp` and `<name>-<width>.<fallback>`
335
+ # variants under app/assets/images/. This helper emits a <picture> with the WebP
336
+ # source and a JPG/PNG fallback img, both with srcset for the configured widths.
337
+ #
338
+ # Usage:
339
+ # lean_cms_picture_tag("wire-panel", alt: "Wiring", class: "rounded-2xl")
340
+ # lean_cms_picture_tag("cas-logo", alt: "CAS", format: :png, widths: [128, 256])
341
+ def lean_cms_picture_tag(name, alt:, widths: [640, 1280, 1920], format: :jpg, sizes: "100vw", **img_options)
342
+ fallback_ext = format.to_s
343
+ webp_srcset = widths.map { |w| "#{asset_path("#{name}-#{w}.webp")} #{w}w" }.join(", ")
344
+ fallback_srcset = widths.map { |w| "#{asset_path("#{name}-#{w}.#{fallback_ext}")} #{w}w" }.join(", ")
345
+ default_width = widths.max
346
+
347
+ content_tag(:picture) do
348
+ concat(tag(:source, type: "image/webp", srcset: webp_srcset, sizes: sizes))
349
+ concat(image_tag("#{name}-#{default_width}.#{fallback_ext}",
350
+ srcset: fallback_srcset,
351
+ sizes: sizes,
352
+ alt: alt,
353
+ loading: img_options.delete(:loading) || "lazy",
354
+ decoding: img_options.delete(:decoding) || "async",
355
+ **img_options))
356
+ end
357
+ end
358
+ end
359
+ end
@@ -0,0 +1,317 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["cardsList", "hiddenInput"]
5
+ static values = { initial: String }
6
+
7
+ connect() {
8
+ // Parse initial cards from JSON
9
+ try {
10
+ this.cards = JSON.parse(this.initialValue || '[]')
11
+ } catch (e) {
12
+ console.error('Failed to parse initial cards:', e)
13
+ this.cards = []
14
+ }
15
+
16
+ // Store temporary image files for upload
17
+ this.pendingImages = {}
18
+
19
+ console.log('Cards editor initialized with:', this.cards)
20
+ this.render()
21
+ }
22
+
23
+ addCard() {
24
+ this.cards.push({
25
+ icon: 'gear',
26
+ icon_color: 'white',
27
+ bg_color: '',
28
+ heading: '',
29
+ text: '',
30
+ alignment: 'left',
31
+ use_image: false,
32
+ image_id: null,
33
+ image_preview: null
34
+ })
35
+ this.render()
36
+ }
37
+
38
+ removeCard(event) {
39
+ const index = parseInt(event.currentTarget.dataset.index)
40
+ this.cards.splice(index, 1)
41
+ this.render()
42
+ }
43
+
44
+ updateCard(event) {
45
+ const input = event.currentTarget
46
+ const index = parseInt(input.dataset.index)
47
+ const field = input.dataset.field
48
+
49
+ if (field === 'use_image') {
50
+ this.cards[index][field] = input.checked
51
+ this.render()
52
+ } else {
53
+ this.cards[index][field] = input.value
54
+ this.updateHiddenInput()
55
+
56
+ // Keep the color swatch in sync when the user types a valid hex value
57
+ if ((field === 'bg_color' || field === 'icon_color') && /^#[0-9a-f]{6}$/i.test(input.value)) {
58
+ const swatch = this.cardsListTarget.querySelector(
59
+ `input[type="color"][data-index="${index}"][data-field="${field}"]`
60
+ )
61
+ if (swatch) swatch.value = input.value
62
+ }
63
+ }
64
+ }
65
+
66
+ updateColorPicker(event) {
67
+ const swatch = event.currentTarget
68
+ const index = parseInt(swatch.dataset.index)
69
+ const field = swatch.dataset.field
70
+ const value = swatch.value
71
+
72
+ this.cards[index][field] = value
73
+ this.updateHiddenInput()
74
+
75
+ // Keep the text input in sync
76
+ const textInput = this.cardsListTarget.querySelector(
77
+ `input[type="text"][data-index="${index}"][data-field="${field}"]`
78
+ )
79
+ if (textInput) textInput.value = value
80
+ }
81
+
82
+ handleImageUpload(event) {
83
+ const input = event.currentTarget
84
+ const index = parseInt(input.dataset.index)
85
+ const file = input.files[0]
86
+
87
+ if (!file) return
88
+
89
+ // Store file for later upload
90
+ this.pendingImages[index] = file
91
+
92
+ // Create preview URL
93
+ const reader = new FileReader()
94
+ reader.onload = (e) => {
95
+ this.cards[index].image_preview = e.target.result
96
+ this.render()
97
+ }
98
+ reader.readAsDataURL(file)
99
+ }
100
+
101
+ render() {
102
+ if (this.cards.length === 0) {
103
+ this.cardsListTarget.innerHTML = `
104
+ <div class="text-sm text-gray-500 py-4 text-center">
105
+ No cards yet. Click "Add Card" to create one.
106
+ </div>
107
+ `
108
+ } else {
109
+ this.cardsListTarget.innerHTML = this.cards.map((card, index) => this.renderCard(card, index)).join('')
110
+ }
111
+
112
+ this.updateHiddenInput()
113
+ }
114
+
115
+ renderCard(card, index) {
116
+ const useImage = card.use_image === true || card.use_image === 'true'
117
+ const hasImagePreview = card.image_preview || (card.image_id && card.image_url)
118
+
119
+ return `
120
+ <div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
121
+ <div class="flex items-center justify-between mb-3">
122
+ <h4 class="font-semibold text-gray-900">Card ${index + 1}</h4>
123
+ <button type="button"
124
+ data-action="click->cards-editor#removeCard"
125
+ data-index="${index}"
126
+ class="text-red-600 hover:text-red-800 text-sm font-medium cursor-pointer">
127
+ Remove
128
+ </button>
129
+ </div>
130
+
131
+ <div class="grid grid-cols-2 gap-4">
132
+ <!-- Use Image Toggle -->
133
+ <div class="col-span-2">
134
+ <label class="flex items-center">
135
+ <input type="checkbox"
136
+ ${useImage ? 'checked' : ''}
137
+ data-action="change->cards-editor#updateCard"
138
+ data-index="${index}"
139
+ data-field="use_image"
140
+ class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2">
141
+ <span class="ml-2 text-xs font-medium text-gray-700">Use uploaded image instead of icon</span>
142
+ </label>
143
+ </div>
144
+
145
+ ${useImage ? `
146
+ <!-- Image Upload -->
147
+ <div class="col-span-2">
148
+ <label class="block text-xs font-medium text-gray-700 mb-1">Image</label>
149
+ ${hasImagePreview ? `
150
+ <div class="mb-2">
151
+ <img src="${card.image_preview || card.image_url}"
152
+ alt="Preview"
153
+ class="max-w-xs h-32 object-contain border border-gray-300 rounded-lg">
154
+ </div>
155
+ ` : ''}
156
+ <input type="file"
157
+ accept="image/*"
158
+ data-action="change->cards-editor#handleImageUpload"
159
+ data-index="${index}"
160
+ class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 focus:outline-none">
161
+ <p class="text-xs text-gray-500 mt-1">Upload an image for this card</p>
162
+ </div>
163
+ ` : `
164
+ <!-- Icon -->
165
+ <div>
166
+ <label class="block text-xs font-medium text-gray-700 mb-1">Icon</label>
167
+ <select data-action="change->cards-editor#updateCard"
168
+ data-index="${index}"
169
+ data-field="icon"
170
+ class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
171
+ ${this.renderIconOptions(card.icon)}
172
+ </select>
173
+ </div>
174
+
175
+ <!-- Icon Color -->
176
+ <div>
177
+ <label class="block text-xs font-medium text-gray-700 mb-1">Icon Color</label>
178
+ ${this.renderColorInput(card.icon_color, index, 'icon_color', '#000000 or white')}
179
+ </div>
180
+ `}
181
+
182
+ <!-- Background Color -->
183
+ <div>
184
+ <label class="block text-xs font-medium text-gray-700 mb-1">Background Color</label>
185
+ ${this.renderColorInput(card.bg_color, index, 'bg_color', '#ffffff or gradient-red')}
186
+ </div>
187
+
188
+ <!-- Alignment -->
189
+ <div>
190
+ <label class="block text-xs font-medium text-gray-700 mb-1">Alignment</label>
191
+ <select data-action="change->cards-editor#updateCard"
192
+ data-index="${index}"
193
+ data-field="alignment"
194
+ class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
195
+ ${this.renderAlignmentOptions(card.alignment)}
196
+ </select>
197
+ </div>
198
+
199
+ <!-- Heading -->
200
+ <div class="col-span-2">
201
+ <label class="block text-xs font-medium text-gray-700 mb-1">Heading</label>
202
+ <input type="text"
203
+ value="${this.escapeHtml(card.heading || '')}"
204
+ data-action="input->cards-editor#updateCard"
205
+ data-index="${index}"
206
+ data-field="heading"
207
+ class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
208
+ placeholder="Card heading">
209
+ </div>
210
+
211
+ <!-- Text -->
212
+ <div class="col-span-2">
213
+ <label class="block text-xs font-medium text-gray-700 mb-1">Text</label>
214
+ <textarea data-action="input->cards-editor#updateCard"
215
+ data-index="${index}"
216
+ data-field="text"
217
+ rows="2"
218
+ class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
219
+ placeholder="Card description">${this.escapeHtml(card.text || '')}</textarea>
220
+ </div>
221
+ </div>
222
+ </div>
223
+ `
224
+ }
225
+
226
+ renderIconOptions(selected) {
227
+ const icons = [
228
+ 'checkmark-badge',
229
+ 'checkmark-circle',
230
+ 'lock',
231
+ 'lightning-bolt',
232
+ 'users',
233
+ 'sliders',
234
+ 'globe',
235
+ 'support',
236
+ 'control-panel',
237
+ 'gear'
238
+ ]
239
+
240
+ return icons.map(icon =>
241
+ `<option value="${icon}" ${icon === selected ? 'selected' : ''}>${this.humanize(icon)}</option>`
242
+ ).join('')
243
+ }
244
+
245
+ renderAlignmentOptions(selected) {
246
+ const alignments = ['left', 'center', 'right']
247
+
248
+ return alignments.map(align =>
249
+ `<option value="${align}" ${align === selected ? 'selected' : ''}>${this.capitalize(align)}</option>`
250
+ ).join('')
251
+ }
252
+
253
+ updateHiddenInput() {
254
+ // Clean up cards data before saving (remove preview URLs, keep only IDs)
255
+ const cleanedCards = this.cards.map(card => {
256
+ const cleaned = { ...card }
257
+ // Remove preview URL from saved data (it's only for display)
258
+ if (cleaned.image_preview) {
259
+ delete cleaned.image_preview
260
+ }
261
+ // Remove image_url if present (server will provide this)
262
+ if (cleaned.image_url) {
263
+ delete cleaned.image_url
264
+ }
265
+ return cleaned
266
+ })
267
+ this.hiddenInputTarget.value = JSON.stringify(cleanedCards)
268
+ }
269
+
270
+ // Get pending image files for form submission
271
+ getPendingImages() {
272
+ return this.pendingImages
273
+ }
274
+
275
+ renderColorInput(value, index, field, placeholder = '') {
276
+ const safeValue = this.escapeHtml(value || '')
277
+ // Only pre-fill the swatch if the value is already a valid 6-digit hex
278
+ const isHex = /^#[0-9a-f]{6}$/i.test(value || '')
279
+ const swatchValue = isHex ? value : '#ffffff'
280
+
281
+ return `
282
+ <div class="flex items-center gap-2">
283
+ <input type="color"
284
+ value="${swatchValue}"
285
+ data-action="input->cards-editor#updateColorPicker"
286
+ data-index="${index}"
287
+ data-field="${field}"
288
+ title="Pick a color"
289
+ class="cards-color-swatch">
290
+ <input type="text"
291
+ value="${safeValue}"
292
+ data-action="input->cards-editor#updateCard"
293
+ data-index="${index}"
294
+ data-field="${field}"
295
+ class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
296
+ placeholder="${placeholder}">
297
+ </div>
298
+ `
299
+ }
300
+
301
+ escapeHtml(text) {
302
+ const div = document.createElement('div')
303
+ div.textContent = text
304
+ return div.innerHTML
305
+ }
306
+
307
+ humanize(str) {
308
+ return str.split('-').map(word =>
309
+ word.charAt(0).toUpperCase() + word.slice(1)
310
+ ).join(' ')
311
+ }
312
+
313
+ capitalize(str) {
314
+ return str.charAt(0).toUpperCase() + str.slice(1)
315
+ }
316
+ }
317
+