ruby_cms 0.1.0

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 (131) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/dhh.mdc +698 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/README.md +235 -0
  7. data/Rakefile +30 -0
  8. data/app/components/ruby_cms/admin/admin_page/admin_table_content.rb +32 -0
  9. data/app/components/ruby_cms/admin/admin_page.rb +345 -0
  10. data/app/components/ruby_cms/admin/base_component.rb +78 -0
  11. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +149 -0
  12. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +127 -0
  13. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +15 -0
  14. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +41 -0
  15. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +33 -0
  16. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +174 -0
  17. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +59 -0
  18. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +159 -0
  19. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +192 -0
  20. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +97 -0
  21. data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +137 -0
  22. data/app/controllers/concerns/ruby_cms/admin_pagination.rb +120 -0
  23. data/app/controllers/concerns/ruby_cms/admin_turbo_table.rb +68 -0
  24. data/app/controllers/concerns/ruby_cms/page_tracking.rb +52 -0
  25. data/app/controllers/concerns/ruby_cms/visitor_error_capture.rb +39 -0
  26. data/app/controllers/ruby_cms/admin/analytics_controller.rb +191 -0
  27. data/app/controllers/ruby_cms/admin/base_controller.rb +105 -0
  28. data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +390 -0
  29. data/app/controllers/ruby_cms/admin/dashboard_controller.rb +50 -0
  30. data/app/controllers/ruby_cms/admin/locale_controller.rb +20 -0
  31. data/app/controllers/ruby_cms/admin/permissions_controller.rb +66 -0
  32. data/app/controllers/ruby_cms/admin/settings_controller.rb +223 -0
  33. data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +59 -0
  34. data/app/controllers/ruby_cms/admin/users_controller.rb +107 -0
  35. data/app/controllers/ruby_cms/admin/visitor_errors_controller.rb +89 -0
  36. data/app/controllers/ruby_cms/admin/visual_editor_controller.rb +322 -0
  37. data/app/controllers/ruby_cms/errors_controller.rb +35 -0
  38. data/app/helpers/ruby_cms/admin/admin_page_helper.rb +21 -0
  39. data/app/helpers/ruby_cms/admin/bulk_action_table_helper.rb +159 -0
  40. data/app/helpers/ruby_cms/application_helper.rb +41 -0
  41. data/app/helpers/ruby_cms/bulk_action_table_helper.rb +151 -0
  42. data/app/helpers/ruby_cms/content_blocks_helper.rb +375 -0
  43. data/app/helpers/ruby_cms/settings_helper.rb +160 -0
  44. data/app/javascript/controllers/ruby_cms/auto_save_preference_controller.js +73 -0
  45. data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +553 -0
  46. data/app/javascript/controllers/ruby_cms/clickable_row_controller.js +28 -0
  47. data/app/javascript/controllers/ruby_cms/flash_messages_controller.js +29 -0
  48. data/app/javascript/controllers/ruby_cms/index.js +104 -0
  49. data/app/javascript/controllers/ruby_cms/locale_tabs_controller.js +34 -0
  50. data/app/javascript/controllers/ruby_cms/mobile_menu_controller.js +55 -0
  51. data/app/javascript/controllers/ruby_cms/nav_order_sortable_controller.js +192 -0
  52. data/app/javascript/controllers/ruby_cms/page_preview_controller.js +135 -0
  53. data/app/javascript/controllers/ruby_cms/toggle_controller.js +39 -0
  54. data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +321 -0
  55. data/app/models/concerns/content_block/publishable.rb +54 -0
  56. data/app/models/concerns/content_block/searchable.rb +22 -0
  57. data/app/models/content_block.rb +155 -0
  58. data/app/models/ruby_cms/content_block.rb +8 -0
  59. data/app/models/ruby_cms/permission.rb +28 -0
  60. data/app/models/ruby_cms/permittable.rb +39 -0
  61. data/app/models/ruby_cms/preference.rb +111 -0
  62. data/app/models/ruby_cms/user_permission.rb +12 -0
  63. data/app/models/ruby_cms/visitor_error.rb +109 -0
  64. data/app/services/ruby_cms/analytics/report.rb +362 -0
  65. data/app/services/ruby_cms/security_tracker.rb +92 -0
  66. data/app/views/layouts/ruby_cms/_admin_flash_messages.html.erb +37 -0
  67. data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +121 -0
  68. data/app/views/layouts/ruby_cms/admin.html.erb +81 -0
  69. data/app/views/layouts/ruby_cms/minimal.html.erb +181 -0
  70. data/app/views/ruby_cms/admin/analytics/index.html.erb +160 -0
  71. data/app/views/ruby_cms/admin/analytics/page_details.html.erb +84 -0
  72. data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -0
  73. data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +40 -0
  74. data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +58 -0
  75. data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +51 -0
  76. data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +31 -0
  77. data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +4 -0
  78. data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +21 -0
  79. data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +125 -0
  80. data/app/views/ruby_cms/admin/content_blocks/_form.html.erb +161 -0
  81. data/app/views/ruby_cms/admin/content_blocks/_row.html.erb +25 -0
  82. data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +17 -0
  83. data/app/views/ruby_cms/admin/content_blocks/index.html.erb +66 -0
  84. data/app/views/ruby_cms/admin/content_blocks/new.html.erb +5 -0
  85. data/app/views/ruby_cms/admin/content_blocks/show.html.erb +110 -0
  86. data/app/views/ruby_cms/admin/dashboard/index.html.erb +198 -0
  87. data/app/views/ruby_cms/admin/permissions/_row.html.erb +11 -0
  88. data/app/views/ruby_cms/admin/permissions/index.html.erb +62 -0
  89. data/app/views/ruby_cms/admin/settings/index.html.erb +220 -0
  90. data/app/views/ruby_cms/admin/shared/_bulk_action_table_index.html.erb +56 -0
  91. data/app/views/ruby_cms/admin/user_permissions/index.html.erb +55 -0
  92. data/app/views/ruby_cms/admin/users/_row.html.erb +14 -0
  93. data/app/views/ruby_cms/admin/users/index.html.erb +70 -0
  94. data/app/views/ruby_cms/admin/visitor_errors/_row.html.erb +35 -0
  95. data/app/views/ruby_cms/admin/visitor_errors/index.html.erb +57 -0
  96. data/app/views/ruby_cms/admin/visitor_errors/show.html.erb +147 -0
  97. data/app/views/ruby_cms/admin/visual_editor/index.html.erb +144 -0
  98. data/app/views/ruby_cms/errors/not_found.html.erb +92 -0
  99. data/config/database.yml +6 -0
  100. data/config/importmap.rb +36 -0
  101. data/config/locales/en.yml +101 -0
  102. data/config/routes.rb +65 -0
  103. data/db/migrate/20260125000001_create_ruby_cms_permissions.rb +14 -0
  104. data/db/migrate/20260125000002_create_ruby_cms_user_permissions.rb +14 -0
  105. data/db/migrate/20260125000003_create_ruby_cms_content_blocks.rb +19 -0
  106. data/db/migrate/20260125000010_add_indexes_to_ruby_cms_tables.rb +9 -0
  107. data/db/migrate/20260127000001_add_locale_to_ruby_cms_content_blocks.rb +34 -0
  108. data/db/migrate/20260129000001_create_ruby_cms_visitor_errors.rb +24 -0
  109. data/db/migrate/20260130000001_add_referer_and_query_to_ruby_cms_visitor_errors.rb +8 -0
  110. data/db/migrate/20260130000002_create_ruby_cms_preferences.rb +16 -0
  111. data/db/migrate/20260130000003_add_category_to_ruby_cms_preferences.rb +8 -0
  112. data/db/migrate/20260211000001_add_ruby_cms_analytics_fields_to_ahoy_events.rb +19 -0
  113. data/db/migrate/20260212000001_use_unprefixed_cms_tables.rb +146 -0
  114. data/exe/ruby_cms +25 -0
  115. data/lib/generators/ruby_cms/install_generator.rb +1062 -0
  116. data/lib/generators/ruby_cms/templates/admin.html.erb +82 -0
  117. data/lib/generators/ruby_cms/templates/ruby_cms.rb +86 -0
  118. data/lib/ruby_cms/app_integration.rb +82 -0
  119. data/lib/ruby_cms/cli.rb +169 -0
  120. data/lib/ruby_cms/content_blocks_grouping.rb +41 -0
  121. data/lib/ruby_cms/content_blocks_sync.rb +329 -0
  122. data/lib/ruby_cms/css_compiler.rb +35 -0
  123. data/lib/ruby_cms/engine.rb +498 -0
  124. data/lib/ruby_cms/settings.rb +145 -0
  125. data/lib/ruby_cms/settings_registry.rb +289 -0
  126. data/lib/ruby_cms/version.rb +5 -0
  127. data/lib/ruby_cms.rb +195 -0
  128. data/lib/tasks/ruby_cms.rake +27 -0
  129. data/log/test.log +17875 -0
  130. data/sig/ruby_cms.rbs +4 -0
  131. metadata +223 -0
@@ -0,0 +1,321 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = [
5
+ "modal",
6
+ "modalBody",
7
+ "previewFrame",
8
+ "contentType",
9
+ "contentInput",
10
+ "richContentInput",
11
+ "richTextContainer",
12
+ "textContainer",
13
+ "blockKey",
14
+ "charCount",
15
+ "lastUpdated",
16
+ "saveButton",
17
+ "toast",
18
+ "toastMessage"
19
+ ]
20
+
21
+ static values = {
22
+ currentPage: String
23
+ }
24
+
25
+ connect() {
26
+ this.currentContentBlockKey = null
27
+ this.currentContentBlockLocale = null
28
+ this.currentBlockIndex = 0
29
+ this.editMode = false
30
+
31
+ // Listen for messages from iframe
32
+ this.boundHandleMessage = this.handleMessage.bind(this)
33
+ window.addEventListener("message", this.boundHandleMessage)
34
+
35
+ // Listen for Escape key globally when modal is open
36
+ this.boundHandleEscape = this.handleEscape.bind(this)
37
+
38
+ // Listen for iframe load
39
+ this.previewFrameTarget.addEventListener("load", () => {
40
+ console.log("Preview frame loaded")
41
+ })
42
+ }
43
+
44
+ disconnect() {
45
+ if (this.boundHandleMessage) {
46
+ window.removeEventListener("message", this.boundHandleMessage)
47
+ }
48
+ document.removeEventListener("keydown", this.boundHandleEscape)
49
+ }
50
+
51
+ handleMessage(event) {
52
+ // Only accept same-origin messages (iframe preview is same app)
53
+ if (event.origin !== window.location.origin) return
54
+
55
+ const { type, blockId, blockIndex, page } = event.data
56
+
57
+ if (type === "CONTENT_BLOCK_CLICKED") {
58
+ this.openBlockEditor(blockId, blockIndex)
59
+ }
60
+ }
61
+
62
+ async openBlockEditor(blockKey, blockIndex = 0) {
63
+ this.currentContentBlockKey = blockKey
64
+ this.currentBlockIndex = blockIndex
65
+ this.blockKeyTarget.textContent = blockKey
66
+
67
+ try {
68
+ const response = await fetch(`/admin/content_blocks?search=${encodeURIComponent(blockKey)}&format=json`, {
69
+ headers: { "Accept": "application/json" }
70
+ })
71
+ if (!response.ok) throw new Error("Failed to fetch content block")
72
+
73
+ const data = await response.json()
74
+ const blocks = data.content_blocks || []
75
+ const currentLocale = this.getCurrentLocale()
76
+ // Prefer block matching key and current locale; else first block with same key
77
+ const block = blocks.find(b => b.key === blockKey && (b.locale === currentLocale || !b.locale)) ||
78
+ blocks.find(b => b.key === blockKey) ||
79
+ (blocks[blockIndex]?.key === blockKey ? blocks[blockIndex] : null) ||
80
+ {}
81
+
82
+ this.currentContentBlockLocale = block.locale || currentLocale
83
+
84
+ const contentType = String(block.content_type || "text").toLowerCase()
85
+ this.contentTypeTarget.value = contentType
86
+ this.changeContentType()
87
+
88
+ if (contentType === "rich_text") {
89
+ let html = String(block.rich_content != null ? block.rich_content : "").trim()
90
+ if (!html && block.content) {
91
+ const text = String(block.content).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
92
+ html = `<p>${text}</p>`
93
+ }
94
+ if (html && !html.trimStart().startsWith("<")) {
95
+ const escaped = html.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
96
+ html = `<p>${escaped}</p>`
97
+ }
98
+ this.richContentInputTarget.value = html || ""
99
+ this.lastUpdatedTarget.textContent = block.updated_at || "Never"
100
+ this.updateCharCount()
101
+
102
+ this.modalTarget.classList.remove("hidden")
103
+ document.addEventListener("keydown", this.boundHandleEscape)
104
+
105
+ const tryLoadHTML = (attempt = 0) => {
106
+ const editorEl = this.richTextContainerTarget.querySelector("trix-editor")
107
+ if (editorEl?.editor) {
108
+ editorEl.editor.loadHTML(html || "")
109
+ this.updateCharCount()
110
+ editorEl.focus?.()
111
+ } else if (attempt < 50) {
112
+ setTimeout(() => tryLoadHTML(attempt + 1), 80)
113
+ }
114
+ }
115
+ requestAnimationFrame(() => {
116
+ requestAnimationFrame(() => setTimeout(() => tryLoadHTML(), 120))
117
+ })
118
+ } else {
119
+ this.contentInputTarget.value = block.content || ""
120
+ this.lastUpdatedTarget.textContent = block.updated_at || "Never"
121
+ this.updateCharCount()
122
+ this.modalTarget.classList.remove("hidden")
123
+ document.addEventListener("keydown", this.boundHandleEscape)
124
+ setTimeout(() => this.contentInputTarget.focus(), 50)
125
+ }
126
+
127
+ this.sendMessageToPreview({
128
+ type: "HIGHLIGHT_BLOCK",
129
+ blockId: blockKey,
130
+ blockIndex: blockIndex
131
+ })
132
+ } catch (error) {
133
+ console.error("Error loading content block:", error)
134
+ alert("Failed to load content block")
135
+ }
136
+ }
137
+
138
+ closeModal() {
139
+ this.modalTarget.classList.add("hidden")
140
+ this.currentContentBlockKey = null
141
+ this.currentContentBlockLocale = null
142
+ this.currentBlockIndex = 0
143
+
144
+ document.removeEventListener("keydown", this.boundHandleEscape)
145
+ this.sendMessageToPreview({ type: "CLEAR_HIGHLIGHT" })
146
+ }
147
+
148
+ handleEscape(event) {
149
+ // Only handle Escape if modal is visible
150
+ if (event.key === "Escape" && !this.modalTarget.classList.contains("hidden")) {
151
+ event.preventDefault()
152
+ event.stopPropagation()
153
+ this.closeModal()
154
+ }
155
+ }
156
+
157
+ changeContentType() {
158
+ const contentType = this.contentTypeTarget.value
159
+
160
+ if (contentType === "rich_text") {
161
+ this.textContainerTarget.style.display = "none"
162
+ this.richTextContainerTarget.classList.add("ruby_cms-visual-editor-modal__rich-text-container--visible")
163
+ } else {
164
+ this.textContainerTarget.style.display = "block"
165
+ this.richTextContainerTarget.classList.remove("ruby_cms-visual-editor-modal__rich-text-container--visible")
166
+ }
167
+
168
+ this.updateCharCount()
169
+ }
170
+
171
+ updateCharCount() {
172
+ const contentType = this.contentTypeTarget.value
173
+ let content = ""
174
+
175
+ if (contentType === "rich_text") {
176
+ const editor = this.richTextContainerTarget.querySelector("trix-editor")
177
+ if (editor && editor.editor) {
178
+ content = editor.editor.getDocument().toString()
179
+ }
180
+ } else {
181
+ content = this.contentInputTarget.value
182
+ }
183
+
184
+ this.charCountTarget.textContent = `${content.length} characters`
185
+ }
186
+
187
+ async saveContent(event) {
188
+ event.preventDefault()
189
+
190
+ if (!this.currentContentBlockKey) return
191
+
192
+ const contentType = this.contentTypeTarget.value
193
+ const payload = {
194
+ key: this.currentContentBlockKey,
195
+ content_type: contentType,
196
+ locale: this.currentContentBlockLocale || null
197
+ }
198
+
199
+ if (contentType === "rich_text") {
200
+ const editor = this.richTextContainerTarget.querySelector("trix-editor")
201
+ payload.rich_content = editor ? editor.value : ""
202
+ } else {
203
+ payload.content = this.contentInputTarget.value
204
+ }
205
+
206
+ try {
207
+ this.saveButtonTarget.disabled = true
208
+ this.saveButtonTarget.textContent = "Saving..."
209
+
210
+ const response = await fetch("/admin/visual_editor/quick_update", {
211
+ method: "PATCH",
212
+ headers: {
213
+ "Content-Type": "application/json",
214
+ "Accept": "application/json",
215
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
216
+ },
217
+ body: JSON.stringify(payload)
218
+ })
219
+
220
+ const data = await response.json()
221
+
222
+ if (!response.ok || !data.success) {
223
+ throw new Error(data.message || "Failed to save")
224
+ }
225
+
226
+ // Update last updated time
227
+ this.lastUpdatedTarget.textContent = data.updated_at
228
+
229
+ // Update content in preview (same message format as app so page_preview_controller works)
230
+ const contentToDisplay = contentType === "rich_text"
231
+ ? (data.rich_content_html || data.content || "")
232
+ : (data.content || "")
233
+
234
+ this.sendMessageToPreview({
235
+ type: "content-updated",
236
+ key: this.currentContentBlockKey,
237
+ content: contentToDisplay,
238
+ blockIndex: this.currentBlockIndex ?? 0
239
+ })
240
+
241
+ // Close modal
242
+ this.closeModal()
243
+
244
+ // Show success toast
245
+ this.showToast(data.message || "Content updated successfully")
246
+
247
+ } catch (error) {
248
+ console.error("Error saving content:", error)
249
+ alert(error.message || "Failed to save content")
250
+ } finally {
251
+ this.saveButtonTarget.disabled = false
252
+ this.saveButtonTarget.textContent = "Save Changes"
253
+ }
254
+ }
255
+
256
+ handleKeydown(event) {
257
+ // Enter to submit (but Shift+Enter allows newline in textareas)
258
+ if (event.key === "Enter") {
259
+ const isTextarea = event.target.matches("textarea")
260
+ const isTrixEditor = event.target.matches("trix-editor") || event.target.closest("trix-editor")
261
+
262
+ // Allow Shift+Enter for newlines in textareas/trix
263
+ if ((isTextarea || isTrixEditor) && event.shiftKey) {
264
+ return // Let the default behavior happen (newline)
265
+ }
266
+
267
+ // Enter without Shift submits the form
268
+ if (!isTextarea && !isTrixEditor) {
269
+ // Enter outside textarea/trix submits
270
+ event.preventDefault()
271
+ this.saveContent(event)
272
+ } else if (!event.shiftKey) {
273
+ // Enter in textarea/trix without Shift submits
274
+ event.preventDefault()
275
+ this.saveContent(event)
276
+ }
277
+ }
278
+
279
+ // Ctrl/Cmd + Enter to save (alternative shortcut)
280
+ if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
281
+ event.preventDefault()
282
+ this.saveContent(event)
283
+ }
284
+ }
285
+
286
+ refreshPreview() {
287
+ const iframe = this.previewFrameTarget
288
+ iframe.src = iframe.src
289
+ }
290
+
291
+ sendMessageToPreview(message) {
292
+ const iframe = this.previewFrameTarget
293
+ if (iframe && iframe.contentWindow) {
294
+ iframe.contentWindow.postMessage(message, window.location.origin)
295
+ }
296
+ }
297
+
298
+ showToast(message) {
299
+ this.toastMessageTarget.textContent = message
300
+ this.toastTarget.classList.remove("hidden")
301
+
302
+ setTimeout(() => {
303
+ this.toastTarget.classList.add("hidden")
304
+ }, 3000)
305
+ }
306
+
307
+ getCurrentLocale() {
308
+ // Try to get locale from meta tag
309
+ const metaLocale = document.querySelector('meta[name="locale"]')
310
+ if (metaLocale) {
311
+ return metaLocale.content
312
+ }
313
+ // Try HTML lang attribute
314
+ const htmlLang = document.documentElement.lang
315
+ if (htmlLang) {
316
+ return htmlLang
317
+ }
318
+ // Fallback to default
319
+ return 'en'
320
+ }
321
+ }
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # DHH-style concern for publish/unpublish behavior.
4
+ # Extracts publishing logic to a reusable, self-contained module.
5
+ #
6
+ # @example
7
+ # class ContentBlock < ApplicationRecord
8
+ # include ContentBlock::Publishable
9
+ # end
10
+ #
11
+ # block.publish(user: Current.user)
12
+ # block.unpublish(user: Current.user)
13
+ # ContentBlock.published
14
+ # ContentBlock.unpublished
15
+ module ContentBlock::Publishable # rubocop:disable Style/ClassAndModuleChildren
16
+ extend ActiveSupport::Concern
17
+
18
+ included do
19
+ scope :published, -> { where(published: true) }
20
+ scope :unpublished, -> { where(published: false) }
21
+ end
22
+
23
+ # Publish the content block.
24
+ # @param user [User, nil] The user performing the action
25
+ # @return [Boolean] True if successfully published
26
+ def publish(user: nil)
27
+ transaction do
28
+ self.updated_by = user if user && respond_to?(:updated_by=)
29
+ update!(published: true)
30
+ end
31
+ true
32
+ rescue ActiveRecord::RecordInvalid
33
+ false
34
+ end
35
+
36
+ # Unpublish the content block.
37
+ # @param user [User, nil] The user performing the action
38
+ # @return [Boolean] True if successfully unpublished
39
+ def unpublish(user: nil)
40
+ transaction do
41
+ self.updated_by = user if user && respond_to?(:updated_by=)
42
+ update!(published: false)
43
+ end
44
+ true
45
+ rescue ActiveRecord::RecordInvalid
46
+ false
47
+ end
48
+
49
+ # Check if content block is currently published.
50
+ # @return [Boolean]
51
+ def published?
52
+ published == true
53
+ end
54
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # DHH-style concern for search behavior.
4
+ # Extracts search and filtering logic to a reusable module.
5
+ #
6
+ # @example
7
+ # ContentBlock.search_by_term("welcome")
8
+ module ContentBlock::Searchable # rubocop:disable Style/ClassAndModuleChildren
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ # Search content blocks by key or title.
13
+ # @param term [String] The search term
14
+ # @return [ActiveRecord::Relation]
15
+ scope :search_by_term, lambda {|term|
16
+ return all if term.blank?
17
+
18
+ search_pattern = "%#{term.to_s.downcase}%"
19
+ where("LOWER(key) LIKE ? OR LOWER(title) LIKE ?", search_pattern, search_pattern)
20
+ }
21
+ end
22
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Public ContentBlock model exposed to the host app.
4
+ class ContentBlock < ApplicationRecord
5
+ include Publishable
6
+ include Searchable
7
+
8
+ self.table_name = "content_blocks"
9
+
10
+ def self.action_text_available?
11
+ return false unless defined?(::ActionText::RichText)
12
+ return false unless ActiveRecord::Base.connected?
13
+
14
+ ActiveRecord::Base.connection.data_source_exists?("action_text_rich_texts")
15
+ rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
16
+ false
17
+ end
18
+
19
+ def self.active_storage_available?
20
+ return false unless defined?(::ActiveStorage::Blob)
21
+ return false unless ActiveRecord::Base.connected?
22
+
23
+ c = ActiveRecord::Base.connection
24
+ c.data_source_exists?("active_storage_blobs") &&
25
+ c.data_source_exists?("active_storage_attachments")
26
+ rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
27
+ false
28
+ end
29
+
30
+ has_rich_text :rich_content if action_text_available?
31
+ has_one_attached :image if active_storage_available?
32
+
33
+ belongs_to :updated_by, class_name: "User", optional: true
34
+
35
+ CONTENT_TYPES = %w[text rich_text image link list].freeze
36
+
37
+ validates :key, presence: true
38
+ validates :locale, presence: true
39
+ validates :content_type, inclusion: { in: CONTENT_TYPES }
40
+ validates :key, uniqueness: { scope: :locale }
41
+ validate :key_not_reserved
42
+ validate :image_content_type, if: -> { respond_to?(:image) && image.attached? }
43
+ validate :image_size, if: -> { respond_to?(:image) && image.attached? }
44
+
45
+ scope :chronologically, -> { order(created_at: :asc) }
46
+ scope :reverse_chronologically, -> { order(created_at: :desc) }
47
+ scope :alphabetically, -> { order(:key) }
48
+ scope :for_locale, ->(locale) { where(locale: locale.to_s) }
49
+ scope :for_current_locale, -> { where(locale: I18n.locale.to_s) }
50
+ scope :preloaded, -> { includes(:updated_by) }
51
+
52
+ scope :indexed_by, lambda {|index|
53
+ case index.to_s
54
+ when "published" then published
55
+ when "unpublished" then unpublished
56
+ else all
57
+ end
58
+ }
59
+
60
+ scope :sorted_by, lambda {|sort|
61
+ case sort.to_s
62
+ when "latest" then reverse_chronologically
63
+ when "oldest" then chronologically
64
+ else alphabetically
65
+ end
66
+ }
67
+
68
+ def self.accessible_by(_user)
69
+ all
70
+ end
71
+
72
+ def self.by_key
73
+ alphabetically
74
+ end
75
+
76
+ def can_edit?(user)
77
+ user&.can?(:manage_content_blocks, record: self)
78
+ end
79
+
80
+ def can_delete?(user)
81
+ user&.can?(:manage_content_blocks, record: self)
82
+ end
83
+
84
+ def record_update_by(user)
85
+ self.updated_by = user if user
86
+ end
87
+
88
+ def content_body
89
+ if content_type == "rich_text" && self.class.action_text_available? &&
90
+ respond_to?(:rich_content)
91
+ rich_content.to_s
92
+ else
93
+ content.to_s
94
+ end
95
+ end
96
+
97
+ def self.find_by_key_and_locale(key, locale: nil, default_locale: nil)
98
+ locale ||= I18n.locale.to_s
99
+ default_locale ||= begin
100
+ I18n.default_locale.to_s
101
+ rescue StandardError
102
+ "en"
103
+ end
104
+
105
+ block = find_by(key: key.to_s, locale: locale.to_s)
106
+ return block if block
107
+ return nil if locale.to_s == default_locale.to_s
108
+
109
+ find_by(key: key.to_s, locale: default_locale.to_s)
110
+ end
111
+
112
+ private
113
+
114
+ def key_not_reserved
115
+ prefixes =
116
+ Array(RubyCms::Settings.get(:reserved_key_prefixes, default: ["admin_"])).map(&:to_s)
117
+ return unless key.to_s.start_with?(*prefixes)
118
+
119
+ errors.add(:key, :reserved)
120
+ rescue StandardError
121
+ errors.add(:key, :reserved) if key.to_s.start_with?("admin_")
122
+ end
123
+
124
+ def image_content_type
125
+ return unless respond_to?(:image) && image.attached?
126
+
127
+ return if image.content_type.in?(allowed_image_content_types)
128
+
129
+ errors.add(:image, :content_type_invalid)
130
+ rescue StandardError
131
+ errors.add(:image, :content_type_invalid)
132
+ end
133
+
134
+ def allowed_image_content_types
135
+ Array(
136
+ RubyCms::Settings.get(
137
+ :image_content_types,
138
+ default: ["image/png", "image/jpeg", "image/gif", "image/webp"]
139
+ )
140
+ ).map(&:to_s)
141
+ rescue StandardError
142
+ ["image/png", "image/jpeg", "image/gif", "image/webp"]
143
+ end
144
+
145
+ def image_size
146
+ return unless respond_to?(:image) && image.attached?
147
+
148
+ limit = RubyCms::Settings.get(:image_max_size, default: 5 * 1024 * 1024).to_i
149
+ return if image.byte_size <= limit
150
+
151
+ errors.add(:image, :file_too_large)
152
+ rescue StandardError
153
+ errors.add(:image, :file_too_large)
154
+ end
155
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ # Backwards-compatible shim: keep RubyCms::ContentBlock working,
5
+ # but the canonical model is the top-level ::ContentBlock.
6
+ class ContentBlock < ::ContentBlock
7
+ end
8
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ class Permission < ::ApplicationRecord
5
+ self.table_name = "permissions"
6
+
7
+ has_many :user_permissions, dependent: :destroy, class_name: "RubyCms::UserPermission"
8
+ has_many :users, through: :user_permissions
9
+
10
+ validates :key, presence: true, uniqueness: true
11
+
12
+ DEFAULT_KEYS = %w[
13
+ manage_admin
14
+ manage_permissions
15
+ manage_content_blocks
16
+ manage_visitor_errors
17
+ manage_analytics
18
+ ].freeze
19
+
20
+ class << self
21
+ def ensure_defaults!
22
+ DEFAULT_KEYS.each do |k|
23
+ find_or_create_by!(key: k) {|p| p.name = k.humanize }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module Permittable
5
+ extend ActiveSupport::Concern
6
+
7
+ # Check if the user has a permission. record: reserved for future record-scoped permissions.
8
+ # Default-deny: unknown permission key = forbidden. Permission lookups are cached per-request.
9
+ def can?(permission_key, record: nil)
10
+ return bootstrap_allowed?(permission_key) if bootstrap?
11
+
12
+ k = permission_key.to_s
13
+ return false unless RubyCms::Permission.exists?(key: k)
14
+
15
+ # Treat manage_admin as a super-permission for admin features.
16
+ cms_permission_keys_cached.include?(k) ||
17
+ cms_permission_keys_cached.include?("manage_admin") ||
18
+ record&.can_edit?(self)
19
+ end
20
+
21
+ def bootstrap?
22
+ RubyCms::Permission.none?
23
+ end
24
+
25
+ def bootstrap_allowed?(permission_key)
26
+ return false unless Rails.application.config.ruby_cms.bootstrap_admin_with_role
27
+ return false unless respond_to?(:admin?) && admin?
28
+
29
+ permission_key.to_s == "manage_admin"
30
+ end
31
+
32
+ # Per-request cache of this user's permission keys. Never rely on client-side checks.
33
+ def cms_permission_keys_cached
34
+ @cms_permission_keys_cached ||=
35
+ RubyCms::UserPermission.where(user: self)
36
+ .joins(:permission).pluck("permissions.key")
37
+ end
38
+ end
39
+ end