panda-cms 0.8.2 → 0.10.2

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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +75 -5
  3. data/app/components/panda/cms/code_component.rb +154 -39
  4. data/app/components/panda/cms/grid_component.rb +26 -6
  5. data/app/components/panda/cms/menu_component.rb +72 -34
  6. data/app/components/panda/cms/page_menu_component.rb +102 -13
  7. data/app/components/panda/cms/rich_text_component.rb +229 -139
  8. data/app/components/panda/cms/text_component.rb +107 -42
  9. data/app/controllers/panda/cms/admin/base_controller.rb +19 -3
  10. data/app/controllers/panda/cms/admin/dashboard_controller.rb +3 -3
  11. data/app/controllers/panda/cms/admin/files_controller.rb +7 -0
  12. data/app/controllers/panda/cms/admin/menus_controller.rb +47 -3
  13. data/app/controllers/panda/cms/admin/pages_controller.rb +11 -2
  14. data/app/controllers/panda/cms/admin/posts_controller.rb +3 -1
  15. data/app/controllers/panda/cms/form_submissions_controller.rb +134 -11
  16. data/app/controllers/panda/cms/pages_controller.rb +7 -2
  17. data/app/controllers/panda/cms/posts_controller.rb +16 -0
  18. data/app/helpers/panda/cms/application_helper.rb +17 -4
  19. data/app/helpers/panda/cms/asset_helper.rb +14 -61
  20. data/app/helpers/panda/cms/forms_helper.rb +60 -0
  21. data/app/helpers/panda/cms/seo_helper.rb +85 -0
  22. data/app/javascript/panda/cms/{application_panda_cms.js → application.js} +5 -1
  23. data/app/javascript/panda/cms/controllers/code_editor_controller.js +95 -0
  24. data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +31 -4
  25. data/app/javascript/panda/cms/controllers/file_gallery_controller.js +128 -0
  26. data/app/javascript/panda/cms/controllers/file_upload_controller.js +165 -0
  27. data/app/javascript/panda/cms/controllers/index.js +54 -13
  28. data/app/javascript/panda/cms/controllers/inline_code_editor_controller.js +96 -0
  29. data/app/javascript/panda/cms/controllers/menu_form_controller.js +53 -0
  30. data/app/javascript/panda/cms/controllers/nested_form_controller.js +35 -0
  31. data/app/javascript/panda/cms/controllers/page_form_controller.js +454 -0
  32. data/app/javascript/panda/cms/controllers/tree_controller.js +214 -0
  33. data/app/javascript/panda/cms/stimulus-loading.js +6 -7
  34. data/app/models/panda/cms/block_content.rb +9 -0
  35. data/app/models/panda/cms/menu.rb +12 -0
  36. data/app/models/panda/cms/page.rb +147 -0
  37. data/app/models/panda/cms/post.rb +98 -0
  38. data/app/views/layouts/homepage.html.erb +1 -4
  39. data/app/views/layouts/page.html.erb +1 -4
  40. data/app/views/panda/cms/admin/dashboard/show.html.erb +5 -5
  41. data/app/views/panda/cms/admin/files/_file_details.html.erb +45 -0
  42. data/app/views/panda/cms/admin/files/index.html.erb +11 -118
  43. data/app/views/panda/cms/admin/forms/index.html.erb +2 -2
  44. data/app/views/panda/cms/admin/forms/new.html.erb +1 -2
  45. data/app/views/panda/cms/admin/forms/show.html.erb +15 -30
  46. data/app/views/panda/cms/admin/menus/_menu_item_fields.html.erb +11 -0
  47. data/app/views/panda/cms/admin/menus/edit.html.erb +62 -0
  48. data/app/views/panda/cms/admin/menus/index.html.erb +3 -2
  49. data/app/views/panda/cms/admin/menus/new.html.erb +38 -0
  50. data/app/views/panda/cms/admin/pages/edit.html.erb +147 -22
  51. data/app/views/panda/cms/admin/pages/index.html.erb +49 -11
  52. data/app/views/panda/cms/admin/pages/new.html.erb +3 -11
  53. data/app/views/panda/cms/admin/posts/_form.html.erb +44 -15
  54. data/app/views/panda/cms/admin/posts/edit.html.erb +2 -2
  55. data/app/views/panda/cms/admin/posts/index.html.erb +6 -6
  56. data/app/views/panda/cms/admin/posts/new.html.erb +1 -1
  57. data/app/views/panda/cms/admin/settings/bulk_editor/new.html.erb +1 -1
  58. data/app/views/panda/cms/admin/settings/index.html.erb +3 -3
  59. data/app/views/shared/_header.html.erb +1 -4
  60. data/config/brakeman.ignore +38 -0
  61. data/config/importmap.rb +10 -10
  62. data/config/initializers/panda/cms/healthcheck_log_silencer.rb.disabled +31 -0
  63. data/config/initializers/panda/cms.rb +52 -10
  64. data/config/locales/en.yml +41 -0
  65. data/config/routes.rb +5 -3
  66. data/db/migrate/20240305000000_convert_html_content_to_editor_js.rb +2 -2
  67. data/db/migrate/20240315125421_add_nested_sets_to_panda_cms_pages.rb +6 -1
  68. data/db/migrate/20250809231125_migrate_users_to_panda_core.rb +23 -21
  69. data/db/migrate/20251104150640_add_cached_last_updated_at_to_panda_cms_pages.rb +22 -0
  70. data/db/migrate/20251104172242_add_page_type_to_panda_cms_pages.rb +6 -0
  71. data/db/migrate/20251104172638_set_page_types_for_existing_pages.rb +27 -0
  72. data/db/migrate/20251105000001_add_pending_review_status_to_pages_and_posts.panda_cms.rb +21 -0
  73. data/db/migrate/20251109131150_add_seo_fields_to_pages.rb +32 -0
  74. data/db/migrate/20251109131205_add_seo_fields_to_posts.rb +27 -0
  75. data/db/migrate/20251110114258_add_spam_tracking_to_form_submissions.rb +7 -0
  76. data/db/migrate/20251110122812_add_performance_indexes_to_pages_and_redirects.rb +13 -0
  77. data/lib/generators/panda/cms/install_generator.rb +2 -5
  78. data/lib/panda/cms/asset_loader.rb +46 -76
  79. data/lib/panda/cms/bulk_editor.rb +288 -12
  80. data/lib/panda/cms/debug.rb +29 -0
  81. data/lib/panda/cms/engine/asset_config.rb +49 -0
  82. data/lib/panda/cms/engine/autoload_config.rb +19 -0
  83. data/lib/panda/cms/engine/backtrace_config.rb +42 -0
  84. data/lib/panda/cms/engine/core_config.rb +106 -0
  85. data/lib/panda/cms/engine/helper_config.rb +20 -0
  86. data/lib/panda/cms/engine/route_config.rb +34 -0
  87. data/lib/panda/cms/engine/view_component_config.rb +31 -0
  88. data/lib/panda/cms/engine.rb +44 -162
  89. data/lib/panda/cms/features.rb +52 -0
  90. data/lib/panda/cms.rb +10 -0
  91. data/lib/panda-cms/version.rb +1 -1
  92. data/lib/panda-cms.rb +20 -7
  93. data/lib/tasks/panda_cms_tasks.rake +16 -0
  94. metadata +41 -50
  95. data/app/components/panda/cms/admin/container_component.html.erb +0 -13
  96. data/app/components/panda/cms/admin/flash_message_component.html.erb +0 -31
  97. data/app/components/panda/cms/admin/panel_component.html.erb +0 -7
  98. data/app/components/panda/cms/admin/slideover_component.html.erb +0 -9
  99. data/app/components/panda/cms/admin/slideover_component.rb +0 -15
  100. data/app/components/panda/cms/admin/statistics_component.html.erb +0 -4
  101. data/app/components/panda/cms/admin/statistics_component.rb +0 -16
  102. data/app/components/panda/cms/admin/tab_bar_component.html.erb +0 -35
  103. data/app/components/panda/cms/admin/tab_bar_component.rb +0 -15
  104. data/app/components/panda/cms/admin/table_component.html.erb +0 -29
  105. data/app/components/panda/cms/admin/user_activity_component.html.erb +0 -7
  106. data/app/components/panda/cms/admin/user_activity_component.rb +0 -20
  107. data/app/components/panda/cms/admin/user_display_component.html.erb +0 -17
  108. data/app/components/panda/cms/admin/user_display_component.rb +0 -21
  109. data/app/components/panda/cms/grid_component.html.erb +0 -6
  110. data/app/components/panda/cms/menu_component.html.erb +0 -6
  111. data/app/components/panda/cms/page_menu_component.html.erb +0 -21
  112. data/app/components/panda/cms/rich_text_component.html.erb +0 -90
  113. data/app/javascript/panda_cms/stimulus-loading.js +0 -39
  114. data/app/views/layouts/panda/cms/application.html.erb +0 -42
  115. data/app/views/panda/cms/admin/shared/_breadcrumbs.html.erb +0 -28
  116. data/app/views/panda/cms/admin/shared/_flash.html.erb +0 -5
  117. data/app/views/panda/cms/admin/shared/_sidebar.html.erb +0 -41
  118. data/app/views/panda/cms/shared/_footer.html.erb +0 -2
  119. data/app/views/panda/cms/shared/_header.html.erb +0 -25
  120. data/app/views/panda/cms/shared/_importmap.html.erb +0 -34
  121. data/config/initializers/inflections.rb +0 -5
  122. data/config/initializers/panda/cms/healthcheck_log_silencer.rb +0 -13
  123. data/lib/tasks/assets.rake +0 -587
@@ -0,0 +1,35 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["template", "target"]
5
+ static values = {
6
+ wrapperSelector: String
7
+ }
8
+
9
+ connect() {
10
+ console.log("Nested form controller connected")
11
+ }
12
+
13
+ add(event) {
14
+ event.preventDefault()
15
+
16
+ const content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, new Date().getTime())
17
+ this.targetTarget.insertAdjacentHTML('beforebegin', content)
18
+ }
19
+
20
+ remove(event) {
21
+ event.preventDefault()
22
+
23
+ const wrapper = event.target.closest(this.wrapperSelectorValue)
24
+
25
+ if (wrapper.dataset.newRecord === "true") {
26
+ wrapper.remove()
27
+ } else {
28
+ wrapper.style.display = 'none'
29
+ const destroyInput = wrapper.querySelector("input[name*='_destroy']")
30
+ if (destroyInput) {
31
+ destroyInput.value = '1'
32
+ }
33
+ }
34
+ }
35
+ }
@@ -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
+ }
@@ -0,0 +1,214 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["row", "toggle", "container"]
5
+ static values = {
6
+ collapsed: { type: Array, default: [] }
7
+ }
8
+
9
+ connect() {
10
+ const hasStoredState = localStorage.getItem('panda-cms-pages-collapsed')
11
+
12
+ if (hasStoredState) {
13
+ this.loadCollapsedState()
14
+ } else {
15
+ this.initializeTree()
16
+ }
17
+ }
18
+
19
+ // Helper to get the actual table row element from our target div
20
+ getTableRow(rowTarget) {
21
+ return rowTarget.closest('.table-row')
22
+ }
23
+
24
+ initializeTree() {
25
+ // Collapse all level 1 pages (direct children of Home) by default
26
+ this.rowTargets.forEach(row => {
27
+ const level = parseInt(row.dataset.level)
28
+ const pageId = row.dataset.pageId
29
+
30
+ // If it's a level 1 page with children, mark it as collapsed
31
+ if (level === 1) {
32
+ const hasChildren = row.querySelector('[data-tree-target="toggle"]')
33
+ if (hasChildren) {
34
+ this.collapsedValue = [...this.collapsedValue, pageId]
35
+ this.updateToggleIcon(pageId, true)
36
+ }
37
+ }
38
+
39
+ // Hide everything below level 1 (level > 1)
40
+ if (level > 1) {
41
+ const tableRow = this.getTableRow(row)
42
+ if (tableRow) tableRow.style.display = 'none'
43
+ }
44
+ })
45
+
46
+ // Save the initial collapsed state
47
+ this.saveCollapsedState()
48
+
49
+ // Fade in the tree after initialization
50
+ this.showTree()
51
+ }
52
+
53
+ toggle(event) {
54
+ event.preventDefault()
55
+ const row = event.currentTarget.closest('[data-tree-target="row"]')
56
+ const pageId = row.dataset.pageId
57
+ const level = parseInt(row.dataset.level)
58
+
59
+ if (this.isCollapsed(pageId)) {
60
+ this.expand(pageId, level)
61
+ } else {
62
+ this.collapse(pageId, level)
63
+ }
64
+
65
+ this.saveCollapsedState()
66
+ }
67
+
68
+ collapse(pageId, level) {
69
+ // Add to collapsed set
70
+ if (!this.collapsedValue.includes(pageId)) {
71
+ this.collapsedValue = [...this.collapsedValue, pageId]
72
+ }
73
+
74
+ // Hide all descendant rows
75
+ const descendants = this.getDescendantRows(pageId, level)
76
+ descendants.forEach(row => {
77
+ const tableRow = this.getTableRow(row)
78
+ if (tableRow) {
79
+ tableRow.style.display = 'none'
80
+ }
81
+ })
82
+
83
+ // Update toggle icon
84
+ this.updateToggleIcon(pageId, true)
85
+ }
86
+
87
+ expand(pageId, level) {
88
+ // Remove from collapsed set
89
+ this.collapsedValue = this.collapsedValue.filter(id => id !== pageId)
90
+
91
+ // Show direct children only (they will handle their own children)
92
+ const directChildren = this.getDirectChildRows(pageId, level)
93
+ directChildren.forEach(row => {
94
+ const tableRow = this.getTableRow(row)
95
+ if (tableRow) tableRow.style.display = ''
96
+ })
97
+
98
+ // Update toggle icon
99
+ this.updateToggleIcon(pageId, false)
100
+ }
101
+
102
+ getDescendantRows(pageId, parentLevel) {
103
+ const allRows = this.rowTargets
104
+ const parentIndex = allRows.findIndex(row => row.dataset.pageId === pageId)
105
+ const descendants = []
106
+
107
+ for (let i = parentIndex + 1; i < allRows.length; i++) {
108
+ const rowLevel = parseInt(allRows[i].dataset.level)
109
+ if (rowLevel <= parentLevel) break
110
+ descendants.push(allRows[i])
111
+ }
112
+
113
+ return descendants
114
+ }
115
+
116
+ getDirectChildRows(pageId, parentLevel) {
117
+ const allRows = this.rowTargets
118
+ const parentIndex = allRows.findIndex(row => row.dataset.pageId === pageId)
119
+ const children = []
120
+
121
+ for (let i = parentIndex + 1; i < allRows.length; i++) {
122
+ const rowLevel = parseInt(allRows[i].dataset.level)
123
+ if (rowLevel <= parentLevel) break
124
+ if (rowLevel === parentLevel + 1) children.push(allRows[i])
125
+ }
126
+
127
+ return children
128
+ }
129
+
130
+ isCollapsed(pageId) {
131
+ return this.collapsedValue.includes(pageId)
132
+ }
133
+
134
+ updateToggleIcon(pageId, collapsed) {
135
+ const row = this.rowTargets.find(r => r.dataset.pageId === pageId)
136
+ if (!row) return
137
+
138
+ const toggle = row.querySelector('[data-tree-target="toggle"]')
139
+ if (!toggle) return
140
+
141
+ const icon = toggle.querySelector('i')
142
+ if (icon) {
143
+ if (collapsed) {
144
+ icon.classList.remove('fa-chevron-down')
145
+ icon.classList.add('fa-chevron-right')
146
+ } else {
147
+ icon.classList.remove('fa-chevron-right')
148
+ icon.classList.add('fa-chevron-down')
149
+ }
150
+ }
151
+ }
152
+
153
+ loadCollapsedState() {
154
+ try {
155
+ const stored = localStorage.getItem('panda-cms-pages-collapsed')
156
+ if (stored) {
157
+ this.collapsedValue = JSON.parse(stored)
158
+
159
+ // First, show all pages at level 1 (direct children of Home)
160
+ this.rowTargets.forEach(row => {
161
+ const level = parseInt(row.dataset.level)
162
+ if (level === 1) {
163
+ const tableRow = this.getTableRow(row)
164
+ if (tableRow) tableRow.style.display = ''
165
+ }
166
+ })
167
+
168
+ // Then apply collapsed state - hiding descendants of collapsed items
169
+ this.collapsedValue.forEach(pageId => {
170
+ const row = this.rowTargets.find(r => r.dataset.pageId === pageId)
171
+ if (row) {
172
+ const level = parseInt(row.dataset.level)
173
+ this.collapse(pageId, level)
174
+ }
175
+ })
176
+
177
+ // For level 1 items NOT in collapsed list, show their children
178
+ this.rowTargets.forEach(row => {
179
+ const level = parseInt(row.dataset.level)
180
+ const pageId = row.dataset.pageId
181
+ const hasToggle = row.querySelector('[data-tree-target="toggle"]')
182
+
183
+ if (level === 1 && hasToggle && !this.isCollapsed(pageId)) {
184
+ // This level 1 item is expanded, show its direct children
185
+ this.getDirectChildRows(pageId, level).forEach(childRow => {
186
+ const tableRow = this.getTableRow(childRow)
187
+ if (tableRow) tableRow.style.display = ''
188
+ })
189
+ }
190
+ })
191
+
192
+ // Fade in the tree after loading state
193
+ this.showTree()
194
+ }
195
+ } catch (e) {
196
+ console.error('Error loading collapsed state:', e)
197
+ }
198
+ }
199
+
200
+ showTree() {
201
+ // Fade in the container
202
+ if (this.hasContainerTarget) {
203
+ this.containerTarget.style.opacity = '1'
204
+ }
205
+ }
206
+
207
+ saveCollapsedState() {
208
+ try {
209
+ localStorage.setItem('panda-cms-pages-collapsed', JSON.stringify(this.collapsedValue))
210
+ } catch (e) {
211
+ console.error('Error saving collapsed state:', e)
212
+ }
213
+ }
214
+ }