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,59 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["overlay"]
5
+
6
+ connect() {
7
+ this.isStuck = false
8
+ this.scrollHandler = this.handleScroll.bind(this)
9
+ }
10
+
11
+ mouseEnter() {
12
+ window.addEventListener("scroll", this.scrollHandler)
13
+ this.handleScroll()
14
+ }
15
+
16
+ mouseLeave() {
17
+ window.removeEventListener("scroll", this.scrollHandler)
18
+ this.unstick()
19
+ }
20
+
21
+ handleScroll() {
22
+ const containerRect = this.element.getBoundingClientRect()
23
+ const naturalOffset = 12 // Standard offset from container top (don't use CSS top which varies)
24
+ const stickyTop = 140 // Must clear header + admin bar
25
+ const buffer = 20 // Hysteresis buffer to prevent flickering
26
+
27
+ // Calculate where the overlay would naturally be positioned
28
+ const naturalTop = containerRect.top + naturalOffset
29
+
30
+ // Stick when the overlay's natural position would be above the sticky position
31
+ // Use different thresholds for stick vs unstick to prevent flickering
32
+ if (!this.isStuck && naturalTop < stickyTop) {
33
+ this.stick()
34
+ } else if (this.isStuck && naturalTop > stickyTop + buffer) {
35
+ this.unstick()
36
+ }
37
+ }
38
+
39
+ stick() {
40
+ if (!this.isStuck) {
41
+ const rect = this.overlayTarget.getBoundingClientRect()
42
+ this.overlayTarget.style.left = `${rect.left}px`
43
+ this.overlayTarget.classList.add("cms-overlay-stuck")
44
+ this.isStuck = true
45
+ }
46
+ }
47
+
48
+ unstick() {
49
+ if (this.isStuck) {
50
+ this.overlayTarget.classList.remove("cms-overlay-stuck")
51
+ this.overlayTarget.style.left = ""
52
+ this.isStuck = false
53
+ }
54
+ }
55
+
56
+ disconnect() {
57
+ window.removeEventListener("scroll", this.scrollHandler)
58
+ }
59
+ }
@@ -0,0 +1,68 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ async handleSubmit(event) {
5
+ event.preventDefault()
6
+
7
+ const form = event.target
8
+ const formData = new FormData(form)
9
+ const fieldId = form.action.match(/\/page-contents\/field\/(\d+)/)[1]
10
+
11
+ // Handle bullets special case
12
+ const bulletsFieldId = formData.get('bullets_field_id')
13
+ if (bulletsFieldId) {
14
+ const bulletItems = formData.getAll('bullet_items[]').filter(item => item.trim() !== '')
15
+ formData.delete('bullets_field_id')
16
+ formData.delete('bullet_items[]')
17
+ formData.set('value', JSON.stringify(bulletItems))
18
+ }
19
+
20
+ // Handle cards - get the JSON from the hidden input and add file inputs
21
+ const cardsValue = formData.get('value')
22
+ if (cardsValue && cardsValue.startsWith('[')) {
23
+ // Already JSON, keep it
24
+ // Add file inputs for card images
25
+ const cardsEditor = this.application.getControllerForElementAndIdentifier(
26
+ form.querySelector('[data-controller*="cards-editor"]'),
27
+ "cards-editor"
28
+ )
29
+ if (cardsEditor && cardsEditor.pendingImages) {
30
+ Object.keys(cardsEditor.pendingImages).forEach(index => {
31
+ const file = cardsEditor.pendingImages[index]
32
+ formData.append(`card_images[${index}]`, file)
33
+ })
34
+ }
35
+ } else if (cardsValue) {
36
+ // Try to parse if it's a string
37
+ try {
38
+ JSON.parse(cardsValue)
39
+ } catch (e) {
40
+ // Not JSON, keep as is
41
+ }
42
+ }
43
+
44
+ try {
45
+ const response = await fetch(form.action, {
46
+ method: 'PATCH',
47
+ headers: {
48
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
49
+ },
50
+ body: formData
51
+ })
52
+
53
+ const data = await response.json()
54
+
55
+ if (data.success) {
56
+ // Close modal and reload page to show updated content
57
+ const modalEvent = new CustomEvent('cms:close-field-editor')
58
+ window.dispatchEvent(modalEvent)
59
+ window.location.reload()
60
+ } else {
61
+ alert('Error: ' + (data.errors ? data.errors.join(', ') : 'Failed to save'))
62
+ }
63
+ } catch (error) {
64
+ alert('Failed to save: ' + error.message)
65
+ }
66
+ }
67
+ }
68
+
@@ -0,0 +1,79 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["modal", "content", "loader"]
5
+
6
+ connect() {
7
+ // Listen for open modal events
8
+ window.addEventListener('cms:open-field-editor', this.handleOpenModal.bind(this))
9
+ // Listen for close modal events
10
+ window.addEventListener('cms:close-field-editor', this.close.bind(this))
11
+ }
12
+
13
+ disconnect() {
14
+ window.removeEventListener('cms:open-field-editor', this.handleOpenModal.bind(this))
15
+ window.removeEventListener('cms:close-field-editor', this.close.bind(this))
16
+ }
17
+
18
+ async handleOpenModal(event) {
19
+ const { fieldId, type, page, section, key } = event.detail
20
+
21
+ this.showModal()
22
+ this.showLoader()
23
+
24
+ try {
25
+ // Fetch the editor form via AJAX
26
+ const response = await fetch(`/lean-cms/page-contents/field/${fieldId}/edit`, {
27
+ headers: {
28
+ 'Accept': 'text/html'
29
+ }
30
+ })
31
+
32
+ if (response.ok) {
33
+ const html = await response.text()
34
+ this.contentTarget.innerHTML = html
35
+ this.hideLoader()
36
+ } else {
37
+ throw new Error('Failed to load editor')
38
+ }
39
+ } catch (error) {
40
+ this.showError('Failed to load editor')
41
+ setTimeout(() => this.close(), 2000)
42
+ }
43
+ }
44
+
45
+ showModal() {
46
+ this.modalTarget.classList.remove('hidden')
47
+ document.body.style.overflow = 'hidden'
48
+ }
49
+
50
+ hideLoader() {
51
+ this.loaderTarget.classList.add('hidden')
52
+ }
53
+
54
+ showLoader() {
55
+ this.loaderTarget.classList.remove('hidden')
56
+ }
57
+
58
+ showError(message) {
59
+ this.contentTarget.innerHTML = `
60
+ <div class="p-8 text-center text-red-600">
61
+ ${message}
62
+ </div>
63
+ `
64
+ this.hideLoader()
65
+ }
66
+
67
+ close() {
68
+ this.modalTarget.classList.add('hidden')
69
+ document.body.style.overflow = ''
70
+ this.contentTarget.innerHTML = ''
71
+ }
72
+
73
+ handleBackdropClick(event) {
74
+ if (event.target === event.currentTarget) {
75
+ this.close()
76
+ }
77
+ }
78
+ }
79
+
@@ -0,0 +1,414 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ fieldId: Number,
6
+ type: String,
7
+ inline: Boolean,
8
+ page: String,
9
+ section: String,
10
+ key: String
11
+ }
12
+
13
+
14
+ connect() {
15
+ this.addEditIcon()
16
+ // Extract text content, stripping any HTML tags
17
+ this.originalValue = this.element.textContent.trim()
18
+ // Store the blur handler reference so we can remove it
19
+ this.blurHandler = null
20
+ this.isEditing = false
21
+ }
22
+
23
+ addEditIcon() {
24
+ // Create edit icon container
25
+ const iconContainer = document.createElement('span')
26
+ iconContainer.className = 'cms-inline-edit-icons'
27
+
28
+ // Check if inline editing is enabled (default to true)
29
+ const isEnabled = localStorage.getItem('cms-inline-editing-enabled')
30
+ const enabled = isEnabled === null ? true : isEnabled === 'true'
31
+ if (!enabled) {
32
+ iconContainer.style.display = 'none'
33
+ }
34
+
35
+ // Create edit icon
36
+ const editIcon = document.createElement('span')
37
+ editIcon.className = 'cms-inline-edit-icon cms-edit-icon-edit'
38
+ editIcon.innerHTML = `
39
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
40
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
41
+ </svg>
42
+ `
43
+ editIcon.addEventListener('click', (e) => {
44
+ e.preventDefault()
45
+ e.stopPropagation()
46
+ this.handleEdit()
47
+ })
48
+
49
+ // Create undo icon
50
+ const undoIcon = document.createElement('span')
51
+ undoIcon.className = 'cms-inline-edit-icon cms-edit-icon-undo'
52
+ undoIcon.title = 'Undo last change'
53
+ undoIcon.innerHTML = `
54
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
55
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
56
+ </svg>
57
+ `
58
+ undoIcon.addEventListener('click', (e) => {
59
+ e.preventDefault()
60
+ e.stopPropagation()
61
+ this.undoLastChange()
62
+ })
63
+
64
+ iconContainer.appendChild(editIcon)
65
+ iconContainer.appendChild(undoIcon)
66
+ this.element.appendChild(iconContainer)
67
+ // Store reference to icon container so we can remove it later if needed
68
+ this.iconContainer = iconContainer
69
+ }
70
+
71
+ handleEdit() {
72
+ if (this.inlineValue) {
73
+ this.startInlineEdit()
74
+ } else {
75
+ this.openModal()
76
+ }
77
+ }
78
+
79
+ startInlineEdit() {
80
+ const value = this.element.textContent.trim()
81
+ const useTextarea = value.length > 80
82
+
83
+ // Build modal DOM
84
+ const overlay = document.createElement('div')
85
+ overlay.className = 'cms-text-edit-overlay'
86
+
87
+ const dialog = document.createElement('div')
88
+ dialog.className = 'cms-text-edit-dialog'
89
+
90
+ // Header
91
+ const header = document.createElement('div')
92
+ header.className = 'cms-text-edit-header'
93
+ header.innerHTML = `
94
+ <span class="cms-text-edit-label">Edit Text</span>
95
+ <button type="button" class="cms-text-edit-close" aria-label="Close">
96
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
97
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
98
+ </svg>
99
+ </button>
100
+ `
101
+
102
+ // Input
103
+ let input
104
+ if (useTextarea) {
105
+ input = document.createElement('textarea')
106
+ input.rows = 4
107
+ input.className = 'cms-text-edit-input'
108
+ } else {
109
+ input = document.createElement('input')
110
+ input.type = 'text'
111
+ input.className = 'cms-text-edit-input'
112
+ }
113
+ input.value = value
114
+
115
+ // Footer buttons
116
+ const footer = document.createElement('div')
117
+ footer.className = 'cms-text-edit-footer'
118
+ footer.innerHTML = `
119
+ <button type="button" class="cms-text-edit-btn cms-text-edit-btn-cancel">Cancel</button>
120
+ <button type="button" class="cms-text-edit-btn cms-text-edit-btn-save">Save</button>
121
+ `
122
+
123
+ dialog.appendChild(header)
124
+ dialog.appendChild(input)
125
+ dialog.appendChild(footer)
126
+ overlay.appendChild(dialog)
127
+ document.body.appendChild(overlay)
128
+
129
+ input.focus()
130
+ useTextarea ? input.setSelectionRange(0, input.value.length) : input.select()
131
+
132
+ this.isEditing = true
133
+ this._activeOverlay = overlay
134
+
135
+ const close = () => {
136
+ overlay.remove()
137
+ this.isEditing = false
138
+ this._activeOverlay = null
139
+ }
140
+
141
+ const save = () => {
142
+ this.saveInlineFromDialog(input.value.trim(), close)
143
+ }
144
+
145
+ footer.querySelector('.cms-text-edit-btn-save').addEventListener('click', save)
146
+ footer.querySelector('.cms-text-edit-btn-cancel').addEventListener('click', close)
147
+ header.querySelector('.cms-text-edit-close').addEventListener('click', close)
148
+
149
+ // Close on backdrop click
150
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) close() })
151
+
152
+ // Keyboard shortcuts
153
+ input.addEventListener('keydown', (e) => {
154
+ if (e.key === 'Escape') { close() }
155
+ else if (e.key === 'Enter' && (!useTextarea || e.ctrlKey || e.metaKey)) {
156
+ e.preventDefault()
157
+ save()
158
+ }
159
+ })
160
+ }
161
+
162
+ async saveInlineFromDialog(newValue, closeFn) {
163
+ if (newValue === this.originalValue) { closeFn(); return }
164
+
165
+ const saveBtn = this._activeOverlay?.querySelector('.cms-text-edit-btn-save')
166
+ if (saveBtn) { saveBtn.textContent = 'Saving…'; saveBtn.disabled = true }
167
+
168
+ try {
169
+ const response = await fetch(`/lean-cms/page-contents/field/${this.fieldIdValue}`, {
170
+ method: 'PATCH',
171
+ headers: {
172
+ 'Content-Type': 'application/json',
173
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
174
+ },
175
+ body: JSON.stringify({ value: newValue })
176
+ })
177
+
178
+ const data = await response.json()
179
+
180
+ if (data.success) {
181
+ this.originalValue = newValue
182
+ this.element.innerHTML = ''
183
+ this.element.textContent = newValue
184
+ void this.element.offsetHeight
185
+ this.addEditIcon()
186
+ this.showFeedback('Saved!', 'success')
187
+ closeFn()
188
+ } else {
189
+ this.showFeedback('Error: ' + data.errors.join(', '), 'error')
190
+ if (saveBtn) { saveBtn.textContent = 'Save'; saveBtn.disabled = false }
191
+ }
192
+ } catch (error) {
193
+ this.showFeedback('Save failed', 'error')
194
+ if (saveBtn) { saveBtn.textContent = 'Save'; saveBtn.disabled = false }
195
+ }
196
+ }
197
+
198
+ openModal() {
199
+ // Dispatch custom event that modal controller will listen for
200
+ const event = new CustomEvent('cms:open-field-editor', {
201
+ detail: {
202
+ fieldId: this.fieldIdValue,
203
+ type: this.typeValue,
204
+ page: this.pageValue,
205
+ section: this.sectionValue,
206
+ key: this.keyValue
207
+ }
208
+ })
209
+ window.dispatchEvent(event)
210
+ }
211
+
212
+ async undoLastChange() {
213
+ // Fetch the preview first so we can show a diff
214
+ let preview = null
215
+ try {
216
+ const res = await fetch(`/lean-cms/page-contents/field/${this.fieldIdValue}/undo/preview`, {
217
+ headers: { 'Accept': 'application/json' }
218
+ })
219
+ if (res.ok) preview = await res.json()
220
+ } catch (_) {
221
+ // If preview fails, fall back to generic confirm
222
+ }
223
+
224
+ if (preview && preview.success) {
225
+ this.showUndoDiffDialog(preview.current_value, preview.previous_value)
226
+ } else {
227
+ this.showConfirmDialog({
228
+ title: 'Undo Last Change',
229
+ message: preview?.error || 'Revert this field to its previous value? This cannot be undone again.',
230
+ confirmLabel: 'Yes, Undo',
231
+ confirmClass: 'cms-text-edit-btn-danger',
232
+ onConfirm: () => this.performUndo()
233
+ })
234
+ }
235
+ }
236
+
237
+ showUndoDiffDialog(currentValue, previousValue) {
238
+ const { oldHtml, newHtml } = this.diffWords(currentValue, previousValue)
239
+
240
+ const overlay = document.createElement('div')
241
+ overlay.className = 'cms-text-edit-overlay'
242
+
243
+ const dialog = document.createElement('div')
244
+ dialog.className = 'cms-text-edit-dialog cms-confirm-dialog'
245
+
246
+ dialog.innerHTML = `
247
+ <div class="cms-text-edit-header">
248
+ <span class="cms-text-edit-label">Undo Last Change</span>
249
+ <button type="button" class="cms-text-edit-close" aria-label="Close">
250
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
251
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
252
+ </svg>
253
+ </button>
254
+ </div>
255
+ <div class="cms-diff-block">
256
+ <div class="cms-diff-row cms-diff-row-current">
257
+ <span class="cms-diff-label">Current</span>
258
+ <span class="cms-diff-value">${oldHtml}</span>
259
+ </div>
260
+ <div class="cms-diff-row cms-diff-row-previous">
261
+ <span class="cms-diff-label">Reverts to</span>
262
+ <span class="cms-diff-value">${newHtml}</span>
263
+ </div>
264
+ </div>
265
+ <div class="cms-text-edit-footer">
266
+ <button type="button" class="cms-text-edit-btn cms-text-edit-btn-cancel">Cancel</button>
267
+ <button type="button" class="cms-text-edit-btn cms-text-edit-btn-danger">Yes, Undo</button>
268
+ </div>
269
+ `
270
+
271
+ overlay.appendChild(dialog)
272
+ document.body.appendChild(overlay)
273
+
274
+ const close = () => overlay.remove()
275
+
276
+ dialog.querySelector('.cms-text-edit-close').addEventListener('click', close)
277
+ dialog.querySelector('.cms-text-edit-btn-cancel').addEventListener('click', close)
278
+ dialog.querySelector('.cms-text-edit-btn-danger').addEventListener('click', () => {
279
+ close()
280
+ this.performUndo()
281
+ })
282
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) close() })
283
+ document.addEventListener('keydown', function handler(e) {
284
+ if (e.key === 'Escape') { close(); document.removeEventListener('keydown', handler) }
285
+ })
286
+ }
287
+
288
+ // Word-level diff using LCS — returns { oldHtml, newHtml } with <del>/<ins> markup
289
+ diffWords(oldText, newText) {
290
+ const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
291
+
292
+ if (oldText === newText) {
293
+ const e = escape(oldText)
294
+ return { oldHtml: e, newHtml: e }
295
+ }
296
+
297
+ // Split preserving whitespace tokens so spacing is maintained
298
+ const tokenize = (s) => s.split(/(\s+)/)
299
+ const a = tokenize(oldText)
300
+ const b = tokenize(newText)
301
+ const m = a.length, n = b.length
302
+
303
+ // Build LCS DP table
304
+ const dp = Array.from({ length: m + 1 }, () => new Uint16Array(n + 1))
305
+ for (let i = 1; i <= m; i++) {
306
+ for (let j = 1; j <= n; j++) {
307
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1])
308
+ }
309
+ }
310
+
311
+ // Backtrack to produce ops
312
+ const ops = []
313
+ let i = m, j = n
314
+ while (i > 0 || j > 0) {
315
+ if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
316
+ ops.unshift({ type: 'equal', val: a[i - 1] }); i--; j--
317
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
318
+ ops.unshift({ type: 'insert', val: b[j - 1] }); j--
319
+ } else {
320
+ ops.unshift({ type: 'delete', val: a[i - 1] }); i--
321
+ }
322
+ }
323
+
324
+ let oldHtml = '', newHtml = ''
325
+ for (const op of ops) {
326
+ const e = escape(op.val)
327
+ if (op.type === 'equal') { oldHtml += e; newHtml += e }
328
+ else if (op.type === 'delete') { oldHtml += `<del>${e}</del>` }
329
+ else { newHtml += `<ins>${e}</ins>` }
330
+ }
331
+
332
+ return { oldHtml, newHtml }
333
+ }
334
+
335
+ async performUndo() {
336
+ try {
337
+ const response = await fetch(`/lean-cms/page-contents/field/${this.fieldIdValue}/undo`, {
338
+ method: 'POST',
339
+ headers: {
340
+ 'Content-Type': 'application/json',
341
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
342
+ }
343
+ })
344
+
345
+ if (!response.ok) {
346
+ this.showFeedback('Error: Server returned ' + response.status, 'error')
347
+ return
348
+ }
349
+
350
+ const data = await response.json()
351
+
352
+ if (data.success) {
353
+ this.originalValue = data.value
354
+ this.element.innerHTML = ''
355
+ this.element.textContent = data.value
356
+ void this.element.offsetHeight
357
+ this.addEditIcon()
358
+ this.showFeedback('Undone!', 'success')
359
+ } else {
360
+ this.showFeedback('Error: ' + (data.error || 'Could not undo'), 'error')
361
+ }
362
+ } catch (error) {
363
+ this.showFeedback('Undo failed: ' + error.message, 'error')
364
+ }
365
+ }
366
+
367
+ showConfirmDialog({ title, message, confirmLabel = 'Confirm', confirmClass = 'cms-text-edit-btn-save', onConfirm }) {
368
+ const overlay = document.createElement('div')
369
+ overlay.className = 'cms-text-edit-overlay'
370
+
371
+ overlay.innerHTML = `
372
+ <div class="cms-text-edit-dialog cms-confirm-dialog">
373
+ <div class="cms-text-edit-header">
374
+ <span class="cms-text-edit-label">${title}</span>
375
+ <button type="button" class="cms-text-edit-close" aria-label="Close">
376
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
377
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
378
+ </svg>
379
+ </button>
380
+ </div>
381
+ <p class="cms-confirm-message">${message}</p>
382
+ <div class="cms-text-edit-footer">
383
+ <button type="button" class="cms-text-edit-btn cms-text-edit-btn-cancel">Cancel</button>
384
+ <button type="button" class="cms-text-edit-btn ${confirmClass}">${confirmLabel}</button>
385
+ </div>
386
+ </div>
387
+ `
388
+
389
+ document.body.appendChild(overlay)
390
+
391
+ const close = () => overlay.remove()
392
+
393
+ overlay.querySelector('.cms-text-edit-close').addEventListener('click', close)
394
+ overlay.querySelector('.cms-text-edit-btn-cancel').addEventListener('click', close)
395
+ overlay.querySelector(`.${confirmClass}`).addEventListener('click', () => {
396
+ close()
397
+ onConfirm()
398
+ })
399
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) close() })
400
+ document.addEventListener('keydown', function handler(e) {
401
+ if (e.key === 'Escape') { close(); document.removeEventListener('keydown', handler) }
402
+ })
403
+ }
404
+
405
+ showFeedback(message, type) {
406
+ const feedback = document.createElement('div')
407
+ feedback.className = `cms-inline-feedback cms-inline-feedback-${type}`
408
+ feedback.textContent = message
409
+ this.element.appendChild(feedback)
410
+
411
+ setTimeout(() => feedback.remove(), 2000)
412
+ }
413
+ }
414
+
@@ -0,0 +1,81 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["checkbox"]
5
+
6
+ connect() {
7
+ // Check localStorage for saved preference (default to true)
8
+ const isEnabled = localStorage.getItem('cms-inline-editing-enabled')
9
+ const enabled = isEnabled === null ? true : isEnabled === 'true'
10
+
11
+ this.checkboxTarget.checked = enabled
12
+ this.updateIconsVisibility(enabled)
13
+ }
14
+
15
+ toggle(event) {
16
+ const enabled = event.target.checked
17
+
18
+ // Save preference to localStorage
19
+ localStorage.setItem('cms-inline-editing-enabled', enabled.toString())
20
+
21
+ // Update visibility of all inline edit icons
22
+ this.updateIconsVisibility(enabled)
23
+
24
+ // Show a brief feedback message
25
+ this.showFeedback(enabled ? 'Inline editing enabled' : 'Inline editing disabled')
26
+ }
27
+
28
+ updateIconsVisibility(enabled) {
29
+ // Find all inline edit icon containers
30
+ const iconContainers = document.querySelectorAll('.cms-inline-edit-icons')
31
+
32
+ iconContainers.forEach(container => {
33
+ if (enabled) {
34
+ container.style.display = ''
35
+ } else {
36
+ container.style.display = 'none'
37
+ }
38
+ })
39
+
40
+ // Toggle class on body to control all CMS editing UI
41
+ if (enabled) {
42
+ document.body.classList.remove('cms-inline-editing-disabled')
43
+ } else {
44
+ document.body.classList.add('cms-inline-editing-disabled')
45
+ }
46
+
47
+ // Also toggle the disabled class on editable sections (for dotted borders)
48
+ const editableSections = document.querySelectorAll('.cms-editable-section')
49
+
50
+ editableSections.forEach(section => {
51
+ if (enabled) {
52
+ section.classList.remove('cms-inline-editing-disabled')
53
+ } else {
54
+ section.classList.add('cms-inline-editing-disabled')
55
+ }
56
+ })
57
+ }
58
+
59
+ showFeedback(message) {
60
+ // Create a temporary feedback message
61
+ const feedback = document.createElement('div')
62
+ feedback.textContent = message
63
+ feedback.className = 'fixed top-12 right-6 bg-gray-800 text-white px-4 py-2 rounded shadow-lg z-[70] transition-opacity'
64
+ feedback.style.opacity = '0'
65
+
66
+ document.body.appendChild(feedback)
67
+
68
+ // Fade in
69
+ setTimeout(() => {
70
+ feedback.style.opacity = '1'
71
+ }, 10)
72
+
73
+ // Fade out and remove after 2 seconds
74
+ setTimeout(() => {
75
+ feedback.style.opacity = '0'
76
+ setTimeout(() => {
77
+ feedback.remove()
78
+ }, 300)
79
+ }, 2000)
80
+ }
81
+ }