panda-cms 0.10.0 → 0.10.3

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +79 -11
  3. data/app/assets/tailwind/panda/cms/_application.css +1 -0
  4. data/app/components/panda/cms/admin/popular_pages_component.rb +62 -0
  5. data/app/components/panda/cms/code_component.rb +46 -9
  6. data/app/components/panda/cms/menu_component.rb +18 -5
  7. data/app/components/panda/cms/page_menu_component.rb +9 -1
  8. data/app/components/panda/cms/rich_text_component.rb +49 -17
  9. data/app/components/panda/cms/text_component.rb +46 -14
  10. data/app/controllers/panda/cms/admin/menus_controller.rb +2 -2
  11. data/app/controllers/panda/cms/admin/pages_controller.rb +6 -2
  12. data/app/controllers/panda/cms/admin/posts_controller.rb +3 -1
  13. data/app/controllers/panda/cms/form_submissions_controller.rb +134 -11
  14. data/app/controllers/panda/cms/pages_controller.rb +7 -2
  15. data/app/controllers/panda/cms/posts_controller.rb +16 -0
  16. data/app/helpers/panda/cms/application_helper.rb +2 -3
  17. data/app/helpers/panda/cms/asset_helper.rb +14 -72
  18. data/app/helpers/panda/cms/forms_helper.rb +60 -0
  19. data/app/helpers/panda/cms/seo_helper.rb +85 -0
  20. data/app/javascript/panda/cms/{application_panda_cms.js → application.js} +4 -0
  21. data/app/javascript/panda/cms/controllers/editor_form_controller.js +3 -3
  22. data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +35 -8
  23. data/app/javascript/panda/cms/controllers/file_upload_controller.js +165 -0
  24. data/app/javascript/panda/cms/controllers/index.js +6 -0
  25. data/app/javascript/panda/cms/controllers/menu_form_controller.js +14 -1
  26. data/app/javascript/panda/cms/controllers/page_form_controller.js +454 -0
  27. data/app/javascript/panda/cms/stimulus-loading.js +2 -1
  28. data/app/jobs/panda/cms/record_visit_job.rb +2 -1
  29. data/app/models/panda/cms/menu.rb +12 -0
  30. data/app/models/panda/cms/page.rb +106 -0
  31. data/app/models/panda/cms/post.rb +97 -0
  32. data/app/models/panda/cms/visit.rb +16 -1
  33. data/app/services/panda/social/instagram_feed_service.rb +54 -54
  34. data/app/views/layouts/homepage.html.erb +1 -4
  35. data/app/views/layouts/page.html.erb +1 -4
  36. data/app/views/panda/cms/admin/dashboard/show.html.erb +11 -4
  37. data/app/views/panda/cms/admin/files/index.html.erb +1 -1
  38. data/app/views/panda/cms/admin/forms/new.html.erb +1 -1
  39. data/app/views/panda/cms/admin/forms/show.html.erb +3 -3
  40. data/app/views/panda/cms/admin/menus/_menu_item_fields.html.erb +3 -3
  41. data/app/views/panda/cms/admin/menus/edit.html.erb +12 -14
  42. data/app/views/panda/cms/admin/menus/index.html.erb +1 -1
  43. data/app/views/panda/cms/admin/menus/new.html.erb +6 -8
  44. data/app/views/panda/cms/admin/pages/edit.html.erb +213 -20
  45. data/app/views/panda/cms/admin/pages/index.html.erb +6 -6
  46. data/app/views/panda/cms/admin/posts/_form.html.erb +47 -8
  47. data/app/views/panda/cms/admin/posts/edit.html.erb +1 -1
  48. data/app/views/panda/cms/admin/posts/index.html.erb +4 -4
  49. data/app/views/panda/cms/shared/_favicons.html.erb +7 -7
  50. data/app/views/shared/_header.html.erb +1 -4
  51. data/config/brakeman.ignore +38 -0
  52. data/config/importmap.rb +7 -6
  53. data/config/initializers/groupdate.rb +5 -0
  54. data/config/locales/en.yml +42 -2
  55. data/config/routes.rb +1 -1
  56. data/db/migrate/20240315125421_add_nested_sets_to_panda_cms_pages.rb +0 -10
  57. data/db/migrate/20240316230706_add_nested_to_panda_cms_menu_items.rb +0 -6
  58. data/db/migrate/20240317230622_create_panda_cms_visits.rb +1 -1
  59. data/db/migrate/20240805121123_create_panda_cms_posts.rb +1 -1
  60. data/db/migrate/20240806112735_fix_panda_cms_visits_column_names.rb +1 -1
  61. data/db/migrate/20240923234535_add_depth_to_panda_cms_menus.rb +0 -6
  62. data/db/migrate/20250106223303_add_author_id_to_panda_cms_posts.rb +1 -3
  63. data/db/migrate/20251109131150_add_seo_fields_to_pages.rb +32 -0
  64. data/db/migrate/20251109131205_add_seo_fields_to_posts.rb +27 -0
  65. data/db/migrate/20251110114258_add_spam_tracking_to_form_submissions.rb +7 -0
  66. data/db/migrate/20251110122812_add_performance_indexes_to_pages_and_redirects.rb +13 -0
  67. data/db/migrate/20251117234530_add_index_to_visited_at_on_panda_cms_visits.rb +7 -0
  68. data/db/migrate/20251118015100_backfill_visited_at_for_existing_visits.rb +17 -0
  69. data/db/seeds.rb +5 -0
  70. data/lib/panda/cms/asset_loader.rb +42 -78
  71. data/lib/panda/cms/bulk_editor.rb +288 -12
  72. data/lib/panda/cms/engine/asset_config.rb +49 -0
  73. data/lib/panda/cms/engine/autoload_config.rb +37 -0
  74. data/lib/panda/cms/engine/backtrace_config.rb +42 -0
  75. data/lib/panda/cms/engine/core_config.rb +106 -0
  76. data/lib/panda/cms/engine/helper_config.rb +20 -0
  77. data/lib/panda/cms/engine/route_config.rb +33 -0
  78. data/lib/panda/cms/engine/view_component_config.rb +31 -0
  79. data/lib/panda/cms/engine.rb +32 -228
  80. data/lib/{panda-cms → panda/cms}/version.rb +1 -1
  81. data/lib/panda/cms.rb +12 -0
  82. data/lib/panda-cms.rb +24 -3
  83. data/lib/tasks/ci.rake +0 -0
  84. metadata +32 -67
  85. data/app/assets/builds/panda.cms.css +0 -2754
  86. data/app/assets/stylesheets/panda/cms/application.tailwind.css +0 -162
  87. data/app/assets/stylesheets/panda/cms/editor.css +0 -120
  88. data/app/assets/tailwind/application.css +0 -178
  89. data/app/assets/tailwind/tailwind.config.js +0 -15
  90. data/app/javascript/panda_cms/stimulus-loading.js +0 -39
  91. data/app/views/panda/cms/shared/_importmap.html.erb +0 -34
  92. data/config/initializers/inflections.rb +0 -5
  93. data/config/initializers/panda/cms/healthcheck_log_silencer.rb.disabled +0 -31
  94. data/db/migrate/20240317010532_create_panda_cms_users.rb +0 -14
  95. data/db/migrate/20240324205703_create_active_storage_tables.active_storage.rb +0 -61
  96. data/db/migrate/20240408084718_default_panda_cms_users_admin_to_false.rb +0 -7
  97. data/db/migrate/20240701225422_add_service_name_to_active_storage_blobs.active_storage.rb +0 -24
  98. data/db/migrate/20240701225423_create_active_storage_variant_records.active_storage.rb +0 -30
  99. data/db/migrate/20240701225424_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb +0 -10
  100. data/db/migrate/20241119214548_convert_post_content_to_editor_js.rb +0 -37
  101. data/db/migrate/20250809231125_migrate_users_to_panda_core.rb +0 -113
  102. data/lib/generators/panda/cms/install_generator.rb +0 -28
  103. data/lib/tasks/assets.rake +0 -540
  104. data/public/panda-cms-assets/editor-js/core/editorjs.min.js +0 -83
  105. data/public/panda-cms-assets/editor-js/plugins/embed.min.js +0 -2
  106. data/public/panda-cms-assets/editor-js/plugins/header.min.js +0 -9
  107. data/public/panda-cms-assets/editor-js/plugins/nested-list.min.js +0 -2
  108. data/public/panda-cms-assets/editor-js/plugins/paragraph.min.js +0 -9
  109. data/public/panda-cms-assets/editor-js/plugins/quote.min.js +0 -2
  110. data/public/panda-cms-assets/editor-js/plugins/simple-image.min.js +0 -2
  111. data/public/panda-cms-assets/editor-js/plugins/table.min.js +0 -2
  112. data/public/panda-cms-assets/favicons/android-chrome-192x192.png +0 -0
  113. data/public/panda-cms-assets/favicons/android-chrome-512x512.png +0 -0
  114. data/public/panda-cms-assets/favicons/apple-touch-icon.png +0 -0
  115. data/public/panda-cms-assets/favicons/browserconfig.xml +0 -9
  116. data/public/panda-cms-assets/favicons/favicon-16x16.png +0 -0
  117. data/public/panda-cms-assets/favicons/favicon-32x32.png +0 -0
  118. data/public/panda-cms-assets/favicons/favicon.ico +0 -0
  119. data/public/panda-cms-assets/favicons/mstile-150x150.png +0 -0
  120. data/public/panda-cms-assets/favicons/safari-pinned-tab.svg +0 -61
  121. data/public/panda-cms-assets/favicons/site.webmanifest +0 -14
  122. data/public/panda-cms-assets/manifest.json +0 -20
  123. data/public/panda-cms-assets/panda-cms-0.7.4.css +0 -26
  124. data/public/panda-cms-assets/panda-cms-0.7.4.js +0 -150
  125. data/public/panda-cms-assets/panda-logo-screenprint.png +0 -0
  126. data/public/panda-cms-assets/panda-nav.png +0 -0
  127. data/public/panda-cms-assets/rich_text_editor.css +0 -568
  128. /data/db/migrate/{20251105000001_add_pending_review_status_to_pages_and_posts.panda_cms.rb → 20251105000001_add_pending_review_status_to_pages_and_posts.rb} +0 -0
@@ -0,0 +1,165 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["input", "preview", "dropzone", "fileInfo", "removeButton"]
5
+
6
+ connect() {
7
+ // Setup drag and drop handlers
8
+ if (this.hasDropzoneTarget) {
9
+ this.setupDragAndDrop()
10
+ }
11
+ }
12
+
13
+ setupDragAndDrop() {
14
+ const dropzone = this.dropzoneTarget
15
+
16
+ // Prevent default drag behaviors
17
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
18
+ dropzone.addEventListener(eventName, this.preventDefaults.bind(this), false)
19
+ document.body.addEventListener(eventName, this.preventDefaults.bind(this), false)
20
+ })
21
+
22
+ // Highlight drop zone when item is dragged over it
23
+ ['dragenter', 'dragover'].forEach(eventName => {
24
+ dropzone.addEventListener(eventName, this.highlight.bind(this), false)
25
+ })
26
+
27
+ ;['dragleave', 'drop'].forEach(eventName => {
28
+ dropzone.addEventListener(eventName, this.unhighlight.bind(this), false)
29
+ })
30
+
31
+ // Handle dropped files
32
+ dropzone.addEventListener('drop', this.handleDrop.bind(this), false)
33
+ }
34
+
35
+ preventDefaults(e) {
36
+ e.preventDefault()
37
+ e.stopPropagation()
38
+ }
39
+
40
+ highlight(e) {
41
+ this.dropzoneTarget.classList.add('border-indigo-600', 'bg-indigo-50', 'dark:bg-indigo-950', 'dark:border-indigo-400')
42
+ }
43
+
44
+ unhighlight(e) {
45
+ this.dropzoneTarget.classList.remove('border-indigo-600', 'bg-indigo-50', 'dark:bg-indigo-950', 'dark:border-indigo-400')
46
+ }
47
+
48
+ handleDrop(e) {
49
+ const dt = e.dataTransfer
50
+ const files = dt.files
51
+
52
+ if (files.length > 0) {
53
+ // Update the file input with the dropped file
54
+ this.inputTarget.files = files
55
+ this.handleFileSelect()
56
+ }
57
+ }
58
+
59
+ // Triggered when user selects file via input or drag-drop
60
+ handleFileSelect() {
61
+ const file = this.inputTarget.files[0]
62
+
63
+ if (!file) {
64
+ return
65
+ }
66
+
67
+ // Show file preview if it's an image
68
+ if (file.type.startsWith('image/')) {
69
+ this.showImagePreview(file)
70
+ } else {
71
+ this.showFileInfo(file)
72
+ }
73
+ }
74
+
75
+ showImagePreview(file) {
76
+ const reader = new FileReader()
77
+
78
+ reader.onload = (e) => {
79
+ if (this.hasPreviewTarget) {
80
+ // Create or update preview image
81
+ const existingImage = this.previewTarget.querySelector('img')
82
+ if (existingImage) {
83
+ existingImage.src = e.target.result
84
+ } else {
85
+ const img = document.createElement('img')
86
+ img.src = e.target.result
87
+ img.className = 'max-w-xs rounded border border-gray-300 dark:border-gray-600'
88
+ this.previewTarget.innerHTML = ''
89
+ this.previewTarget.appendChild(img)
90
+ }
91
+ this.previewTarget.classList.remove('hidden')
92
+ }
93
+
94
+ // Show file info with remove button
95
+ this.showFileInfo(file, true)
96
+ }
97
+
98
+ reader.readAsDataURL(file)
99
+ }
100
+
101
+ showFileInfo(file, withPreview = false) {
102
+ if (!this.hasFileInfoTarget) {
103
+ return
104
+ }
105
+
106
+ const fileSize = this.humanFileSize(file.size)
107
+ const fileName = file.name
108
+
109
+ this.fileInfoTarget.innerHTML = `
110
+ <div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
111
+ <div class="flex items-center gap-x-3 flex-1 min-w-0">
112
+ <svg class="size-8 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
113
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
114
+ </svg>
115
+ <div class="flex-1 min-w-0">
116
+ <p class="text-sm font-medium text-gray-900 dark:text-white truncate">${fileName}</p>
117
+ <p class="text-xs text-gray-500 dark:text-gray-400">${fileSize}</p>
118
+ </div>
119
+ </div>
120
+ <button type="button"
121
+ data-action="click->file-upload#removeFile"
122
+ class="flex-shrink-0 ml-3 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
123
+ <svg class="size-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
124
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
125
+ </svg>
126
+ </button>
127
+ </div>
128
+ `
129
+ this.fileInfoTarget.classList.remove('hidden')
130
+
131
+ // Hide the dropzone upload area
132
+ if (this.hasDropzoneTarget) {
133
+ this.dropzoneTarget.classList.add('hidden')
134
+ }
135
+ }
136
+
137
+ removeFile() {
138
+ // Clear the file input
139
+ this.inputTarget.value = ''
140
+
141
+ // Hide preview and file info
142
+ if (this.hasPreviewTarget) {
143
+ this.previewTarget.classList.add('hidden')
144
+ this.previewTarget.innerHTML = ''
145
+ }
146
+
147
+ if (this.hasFileInfoTarget) {
148
+ this.fileInfoTarget.classList.add('hidden')
149
+ this.fileInfoTarget.innerHTML = ''
150
+ }
151
+
152
+ // Show the dropzone again
153
+ if (this.hasDropzoneTarget) {
154
+ this.dropzoneTarget.classList.remove('hidden')
155
+ }
156
+ }
157
+
158
+ humanFileSize(bytes) {
159
+ if (bytes === 0) return '0 Bytes'
160
+ const k = 1024
161
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
162
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
163
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
164
+ }
165
+ }
@@ -21,6 +21,12 @@ pandaCmsApplication.register("tree", TreeController)
21
21
  import FileGalleryController from "./file_gallery_controller.js"
22
22
  pandaCmsApplication.register("file-gallery", FileGalleryController)
23
23
 
24
+ import FileUploadController from "./file_upload_controller.js"
25
+ pandaCmsApplication.register("file-upload", FileUploadController)
26
+
27
+ import PageFormController from "./page_form_controller.js"
28
+ pandaCmsApplication.register("page-form", PageFormController)
29
+
24
30
  import NestedFormController from "./nested_form_controller.js"
25
31
  pandaCmsApplication.register("nested-form", NestedFormController)
26
32
 
@@ -10,25 +10,38 @@ export default class extends Controller {
10
10
  }
11
11
 
12
12
  kindChanged(event) {
13
+ console.log("[menu-form] kindChanged called", event)
13
14
  this.updateFieldsVisibility()
14
15
  }
15
16
 
16
17
  updateFieldsVisibility() {
18
+ console.log("[menu-form] updateFieldsVisibility called")
17
19
  const kindSelect = this.element.querySelector('select[name*="[kind]"]')
18
- if (!kindSelect) return
20
+ console.log("[menu-form] kindSelect found:", !!kindSelect)
21
+
22
+ if (!kindSelect) {
23
+ console.error("[menu-form] Could not find kind select!")
24
+ return
25
+ }
19
26
 
20
27
  const selectedKind = kindSelect.value
28
+ console.log("[menu-form] selectedKind:", selectedKind)
21
29
 
22
30
  if (selectedKind === "auto") {
23
31
  // Show start page field, hide menu items section
32
+ console.log("[menu-form] AUTO - Showing start page field")
24
33
  if (this.hasStartPageFieldTarget) {
34
+ console.log("[menu-form] Removing hidden from start page field")
25
35
  this.startPageFieldTarget.classList.remove("hidden")
36
+ } else {
37
+ console.error("[menu-form] Start page field target not found!")
26
38
  }
27
39
  if (this.hasMenuItemsSectionTarget) {
28
40
  this.menuItemsSectionTarget.classList.add("hidden")
29
41
  }
30
42
  } else {
31
43
  // Hide start page field, show menu items section
44
+ console.log("[menu-form] STATIC - Hiding start page field")
32
45
  if (this.hasStartPageFieldTarget) {
33
46
  this.startPageFieldTarget.classList.add("hidden")
34
47
  }
@@ -0,0 +1,454 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = [
5
+ "inheritCheckbox",
6
+ "pageTitle",
7
+ "seoTitle",
8
+ "seoDescription",
9
+ "seoKeywords",
10
+ "canonicalUrl",
11
+ "ogTitle",
12
+ "ogDescription",
13
+ "ogType",
14
+ "generateButton"
15
+ ]
16
+
17
+ static values = {
18
+ parentSeoData: Object,
19
+ pageId: String,
20
+ aiGenerationUrl: String,
21
+ maxSeoTitle: { type: Number, default: 70 },
22
+ maxSeoDescription: { type: Number, default: 160 },
23
+ maxOgTitle: { type: Number, default: 60 },
24
+ maxOgDescription: { type: Number, default: 200 }
25
+ }
26
+
27
+ connect() {
28
+ console.log("[Page Form] Controller connected")
29
+
30
+ // Initialize character counters
31
+ this.initializeCharacterCounters()
32
+
33
+ // Set initial inherit state if checkbox exists
34
+ if (this.hasInheritCheckboxTarget) {
35
+ this.updateInheritState()
36
+ }
37
+
38
+ // Add auto-fill listeners
39
+ this.setupAutoFillListeners()
40
+
41
+ // Hide AI generation button if URL is not available (cms-pro not installed)
42
+ if (this.hasGenerateButtonTarget && !this.hasAiGenerationUrlValue) {
43
+ this.generateButtonTarget.closest('div').style.display = 'none'
44
+ }
45
+ }
46
+
47
+ initializeCharacterCounters() {
48
+ // Add character counters for fields with limits
49
+ const fieldConfigs = [
50
+ { target: "seoTitle", max: this.maxSeoTitleValue, label: "SEO Title" },
51
+ { target: "seoDescription", max: this.maxSeoDescriptionValue, label: "SEO Description" },
52
+ { target: "ogTitle", max: this.maxOgTitleValue, label: "Social Media Title" },
53
+ { target: "ogDescription", max: this.maxOgDescriptionValue, label: "Social Media Description" }
54
+ ]
55
+
56
+ fieldConfigs.forEach(config => {
57
+ const targetName = `${config.target}Target`
58
+ if (this[`has${config.target.charAt(0).toUpperCase() + config.target.slice(1)}Target`]) {
59
+ const field = this[targetName]
60
+ this.createCharacterCounter(field, config.max)
61
+
62
+ // Update counter on input
63
+ field.addEventListener('input', () => {
64
+ this.updateCharacterCounter(field, config.max)
65
+ })
66
+ }
67
+ })
68
+ }
69
+
70
+ createCharacterCounter(field, maxLength) {
71
+ // Find or create counter element
72
+ const container = field.closest('.panda-core-field-container')
73
+ if (!container) return
74
+
75
+ let counter = container.querySelector('.character-counter')
76
+ if (!counter) {
77
+ counter = document.createElement('div')
78
+ counter.className = 'character-counter text-xs mt-1 text-gray-500 dark:text-gray-400'
79
+
80
+ // Insert after the field but before error messages
81
+ const errorMsg = container.querySelector('.text-red-600')
82
+ if (errorMsg) {
83
+ errorMsg.before(counter)
84
+ } else {
85
+ container.appendChild(counter)
86
+ }
87
+ }
88
+
89
+ this.updateCharacterCounter(field, maxLength)
90
+ }
91
+
92
+ updateCharacterCounter(field, maxLength) {
93
+ const container = field.closest('.panda-core-field-container')
94
+ if (!container) return
95
+
96
+ const counter = container.querySelector('.character-counter')
97
+ if (!counter) return
98
+
99
+ const currentLength = field.value.length
100
+ const remaining = maxLength - currentLength
101
+
102
+ // Update counter text and styling
103
+ counter.textContent = `${currentLength} / ${maxLength} characters`
104
+
105
+ if (remaining < 0) {
106
+ counter.classList.remove('text-gray-500', 'dark:text-gray-400', 'text-yellow-600', 'dark:text-yellow-400')
107
+ counter.classList.add('text-red-600', 'dark:text-red-400', 'font-semibold')
108
+ counter.textContent += ` (${Math.abs(remaining)} over limit)`
109
+ } else if (remaining < 10) {
110
+ counter.classList.remove('text-gray-500', 'dark:text-gray-400', 'text-red-600', 'dark:text-red-400')
111
+ counter.classList.add('text-yellow-600', 'dark:text-yellow-400')
112
+ } else {
113
+ counter.classList.remove('text-red-600', 'dark:text-red-400', 'text-yellow-600', 'dark:text-yellow-400', 'font-semibold')
114
+ counter.classList.add('text-gray-500', 'dark:text-gray-400')
115
+ }
116
+ }
117
+
118
+ setupAutoFillListeners() {
119
+ // Initialize auto-fill on connect (for page load)
120
+ this.applyAutoFillDefaults()
121
+
122
+ // Auto-fill SEO title from page title on blur
123
+ if (this.hasPageTitleTarget && this.hasSeoTitleTarget) {
124
+ this.pageTitleTarget.addEventListener('blur', () => {
125
+ this.autoFillSeoTitle()
126
+ })
127
+ }
128
+
129
+ // Auto-fill OG title from SEO title on blur
130
+ if (this.hasSeoTitleTarget && this.hasOgTitleTarget) {
131
+ this.seoTitleTarget.addEventListener('blur', () => {
132
+ this.autoFillOgTitle()
133
+ })
134
+ }
135
+
136
+ // Auto-fill OG description from SEO description on blur
137
+ if (this.hasSeoDescriptionTarget && this.hasOgDescriptionTarget) {
138
+ this.seoDescriptionTarget.addEventListener('blur', () => {
139
+ this.autoFillOgDescription()
140
+ })
141
+ }
142
+ }
143
+
144
+ applyAutoFillDefaults() {
145
+ // On page load, set placeholders for empty fields
146
+ if (this.hasSeoTitleTarget && this.hasPageTitleTarget) {
147
+ this.updatePlaceholder(this.seoTitleTarget, this.pageTitleTarget.value)
148
+ }
149
+
150
+ if (this.hasOgTitleTarget) {
151
+ const fallback = this.getSeoTitleValue() || this.getPageTitleValue() || ''
152
+ this.updatePlaceholder(this.ogTitleTarget, fallback)
153
+ }
154
+
155
+ if (this.hasOgDescriptionTarget && this.hasSeoDescriptionTarget) {
156
+ this.updatePlaceholder(this.ogDescriptionTarget, this.seoDescriptionTarget.value)
157
+ }
158
+ }
159
+
160
+ autoFillSeoTitle() {
161
+ if (!this.hasSeoTitleTarget || !this.hasPageTitleTarget) return
162
+
163
+ // Only fill if SEO title is empty
164
+ if (this.seoTitleTarget.value.trim() === '') {
165
+ const pageTitle = this.pageTitleTarget.value.trim()
166
+ if (pageTitle) {
167
+ this.seoTitleTarget.value = pageTitle
168
+ this.updateCharacterCounter(this.seoTitleTarget, this.maxSeoTitleValue)
169
+ }
170
+ }
171
+ }
172
+
173
+ autoFillOgTitle() {
174
+ if (!this.hasOgTitleTarget) return
175
+
176
+ // Only fill if OG title is empty
177
+ if (this.ogTitleTarget.value.trim() === '') {
178
+ const fallback = this.getSeoTitleValue() || this.getPageTitleValue() || ''
179
+ if (fallback) {
180
+ this.ogTitleTarget.value = fallback
181
+ this.updateCharacterCounter(this.ogTitleTarget, this.maxOgTitleValue)
182
+ }
183
+ }
184
+ }
185
+
186
+ autoFillOgDescription() {
187
+ if (!this.hasOgDescriptionTarget || !this.hasSeoDescriptionTarget) return
188
+
189
+ // Only fill if OG description is empty
190
+ if (this.ogDescriptionTarget.value.trim() === '') {
191
+ const seoDesc = this.seoDescriptionTarget.value.trim()
192
+ if (seoDesc) {
193
+ this.ogDescriptionTarget.value = seoDesc
194
+ this.updateCharacterCounter(this.ogDescriptionTarget, this.maxOgDescriptionValue)
195
+ }
196
+ }
197
+ }
198
+
199
+ getPageTitleValue() {
200
+ return this.hasPageTitleTarget ? this.pageTitleTarget.value.trim() : ''
201
+ }
202
+
203
+ getSeoTitleValue() {
204
+ return this.hasSeoTitleTarget ? this.seoTitleTarget.value.trim() : ''
205
+ }
206
+
207
+ updatePlaceholder(field, value) {
208
+ if (field.value.trim() === '' && value && value.trim() !== '') {
209
+ field.setAttribute('placeholder', value)
210
+ }
211
+ }
212
+
213
+ // Handle inherit settings checkbox toggle
214
+ toggleInherit(event) {
215
+ const isChecked = event.target.checked
216
+ console.log(`[Page Form] Inherit settings: ${isChecked}`)
217
+
218
+ this.updateInheritState()
219
+ }
220
+
221
+ updateInheritState() {
222
+ if (!this.hasInheritCheckboxTarget) return
223
+
224
+ const isInheriting = this.inheritCheckboxTarget.checked
225
+
226
+ // Get SEO field targets
227
+ const seoFields = [
228
+ 'seoTitle',
229
+ 'seoDescription',
230
+ 'seoKeywords',
231
+ 'canonicalUrl',
232
+ 'ogTitle',
233
+ 'ogDescription',
234
+ 'ogType'
235
+ ]
236
+
237
+ seoFields.forEach(fieldName => {
238
+ const targetName = `${fieldName}Target`
239
+ const hasTarget = this[`has${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}Target`]
240
+
241
+ if (hasTarget) {
242
+ const field = this[targetName]
243
+
244
+ if (isInheriting) {
245
+ // Copy parent values and make readonly
246
+ this.copyParentValue(field, fieldName)
247
+ field.setAttribute('readonly', true)
248
+ field.classList.add('cursor-not-allowed', 'bg-gray-50', 'dark:bg-white/10')
249
+ } else {
250
+ // Make editable
251
+ field.removeAttribute('readonly')
252
+ field.classList.remove('cursor-not-allowed', 'bg-gray-50', 'dark:bg-white/10')
253
+ }
254
+ }
255
+ })
256
+ }
257
+
258
+ copyParentValue(field, fieldName) {
259
+ // If we have parent SEO data, use it
260
+ if (this.hasParentSeoDataValue && this.parentSeoDataValue[fieldName]) {
261
+ field.value = this.parentSeoDataValue[fieldName]
262
+
263
+ // Update character counter if it exists
264
+ const maxValue = this[`max${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}Value`]
265
+ if (maxValue) {
266
+ this.updateCharacterCounter(field, maxValue)
267
+ }
268
+ }
269
+ }
270
+
271
+ // Validate form before submission
272
+ validateForm(event) {
273
+ let isValid = true
274
+ const errors = []
275
+
276
+ // Check SEO title length
277
+ if (this.hasSeoTitleTarget && this.seoTitleTarget.value.length > this.maxSeoTitleValue) {
278
+ errors.push(`SEO Title is ${this.seoTitleTarget.value.length - this.maxSeoTitleValue} characters over the ${this.maxSeoTitleValue} character limit`)
279
+ isValid = false
280
+ }
281
+
282
+ // Check SEO description length
283
+ if (this.hasSeoDescriptionTarget && this.seoDescriptionTarget.value.length > this.maxSeoDescriptionValue) {
284
+ errors.push(`SEO Description is ${this.seoDescriptionTarget.value.length - this.maxSeoDescriptionValue} characters over the ${this.maxSeoDescriptionValue} character limit`)
285
+ isValid = false
286
+ }
287
+
288
+ // Check OG title length
289
+ if (this.hasOgTitleTarget && this.ogTitleTarget.value.length > this.maxOgTitleValue) {
290
+ errors.push(`Social Media Title is ${this.ogTitleTarget.value.length - this.maxOgTitleValue} characters over the ${this.maxOgTitleValue} character limit`)
291
+ isValid = false
292
+ }
293
+
294
+ // Check OG description length
295
+ if (this.hasOgDescriptionTarget && this.ogDescriptionTarget.value.length > this.maxOgDescriptionValue) {
296
+ errors.push(`Social Media Description is ${this.ogDescriptionTarget.value.length - this.maxOgDescriptionValue} characters over the ${this.maxOgDescriptionValue} character limit`)
297
+ isValid = false
298
+ }
299
+
300
+ if (!isValid) {
301
+ // Show validation errors
302
+ const errorMessage = errors.join('\n')
303
+
304
+ // If we can prevent form submission, do so and show errors
305
+ if (event && event.preventDefault) {
306
+ event.preventDefault()
307
+ alert(`Please fix the following errors:\n\n${errorMessage}`)
308
+ }
309
+
310
+ return false
311
+ }
312
+
313
+ return true
314
+ }
315
+
316
+ // Generate SEO content using AI
317
+ async generateSeoWithAI() {
318
+ if (!this.hasAiGenerationUrlValue) {
319
+ console.error("[Page Form] No AI generation URL available")
320
+ this.showError("AI generation is not available. Please ensure panda-cms-pro is installed and configured.")
321
+ return
322
+ }
323
+
324
+ // Get CSRF token
325
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
326
+ if (!csrfToken) {
327
+ console.error("[Page Form] CSRF token not found")
328
+ this.showError("Security token not found. Please refresh the page.")
329
+ return
330
+ }
331
+
332
+ // Disable button and show loading state
333
+ const button = this.hasGenerateButtonTarget ? this.generateButtonTarget : null
334
+ const originalButtonText = button?.innerHTML || ""
335
+
336
+ if (button) {
337
+ button.disabled = true
338
+ button.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Generating...'
339
+ }
340
+
341
+ try {
342
+ // Make API request using the URL from Rails
343
+ const response = await fetch(this.aiGenerationUrlValue, {
344
+ method: 'POST',
345
+ headers: {
346
+ 'Content-Type': 'application/json',
347
+ 'X-CSRF-Token': csrfToken,
348
+ 'Accept': 'application/json'
349
+ }
350
+ })
351
+
352
+ const data = await response.json()
353
+
354
+ if (response.ok && data.success) {
355
+ // Fill in the generated SEO fields
356
+ this.fillGeneratedFields(data)
357
+ this.showSuccess(`SEO content generated successfully using ${data.provider} (${data.model})`)
358
+ } else {
359
+ // Handle errors from the API
360
+ const errorMessage = data.message || "Failed to generate SEO content"
361
+
362
+ if (data.error === 'no_provider') {
363
+ this.showError("No AI provider configured. Please configure an AI provider in settings.")
364
+ } else {
365
+ this.showError(errorMessage)
366
+ }
367
+ }
368
+ } catch (error) {
369
+ console.error("[Page Form] AI generation error:", error)
370
+ this.showError("Failed to connect to AI service. Please try again.")
371
+ } finally {
372
+ // Re-enable button
373
+ if (button) {
374
+ button.disabled = false
375
+ button.innerHTML = originalButtonText
376
+ }
377
+ }
378
+ }
379
+
380
+ fillGeneratedFields(data) {
381
+ // Fill SEO fields with generated content
382
+ if (data.seo_title && this.hasSeoTitleTarget) {
383
+ this.seoTitleTarget.value = data.seo_title
384
+ this.updateCharacterCounter(this.seoTitleTarget, this.maxSeoTitleValue)
385
+ }
386
+
387
+ if (data.seo_description && this.hasSeoDescriptionTarget) {
388
+ this.seoDescriptionTarget.value = data.seo_description
389
+ this.updateCharacterCounter(this.seoDescriptionTarget, this.maxSeoDescriptionValue)
390
+ }
391
+
392
+ if (data.seo_keywords && this.hasSeoKeywordsTarget) {
393
+ this.seoKeywordsTarget.value = data.seo_keywords
394
+ }
395
+
396
+ if (data.og_title && this.hasOgTitleTarget) {
397
+ this.ogTitleTarget.value = data.og_title
398
+ this.updateCharacterCounter(this.ogTitleTarget, this.maxOgTitleValue)
399
+ }
400
+
401
+ if (data.og_description && this.hasOgDescriptionTarget) {
402
+ this.ogDescriptionTarget.value = data.og_description
403
+ this.updateCharacterCounter(this.ogDescriptionTarget, this.maxOgDescriptionValue)
404
+ }
405
+
406
+ console.log("[Page Form] Successfully filled AI-generated SEO fields")
407
+ }
408
+
409
+ showSuccess(message) {
410
+ // Find or create success message element
411
+ const successEl = document.getElementById('successMessage')
412
+ if (successEl) {
413
+ const messageContainer = successEl.querySelector('[role="alert"]')
414
+ if (messageContainer) {
415
+ const messageText = messageContainer.querySelector('p')
416
+ if (messageText) {
417
+ messageText.textContent = message
418
+ }
419
+ }
420
+ successEl.classList.remove('hidden')
421
+
422
+ // Auto-hide after 5 seconds
423
+ setTimeout(() => {
424
+ successEl.classList.add('hidden')
425
+ }, 5000)
426
+ } else {
427
+ // Fallback to alert if flash message component not found
428
+ alert(message)
429
+ }
430
+ }
431
+
432
+ showError(message) {
433
+ // Find or create error message element
434
+ const errorEl = document.getElementById('errorMessage')
435
+ if (errorEl) {
436
+ const messageContainer = errorEl.querySelector('[role="alert"]')
437
+ if (messageContainer) {
438
+ const messageText = messageContainer.querySelector('p')
439
+ if (messageText) {
440
+ messageText.textContent = message
441
+ }
442
+ }
443
+ errorEl.classList.remove('hidden')
444
+
445
+ // Auto-hide after 8 seconds
446
+ setTimeout(() => {
447
+ errorEl.classList.add('hidden')
448
+ }, 8000)
449
+ } else {
450
+ // Fallback to alert if flash message component not found
451
+ alert(message)
452
+ }
453
+ }
454
+ }
@@ -3,7 +3,8 @@
3
3
 
4
4
  // Import the shared Stimulus application from Panda Core
5
5
  // This ensures all controllers (Core and CMS) are registered in the same application
6
- import { application } from "/panda/core/application.js"
6
+ // Use the importmap module name, not an absolute path
7
+ import { application } from "panda/core/application"
7
8
 
8
9
  // The application is already started and configured in Core
9
10
  // No need to start it again or configure debug mode
@@ -23,7 +23,8 @@ module Panda
23
23
  user_agent: user_agent,
24
24
  ip_address: ip_address,
25
25
  referrer: referer, # TODO: Fix the naming of this column
26
- params: params
26
+ params: params,
27
+ visited_at: Time.current
27
28
  )
28
29
  end
29
30
  end