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.
- checksums.yaml +4 -4
- data/README.md +75 -5
- data/app/components/panda/cms/code_component.rb +154 -39
- data/app/components/panda/cms/grid_component.rb +26 -6
- data/app/components/panda/cms/menu_component.rb +72 -34
- data/app/components/panda/cms/page_menu_component.rb +102 -13
- data/app/components/panda/cms/rich_text_component.rb +229 -139
- data/app/components/panda/cms/text_component.rb +107 -42
- data/app/controllers/panda/cms/admin/base_controller.rb +19 -3
- data/app/controllers/panda/cms/admin/dashboard_controller.rb +3 -3
- data/app/controllers/panda/cms/admin/files_controller.rb +7 -0
- data/app/controllers/panda/cms/admin/menus_controller.rb +47 -3
- data/app/controllers/panda/cms/admin/pages_controller.rb +11 -2
- data/app/controllers/panda/cms/admin/posts_controller.rb +3 -1
- data/app/controllers/panda/cms/form_submissions_controller.rb +134 -11
- data/app/controllers/panda/cms/pages_controller.rb +7 -2
- data/app/controllers/panda/cms/posts_controller.rb +16 -0
- data/app/helpers/panda/cms/application_helper.rb +17 -4
- data/app/helpers/panda/cms/asset_helper.rb +14 -61
- data/app/helpers/panda/cms/forms_helper.rb +60 -0
- data/app/helpers/panda/cms/seo_helper.rb +85 -0
- data/app/javascript/panda/cms/{application_panda_cms.js → application.js} +5 -1
- data/app/javascript/panda/cms/controllers/code_editor_controller.js +95 -0
- data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +31 -4
- data/app/javascript/panda/cms/controllers/file_gallery_controller.js +128 -0
- data/app/javascript/panda/cms/controllers/file_upload_controller.js +165 -0
- data/app/javascript/panda/cms/controllers/index.js +54 -13
- data/app/javascript/panda/cms/controllers/inline_code_editor_controller.js +96 -0
- data/app/javascript/panda/cms/controllers/menu_form_controller.js +53 -0
- data/app/javascript/panda/cms/controllers/nested_form_controller.js +35 -0
- data/app/javascript/panda/cms/controllers/page_form_controller.js +454 -0
- data/app/javascript/panda/cms/controllers/tree_controller.js +214 -0
- data/app/javascript/panda/cms/stimulus-loading.js +6 -7
- data/app/models/panda/cms/block_content.rb +9 -0
- data/app/models/panda/cms/menu.rb +12 -0
- data/app/models/panda/cms/page.rb +147 -0
- data/app/models/panda/cms/post.rb +98 -0
- data/app/views/layouts/homepage.html.erb +1 -4
- data/app/views/layouts/page.html.erb +1 -4
- data/app/views/panda/cms/admin/dashboard/show.html.erb +5 -5
- data/app/views/panda/cms/admin/files/_file_details.html.erb +45 -0
- data/app/views/panda/cms/admin/files/index.html.erb +11 -118
- data/app/views/panda/cms/admin/forms/index.html.erb +2 -2
- data/app/views/panda/cms/admin/forms/new.html.erb +1 -2
- data/app/views/panda/cms/admin/forms/show.html.erb +15 -30
- data/app/views/panda/cms/admin/menus/_menu_item_fields.html.erb +11 -0
- data/app/views/panda/cms/admin/menus/edit.html.erb +62 -0
- data/app/views/panda/cms/admin/menus/index.html.erb +3 -2
- data/app/views/panda/cms/admin/menus/new.html.erb +38 -0
- data/app/views/panda/cms/admin/pages/edit.html.erb +147 -22
- data/app/views/panda/cms/admin/pages/index.html.erb +49 -11
- data/app/views/panda/cms/admin/pages/new.html.erb +3 -11
- data/app/views/panda/cms/admin/posts/_form.html.erb +44 -15
- data/app/views/panda/cms/admin/posts/edit.html.erb +2 -2
- data/app/views/panda/cms/admin/posts/index.html.erb +6 -6
- data/app/views/panda/cms/admin/posts/new.html.erb +1 -1
- data/app/views/panda/cms/admin/settings/bulk_editor/new.html.erb +1 -1
- data/app/views/panda/cms/admin/settings/index.html.erb +3 -3
- data/app/views/shared/_header.html.erb +1 -4
- data/config/brakeman.ignore +38 -0
- data/config/importmap.rb +10 -10
- data/config/initializers/panda/cms/healthcheck_log_silencer.rb.disabled +31 -0
- data/config/initializers/panda/cms.rb +52 -10
- data/config/locales/en.yml +41 -0
- data/config/routes.rb +5 -3
- data/db/migrate/20240305000000_convert_html_content_to_editor_js.rb +2 -2
- data/db/migrate/20240315125421_add_nested_sets_to_panda_cms_pages.rb +6 -1
- data/db/migrate/20250809231125_migrate_users_to_panda_core.rb +23 -21
- data/db/migrate/20251104150640_add_cached_last_updated_at_to_panda_cms_pages.rb +22 -0
- data/db/migrate/20251104172242_add_page_type_to_panda_cms_pages.rb +6 -0
- data/db/migrate/20251104172638_set_page_types_for_existing_pages.rb +27 -0
- data/db/migrate/20251105000001_add_pending_review_status_to_pages_and_posts.panda_cms.rb +21 -0
- data/db/migrate/20251109131150_add_seo_fields_to_pages.rb +32 -0
- data/db/migrate/20251109131205_add_seo_fields_to_posts.rb +27 -0
- data/db/migrate/20251110114258_add_spam_tracking_to_form_submissions.rb +7 -0
- data/db/migrate/20251110122812_add_performance_indexes_to_pages_and_redirects.rb +13 -0
- data/lib/generators/panda/cms/install_generator.rb +2 -5
- data/lib/panda/cms/asset_loader.rb +46 -76
- data/lib/panda/cms/bulk_editor.rb +288 -12
- data/lib/panda/cms/debug.rb +29 -0
- data/lib/panda/cms/engine/asset_config.rb +49 -0
- data/lib/panda/cms/engine/autoload_config.rb +19 -0
- data/lib/panda/cms/engine/backtrace_config.rb +42 -0
- data/lib/panda/cms/engine/core_config.rb +106 -0
- data/lib/panda/cms/engine/helper_config.rb +20 -0
- data/lib/panda/cms/engine/route_config.rb +34 -0
- data/lib/panda/cms/engine/view_component_config.rb +31 -0
- data/lib/panda/cms/engine.rb +44 -162
- data/lib/panda/cms/features.rb +52 -0
- data/lib/panda/cms.rb +10 -0
- data/lib/panda-cms/version.rb +1 -1
- data/lib/panda-cms.rb +20 -7
- data/lib/tasks/panda_cms_tasks.rake +16 -0
- metadata +41 -50
- data/app/components/panda/cms/admin/container_component.html.erb +0 -13
- data/app/components/panda/cms/admin/flash_message_component.html.erb +0 -31
- data/app/components/panda/cms/admin/panel_component.html.erb +0 -7
- data/app/components/panda/cms/admin/slideover_component.html.erb +0 -9
- data/app/components/panda/cms/admin/slideover_component.rb +0 -15
- data/app/components/panda/cms/admin/statistics_component.html.erb +0 -4
- data/app/components/panda/cms/admin/statistics_component.rb +0 -16
- data/app/components/panda/cms/admin/tab_bar_component.html.erb +0 -35
- data/app/components/panda/cms/admin/tab_bar_component.rb +0 -15
- data/app/components/panda/cms/admin/table_component.html.erb +0 -29
- data/app/components/panda/cms/admin/user_activity_component.html.erb +0 -7
- data/app/components/panda/cms/admin/user_activity_component.rb +0 -20
- data/app/components/panda/cms/admin/user_display_component.html.erb +0 -17
- data/app/components/panda/cms/admin/user_display_component.rb +0 -21
- data/app/components/panda/cms/grid_component.html.erb +0 -6
- data/app/components/panda/cms/menu_component.html.erb +0 -6
- data/app/components/panda/cms/page_menu_component.html.erb +0 -21
- data/app/components/panda/cms/rich_text_component.html.erb +0 -90
- data/app/javascript/panda_cms/stimulus-loading.js +0 -39
- data/app/views/layouts/panda/cms/application.html.erb +0 -42
- data/app/views/panda/cms/admin/shared/_breadcrumbs.html.erb +0 -28
- data/app/views/panda/cms/admin/shared/_flash.html.erb +0 -5
- data/app/views/panda/cms/admin/shared/_sidebar.html.erb +0 -41
- data/app/views/panda/cms/shared/_footer.html.erb +0 -2
- data/app/views/panda/cms/shared/_header.html.erb +0 -25
- data/app/views/panda/cms/shared/_importmap.html.erb +0 -34
- data/config/initializers/inflections.rb +0 -5
- data/config/initializers/panda/cms/healthcheck_log_silencer.rb +0 -13
- 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
|
|
549
|
-
const slideoverToggle = document.getElementById('slideover-toggle')
|
|
576
|
+
// Watch for slideover visibility changes
|
|
550
577
|
const slideover = document.getElementById('slideover')
|
|
551
578
|
|
|
552
|
-
if (
|
|
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 "
|
|
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
|
|
12
|
-
import DashboardController from "
|
|
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
|
|
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
|
|
22
|
-
pandaCmsApplication.register("
|
|
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
|
-
//
|
|
25
|
-
|
|
26
|
-
pandaCmsApplication.register("
|
|
64
|
+
// Register the lazy-loading proxy controllers
|
|
65
|
+
pandaCmsApplication.register("editor-form", EditorFormLazyController)
|
|
66
|
+
pandaCmsApplication.register("editor-iframe", EditorIframeLazyController)
|
|
27
67
|
|
|
28
|
-
|
|
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
|
+
}
|