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,95 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["previewTab", "codeTab", "previewView", "codeView", "codeInput", "saveMessage"]
5
+ static values = {
6
+ pageId: String,
7
+ blockContentId: String
8
+ }
9
+
10
+ connect() {
11
+ // Preview is active by default
12
+ }
13
+
14
+ showPreview(event) {
15
+ event.preventDefault()
16
+
17
+ // Toggle tabs
18
+ this.previewTabTarget.classList.remove("border-transparent", "text-gray-500", "hover:border-gray-300", "hover:text-gray-700")
19
+ this.previewTabTarget.classList.add("border-primary", "text-primary")
20
+
21
+ this.codeTabTarget.classList.remove("border-primary", "text-primary")
22
+ this.codeTabTarget.classList.add("border-transparent", "text-gray-500", "hover:border-gray-300", "hover:text-gray-700")
23
+
24
+ // Toggle views
25
+ this.previewViewTarget.classList.remove("hidden")
26
+ this.codeViewTarget.classList.add("hidden")
27
+ }
28
+
29
+ showCode(event) {
30
+ event.preventDefault()
31
+
32
+ // Toggle tabs
33
+ this.codeTabTarget.classList.remove("border-transparent", "text-gray-500", "hover:border-gray-300", "hover:text-gray-700")
34
+ this.codeTabTarget.classList.add("border-primary", "text-primary")
35
+
36
+ this.previewTabTarget.classList.remove("border-primary", "text-primary")
37
+ this.previewTabTarget.classList.add("border-transparent", "text-gray-500", "hover:border-gray-300", "hover:text-gray-700")
38
+
39
+ // Toggle views
40
+ this.codeViewTarget.classList.remove("hidden")
41
+ this.previewViewTarget.classList.add("hidden")
42
+ }
43
+
44
+ async saveCode(event) {
45
+ event.preventDefault()
46
+
47
+ const blockContentId = this.codeInputTarget.dataset.codeEditorBlockContentIdValue
48
+ const code = this.codeInputTarget.value
49
+ const pageId = this.pageIdValue
50
+
51
+ try {
52
+ const response = await fetch(`/admin/cms/pages/${pageId}/block_contents/${blockContentId}`, {
53
+ method: 'PATCH',
54
+ headers: {
55
+ 'Content-Type': 'application/json',
56
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
57
+ },
58
+ body: JSON.stringify({
59
+ content: code
60
+ })
61
+ })
62
+
63
+ if (response.ok) {
64
+ this.showSaveMessage('Code saved successfully!', 'success')
65
+
66
+ // Reload the preview iframe
67
+ const iframe = document.getElementById('editablePageFrame')
68
+ if (iframe) {
69
+ iframe.src = iframe.src
70
+ }
71
+ } else {
72
+ this.showSaveMessage('Error saving code', 'error')
73
+ }
74
+ } catch (error) {
75
+ console.error('Error saving code:', error)
76
+ this.showSaveMessage('Error saving code', 'error')
77
+ }
78
+ }
79
+
80
+ showSaveMessage(message, type) {
81
+ const messageEl = this.saveMessageTarget
82
+ messageEl.textContent = message
83
+ messageEl.classList.remove('hidden')
84
+
85
+ if (type === 'success') {
86
+ messageEl.className = 'mt-2 p-3 rounded-md bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-400'
87
+ } else {
88
+ messageEl.className = 'mt-2 p-3 rounded-md bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-400'
89
+ }
90
+
91
+ setTimeout(() => {
92
+ messageEl.classList.add('hidden')
93
+ }, 3000)
94
+ }
95
+ }
@@ -8,7 +8,8 @@ export default class extends Controller {
8
8
  static values = {
9
9
  pageId: Number,
10
10
  adminPath: String,
11
- autosave: Boolean
11
+ autosave: Boolean,
12
+ assets: String
12
13
  }
13
14
 
14
15
  connect() {
@@ -65,6 +66,33 @@ export default class extends Controller {
65
66
  this.body = this.frameDocument.body
66
67
  this.head = this.frameDocument.head
67
68
 
69
+ // Inject CMS assets into the iframe head
70
+ if (this.hasAssetsValue && this.assetsValue) {
71
+ console.debug("[Panda CMS] Injecting assets into iframe", {
72
+ assetsLength: this.assetsValue.length,
73
+ assetsPreview: this.assetsValue.substring(0, 200)
74
+ })
75
+ const assetsHTML = this.assetsValue
76
+ const tempDiv = document.createElement('div')
77
+ tempDiv.innerHTML = assetsHTML
78
+
79
+ // Append each node to the iframe's head
80
+ Array.from(tempDiv.childNodes).forEach(node => {
81
+ if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE) {
82
+ const importedNode = this.frameDocument.importNode(node, true)
83
+ this.head.appendChild(importedNode)
84
+ console.debug("[Panda CMS] Injected node:", node.nodeName)
85
+ }
86
+ })
87
+
88
+ console.debug("[Panda CMS] Assets injected successfully - head element count:", this.head.children.length)
89
+ } else {
90
+ console.warn("[Panda CMS] No assets to inject", {
91
+ hasAssetsValue: this.hasAssetsValue,
92
+ assetsValue: this.assetsValue
93
+ })
94
+ }
95
+
68
96
  // Ensure iframe content is properly positioned but doesn't block UI
69
97
  this.body.style.position = "relative"
70
98
  this.body.style.zIndex = "1"
@@ -545,11 +573,10 @@ export default class extends Controller {
545
573
  }
546
574
 
547
575
  setupSlideoverHandling() {
548
- // Watch for slideover toggle
549
- const slideoverToggle = document.getElementById('slideover-toggle')
576
+ // Watch for slideover visibility changes
550
577
  const slideover = document.getElementById('slideover')
551
578
 
552
- if (slideoverToggle && slideover) {
579
+ if (slideover) {
553
580
  const observer = new MutationObserver((mutations) => {
554
581
  mutations.forEach((mutation) => {
555
582
  if (mutation.attributeName === 'class') {
@@ -0,0 +1,128 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["slideoverContent"]
5
+
6
+ selectFile(event) {
7
+ const button = event.currentTarget
8
+ const fileData = {
9
+ id: button.dataset.fileId,
10
+ url: button.dataset.fileUrl,
11
+ name: button.dataset.fileName,
12
+ size: button.dataset.fileSize,
13
+ type: button.dataset.fileType,
14
+ created: button.dataset.fileCreated
15
+ }
16
+
17
+ // Update slideover content
18
+ this.updateSlideover(fileData)
19
+
20
+ // Update selected state
21
+ this.updateSelectedState(button)
22
+ }
23
+
24
+ updateSlideover(fileData) {
25
+ if (!this.hasSlideoverContentTarget) return
26
+
27
+ // Build the slideover content HTML
28
+ const html = this.buildSlideoverHTML(fileData)
29
+ this.slideoverContentTarget.innerHTML = html
30
+
31
+ // Show the slideover if it's hidden
32
+ const slideover = document.getElementById("slideover")
33
+ if (slideover && slideover.classList.contains("hidden")) {
34
+ slideover.classList.remove("hidden")
35
+ }
36
+
37
+ // Trigger the toggle controller to show the slideover
38
+ const toggleController = this.application.getControllerForElementAndIdentifier(
39
+ document.getElementById("panda-inner-container"),
40
+ "toggle"
41
+ )
42
+ if (toggleController) {
43
+ toggleController.show()
44
+ }
45
+ }
46
+
47
+ updateSelectedState(selectedButton) {
48
+ // Remove selected state from all file items
49
+ const allFileItems = this.element.querySelectorAll('[data-action*="file-gallery#selectFile"]')
50
+ allFileItems.forEach(button => {
51
+ const container = button.closest('.relative').querySelector('.group')
52
+ if (container) {
53
+ container.classList.remove('outline', 'outline-2', 'outline-offset-2', 'outline-panda-dark', 'dark:outline-panda-light')
54
+ container.classList.add('focus-within:outline-2', 'focus-within:outline-offset-2', 'focus-within:outline-indigo-600', 'dark:focus-within:outline-indigo-500')
55
+ }
56
+ })
57
+
58
+ // Add selected state to clicked item
59
+ const container = selectedButton.closest('.relative').querySelector('.group')
60
+ if (container) {
61
+ container.classList.add('outline', 'outline-2', 'outline-offset-2', 'outline-panda-dark', 'dark:outline-panda-light')
62
+ container.classList.remove('focus-within:outline-2', 'focus-within:outline-offset-2', 'focus-within:outline-indigo-600', 'dark:focus-within:outline-indigo-500')
63
+ }
64
+ }
65
+
66
+ buildSlideoverHTML(fileData) {
67
+ const isImage = fileData.type && fileData.type.startsWith('image/')
68
+ const humanSize = this.humanFileSize(parseInt(fileData.size))
69
+ const createdDate = new Date(fileData.created).toLocaleDateString('en-US', {
70
+ year: 'numeric',
71
+ month: 'long',
72
+ day: 'numeric'
73
+ })
74
+
75
+ return `
76
+ <div>
77
+ <div class="block overflow-hidden w-full rounded-lg aspect-h-7 aspect-w-10">
78
+ ${isImage ?
79
+ `<img src="${fileData.url}" alt="${fileData.name}" class="object-cover">` :
80
+ `<div class="flex items-center justify-center h-full bg-gray-100">
81
+ <div class="text-center">
82
+ <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
83
+ <path stroke-linecap="round" stroke-linejoin="round" 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" />
84
+ </svg>
85
+ <p class="mt-1 text-xs text-gray-500 uppercase">${fileData.type ? fileData.type.split('/')[1] : 'file'}</p>
86
+ </div>
87
+ </div>`
88
+ }
89
+ </div>
90
+ <div class="flex justify-between items-start mt-4">
91
+ <div>
92
+ <h2 class="text-lg font-medium text-gray-900">
93
+ <span class="sr-only">Details for </span>${fileData.name}
94
+ </h2>
95
+ <p class="text-sm font-medium text-gray-500">${humanSize}</p>
96
+ </div>
97
+ </div>
98
+ </div>
99
+
100
+ <div>
101
+ <h3 class="font-medium text-gray-900">Information</h3>
102
+ <dl class="mt-2 border-t border-b border-gray-200 divide-y divide-gray-200">
103
+ <div class="flex justify-between py-3 text-sm font-medium">
104
+ <dt class="text-gray-500">Created</dt>
105
+ <dd class="text-gray-900 whitespace-nowrap">${createdDate}</dd>
106
+ </div>
107
+ <div class="flex justify-between py-3 text-sm font-medium">
108
+ <dt class="text-gray-500">Content Type</dt>
109
+ <dd class="text-gray-900 whitespace-nowrap">${fileData.type || 'Unknown'}</dd>
110
+ </div>
111
+ </dl>
112
+ </div>
113
+
114
+ <div class="flex gap-x-3">
115
+ <a href="${fileData.url}?disposition=attachment" class="flex-1 py-2 px-3 text-sm font-semibold text-white bg-black rounded-md shadow-sm hover:bg-gray-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-panda-dark text-center">Download</a>
116
+ <button type="button" class="flex-1 py-2 px-3 text-sm font-semibold text-gray-900 bg-white rounded-md ring-1 ring-inset shadow-sm hover:bg-gray-50 ring-mid">Delete</button>
117
+ </div>
118
+ `
119
+ }
120
+
121
+ humanFileSize(bytes) {
122
+ if (bytes === 0) return '0 Bytes'
123
+ const k = 1024
124
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
125
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
126
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
127
+ }
128
+ }
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  console.debug("[Panda CMS] Importing Panda CMS Stimulus Controller...")
2
2
 
3
- import { application } from "@hotwired/stimulus-loading"
3
+ import { application } from "../stimulus-loading.js"
4
4
 
5
5
  console.debug("[Panda CMS] Using shared Stimulus application...")
6
6
 
@@ -8,24 +8,65 @@ const pandaCmsApplication = application
8
8
 
9
9
  console.debug("[Panda CMS] Registering controllers...")
10
10
 
11
- // Use the same paths as defined in _importmap.html.erb
12
- import DashboardController from "panda/cms/controllers/dashboard_controller"
11
+ // Use relative imports with .js extensions for proper module resolution
12
+ import DashboardController from "./dashboard_controller.js"
13
13
  pandaCmsApplication.register("dashboard", DashboardController)
14
14
 
15
- import EditorFormController from "panda/cms/controllers/editor_form_controller"
16
- pandaCmsApplication.register("editor-form", EditorFormController)
17
-
18
- import SlugController from "panda/cms/controllers/slug_controller"
15
+ import SlugController from "./slug_controller.js"
19
16
  pandaCmsApplication.register("slug", SlugController)
20
17
 
21
- import EditorIframeController from "panda/cms/controllers/editor_iframe_controller"
22
- pandaCmsApplication.register("editor-iframe", EditorIframeController)
18
+ import TreeController from "./tree_controller.js"
19
+ pandaCmsApplication.register("tree", TreeController)
20
+
21
+ import FileGalleryController from "./file_gallery_controller.js"
22
+ pandaCmsApplication.register("file-gallery", FileGalleryController)
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
+
30
+ import NestedFormController from "./nested_form_controller.js"
31
+ pandaCmsApplication.register("nested-form", NestedFormController)
32
+
33
+ import MenuFormController from "./menu_form_controller.js"
34
+ pandaCmsApplication.register("menu-form", MenuFormController)
35
+
36
+ // Lazy load editor controllers only when needed
37
+ // These will only be loaded when the data-controller attribute is present in the DOM
38
+ class EditorFormLazyController {
39
+ connect() {
40
+ // Only import the editor controller when it's actually needed in the DOM
41
+ import("./editor_form_controller.js").then(module => {
42
+ const Controller = module.default
43
+ // Replace this lazy controller with the real one
44
+ pandaCmsApplication.register("editor-form", Controller)
45
+ }).catch(err => {
46
+ console.error("[Panda CMS] Failed to load editor-form controller:", err)
47
+ })
48
+ }
49
+ }
50
+
51
+ class EditorIframeLazyController {
52
+ connect() {
53
+ // Only import the editor controller when it's actually needed in the DOM
54
+ import("./editor_iframe_controller.js").then(module => {
55
+ const Controller = module.default
56
+ // Replace this lazy controller with the real one
57
+ pandaCmsApplication.register("editor-iframe", Controller)
58
+ }).catch(err => {
59
+ console.error("[Panda CMS] Failed to load editor-iframe controller:", err)
60
+ })
61
+ }
62
+ }
23
63
 
24
- // Import and register TailwindCSS Stimulus Components needed by CMS
25
- import { Toggle } from "tailwindcss-stimulus-components"
26
- pandaCmsApplication.register("toggle", Toggle)
64
+ // Register the lazy-loading proxy controllers
65
+ pandaCmsApplication.register("editor-form", EditorFormLazyController)
66
+ pandaCmsApplication.register("editor-iframe", EditorIframeLazyController)
27
67
 
28
- console.debug("[Panda CMS] Registered Toggle controller for slideover functionality")
68
+ // Note: Toggle, Slideover, and other TailwindCSS Stimulus Components
69
+ // are now registered by Panda Core since the admin layout lives there
29
70
 
30
71
  console.debug("[Panda CMS] Components registered...")
31
72
 
@@ -0,0 +1,96 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["previewTab", "codeTab", "previewView", "codeView", "codeInput", "saveMessage"]
5
+ static values = {
6
+ pageId: String,
7
+ blockContentId: String
8
+ }
9
+
10
+ connect() {
11
+ // Preview is active by default
12
+ }
13
+
14
+ showPreview(event) {
15
+ event.preventDefault()
16
+
17
+ // Toggle tabs
18
+ this.previewTabTarget.classList.remove("border-transparent", "text-gray-500", "hover:border-gray-300", "hover:text-gray-700")
19
+ this.previewTabTarget.classList.add("border-primary", "text-primary")
20
+
21
+ this.codeTabTarget.classList.remove("border-primary", "text-primary")
22
+ this.codeTabTarget.classList.add("border-transparent", "text-gray-500", "hover:border-gray-300", "hover:text-gray-700")
23
+
24
+ // Toggle views
25
+ this.previewViewTarget.classList.remove("hidden")
26
+ this.codeViewTarget.classList.add("hidden")
27
+ }
28
+
29
+ showCode(event) {
30
+ event.preventDefault()
31
+
32
+ // Toggle tabs
33
+ this.codeTabTarget.classList.remove("border-transparent", "text-gray-500", "hover:border-gray-300", "hover:text-gray-700")
34
+ this.codeTabTarget.classList.add("border-primary", "text-primary")
35
+
36
+ this.previewTabTarget.classList.remove("border-primary", "text-primary")
37
+ this.previewTabTarget.classList.add("border-transparent", "text-gray-500", "hover:border-gray-300", "hover:text-gray-700")
38
+
39
+ // Toggle views
40
+ this.codeViewTarget.classList.remove("hidden")
41
+ this.previewViewTarget.classList.add("hidden")
42
+ }
43
+
44
+ async saveCode(event) {
45
+ event.preventDefault()
46
+
47
+ const code = this.codeInputTarget.value
48
+ const pageId = this.pageIdValue
49
+ const blockContentId = this.blockContentIdValue
50
+
51
+ try {
52
+ const response = await fetch(`/admin/cms/pages/${pageId}/block_contents/${blockContentId}`, {
53
+ method: 'PATCH',
54
+ headers: {
55
+ 'Content-Type': 'application/json',
56
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
57
+ },
58
+ body: JSON.stringify({
59
+ content: code
60
+ })
61
+ })
62
+
63
+ if (response.ok) {
64
+ this.showSaveMessage('Code saved successfully!', 'success')
65
+
66
+ // Reload the page to show updated preview
67
+ setTimeout(() => {
68
+ window.location.reload()
69
+ }, 1000)
70
+ } else {
71
+ this.showSaveMessage('Error saving code', 'error')
72
+ }
73
+ } catch (error) {
74
+ console.error('Error saving code:', error)
75
+ this.showSaveMessage('Error saving code', 'error')
76
+ }
77
+ }
78
+
79
+ showSaveMessage(message, type) {
80
+ const messageEl = this.saveMessageTarget
81
+ messageEl.textContent = message
82
+ messageEl.classList.remove('hidden')
83
+
84
+ if (type === 'success') {
85
+ messageEl.className = 'mt-2 p-3 rounded-md bg-green-50 text-green-800 border border-green-200'
86
+ } else {
87
+ messageEl.className = 'mt-2 p-3 rounded-md bg-red-50 text-red-800 border border-red-200'
88
+ }
89
+
90
+ setTimeout(() => {
91
+ if (type !== 'success') { // Keep success message visible until reload
92
+ messageEl.classList.add('hidden')
93
+ }
94
+ }, 3000)
95
+ }
96
+ }
@@ -0,0 +1,53 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["startPageField", "menuItemsSection"]
5
+
6
+ connect() {
7
+ console.log("Menu form controller connected")
8
+ // Initialize visibility based on current kind selection
9
+ this.updateFieldsVisibility()
10
+ }
11
+
12
+ kindChanged(event) {
13
+ console.log("[menu-form] kindChanged called", event)
14
+ this.updateFieldsVisibility()
15
+ }
16
+
17
+ updateFieldsVisibility() {
18
+ console.log("[menu-form] updateFieldsVisibility called")
19
+ const kindSelect = this.element.querySelector('select[name*="[kind]"]')
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
+ }
26
+
27
+ const selectedKind = kindSelect.value
28
+ console.log("[menu-form] selectedKind:", selectedKind)
29
+
30
+ if (selectedKind === "auto") {
31
+ // Show start page field, hide menu items section
32
+ console.log("[menu-form] AUTO - Showing start page field")
33
+ if (this.hasStartPageFieldTarget) {
34
+ console.log("[menu-form] Removing hidden from start page field")
35
+ this.startPageFieldTarget.classList.remove("hidden")
36
+ } else {
37
+ console.error("[menu-form] Start page field target not found!")
38
+ }
39
+ if (this.hasMenuItemsSectionTarget) {
40
+ this.menuItemsSectionTarget.classList.add("hidden")
41
+ }
42
+ } else {
43
+ // Hide start page field, show menu items section
44
+ console.log("[menu-form] STATIC - Hiding start page field")
45
+ if (this.hasStartPageFieldTarget) {
46
+ this.startPageFieldTarget.classList.add("hidden")
47
+ }
48
+ if (this.hasMenuItemsSectionTarget) {
49
+ this.menuItemsSectionTarget.classList.remove("hidden")
50
+ }
51
+ }
52
+ }
53
+ }