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,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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|