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,35 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["template", "target"]
|
|
5
|
+
static values = {
|
|
6
|
+
wrapperSelector: String
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
connect() {
|
|
10
|
+
console.log("Nested form controller connected")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
add(event) {
|
|
14
|
+
event.preventDefault()
|
|
15
|
+
|
|
16
|
+
const content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, new Date().getTime())
|
|
17
|
+
this.targetTarget.insertAdjacentHTML('beforebegin', content)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
remove(event) {
|
|
21
|
+
event.preventDefault()
|
|
22
|
+
|
|
23
|
+
const wrapper = event.target.closest(this.wrapperSelectorValue)
|
|
24
|
+
|
|
25
|
+
if (wrapper.dataset.newRecord === "true") {
|
|
26
|
+
wrapper.remove()
|
|
27
|
+
} else {
|
|
28
|
+
wrapper.style.display = 'none'
|
|
29
|
+
const destroyInput = wrapper.querySelector("input[name*='_destroy']")
|
|
30
|
+
if (destroyInput) {
|
|
31
|
+
destroyInput.value = '1'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = [
|
|
5
|
+
"inheritCheckbox",
|
|
6
|
+
"pageTitle",
|
|
7
|
+
"seoTitle",
|
|
8
|
+
"seoDescription",
|
|
9
|
+
"seoKeywords",
|
|
10
|
+
"canonicalUrl",
|
|
11
|
+
"ogTitle",
|
|
12
|
+
"ogDescription",
|
|
13
|
+
"ogType",
|
|
14
|
+
"generateButton"
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
static values = {
|
|
18
|
+
parentSeoData: Object,
|
|
19
|
+
pageId: String,
|
|
20
|
+
aiGenerationUrl: String,
|
|
21
|
+
maxSeoTitle: { type: Number, default: 70 },
|
|
22
|
+
maxSeoDescription: { type: Number, default: 160 },
|
|
23
|
+
maxOgTitle: { type: Number, default: 60 },
|
|
24
|
+
maxOgDescription: { type: Number, default: 200 }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
connect() {
|
|
28
|
+
console.log("[Page Form] Controller connected")
|
|
29
|
+
|
|
30
|
+
// Initialize character counters
|
|
31
|
+
this.initializeCharacterCounters()
|
|
32
|
+
|
|
33
|
+
// Set initial inherit state if checkbox exists
|
|
34
|
+
if (this.hasInheritCheckboxTarget) {
|
|
35
|
+
this.updateInheritState()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Add auto-fill listeners
|
|
39
|
+
this.setupAutoFillListeners()
|
|
40
|
+
|
|
41
|
+
// Hide AI generation button if URL is not available (cms-pro not installed)
|
|
42
|
+
if (this.hasGenerateButtonTarget && !this.hasAiGenerationUrlValue) {
|
|
43
|
+
this.generateButtonTarget.closest('div').style.display = 'none'
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
initializeCharacterCounters() {
|
|
48
|
+
// Add character counters for fields with limits
|
|
49
|
+
const fieldConfigs = [
|
|
50
|
+
{ target: "seoTitle", max: this.maxSeoTitleValue, label: "SEO Title" },
|
|
51
|
+
{ target: "seoDescription", max: this.maxSeoDescriptionValue, label: "SEO Description" },
|
|
52
|
+
{ target: "ogTitle", max: this.maxOgTitleValue, label: "Social Media Title" },
|
|
53
|
+
{ target: "ogDescription", max: this.maxOgDescriptionValue, label: "Social Media Description" }
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
fieldConfigs.forEach(config => {
|
|
57
|
+
const targetName = `${config.target}Target`
|
|
58
|
+
if (this[`has${config.target.charAt(0).toUpperCase() + config.target.slice(1)}Target`]) {
|
|
59
|
+
const field = this[targetName]
|
|
60
|
+
this.createCharacterCounter(field, config.max)
|
|
61
|
+
|
|
62
|
+
// Update counter on input
|
|
63
|
+
field.addEventListener('input', () => {
|
|
64
|
+
this.updateCharacterCounter(field, config.max)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
createCharacterCounter(field, maxLength) {
|
|
71
|
+
// Find or create counter element
|
|
72
|
+
const container = field.closest('.panda-core-field-container')
|
|
73
|
+
if (!container) return
|
|
74
|
+
|
|
75
|
+
let counter = container.querySelector('.character-counter')
|
|
76
|
+
if (!counter) {
|
|
77
|
+
counter = document.createElement('div')
|
|
78
|
+
counter.className = 'character-counter text-xs mt-1 text-gray-500 dark:text-gray-400'
|
|
79
|
+
|
|
80
|
+
// Insert after the field but before error messages
|
|
81
|
+
const errorMsg = container.querySelector('.text-red-600')
|
|
82
|
+
if (errorMsg) {
|
|
83
|
+
errorMsg.before(counter)
|
|
84
|
+
} else {
|
|
85
|
+
container.appendChild(counter)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.updateCharacterCounter(field, maxLength)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
updateCharacterCounter(field, maxLength) {
|
|
93
|
+
const container = field.closest('.panda-core-field-container')
|
|
94
|
+
if (!container) return
|
|
95
|
+
|
|
96
|
+
const counter = container.querySelector('.character-counter')
|
|
97
|
+
if (!counter) return
|
|
98
|
+
|
|
99
|
+
const currentLength = field.value.length
|
|
100
|
+
const remaining = maxLength - currentLength
|
|
101
|
+
|
|
102
|
+
// Update counter text and styling
|
|
103
|
+
counter.textContent = `${currentLength} / ${maxLength} characters`
|
|
104
|
+
|
|
105
|
+
if (remaining < 0) {
|
|
106
|
+
counter.classList.remove('text-gray-500', 'dark:text-gray-400', 'text-yellow-600', 'dark:text-yellow-400')
|
|
107
|
+
counter.classList.add('text-red-600', 'dark:text-red-400', 'font-semibold')
|
|
108
|
+
counter.textContent += ` (${Math.abs(remaining)} over limit)`
|
|
109
|
+
} else if (remaining < 10) {
|
|
110
|
+
counter.classList.remove('text-gray-500', 'dark:text-gray-400', 'text-red-600', 'dark:text-red-400')
|
|
111
|
+
counter.classList.add('text-yellow-600', 'dark:text-yellow-400')
|
|
112
|
+
} else {
|
|
113
|
+
counter.classList.remove('text-red-600', 'dark:text-red-400', 'text-yellow-600', 'dark:text-yellow-400', 'font-semibold')
|
|
114
|
+
counter.classList.add('text-gray-500', 'dark:text-gray-400')
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
setupAutoFillListeners() {
|
|
119
|
+
// Initialize auto-fill on connect (for page load)
|
|
120
|
+
this.applyAutoFillDefaults()
|
|
121
|
+
|
|
122
|
+
// Auto-fill SEO title from page title on blur
|
|
123
|
+
if (this.hasPageTitleTarget && this.hasSeoTitleTarget) {
|
|
124
|
+
this.pageTitleTarget.addEventListener('blur', () => {
|
|
125
|
+
this.autoFillSeoTitle()
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Auto-fill OG title from SEO title on blur
|
|
130
|
+
if (this.hasSeoTitleTarget && this.hasOgTitleTarget) {
|
|
131
|
+
this.seoTitleTarget.addEventListener('blur', () => {
|
|
132
|
+
this.autoFillOgTitle()
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Auto-fill OG description from SEO description on blur
|
|
137
|
+
if (this.hasSeoDescriptionTarget && this.hasOgDescriptionTarget) {
|
|
138
|
+
this.seoDescriptionTarget.addEventListener('blur', () => {
|
|
139
|
+
this.autoFillOgDescription()
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
applyAutoFillDefaults() {
|
|
145
|
+
// On page load, set placeholders for empty fields
|
|
146
|
+
if (this.hasSeoTitleTarget && this.hasPageTitleTarget) {
|
|
147
|
+
this.updatePlaceholder(this.seoTitleTarget, this.pageTitleTarget.value)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (this.hasOgTitleTarget) {
|
|
151
|
+
const fallback = this.getSeoTitleValue() || this.getPageTitleValue() || ''
|
|
152
|
+
this.updatePlaceholder(this.ogTitleTarget, fallback)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (this.hasOgDescriptionTarget && this.hasSeoDescriptionTarget) {
|
|
156
|
+
this.updatePlaceholder(this.ogDescriptionTarget, this.seoDescriptionTarget.value)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
autoFillSeoTitle() {
|
|
161
|
+
if (!this.hasSeoTitleTarget || !this.hasPageTitleTarget) return
|
|
162
|
+
|
|
163
|
+
// Only fill if SEO title is empty
|
|
164
|
+
if (this.seoTitleTarget.value.trim() === '') {
|
|
165
|
+
const pageTitle = this.pageTitleTarget.value.trim()
|
|
166
|
+
if (pageTitle) {
|
|
167
|
+
this.seoTitleTarget.value = pageTitle
|
|
168
|
+
this.updateCharacterCounter(this.seoTitleTarget, this.maxSeoTitleValue)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
autoFillOgTitle() {
|
|
174
|
+
if (!this.hasOgTitleTarget) return
|
|
175
|
+
|
|
176
|
+
// Only fill if OG title is empty
|
|
177
|
+
if (this.ogTitleTarget.value.trim() === '') {
|
|
178
|
+
const fallback = this.getSeoTitleValue() || this.getPageTitleValue() || ''
|
|
179
|
+
if (fallback) {
|
|
180
|
+
this.ogTitleTarget.value = fallback
|
|
181
|
+
this.updateCharacterCounter(this.ogTitleTarget, this.maxOgTitleValue)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
autoFillOgDescription() {
|
|
187
|
+
if (!this.hasOgDescriptionTarget || !this.hasSeoDescriptionTarget) return
|
|
188
|
+
|
|
189
|
+
// Only fill if OG description is empty
|
|
190
|
+
if (this.ogDescriptionTarget.value.trim() === '') {
|
|
191
|
+
const seoDesc = this.seoDescriptionTarget.value.trim()
|
|
192
|
+
if (seoDesc) {
|
|
193
|
+
this.ogDescriptionTarget.value = seoDesc
|
|
194
|
+
this.updateCharacterCounter(this.ogDescriptionTarget, this.maxOgDescriptionValue)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
getPageTitleValue() {
|
|
200
|
+
return this.hasPageTitleTarget ? this.pageTitleTarget.value.trim() : ''
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
getSeoTitleValue() {
|
|
204
|
+
return this.hasSeoTitleTarget ? this.seoTitleTarget.value.trim() : ''
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
updatePlaceholder(field, value) {
|
|
208
|
+
if (field.value.trim() === '' && value && value.trim() !== '') {
|
|
209
|
+
field.setAttribute('placeholder', value)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Handle inherit settings checkbox toggle
|
|
214
|
+
toggleInherit(event) {
|
|
215
|
+
const isChecked = event.target.checked
|
|
216
|
+
console.log(`[Page Form] Inherit settings: ${isChecked}`)
|
|
217
|
+
|
|
218
|
+
this.updateInheritState()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
updateInheritState() {
|
|
222
|
+
if (!this.hasInheritCheckboxTarget) return
|
|
223
|
+
|
|
224
|
+
const isInheriting = this.inheritCheckboxTarget.checked
|
|
225
|
+
|
|
226
|
+
// Get SEO field targets
|
|
227
|
+
const seoFields = [
|
|
228
|
+
'seoTitle',
|
|
229
|
+
'seoDescription',
|
|
230
|
+
'seoKeywords',
|
|
231
|
+
'canonicalUrl',
|
|
232
|
+
'ogTitle',
|
|
233
|
+
'ogDescription',
|
|
234
|
+
'ogType'
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
seoFields.forEach(fieldName => {
|
|
238
|
+
const targetName = `${fieldName}Target`
|
|
239
|
+
const hasTarget = this[`has${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}Target`]
|
|
240
|
+
|
|
241
|
+
if (hasTarget) {
|
|
242
|
+
const field = this[targetName]
|
|
243
|
+
|
|
244
|
+
if (isInheriting) {
|
|
245
|
+
// Copy parent values and make readonly
|
|
246
|
+
this.copyParentValue(field, fieldName)
|
|
247
|
+
field.setAttribute('readonly', true)
|
|
248
|
+
field.classList.add('cursor-not-allowed', 'bg-gray-50', 'dark:bg-white/10')
|
|
249
|
+
} else {
|
|
250
|
+
// Make editable
|
|
251
|
+
field.removeAttribute('readonly')
|
|
252
|
+
field.classList.remove('cursor-not-allowed', 'bg-gray-50', 'dark:bg-white/10')
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
copyParentValue(field, fieldName) {
|
|
259
|
+
// If we have parent SEO data, use it
|
|
260
|
+
if (this.hasParentSeoDataValue && this.parentSeoDataValue[fieldName]) {
|
|
261
|
+
field.value = this.parentSeoDataValue[fieldName]
|
|
262
|
+
|
|
263
|
+
// Update character counter if it exists
|
|
264
|
+
const maxValue = this[`max${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)}Value`]
|
|
265
|
+
if (maxValue) {
|
|
266
|
+
this.updateCharacterCounter(field, maxValue)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Validate form before submission
|
|
272
|
+
validateForm(event) {
|
|
273
|
+
let isValid = true
|
|
274
|
+
const errors = []
|
|
275
|
+
|
|
276
|
+
// Check SEO title length
|
|
277
|
+
if (this.hasSeoTitleTarget && this.seoTitleTarget.value.length > this.maxSeoTitleValue) {
|
|
278
|
+
errors.push(`SEO Title is ${this.seoTitleTarget.value.length - this.maxSeoTitleValue} characters over the ${this.maxSeoTitleValue} character limit`)
|
|
279
|
+
isValid = false
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Check SEO description length
|
|
283
|
+
if (this.hasSeoDescriptionTarget && this.seoDescriptionTarget.value.length > this.maxSeoDescriptionValue) {
|
|
284
|
+
errors.push(`SEO Description is ${this.seoDescriptionTarget.value.length - this.maxSeoDescriptionValue} characters over the ${this.maxSeoDescriptionValue} character limit`)
|
|
285
|
+
isValid = false
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Check OG title length
|
|
289
|
+
if (this.hasOgTitleTarget && this.ogTitleTarget.value.length > this.maxOgTitleValue) {
|
|
290
|
+
errors.push(`Social Media Title is ${this.ogTitleTarget.value.length - this.maxOgTitleValue} characters over the ${this.maxOgTitleValue} character limit`)
|
|
291
|
+
isValid = false
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Check OG description length
|
|
295
|
+
if (this.hasOgDescriptionTarget && this.ogDescriptionTarget.value.length > this.maxOgDescriptionValue) {
|
|
296
|
+
errors.push(`Social Media Description is ${this.ogDescriptionTarget.value.length - this.maxOgDescriptionValue} characters over the ${this.maxOgDescriptionValue} character limit`)
|
|
297
|
+
isValid = false
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!isValid) {
|
|
301
|
+
// Show validation errors
|
|
302
|
+
const errorMessage = errors.join('\n')
|
|
303
|
+
|
|
304
|
+
// If we can prevent form submission, do so and show errors
|
|
305
|
+
if (event && event.preventDefault) {
|
|
306
|
+
event.preventDefault()
|
|
307
|
+
alert(`Please fix the following errors:\n\n${errorMessage}`)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return false
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return true
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Generate SEO content using AI
|
|
317
|
+
async generateSeoWithAI() {
|
|
318
|
+
if (!this.hasAiGenerationUrlValue) {
|
|
319
|
+
console.error("[Page Form] No AI generation URL available")
|
|
320
|
+
this.showError("AI generation is not available. Please ensure panda-cms-pro is installed and configured.")
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Get CSRF token
|
|
325
|
+
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
|
|
326
|
+
if (!csrfToken) {
|
|
327
|
+
console.error("[Page Form] CSRF token not found")
|
|
328
|
+
this.showError("Security token not found. Please refresh the page.")
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Disable button and show loading state
|
|
333
|
+
const button = this.hasGenerateButtonTarget ? this.generateButtonTarget : null
|
|
334
|
+
const originalButtonText = button?.innerHTML || ""
|
|
335
|
+
|
|
336
|
+
if (button) {
|
|
337
|
+
button.disabled = true
|
|
338
|
+
button.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Generating...'
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
// Make API request using the URL from Rails
|
|
343
|
+
const response = await fetch(this.aiGenerationUrlValue, {
|
|
344
|
+
method: 'POST',
|
|
345
|
+
headers: {
|
|
346
|
+
'Content-Type': 'application/json',
|
|
347
|
+
'X-CSRF-Token': csrfToken,
|
|
348
|
+
'Accept': 'application/json'
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
const data = await response.json()
|
|
353
|
+
|
|
354
|
+
if (response.ok && data.success) {
|
|
355
|
+
// Fill in the generated SEO fields
|
|
356
|
+
this.fillGeneratedFields(data)
|
|
357
|
+
this.showSuccess(`SEO content generated successfully using ${data.provider} (${data.model})`)
|
|
358
|
+
} else {
|
|
359
|
+
// Handle errors from the API
|
|
360
|
+
const errorMessage = data.message || "Failed to generate SEO content"
|
|
361
|
+
|
|
362
|
+
if (data.error === 'no_provider') {
|
|
363
|
+
this.showError("No AI provider configured. Please configure an AI provider in settings.")
|
|
364
|
+
} else {
|
|
365
|
+
this.showError(errorMessage)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
} catch (error) {
|
|
369
|
+
console.error("[Page Form] AI generation error:", error)
|
|
370
|
+
this.showError("Failed to connect to AI service. Please try again.")
|
|
371
|
+
} finally {
|
|
372
|
+
// Re-enable button
|
|
373
|
+
if (button) {
|
|
374
|
+
button.disabled = false
|
|
375
|
+
button.innerHTML = originalButtonText
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
fillGeneratedFields(data) {
|
|
381
|
+
// Fill SEO fields with generated content
|
|
382
|
+
if (data.seo_title && this.hasSeoTitleTarget) {
|
|
383
|
+
this.seoTitleTarget.value = data.seo_title
|
|
384
|
+
this.updateCharacterCounter(this.seoTitleTarget, this.maxSeoTitleValue)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (data.seo_description && this.hasSeoDescriptionTarget) {
|
|
388
|
+
this.seoDescriptionTarget.value = data.seo_description
|
|
389
|
+
this.updateCharacterCounter(this.seoDescriptionTarget, this.maxSeoDescriptionValue)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (data.seo_keywords && this.hasSeoKeywordsTarget) {
|
|
393
|
+
this.seoKeywordsTarget.value = data.seo_keywords
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (data.og_title && this.hasOgTitleTarget) {
|
|
397
|
+
this.ogTitleTarget.value = data.og_title
|
|
398
|
+
this.updateCharacterCounter(this.ogTitleTarget, this.maxOgTitleValue)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (data.og_description && this.hasOgDescriptionTarget) {
|
|
402
|
+
this.ogDescriptionTarget.value = data.og_description
|
|
403
|
+
this.updateCharacterCounter(this.ogDescriptionTarget, this.maxOgDescriptionValue)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
console.log("[Page Form] Successfully filled AI-generated SEO fields")
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
showSuccess(message) {
|
|
410
|
+
// Find or create success message element
|
|
411
|
+
const successEl = document.getElementById('successMessage')
|
|
412
|
+
if (successEl) {
|
|
413
|
+
const messageContainer = successEl.querySelector('[role="alert"]')
|
|
414
|
+
if (messageContainer) {
|
|
415
|
+
const messageText = messageContainer.querySelector('p')
|
|
416
|
+
if (messageText) {
|
|
417
|
+
messageText.textContent = message
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
successEl.classList.remove('hidden')
|
|
421
|
+
|
|
422
|
+
// Auto-hide after 5 seconds
|
|
423
|
+
setTimeout(() => {
|
|
424
|
+
successEl.classList.add('hidden')
|
|
425
|
+
}, 5000)
|
|
426
|
+
} else {
|
|
427
|
+
// Fallback to alert if flash message component not found
|
|
428
|
+
alert(message)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
showError(message) {
|
|
433
|
+
// Find or create error message element
|
|
434
|
+
const errorEl = document.getElementById('errorMessage')
|
|
435
|
+
if (errorEl) {
|
|
436
|
+
const messageContainer = errorEl.querySelector('[role="alert"]')
|
|
437
|
+
if (messageContainer) {
|
|
438
|
+
const messageText = messageContainer.querySelector('p')
|
|
439
|
+
if (messageText) {
|
|
440
|
+
messageText.textContent = message
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
errorEl.classList.remove('hidden')
|
|
444
|
+
|
|
445
|
+
// Auto-hide after 8 seconds
|
|
446
|
+
setTimeout(() => {
|
|
447
|
+
errorEl.classList.add('hidden')
|
|
448
|
+
}, 8000)
|
|
449
|
+
} else {
|
|
450
|
+
// Fallback to alert if flash message component not found
|
|
451
|
+
alert(message)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["row", "toggle", "container"]
|
|
5
|
+
static values = {
|
|
6
|
+
collapsed: { type: Array, default: [] }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
connect() {
|
|
10
|
+
const hasStoredState = localStorage.getItem('panda-cms-pages-collapsed')
|
|
11
|
+
|
|
12
|
+
if (hasStoredState) {
|
|
13
|
+
this.loadCollapsedState()
|
|
14
|
+
} else {
|
|
15
|
+
this.initializeTree()
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Helper to get the actual table row element from our target div
|
|
20
|
+
getTableRow(rowTarget) {
|
|
21
|
+
return rowTarget.closest('.table-row')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
initializeTree() {
|
|
25
|
+
// Collapse all level 1 pages (direct children of Home) by default
|
|
26
|
+
this.rowTargets.forEach(row => {
|
|
27
|
+
const level = parseInt(row.dataset.level)
|
|
28
|
+
const pageId = row.dataset.pageId
|
|
29
|
+
|
|
30
|
+
// If it's a level 1 page with children, mark it as collapsed
|
|
31
|
+
if (level === 1) {
|
|
32
|
+
const hasChildren = row.querySelector('[data-tree-target="toggle"]')
|
|
33
|
+
if (hasChildren) {
|
|
34
|
+
this.collapsedValue = [...this.collapsedValue, pageId]
|
|
35
|
+
this.updateToggleIcon(pageId, true)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Hide everything below level 1 (level > 1)
|
|
40
|
+
if (level > 1) {
|
|
41
|
+
const tableRow = this.getTableRow(row)
|
|
42
|
+
if (tableRow) tableRow.style.display = 'none'
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Save the initial collapsed state
|
|
47
|
+
this.saveCollapsedState()
|
|
48
|
+
|
|
49
|
+
// Fade in the tree after initialization
|
|
50
|
+
this.showTree()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
toggle(event) {
|
|
54
|
+
event.preventDefault()
|
|
55
|
+
const row = event.currentTarget.closest('[data-tree-target="row"]')
|
|
56
|
+
const pageId = row.dataset.pageId
|
|
57
|
+
const level = parseInt(row.dataset.level)
|
|
58
|
+
|
|
59
|
+
if (this.isCollapsed(pageId)) {
|
|
60
|
+
this.expand(pageId, level)
|
|
61
|
+
} else {
|
|
62
|
+
this.collapse(pageId, level)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.saveCollapsedState()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
collapse(pageId, level) {
|
|
69
|
+
// Add to collapsed set
|
|
70
|
+
if (!this.collapsedValue.includes(pageId)) {
|
|
71
|
+
this.collapsedValue = [...this.collapsedValue, pageId]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Hide all descendant rows
|
|
75
|
+
const descendants = this.getDescendantRows(pageId, level)
|
|
76
|
+
descendants.forEach(row => {
|
|
77
|
+
const tableRow = this.getTableRow(row)
|
|
78
|
+
if (tableRow) {
|
|
79
|
+
tableRow.style.display = 'none'
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// Update toggle icon
|
|
84
|
+
this.updateToggleIcon(pageId, true)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
expand(pageId, level) {
|
|
88
|
+
// Remove from collapsed set
|
|
89
|
+
this.collapsedValue = this.collapsedValue.filter(id => id !== pageId)
|
|
90
|
+
|
|
91
|
+
// Show direct children only (they will handle their own children)
|
|
92
|
+
const directChildren = this.getDirectChildRows(pageId, level)
|
|
93
|
+
directChildren.forEach(row => {
|
|
94
|
+
const tableRow = this.getTableRow(row)
|
|
95
|
+
if (tableRow) tableRow.style.display = ''
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// Update toggle icon
|
|
99
|
+
this.updateToggleIcon(pageId, false)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
getDescendantRows(pageId, parentLevel) {
|
|
103
|
+
const allRows = this.rowTargets
|
|
104
|
+
const parentIndex = allRows.findIndex(row => row.dataset.pageId === pageId)
|
|
105
|
+
const descendants = []
|
|
106
|
+
|
|
107
|
+
for (let i = parentIndex + 1; i < allRows.length; i++) {
|
|
108
|
+
const rowLevel = parseInt(allRows[i].dataset.level)
|
|
109
|
+
if (rowLevel <= parentLevel) break
|
|
110
|
+
descendants.push(allRows[i])
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return descendants
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
getDirectChildRows(pageId, parentLevel) {
|
|
117
|
+
const allRows = this.rowTargets
|
|
118
|
+
const parentIndex = allRows.findIndex(row => row.dataset.pageId === pageId)
|
|
119
|
+
const children = []
|
|
120
|
+
|
|
121
|
+
for (let i = parentIndex + 1; i < allRows.length; i++) {
|
|
122
|
+
const rowLevel = parseInt(allRows[i].dataset.level)
|
|
123
|
+
if (rowLevel <= parentLevel) break
|
|
124
|
+
if (rowLevel === parentLevel + 1) children.push(allRows[i])
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return children
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
isCollapsed(pageId) {
|
|
131
|
+
return this.collapsedValue.includes(pageId)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
updateToggleIcon(pageId, collapsed) {
|
|
135
|
+
const row = this.rowTargets.find(r => r.dataset.pageId === pageId)
|
|
136
|
+
if (!row) return
|
|
137
|
+
|
|
138
|
+
const toggle = row.querySelector('[data-tree-target="toggle"]')
|
|
139
|
+
if (!toggle) return
|
|
140
|
+
|
|
141
|
+
const icon = toggle.querySelector('i')
|
|
142
|
+
if (icon) {
|
|
143
|
+
if (collapsed) {
|
|
144
|
+
icon.classList.remove('fa-chevron-down')
|
|
145
|
+
icon.classList.add('fa-chevron-right')
|
|
146
|
+
} else {
|
|
147
|
+
icon.classList.remove('fa-chevron-right')
|
|
148
|
+
icon.classList.add('fa-chevron-down')
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
loadCollapsedState() {
|
|
154
|
+
try {
|
|
155
|
+
const stored = localStorage.getItem('panda-cms-pages-collapsed')
|
|
156
|
+
if (stored) {
|
|
157
|
+
this.collapsedValue = JSON.parse(stored)
|
|
158
|
+
|
|
159
|
+
// First, show all pages at level 1 (direct children of Home)
|
|
160
|
+
this.rowTargets.forEach(row => {
|
|
161
|
+
const level = parseInt(row.dataset.level)
|
|
162
|
+
if (level === 1) {
|
|
163
|
+
const tableRow = this.getTableRow(row)
|
|
164
|
+
if (tableRow) tableRow.style.display = ''
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// Then apply collapsed state - hiding descendants of collapsed items
|
|
169
|
+
this.collapsedValue.forEach(pageId => {
|
|
170
|
+
const row = this.rowTargets.find(r => r.dataset.pageId === pageId)
|
|
171
|
+
if (row) {
|
|
172
|
+
const level = parseInt(row.dataset.level)
|
|
173
|
+
this.collapse(pageId, level)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// For level 1 items NOT in collapsed list, show their children
|
|
178
|
+
this.rowTargets.forEach(row => {
|
|
179
|
+
const level = parseInt(row.dataset.level)
|
|
180
|
+
const pageId = row.dataset.pageId
|
|
181
|
+
const hasToggle = row.querySelector('[data-tree-target="toggle"]')
|
|
182
|
+
|
|
183
|
+
if (level === 1 && hasToggle && !this.isCollapsed(pageId)) {
|
|
184
|
+
// This level 1 item is expanded, show its direct children
|
|
185
|
+
this.getDirectChildRows(pageId, level).forEach(childRow => {
|
|
186
|
+
const tableRow = this.getTableRow(childRow)
|
|
187
|
+
if (tableRow) tableRow.style.display = ''
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// Fade in the tree after loading state
|
|
193
|
+
this.showTree()
|
|
194
|
+
}
|
|
195
|
+
} catch (e) {
|
|
196
|
+
console.error('Error loading collapsed state:', e)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
showTree() {
|
|
201
|
+
// Fade in the container
|
|
202
|
+
if (this.hasContainerTarget) {
|
|
203
|
+
this.containerTarget.style.opacity = '1'
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
saveCollapsedState() {
|
|
208
|
+
try {
|
|
209
|
+
localStorage.setItem('panda-cms-pages-collapsed', JSON.stringify(this.collapsedValue))
|
|
210
|
+
} catch (e) {
|
|
211
|
+
console.error('Error saving collapsed state:', e)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|