panda-cms 0.10.0 → 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 +2 -11
- data/app/components/panda/cms/code_component.rb +45 -8
- data/app/components/panda/cms/menu_component.rb +9 -3
- 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_iframe_controller.js +31 -4
- 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/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/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 +1 -1
- data/app/views/panda/cms/admin/files/index.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 +5 -7
- data/app/views/panda/cms/admin/pages/edit.html.erb +139 -20
- data/app/views/panda/cms/admin/pages/index.html.erb +6 -6
- data/app/views/panda/cms/admin/posts/_form.html.erb +41 -2
- 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/shared/_header.html.erb +1 -4
- data/config/brakeman.ignore +38 -0
- data/config/importmap.rb +8 -6
- data/config/locales/en.yml +41 -0
- data/config/routes.rb +1 -1
- 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/panda/cms/asset_loader.rb +27 -77
- 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 +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 -221
- data/lib/panda/cms.rb +10 -0
- data/lib/panda-cms/version.rb +1 -1
- data/lib/panda-cms.rb +16 -2
- metadata +20 -22
- 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/lib/tasks/assets.rake +0 -540
|
@@ -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
|
|
@@ -6,6 +6,7 @@ module Panda
|
|
|
6
6
|
self.table_name = "panda_cms_menus"
|
|
7
7
|
|
|
8
8
|
after_save :generate_auto_menu_items, if: -> { kind == "auto" }
|
|
9
|
+
after_commit :clear_menu_cache
|
|
9
10
|
|
|
10
11
|
has_many :menu_items, lambda {
|
|
11
12
|
order(lft: :asc)
|
|
@@ -50,6 +51,17 @@ module Panda
|
|
|
50
51
|
|
|
51
52
|
errors.add(:start_page, "can't be blank")
|
|
52
53
|
end
|
|
54
|
+
|
|
55
|
+
#
|
|
56
|
+
# Clear fragment cache when menu is updated
|
|
57
|
+
# This ensures menu changes appear immediately on the front-end
|
|
58
|
+
#
|
|
59
|
+
# @return nil
|
|
60
|
+
# @visibility private
|
|
61
|
+
#
|
|
62
|
+
def clear_menu_cache
|
|
63
|
+
Rails.cache.delete("panda_cms_menu/#{name}/#{id}")
|
|
64
|
+
end
|
|
53
65
|
end
|
|
54
66
|
end
|
|
55
67
|
end
|
|
@@ -51,6 +51,31 @@ module Panda
|
|
|
51
51
|
code: "code"
|
|
52
52
|
}, prefix: :type
|
|
53
53
|
|
|
54
|
+
enum :seo_index_mode, {
|
|
55
|
+
visible: "visible",
|
|
56
|
+
invisible: "invisible"
|
|
57
|
+
}, prefix: :seo
|
|
58
|
+
|
|
59
|
+
enum :og_type, {
|
|
60
|
+
website: "website",
|
|
61
|
+
article: "article",
|
|
62
|
+
profile: "profile",
|
|
63
|
+
video: "video",
|
|
64
|
+
book: "book"
|
|
65
|
+
}, prefix: :og
|
|
66
|
+
|
|
67
|
+
# Active Storage attachment for Open Graph image
|
|
68
|
+
has_one_attached :og_image do |attachable|
|
|
69
|
+
attachable.variant :og_share, resize_to_limit: [1200, 630]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# SEO validations
|
|
73
|
+
validates :seo_title, length: {maximum: 70}, allow_blank: true
|
|
74
|
+
validates :seo_description, length: {maximum: 160}, allow_blank: true
|
|
75
|
+
validates :og_title, length: {maximum: 60}, allow_blank: true
|
|
76
|
+
validates :og_description, length: {maximum: 200}, allow_blank: true
|
|
77
|
+
validates :canonical_url, format: {with: URI::DEFAULT_PARSER.make_regexp(%w[http https])}, allow_blank: true
|
|
78
|
+
|
|
54
79
|
# Callbacks
|
|
55
80
|
after_save :handle_after_save
|
|
56
81
|
before_save :update_cached_last_updated_at
|
|
@@ -91,6 +116,85 @@ module Panda
|
|
|
91
116
|
new_timestamp
|
|
92
117
|
end
|
|
93
118
|
|
|
119
|
+
#
|
|
120
|
+
# Returns the effective SEO title for this page
|
|
121
|
+
# Falls back to page title if not set, with optional inheritance
|
|
122
|
+
#
|
|
123
|
+
# @return [String] The SEO title to use
|
|
124
|
+
# @visibility public
|
|
125
|
+
#
|
|
126
|
+
def effective_seo_title
|
|
127
|
+
return seo_title if seo_title.present?
|
|
128
|
+
return title unless inherit_seo
|
|
129
|
+
|
|
130
|
+
# Traverse up tree to find inherited value
|
|
131
|
+
self_and_ancestors.reverse.find { |p| p.seo_title.present? }&.seo_title || title
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
#
|
|
135
|
+
# Returns the effective SEO description for this page
|
|
136
|
+
# With optional inheritance from parent pages
|
|
137
|
+
#
|
|
138
|
+
# @return [String, nil] The SEO description to use
|
|
139
|
+
# @visibility public
|
|
140
|
+
#
|
|
141
|
+
def effective_seo_description
|
|
142
|
+
return seo_description if seo_description.present?
|
|
143
|
+
return nil unless inherit_seo
|
|
144
|
+
|
|
145
|
+
self_and_ancestors.reverse.find { |p| p.seo_description.present? }&.seo_description
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
#
|
|
149
|
+
# Returns the effective Open Graph title
|
|
150
|
+
# Falls back to SEO title, then page title
|
|
151
|
+
#
|
|
152
|
+
# @return [String] The OG title to use
|
|
153
|
+
# @visibility public
|
|
154
|
+
#
|
|
155
|
+
def effective_og_title
|
|
156
|
+
og_title.presence || effective_seo_title
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
#
|
|
160
|
+
# Returns the effective Open Graph description
|
|
161
|
+
# Falls back to SEO description
|
|
162
|
+
#
|
|
163
|
+
# @return [String, nil] The OG description to use
|
|
164
|
+
# @visibility public
|
|
165
|
+
#
|
|
166
|
+
def effective_og_description
|
|
167
|
+
og_description.presence || effective_seo_description
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
#
|
|
171
|
+
# Returns the effective canonical URL for this page
|
|
172
|
+
# Falls back to the page's own URL if not explicitly set
|
|
173
|
+
#
|
|
174
|
+
# @return [String] The canonical URL to use
|
|
175
|
+
# @visibility public
|
|
176
|
+
#
|
|
177
|
+
def effective_canonical_url
|
|
178
|
+
canonical_url.presence || path
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
#
|
|
182
|
+
# Generates the robots meta tag content based on seo_index_mode
|
|
183
|
+
#
|
|
184
|
+
# @return [String] The robots meta tag content (e.g., "index, follow")
|
|
185
|
+
# @visibility public
|
|
186
|
+
#
|
|
187
|
+
def robots_meta_content
|
|
188
|
+
case seo_index_mode
|
|
189
|
+
when "visible"
|
|
190
|
+
"index, follow"
|
|
191
|
+
when "invisible"
|
|
192
|
+
"noindex, nofollow"
|
|
193
|
+
else
|
|
194
|
+
"index, follow" # Default fallback
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
94
198
|
private
|
|
95
199
|
|
|
96
200
|
def validate_unique_path_in_scope
|
|
@@ -163,6 +267,8 @@ module Panda
|
|
|
163
267
|
def update_cached_last_updated_at
|
|
164
268
|
# Will be set to updated_at automatically during save
|
|
165
269
|
# Block content updates will call refresh_last_updated_at! separately
|
|
270
|
+
# Only update if column exists (for backwards compatibility with older schemas)
|
|
271
|
+
return unless self.class.column_names.include?("cached_last_updated_at")
|
|
166
272
|
self.cached_last_updated_at = Time.current
|
|
167
273
|
end
|
|
168
274
|
end
|