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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -11
  3. data/app/components/panda/cms/code_component.rb +45 -8
  4. data/app/components/panda/cms/menu_component.rb +9 -3
  5. data/app/components/panda/cms/page_menu_component.rb +9 -1
  6. data/app/components/panda/cms/rich_text_component.rb +49 -17
  7. data/app/components/panda/cms/text_component.rb +46 -14
  8. data/app/controllers/panda/cms/admin/menus_controller.rb +2 -2
  9. data/app/controllers/panda/cms/admin/pages_controller.rb +6 -2
  10. data/app/controllers/panda/cms/admin/posts_controller.rb +3 -1
  11. data/app/controllers/panda/cms/form_submissions_controller.rb +134 -11
  12. data/app/controllers/panda/cms/pages_controller.rb +7 -2
  13. data/app/controllers/panda/cms/posts_controller.rb +16 -0
  14. data/app/helpers/panda/cms/application_helper.rb +2 -3
  15. data/app/helpers/panda/cms/asset_helper.rb +14 -72
  16. data/app/helpers/panda/cms/forms_helper.rb +60 -0
  17. data/app/helpers/panda/cms/seo_helper.rb +85 -0
  18. data/app/javascript/panda/cms/{application_panda_cms.js → application.js} +4 -0
  19. data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +31 -4
  20. data/app/javascript/panda/cms/controllers/file_upload_controller.js +165 -0
  21. data/app/javascript/panda/cms/controllers/index.js +6 -0
  22. data/app/javascript/panda/cms/controllers/menu_form_controller.js +14 -1
  23. data/app/javascript/panda/cms/controllers/page_form_controller.js +454 -0
  24. data/app/javascript/panda/cms/stimulus-loading.js +2 -1
  25. data/app/models/panda/cms/menu.rb +12 -0
  26. data/app/models/panda/cms/page.rb +106 -0
  27. data/app/models/panda/cms/post.rb +97 -0
  28. data/app/views/layouts/homepage.html.erb +1 -4
  29. data/app/views/layouts/page.html.erb +1 -4
  30. data/app/views/panda/cms/admin/dashboard/show.html.erb +1 -1
  31. data/app/views/panda/cms/admin/files/index.html.erb +1 -1
  32. data/app/views/panda/cms/admin/forms/show.html.erb +3 -3
  33. data/app/views/panda/cms/admin/menus/_menu_item_fields.html.erb +3 -3
  34. data/app/views/panda/cms/admin/menus/edit.html.erb +12 -14
  35. data/app/views/panda/cms/admin/menus/index.html.erb +1 -1
  36. data/app/views/panda/cms/admin/menus/new.html.erb +5 -7
  37. data/app/views/panda/cms/admin/pages/edit.html.erb +139 -20
  38. data/app/views/panda/cms/admin/pages/index.html.erb +6 -6
  39. data/app/views/panda/cms/admin/posts/_form.html.erb +41 -2
  40. data/app/views/panda/cms/admin/posts/edit.html.erb +1 -1
  41. data/app/views/panda/cms/admin/posts/index.html.erb +4 -4
  42. data/app/views/shared/_header.html.erb +1 -4
  43. data/config/brakeman.ignore +38 -0
  44. data/config/importmap.rb +8 -6
  45. data/config/locales/en.yml +41 -0
  46. data/config/routes.rb +1 -1
  47. data/db/migrate/20251109131150_add_seo_fields_to_pages.rb +32 -0
  48. data/db/migrate/20251109131205_add_seo_fields_to_posts.rb +27 -0
  49. data/db/migrate/20251110114258_add_spam_tracking_to_form_submissions.rb +7 -0
  50. data/db/migrate/20251110122812_add_performance_indexes_to_pages_and_redirects.rb +13 -0
  51. data/lib/panda/cms/asset_loader.rb +27 -77
  52. data/lib/panda/cms/bulk_editor.rb +288 -12
  53. data/lib/panda/cms/engine/asset_config.rb +49 -0
  54. data/lib/panda/cms/engine/autoload_config.rb +19 -0
  55. data/lib/panda/cms/engine/backtrace_config.rb +42 -0
  56. data/lib/panda/cms/engine/core_config.rb +106 -0
  57. data/lib/panda/cms/engine/helper_config.rb +20 -0
  58. data/lib/panda/cms/engine/route_config.rb +34 -0
  59. data/lib/panda/cms/engine/view_component_config.rb +31 -0
  60. data/lib/panda/cms/engine.rb +44 -221
  61. data/lib/panda/cms.rb +10 -0
  62. data/lib/panda-cms/version.rb +1 -1
  63. data/lib/panda-cms.rb +16 -2
  64. metadata +20 -22
  65. data/app/javascript/panda_cms/stimulus-loading.js +0 -39
  66. data/app/views/panda/cms/shared/_importmap.html.erb +0 -34
  67. data/config/initializers/inflections.rb +0 -5
  68. 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
- import { application } from "/panda/core/application.js"
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