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