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.
- checksums.yaml +7 -0
- data/.cursor/dhh.mdc +698 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/README.md +235 -0
- data/Rakefile +30 -0
- data/app/components/ruby_cms/admin/admin_page/admin_table_content.rb +32 -0
- data/app/components/ruby_cms/admin/admin_page.rb +345 -0
- data/app/components/ruby_cms/admin/base_component.rb +78 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +149 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +127 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +15 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +41 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +33 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +174 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +59 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +159 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +192 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +97 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +137 -0
- data/app/controllers/concerns/ruby_cms/admin_pagination.rb +120 -0
- data/app/controllers/concerns/ruby_cms/admin_turbo_table.rb +68 -0
- data/app/controllers/concerns/ruby_cms/page_tracking.rb +52 -0
- data/app/controllers/concerns/ruby_cms/visitor_error_capture.rb +39 -0
- data/app/controllers/ruby_cms/admin/analytics_controller.rb +191 -0
- data/app/controllers/ruby_cms/admin/base_controller.rb +105 -0
- data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +390 -0
- data/app/controllers/ruby_cms/admin/dashboard_controller.rb +50 -0
- data/app/controllers/ruby_cms/admin/locale_controller.rb +20 -0
- data/app/controllers/ruby_cms/admin/permissions_controller.rb +66 -0
- data/app/controllers/ruby_cms/admin/settings_controller.rb +223 -0
- data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +59 -0
- data/app/controllers/ruby_cms/admin/users_controller.rb +107 -0
- data/app/controllers/ruby_cms/admin/visitor_errors_controller.rb +89 -0
- data/app/controllers/ruby_cms/admin/visual_editor_controller.rb +322 -0
- data/app/controllers/ruby_cms/errors_controller.rb +35 -0
- data/app/helpers/ruby_cms/admin/admin_page_helper.rb +21 -0
- data/app/helpers/ruby_cms/admin/bulk_action_table_helper.rb +159 -0
- data/app/helpers/ruby_cms/application_helper.rb +41 -0
- data/app/helpers/ruby_cms/bulk_action_table_helper.rb +151 -0
- data/app/helpers/ruby_cms/content_blocks_helper.rb +375 -0
- data/app/helpers/ruby_cms/settings_helper.rb +160 -0
- data/app/javascript/controllers/ruby_cms/auto_save_preference_controller.js +73 -0
- data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +553 -0
- data/app/javascript/controllers/ruby_cms/clickable_row_controller.js +28 -0
- data/app/javascript/controllers/ruby_cms/flash_messages_controller.js +29 -0
- data/app/javascript/controllers/ruby_cms/index.js +104 -0
- data/app/javascript/controllers/ruby_cms/locale_tabs_controller.js +34 -0
- data/app/javascript/controllers/ruby_cms/mobile_menu_controller.js +55 -0
- data/app/javascript/controllers/ruby_cms/nav_order_sortable_controller.js +192 -0
- data/app/javascript/controllers/ruby_cms/page_preview_controller.js +135 -0
- data/app/javascript/controllers/ruby_cms/toggle_controller.js +39 -0
- data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +321 -0
- data/app/models/concerns/content_block/publishable.rb +54 -0
- data/app/models/concerns/content_block/searchable.rb +22 -0
- data/app/models/content_block.rb +155 -0
- data/app/models/ruby_cms/content_block.rb +8 -0
- data/app/models/ruby_cms/permission.rb +28 -0
- data/app/models/ruby_cms/permittable.rb +39 -0
- data/app/models/ruby_cms/preference.rb +111 -0
- data/app/models/ruby_cms/user_permission.rb +12 -0
- data/app/models/ruby_cms/visitor_error.rb +109 -0
- data/app/services/ruby_cms/analytics/report.rb +362 -0
- data/app/services/ruby_cms/security_tracker.rb +92 -0
- data/app/views/layouts/ruby_cms/_admin_flash_messages.html.erb +37 -0
- data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +121 -0
- data/app/views/layouts/ruby_cms/admin.html.erb +81 -0
- data/app/views/layouts/ruby_cms/minimal.html.erb +181 -0
- data/app/views/ruby_cms/admin/analytics/index.html.erb +160 -0
- data/app/views/ruby_cms/admin/analytics/page_details.html.erb +84 -0
- data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -0
- data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +40 -0
- data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +58 -0
- data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +51 -0
- data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +31 -0
- data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +4 -0
- data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +21 -0
- data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +125 -0
- data/app/views/ruby_cms/admin/content_blocks/_form.html.erb +161 -0
- data/app/views/ruby_cms/admin/content_blocks/_row.html.erb +25 -0
- data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +17 -0
- data/app/views/ruby_cms/admin/content_blocks/index.html.erb +66 -0
- data/app/views/ruby_cms/admin/content_blocks/new.html.erb +5 -0
- data/app/views/ruby_cms/admin/content_blocks/show.html.erb +110 -0
- data/app/views/ruby_cms/admin/dashboard/index.html.erb +198 -0
- data/app/views/ruby_cms/admin/permissions/_row.html.erb +11 -0
- data/app/views/ruby_cms/admin/permissions/index.html.erb +62 -0
- data/app/views/ruby_cms/admin/settings/index.html.erb +220 -0
- data/app/views/ruby_cms/admin/shared/_bulk_action_table_index.html.erb +56 -0
- data/app/views/ruby_cms/admin/user_permissions/index.html.erb +55 -0
- data/app/views/ruby_cms/admin/users/_row.html.erb +14 -0
- data/app/views/ruby_cms/admin/users/index.html.erb +70 -0
- data/app/views/ruby_cms/admin/visitor_errors/_row.html.erb +35 -0
- data/app/views/ruby_cms/admin/visitor_errors/index.html.erb +57 -0
- data/app/views/ruby_cms/admin/visitor_errors/show.html.erb +147 -0
- data/app/views/ruby_cms/admin/visual_editor/index.html.erb +144 -0
- data/app/views/ruby_cms/errors/not_found.html.erb +92 -0
- data/config/database.yml +6 -0
- data/config/importmap.rb +36 -0
- data/config/locales/en.yml +101 -0
- data/config/routes.rb +65 -0
- data/db/migrate/20260125000001_create_ruby_cms_permissions.rb +14 -0
- data/db/migrate/20260125000002_create_ruby_cms_user_permissions.rb +14 -0
- data/db/migrate/20260125000003_create_ruby_cms_content_blocks.rb +19 -0
- data/db/migrate/20260125000010_add_indexes_to_ruby_cms_tables.rb +9 -0
- data/db/migrate/20260127000001_add_locale_to_ruby_cms_content_blocks.rb +34 -0
- data/db/migrate/20260129000001_create_ruby_cms_visitor_errors.rb +24 -0
- data/db/migrate/20260130000001_add_referer_and_query_to_ruby_cms_visitor_errors.rb +8 -0
- data/db/migrate/20260130000002_create_ruby_cms_preferences.rb +16 -0
- data/db/migrate/20260130000003_add_category_to_ruby_cms_preferences.rb +8 -0
- data/db/migrate/20260211000001_add_ruby_cms_analytics_fields_to_ahoy_events.rb +19 -0
- data/db/migrate/20260212000001_use_unprefixed_cms_tables.rb +146 -0
- data/exe/ruby_cms +25 -0
- data/lib/generators/ruby_cms/install_generator.rb +1062 -0
- data/lib/generators/ruby_cms/templates/admin.html.erb +82 -0
- data/lib/generators/ruby_cms/templates/ruby_cms.rb +86 -0
- data/lib/ruby_cms/app_integration.rb +82 -0
- data/lib/ruby_cms/cli.rb +169 -0
- data/lib/ruby_cms/content_blocks_grouping.rb +41 -0
- data/lib/ruby_cms/content_blocks_sync.rb +329 -0
- data/lib/ruby_cms/css_compiler.rb +35 -0
- data/lib/ruby_cms/engine.rb +498 -0
- data/lib/ruby_cms/settings.rb +145 -0
- data/lib/ruby_cms/settings_registry.rb +289 -0
- data/lib/ruby_cms/version.rb +5 -0
- data/lib/ruby_cms.rb +195 -0
- data/lib/tasks/ruby_cms.rake +27 -0
- data/log/test.log +17875 -0
- data/sig/ruby_cms.rbs +4 -0
- 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
|
92
|
+
html = `<p>${text}</p>`
|
|
93
|
+
}
|
|
94
|
+
if (html && !html.trimStart().startsWith("<")) {
|
|
95
|
+
const escaped = html.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
|
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,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
|