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,548 @@
1
+ /* CMS Edit Controls - In-context editing for logged-in CMS users */
2
+
3
+ /* All interactive elements rendered by the CMS — admin bar buttons, modal
4
+ editor controls, the close-X — should show a pointer cursor on hover.
5
+ Tailwind v4's preflight no longer sets cursor:pointer on <button>, so we
6
+ restore it here scoped to the CMS UI surfaces. */
7
+ .cms-modal a,
8
+ .cms-modal button,
9
+ .cms-modal [role="button"],
10
+ .cms-modal input[type="submit"],
11
+ .cms-modal input[type="button"],
12
+ .cms-modal label[for],
13
+ [data-controller~="inline-edit-toggle"] a,
14
+ [data-controller~="inline-edit-toggle"] button,
15
+ [data-controller~="inline-edit-toggle"] [role="button"],
16
+ [data-controller~="inline-edit-toggle"] label[for] {
17
+ cursor: pointer;
18
+ }
19
+
20
+ .cms-editable-section {
21
+ position: relative !important;
22
+ border: 2px dashed transparent !important;
23
+ transition: border-color 0.2s ease;
24
+ isolation: isolate;
25
+ }
26
+
27
+ .cms-editable-section:hover {
28
+ border-color: rgba(37, 99, 235, 0.6) !important;
29
+ }
30
+
31
+ /* Disable section borders when inline editing is turned off */
32
+ .cms-editable-section.cms-inline-editing-disabled:hover {
33
+ border-color: transparent !important;
34
+ }
35
+
36
+ .cms-editable-section.cms-inline-editing-disabled .cms-edit-overlay {
37
+ display: none !important;
38
+ }
39
+
40
+ .cms-edit-overlay {
41
+ position: absolute !important;
42
+ top: 12px !important;
43
+ right: 12px !important;
44
+ opacity: 0;
45
+ transition: opacity 0.2s ease;
46
+ pointer-events: none;
47
+ z-index: 9999 !important;
48
+ }
49
+
50
+ /* Only the very first editable section on the page needs extra space for the header */
51
+ main > .cms-editable-section:first-of-type .cms-edit-overlay {
52
+ top: 120px !important;
53
+ }
54
+
55
+ .cms-editable-section:hover .cms-edit-overlay {
56
+ opacity: 1;
57
+ pointer-events: auto;
58
+ }
59
+
60
+ .cms-edit-controls {
61
+ display: flex !important;
62
+ align-items: center !important;
63
+ gap: 12px !important;
64
+ background: rgba(37, 99, 235, 0.95) !important;
65
+ color: white !important;
66
+ padding: 10px 14px !important;
67
+ border-radius: 8px !important;
68
+ font-size: 13px !important;
69
+ font-weight: 500 !important;
70
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.1) !important;
71
+ backdrop-filter: blur(8px) !important;
72
+ white-space: nowrap !important;
73
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
74
+ }
75
+
76
+ .cms-section-title {
77
+ font-weight: 600 !important;
78
+ letter-spacing: 0.01em !important;
79
+ padding-right: 8px !important;
80
+ border-right: 1px solid rgba(255, 255, 255, 0.3) !important;
81
+ }
82
+
83
+ .cms-edit-button {
84
+ display: inline-flex !important;
85
+ align-items: center !important;
86
+ gap: 6px !important;
87
+ padding: 6px 12px !important;
88
+ background: white !important;
89
+ color: rgb(37, 99, 235) !important;
90
+ border-radius: 6px !important;
91
+ font-size: 12px !important;
92
+ font-weight: 600 !important;
93
+ text-decoration: none !important;
94
+ transition: all 0.15s ease !important;
95
+ line-height: 1 !important;
96
+ }
97
+
98
+ .cms-edit-button:hover {
99
+ background: rgb(243, 244, 246) !important;
100
+ transform: translateY(-1px) !important;
101
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important;
102
+ color: rgb(37, 99, 235) !important;
103
+ text-decoration: none !important;
104
+ }
105
+
106
+ .cms-edit-button::after {
107
+ content: '→' !important;
108
+ font-size: 14px !important;
109
+ margin-left: 2px !important;
110
+ }
111
+
112
+ /* Sticky overlay state - needs to clear the fixed header + admin bar (~128px total) */
113
+ .cms-edit-overlay.cms-overlay-stuck {
114
+ position: fixed !important;
115
+ top: 140px !important;
116
+ right: auto !important; /* Clear right so left positioning works correctly */
117
+ z-index: 9999 !important;
118
+ }
119
+
120
+ /* Make sure edit controls work on dark backgrounds */
121
+ .cms-editable-section.dark-section:hover {
122
+ outline-color: rgba(255, 255, 255, 0.5);
123
+ }
124
+
125
+ /* Ensure proper stacking for sections with their own positioning */
126
+ .cms-editable-section > section,
127
+ .cms-editable-section > div {
128
+ position: relative;
129
+ }
130
+
131
+ /* Inline editing field wrapper */
132
+ .cms-inline-field {
133
+ position: relative;
134
+ display: inline-block;
135
+ min-width: 2em;
136
+ transition: all 0.2s ease;
137
+ }
138
+
139
+ .cms-inline-field:hover {
140
+ background-color: rgba(59, 130, 246, 0.05);
141
+ outline: 2px dashed rgba(59, 130, 246, 0.3);
142
+ outline-offset: 2px;
143
+ }
144
+
145
+ /* Disable inline field hover effects when editing is off */
146
+ body.cms-inline-editing-disabled .cms-inline-field:hover {
147
+ background-color: transparent;
148
+ outline: none;
149
+ }
150
+
151
+ /* Edit icon container */
152
+ .cms-inline-edit-icons {
153
+ position: absolute;
154
+ top: -8px;
155
+ right: -8px;
156
+ display: none;
157
+ gap: 4px;
158
+ z-index: 10;
159
+ }
160
+
161
+ .cms-inline-field:hover .cms-inline-edit-icons {
162
+ display: flex;
163
+ }
164
+
165
+ /* Hide inline edit icons when editing is disabled */
166
+ body.cms-inline-editing-disabled .cms-inline-field:hover .cms-inline-edit-icons {
167
+ display: none !important;
168
+ }
169
+
170
+ /* Individual edit icons */
171
+ .cms-inline-edit-icon {
172
+ width: 24px;
173
+ height: 24px;
174
+ color: white;
175
+ border-radius: 50%;
176
+ display: flex;
177
+ align-items: center;
178
+ justify-content: center;
179
+ cursor: pointer;
180
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
181
+ transition: all 0.2s ease;
182
+ }
183
+
184
+ .cms-edit-icon-edit {
185
+ background: rgb(59, 130, 246);
186
+ }
187
+
188
+ .cms-edit-icon-edit:hover {
189
+ background: rgb(37, 99, 235);
190
+ transform: scale(1.1);
191
+ }
192
+
193
+ .cms-edit-icon-undo {
194
+ background: rgb(251, 146, 60);
195
+ }
196
+
197
+ .cms-edit-icon-undo:hover {
198
+ background: rgb(249, 115, 22);
199
+ transform: scale(1.1);
200
+ }
201
+
202
+ /* Inline input */
203
+ .cms-inline-input {
204
+ width: 100%;
205
+ min-width: 200px;
206
+ padding: 8px 12px;
207
+ border: 2px solid rgb(59, 130, 246);
208
+ border-radius: 4px;
209
+ font: inherit;
210
+ font-size: inherit;
211
+ color: #1f2937 !important;
212
+ background: white;
213
+ outline: none;
214
+ box-sizing: border-box;
215
+ }
216
+
217
+ /* Inline textarea (for multi-line content) */
218
+ .cms-inline-textarea {
219
+ resize: vertical;
220
+ line-height: inherit;
221
+ font-family: inherit;
222
+ text-align: inherit;
223
+ color: #1f2937 !important;
224
+ }
225
+
226
+ /* Feedback message */
227
+ .cms-inline-feedback {
228
+ position: absolute;
229
+ bottom: -24px;
230
+ right: 0;
231
+ padding: 4px 8px;
232
+ border-radius: 4px;
233
+ font-size: 12px;
234
+ font-weight: 500;
235
+ white-space: nowrap;
236
+ z-index: 100;
237
+ }
238
+
239
+ .cms-inline-feedback-success {
240
+ background: rgb(34, 197, 94);
241
+ color: white;
242
+ }
243
+
244
+ .cms-inline-feedback-error {
245
+ background: rgb(239, 68, 68);
246
+ color: white;
247
+ }
248
+
249
+ /* Inline editing buttons */
250
+ .cms-inline-buttons {
251
+ display: flex;
252
+ gap: 8px;
253
+ margin-top: 8px;
254
+ }
255
+
256
+ .cms-inline-button {
257
+ display: inline-flex;
258
+ align-items: center;
259
+ gap: 6px;
260
+ padding: 6px 12px;
261
+ border: none;
262
+ border-radius: 6px;
263
+ font-size: 13px;
264
+ font-weight: 600;
265
+ cursor: pointer;
266
+ transition: all 0.15s ease;
267
+ white-space: nowrap;
268
+ }
269
+
270
+ .cms-inline-button svg {
271
+ flex-shrink: 0;
272
+ }
273
+
274
+ .cms-inline-button-save {
275
+ background: rgb(34, 197, 94);
276
+ color: white;
277
+ }
278
+
279
+ .cms-inline-button-save:hover {
280
+ background: rgb(22, 163, 74);
281
+ transform: translateY(-1px);
282
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
283
+ }
284
+
285
+ .cms-inline-button-cancel {
286
+ background: rgb(107, 114, 128);
287
+ color: white;
288
+ }
289
+
290
+ .cms-inline-button-cancel:hover {
291
+ background: rgb(75, 85, 99);
292
+ transform: translateY(-1px);
293
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
294
+ }
295
+
296
+ /* Modal styles */
297
+ .cms-modal {
298
+ backdrop-filter: blur(4px);
299
+ }
300
+
301
+ .cms-modal.hidden {
302
+ display: none !important;
303
+ }
304
+
305
+ /* Inline text edit dialog (replaces in-place input approach) */
306
+ .cms-text-edit-overlay {
307
+ position: fixed;
308
+ inset: 0;
309
+ z-index: 10001;
310
+ display: flex;
311
+ align-items: center;
312
+ justify-content: center;
313
+ background: rgba(0, 0, 0, 0.45);
314
+ backdrop-filter: blur(2px);
315
+ }
316
+
317
+ .cms-text-edit-dialog {
318
+ background: #fff;
319
+ border-radius: 12px;
320
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25), 0 4px 16px rgba(0, 0, 0, 0.1);
321
+ width: 480px;
322
+ max-width: calc(100vw - 32px);
323
+ overflow: hidden;
324
+ animation: cms-dialog-in 0.15s ease;
325
+ }
326
+
327
+ @keyframes cms-dialog-in {
328
+ from { opacity: 0; transform: scale(0.95) translateY(-8px); }
329
+ to { opacity: 1; transform: scale(1) translateY(0); }
330
+ }
331
+
332
+ .cms-text-edit-header {
333
+ display: flex;
334
+ align-items: center;
335
+ justify-content: space-between;
336
+ padding: 14px 18px;
337
+ border-bottom: 1px solid #e5e7eb;
338
+ }
339
+
340
+ .cms-text-edit-label {
341
+ font-size: 13px;
342
+ font-weight: 600;
343
+ color: #374151;
344
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
345
+ }
346
+
347
+ .cms-text-edit-close {
348
+ display: flex;
349
+ align-items: center;
350
+ justify-content: center;
351
+ width: 28px;
352
+ height: 28px;
353
+ border: none;
354
+ background: transparent;
355
+ color: #9ca3af;
356
+ border-radius: 6px;
357
+ cursor: pointer;
358
+ transition: background 0.15s, color 0.15s;
359
+ }
360
+
361
+ .cms-text-edit-close:hover {
362
+ background: #f3f4f6;
363
+ color: #374151;
364
+ }
365
+
366
+ .cms-text-edit-input {
367
+ display: block;
368
+ width: 100%;
369
+ padding: 14px 18px;
370
+ border: none;
371
+ border-bottom: 1px solid #e5e7eb;
372
+ font-size: 15px;
373
+ font-family: inherit;
374
+ color: #111827;
375
+ background: #f9fafb;
376
+ outline: none;
377
+ resize: vertical;
378
+ box-sizing: border-box;
379
+ transition: background 0.15s;
380
+ }
381
+
382
+ .cms-text-edit-input:focus {
383
+ background: #fff;
384
+ border-bottom-color: #3b82f6;
385
+ }
386
+
387
+ .cms-text-edit-footer {
388
+ display: flex;
389
+ justify-content: flex-end;
390
+ gap: 8px;
391
+ padding: 12px 18px;
392
+ }
393
+
394
+ .cms-text-edit-btn {
395
+ padding: 7px 18px;
396
+ border: none;
397
+ border-radius: 7px;
398
+ font-size: 13px;
399
+ font-weight: 600;
400
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
401
+ cursor: pointer;
402
+ transition: all 0.15s ease;
403
+ }
404
+
405
+ .cms-text-edit-btn-cancel {
406
+ background: #f3f4f6;
407
+ color: #374151;
408
+ }
409
+
410
+ .cms-text-edit-btn-cancel:hover {
411
+ background: #e5e7eb;
412
+ }
413
+
414
+ .cms-text-edit-btn-save {
415
+ background: #2563eb;
416
+ color: #fff;
417
+ }
418
+
419
+ .cms-text-edit-btn-save:hover {
420
+ background: #1d4ed8;
421
+ transform: translateY(-1px);
422
+ box-shadow: 0 2px 6px rgba(37, 99, 235, 0.35);
423
+ }
424
+
425
+ .cms-text-edit-btn-save:disabled {
426
+ opacity: 0.6;
427
+ cursor: not-allowed;
428
+ transform: none;
429
+ box-shadow: none;
430
+ }
431
+
432
+ .cms-text-edit-btn-danger {
433
+ background: #dc2626;
434
+ color: #fff;
435
+ }
436
+
437
+ .cms-text-edit-btn-danger:hover {
438
+ background: #b91c1c;
439
+ transform: translateY(-1px);
440
+ box-shadow: 0 2px 6px rgba(220, 38, 38, 0.35);
441
+ }
442
+
443
+ /* Confirm dialog — slightly wider to accommodate diff */
444
+ .cms-confirm-dialog {
445
+ width: 480px;
446
+ max-width: 92vw;
447
+ }
448
+
449
+ .cms-confirm-message {
450
+ padding: 18px 18px 8px;
451
+ font-size: 14px;
452
+ color: #4b5563;
453
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
454
+ line-height: 1.5;
455
+ margin: 0;
456
+ }
457
+
458
+ /* Undo diff block */
459
+ .cms-diff-block {
460
+ margin: 14px 18px 6px;
461
+ border-radius: 8px;
462
+ overflow: hidden;
463
+ border: 1px solid #e5e7eb;
464
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
465
+ font-size: 13px;
466
+ line-height: 1.55;
467
+ }
468
+
469
+ .cms-diff-row {
470
+ display: flex;
471
+ gap: 0;
472
+ align-items: baseline;
473
+ }
474
+
475
+ .cms-diff-row-current {
476
+ background: #fef2f2;
477
+ border-bottom: 1px solid #fecaca;
478
+ }
479
+
480
+ .cms-diff-row-previous {
481
+ background: #f0fdf4;
482
+ }
483
+
484
+ .cms-diff-label {
485
+ flex: 0 0 96px;
486
+ padding: 9px 10px 9px 12px;
487
+ font-size: 11px;
488
+ font-weight: 600;
489
+ text-transform: uppercase;
490
+ letter-spacing: 0.04em;
491
+ white-space: nowrap;
492
+ text-align: right;
493
+ }
494
+
495
+ .cms-diff-row-current .cms-diff-label { color: #b91c1c; }
496
+ .cms-diff-row-previous .cms-diff-label { color: #15803d; }
497
+
498
+ .cms-diff-value {
499
+ flex: 1;
500
+ padding: 9px 12px 9px 0;
501
+ color: #111827;
502
+ word-break: break-word;
503
+ }
504
+
505
+ /* Highlighted changed words */
506
+ .cms-diff-value del {
507
+ text-decoration: none;
508
+ background: #fca5a5;
509
+ border-radius: 3px;
510
+ padding: 1px 2px;
511
+ }
512
+
513
+ .cms-diff-value ins {
514
+ text-decoration: none;
515
+ background: #86efac;
516
+ border-radius: 3px;
517
+ padding: 1px 2px;
518
+ }
519
+
520
+ /* Cards editor — color swatch button */
521
+ .cards-color-swatch {
522
+ flex: 0 0 36px;
523
+ width: 36px;
524
+ height: 36px;
525
+ padding: 2px;
526
+ border: 1px solid #d1d5db;
527
+ border-radius: 8px;
528
+ cursor: pointer;
529
+ background: none;
530
+ appearance: none;
531
+ -webkit-appearance: none;
532
+ overflow: hidden;
533
+ }
534
+
535
+ .cards-color-swatch::-webkit-color-swatch-wrapper {
536
+ padding: 0;
537
+ border-radius: 6px;
538
+ }
539
+
540
+ .cards-color-swatch::-webkit-color-swatch {
541
+ border: none;
542
+ border-radius: 6px;
543
+ }
544
+
545
+ .cards-color-swatch:hover {
546
+ border-color: #6b7280;
547
+ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
548
+ }
@@ -0,0 +1,14 @@
1
+ /* LeanCMS engine Tailwind sources.
2
+ tailwindcss-rails auto-imports this via app/assets/builds/tailwind/lean_cms.css.
3
+ Paths are relative to this file's location inside the gem. */
4
+
5
+ @source "../../../../app/views/**/*.erb";
6
+ @source "../../../../app/javascript/controllers/**/*.js";
7
+
8
+ /* Safelist for class names that the gem's view components interpolate at
9
+ runtime (e.g. `gap-<%= gap %>` in CardsSectionComponent). Tailwind's
10
+ @source scanner reads literal strings only — it can't follow ERB — so
11
+ those utilities never make it into the host's compiled CSS unless they
12
+ appear elsewhere or are safelisted here. */
13
+ @source inline("md:grid-cols-{1,2,3,4,5,6}");
14
+ @source inline("gap-{2,3,4,5,6,8,10,12}");
@@ -0,0 +1,61 @@
1
+ module LeanCms
2
+ class BaseComponent < ViewComponent::Base
3
+ attr_reader :page
4
+
5
+ def initialize(page: nil, **options)
6
+ @page = page || @view_context.instance_variable_get(:@page)
7
+ super(**options)
8
+ end
9
+
10
+ private
11
+
12
+ # Check if current user can edit CMS content
13
+ def can_edit_cms?
14
+ return false unless @view_context.respond_to?(:authenticated?) && @view_context.authenticated?
15
+ return false unless @view_context.current_user&.has_any_cms_permission?
16
+ LeanCms::Setting.get('in_context_editing', 'true') == 'true'
17
+ end
18
+
19
+ # Generate cache key for this component.
20
+ # Pre-normalization era: `page` may be a raw slug String. In that case we
21
+ # don't have a Page record to read updated_at off of, so fall back to the
22
+ # max(updated_at) over PageContent rows for that slug — which is touched
23
+ # whenever an editor changes any content on the page.
24
+ def cache_key(identifier)
25
+ page_updated_at = if page.is_a?(LeanCms::Page)
26
+ page.updated_at
27
+ else
28
+ LeanCms::PageContent.where(page: page.to_s).maximum(:updated_at)
29
+ end
30
+
31
+ ["lean_cms", page_slug, identifier, page_updated_at&.to_i, can_edit_cms?]
32
+ end
33
+
34
+ # Get page slug (string)
35
+ def page_slug
36
+ page.is_a?(LeanCms::Page) ? page.slug : page.to_s
37
+ end
38
+
39
+ # Find a PageContent field using preloaded data if available
40
+ def find_field(section, key)
41
+ if page.is_a?(LeanCms::Page) && page.page_contents.loaded?
42
+ page.page_contents.find { |pc| pc.section == section.to_s && pc.key == key.to_s }
43
+ elsif page.is_a?(LeanCms::Page)
44
+ LeanCms::PageContent.find_by(page_id: page.id, section: section, key: key)
45
+ else
46
+ LeanCms::PageContent.find_by("page = ? AND section = ? AND key = ?", page.to_s, section.to_s, key.to_s)
47
+ end
48
+ end
49
+
50
+ # Get field value using preloaded data if available
51
+ def field_value(section, key, default: nil)
52
+ field = find_field(section, key)
53
+ field&.display_value || default
54
+ end
55
+
56
+ # Expose helpers to components
57
+ def helpers
58
+ @view_context
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,23 @@
1
+ <% cache cache_key, expires_in: 1.hour do %>
2
+ <% bullet_items = bullets %>
3
+ <% if bullet_items.any? %>
4
+ <% list_html = capture do %>
5
+ <ul class="space-y-3">
6
+ <% bullet_items.each do |bullet| %>
7
+ <li class="flex items-start text-gray-700">
8
+ <svg class="w-5 h-5 text-[#2563eb] mt-1 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
9
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
10
+ </svg>
11
+ <span><%= bullet %></span>
12
+ </li>
13
+ <% end %>
14
+ </ul>
15
+ <% end %>
16
+
17
+ <% if can_edit_cms? && field %>
18
+ <%= content_tag(:div, list_html, class: 'cms-inline-field relative', data: data_attributes) %>
19
+ <% else %>
20
+ <%= list_html %>
21
+ <% end %>
22
+ <% end %>
23
+ <% end %>
@@ -0,0 +1,54 @@
1
+ module LeanCms
2
+ class BulletsSectionComponent < BaseComponent
3
+ attr_reader :section
4
+
5
+ def initialize(section:, page: nil, **options)
6
+ super(page: page)
7
+ @section = section
8
+ @options = options
9
+ end
10
+
11
+ private
12
+
13
+ def cache_key
14
+ super("#{section}_bullets")
15
+ end
16
+
17
+ def bullets
18
+ if page.is_a?(LeanCms::Page) && page.page_contents.loaded?
19
+ content_record = page.page_contents.find { |pc| pc.section == section.to_s && pc.key == 'bullets' }
20
+ return [] unless content_record&.bullets?
21
+ content_record.display_value
22
+ else
23
+ page_key = page.is_a?(LeanCms::Page) ? page.slug : page.to_s
24
+ Rails.cache.fetch("page_bullets/#{page_key}/#{section}", expires_in: 1.hour) do
25
+ content_record = if page.is_a?(LeanCms::Page)
26
+ LeanCms::PageContent.find_by(page_id: page.id, section: section, key: 'bullets')
27
+ else
28
+ LeanCms::PageContent.find_by("page = ? AND section = ? AND key = ?", page.to_s, section.to_s, 'bullets')
29
+ end
30
+ return [] unless content_record&.bullets?
31
+ content_record.display_value
32
+ end
33
+ end
34
+ end
35
+
36
+ def field
37
+ @field ||= find_field(section, 'bullets')
38
+ end
39
+
40
+ def data_attributes
41
+ return {} unless can_edit_cms? && field
42
+
43
+ {
44
+ controller: 'inline-edit',
45
+ inline_edit_field_id_value: field.id,
46
+ inline_edit_type_value: 'bullets',
47
+ inline_edit_inline_value: false,
48
+ inline_edit_page_value: page_slug,
49
+ inline_edit_section_value: section,
50
+ inline_edit_key_value: 'bullets'
51
+ }
52
+ end
53
+ end
54
+ end