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