ruby_cms 0.1.9 → 0.2.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 +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +440 -58
- data/app/controllers/ruby_cms/admin/base_controller.rb +33 -12
- data/app/controllers/ruby_cms/admin/content_block_versions_controller.rb +62 -0
- data/app/controllers/ruby_cms/admin/dashboard_controller.rb +40 -0
- data/app/helpers/ruby_cms/admin/dashboard_helper.rb +20 -0
- data/app/javascript/controllers/ruby_cms/content_block_history_controller.js +91 -0
- data/app/javascript/controllers/ruby_cms/index.js +4 -0
- data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +33 -29
- data/app/models/concerns/content_block/versionable.rb +80 -0
- data/app/models/content_block.rb +1 -0
- data/app/models/content_block_version.rb +34 -0
- data/app/views/admin/content_block_versions/index.html.erb +52 -0
- data/app/views/admin/content_block_versions/show.html.erb +37 -0
- data/app/views/ruby_cms/admin/content_block_versions/index.html.erb +52 -0
- data/app/views/ruby_cms/admin/content_block_versions/show.html.erb +37 -0
- data/app/views/ruby_cms/admin/content_blocks/show.html.erb +12 -0
- data/app/views/ruby_cms/admin/dashboard/blocks/_analytics_overview.html.erb +53 -0
- data/app/views/ruby_cms/admin/dashboard/blocks/_content_blocks_stats.html.erb +17 -0
- data/app/views/ruby_cms/admin/dashboard/blocks/_permissions_stats.html.erb +17 -0
- data/app/views/ruby_cms/admin/dashboard/blocks/_quick_actions.html.erb +62 -0
- data/app/views/ruby_cms/admin/dashboard/blocks/_recent_errors.html.erb +39 -0
- data/app/views/ruby_cms/admin/dashboard/blocks/_users_stats.html.erb +17 -0
- data/app/views/ruby_cms/admin/dashboard/blocks/_visitor_errors_stats.html.erb +24 -0
- data/app/views/ruby_cms/admin/dashboard/index.html.erb +22 -180
- data/config/routes.rb +8 -0
- data/db/migrate/20260328000001_create_content_block_versions.rb +22 -0
- data/lib/generators/ruby_cms/admin_page_generator.rb +126 -0
- data/lib/generators/ruby_cms/templates/admin_page/controller.rb.tt +8 -0
- data/lib/generators/ruby_cms/templates/admin_page/index.html.erb.tt +11 -0
- data/lib/ruby_cms/dashboard_blocks.rb +91 -0
- data/lib/ruby_cms/engine/admin_permissions.rb +69 -0
- data/lib/ruby_cms/engine/content_blocks_tasks.rb +66 -0
- data/lib/ruby_cms/engine/css.rb +14 -0
- data/lib/ruby_cms/engine/dashboard_registration.rb +66 -0
- data/lib/ruby_cms/engine/navigation_registration.rb +80 -0
- data/lib/ruby_cms/engine.rb +23 -278
- data/lib/ruby_cms/icons.rb +118 -0
- data/lib/ruby_cms/version.rb +1 -1
- data/lib/ruby_cms.rb +36 -10
- metadata +28 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCms
|
|
4
|
+
module Admin
|
|
5
|
+
class ContentBlockVersionsController < BaseController
|
|
6
|
+
before_action { require_permission!(:manage_content_blocks) }
|
|
7
|
+
before_action :set_content_block
|
|
8
|
+
before_action :set_version, only: %i[show rollback]
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@versions = @content_block.versions.reverse_chronologically.preloaded
|
|
12
|
+
|
|
13
|
+
respond_to do |format|
|
|
14
|
+
format.html
|
|
15
|
+
format.json { render json: versions_json }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def show
|
|
20
|
+
@previous_version = @version.previous
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def rollback
|
|
24
|
+
@content_block.rollback_to_version!(@version, user: current_user_cms)
|
|
25
|
+
redirect_to ruby_cms_admin_content_block_versions_path(@content_block),
|
|
26
|
+
notice: "Teruggedraaid naar versie #{@version.version_number}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def set_content_block
|
|
32
|
+
@content_block = ContentBlock.find(params[:content_block_id])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def set_version
|
|
36
|
+
@version = @content_block.versions.find(params[:id])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def versions_json
|
|
40
|
+
@versions.map do |v|
|
|
41
|
+
{
|
|
42
|
+
id: v.id,
|
|
43
|
+
version_number: v.version_number,
|
|
44
|
+
event: v.event,
|
|
45
|
+
user: display_user(v.user),
|
|
46
|
+
created_at: v.created_at.strftime("%B %d, %Y at %I:%M %p"),
|
|
47
|
+
metadata: v.metadata
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def display_user(user)
|
|
53
|
+
return "System" if user.blank?
|
|
54
|
+
|
|
55
|
+
%i[email_address email username name].each do |attr|
|
|
56
|
+
return user.public_send(attr) if user.respond_to?(attr) && user.public_send(attr).present?
|
|
57
|
+
end
|
|
58
|
+
user.respond_to?(:id) ? "User ##{user.id}" : "System"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -3,13 +3,53 @@
|
|
|
3
3
|
module RubyCms
|
|
4
4
|
module Admin
|
|
5
5
|
class DashboardController < BaseController
|
|
6
|
+
# First main row: quick actions, recent errors, analytics (fixed keys). Remaining :main blocks render below (host/custom).
|
|
7
|
+
PRIMARY_MAIN_ROW_KEYS = %i[quick_actions recent_errors analytics_overview].freeze
|
|
8
|
+
|
|
6
9
|
def index
|
|
7
10
|
assign_counts
|
|
8
11
|
assign_recent_activity
|
|
12
|
+
assign_analytics_overview_stats
|
|
13
|
+
assign_dashboard_blocks
|
|
9
14
|
end
|
|
10
15
|
|
|
11
16
|
private
|
|
12
17
|
|
|
18
|
+
def assign_analytics_overview_stats
|
|
19
|
+
start_date = 7.days.ago.beginning_of_day
|
|
20
|
+
end_date = Time.current.end_of_day
|
|
21
|
+
@dashboard_analytics_stats = RubyCms::Analytics::Report.new(start_date:, end_date:).dashboard_stats
|
|
22
|
+
rescue StandardError => e
|
|
23
|
+
Rails.logger.warn("[RubyCMS] Dashboard analytics snapshot: #{e.class}: #{e.message}")
|
|
24
|
+
@dashboard_analytics_stats = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def assign_dashboard_blocks
|
|
28
|
+
visible = RubyCms.visible_dashboard_blocks(user: current_user_cms)
|
|
29
|
+
@stats_blocks = visible
|
|
30
|
+
.select {|b| b[:section] == :stats }
|
|
31
|
+
.map {|b| prepare_dashboard_block(b) }
|
|
32
|
+
main = visible
|
|
33
|
+
.select {|b| b[:section] == :main }
|
|
34
|
+
.map {|b| prepare_dashboard_block(b) }
|
|
35
|
+
@primary_main_blocks = PRIMARY_MAIN_ROW_KEYS.filter_map {|k| main.find {|b| b[:key] == k } }
|
|
36
|
+
@extra_main_blocks = main
|
|
37
|
+
.reject {|b| PRIMARY_MAIN_ROW_KEYS.include?(b[:key]) }
|
|
38
|
+
.sort_by {|b| [b[:order], b[:label].to_s] }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def prepare_dashboard_block(block)
|
|
42
|
+
from_data =
|
|
43
|
+
if block[:data].respond_to?(:call)
|
|
44
|
+
block[:data].call(self)
|
|
45
|
+
else
|
|
46
|
+
{}
|
|
47
|
+
end
|
|
48
|
+
from_data = {} unless from_data.kind_of?(Hash)
|
|
49
|
+
|
|
50
|
+
block.merge(locals: { dashboard_block: block }.merge(from_data))
|
|
51
|
+
end
|
|
52
|
+
|
|
13
53
|
def assign_counts
|
|
14
54
|
@content_blocks_count = ::ContentBlock.count
|
|
15
55
|
@content_blocks_published_count = ::ContentBlock.published.count
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCms
|
|
4
|
+
module Admin
|
|
5
|
+
module DashboardHelper
|
|
6
|
+
# Renders a dashboard block from the registry (+ :locals from the controller).
|
|
7
|
+
def render_dashboard_block(block)
|
|
8
|
+
locals = block[:locals]
|
|
9
|
+
locals = {} if locals.nil?
|
|
10
|
+
if block[:partial].present?
|
|
11
|
+
render partial: block[:partial], locals: locals
|
|
12
|
+
elsif block[:render].respond_to?(:call)
|
|
13
|
+
block[:render].call(self, locals)
|
|
14
|
+
else
|
|
15
|
+
""
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["panel", "list"]
|
|
5
|
+
static values = {
|
|
6
|
+
contentBlockId: Number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
connect() {
|
|
10
|
+
this.isOpen = false
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async toggle() {
|
|
14
|
+
if (this.isOpen) {
|
|
15
|
+
this.close()
|
|
16
|
+
} else {
|
|
17
|
+
await this.open()
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async open() {
|
|
22
|
+
if (!this.contentBlockIdValue) return
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetch(
|
|
26
|
+
`/admin/content_blocks/${this.contentBlockIdValue}/versions.json`,
|
|
27
|
+
{ headers: { "Accept": "application/json" } }
|
|
28
|
+
)
|
|
29
|
+
if (!response.ok) throw new Error("Failed to fetch versions")
|
|
30
|
+
|
|
31
|
+
const versions = await response.json()
|
|
32
|
+
this.renderVersions(versions)
|
|
33
|
+
this.panelTarget.classList.remove("hidden")
|
|
34
|
+
this.isOpen = true
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error("Error loading version history:", error)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
close() {
|
|
41
|
+
this.panelTarget.classList.add("hidden")
|
|
42
|
+
this.isOpen = false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
renderVersions(versions) {
|
|
46
|
+
if (!versions.length) {
|
|
47
|
+
this.listTarget.innerHTML = '<p class="text-sm text-muted-foreground p-3">Geen versies gevonden.</p>'
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.listTarget.innerHTML = versions.map(v => `
|
|
52
|
+
<div class="flex items-center justify-between p-3 border-b border-border/40 last:border-0">
|
|
53
|
+
<div>
|
|
54
|
+
<span class="text-xs font-medium">v${v.version_number}</span>
|
|
55
|
+
<span class="text-xs text-muted-foreground ml-1">${v.event}</span>
|
|
56
|
+
<span class="text-xs text-muted-foreground ml-2">${v.user}</span>
|
|
57
|
+
<span class="text-xs text-muted-foreground ml-2">${v.created_at}</span>
|
|
58
|
+
</div>
|
|
59
|
+
<button data-action="click->ruby-cms--content-block-history#rollback"
|
|
60
|
+
data-version-id="${v.id}"
|
|
61
|
+
class="text-xs text-primary hover:underline">
|
|
62
|
+
Herstel
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
`).join("")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async rollback(event) {
|
|
69
|
+
const versionId = event.currentTarget.dataset.versionId
|
|
70
|
+
if (!confirm("Weet je zeker dat je deze versie wilt herstellen?")) return
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch(
|
|
74
|
+
`/admin/content_blocks/${this.contentBlockIdValue}/versions/${versionId}/rollback`,
|
|
75
|
+
{
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: {
|
|
78
|
+
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
|
|
79
|
+
"Accept": "application/json"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
if (!response.ok) throw new Error("Rollback failed")
|
|
84
|
+
|
|
85
|
+
window.location.reload()
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error("Rollback error:", error)
|
|
88
|
+
alert("Rollback mislukt")
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -11,6 +11,8 @@ import LocaleTabsController from "ruby_cms/locale_tabs_controller";
|
|
|
11
11
|
import ClickableRowController from "ruby_cms/clickable_row_controller";
|
|
12
12
|
import AutoSavePreferenceController from "ruby_cms/auto_save_preference_controller";
|
|
13
13
|
import NavOrderSortableController from "ruby_cms/nav_order_sortable_controller";
|
|
14
|
+
import ContentBlockHistoryController from "ruby_cms/content_block_history_controller";
|
|
15
|
+
|
|
14
16
|
|
|
15
17
|
export {
|
|
16
18
|
VisualEditorController,
|
|
@@ -23,6 +25,7 @@ export {
|
|
|
23
25
|
ClickableRowController,
|
|
24
26
|
AutoSavePreferenceController,
|
|
25
27
|
NavOrderSortableController,
|
|
28
|
+
ContentBlockHistoryController,
|
|
26
29
|
};
|
|
27
30
|
|
|
28
31
|
// Helper function to register all RubyCms controllers with a Stimulus application
|
|
@@ -37,6 +40,7 @@ export function registerRubyCmsControllers(application) {
|
|
|
37
40
|
);
|
|
38
41
|
application.register("ruby-cms--toggle", ToggleController);
|
|
39
42
|
application.register("ruby-cms--locale-tabs", LocaleTabsController);
|
|
43
|
+
application.register("ruby-cms--content-block-history", ContentBlockHistoryController);
|
|
40
44
|
application.register("clickable-row", ClickableRowController);
|
|
41
45
|
application.register(
|
|
42
46
|
"ruby-cms--auto-save-preference",
|
|
@@ -17,7 +17,7 @@ export default class extends Controller {
|
|
|
17
17
|
"toast",
|
|
18
18
|
"toastMessage"
|
|
19
19
|
]
|
|
20
|
-
|
|
20
|
+
|
|
21
21
|
static values = {
|
|
22
22
|
currentPage: String
|
|
23
23
|
}
|
|
@@ -25,16 +25,17 @@ export default class extends Controller {
|
|
|
25
25
|
connect() {
|
|
26
26
|
this.currentContentBlockKey = null
|
|
27
27
|
this.currentContentBlockLocale = null
|
|
28
|
+
this.currentContentBlockId = null
|
|
28
29
|
this.currentBlockIndex = 0
|
|
29
30
|
this.editMode = false
|
|
30
31
|
|
|
31
32
|
// Listen for messages from iframe
|
|
32
33
|
this.boundHandleMessage = this.handleMessage.bind(this)
|
|
33
34
|
window.addEventListener("message", this.boundHandleMessage)
|
|
34
|
-
|
|
35
|
+
|
|
35
36
|
// Listen for Escape key globally when modal is open
|
|
36
37
|
this.boundHandleEscape = this.handleEscape.bind(this)
|
|
37
|
-
|
|
38
|
+
|
|
38
39
|
// Listen for iframe load
|
|
39
40
|
this.previewFrameTarget.addEventListener("load", () => {
|
|
40
41
|
console.log("Preview frame loaded")
|
|
@@ -51,9 +52,9 @@ export default class extends Controller {
|
|
|
51
52
|
handleMessage(event) {
|
|
52
53
|
// Only accept same-origin messages (iframe preview is same app)
|
|
53
54
|
if (event.origin !== window.location.origin) return
|
|
54
|
-
|
|
55
|
+
|
|
55
56
|
const { type, blockId, blockIndex, page } = event.data
|
|
56
|
-
|
|
57
|
+
|
|
57
58
|
if (type === "CONTENT_BLOCK_CLICKED") {
|
|
58
59
|
this.openBlockEditor(blockId, blockIndex)
|
|
59
60
|
}
|
|
@@ -75,11 +76,13 @@ export default class extends Controller {
|
|
|
75
76
|
const currentLocale = this.getCurrentLocale()
|
|
76
77
|
// Prefer block matching key and current locale; else first block with same key
|
|
77
78
|
const block = blocks.find(b => b.key === blockKey && (b.locale === currentLocale || !b.locale)) ||
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
blocks.find(b => b.key === blockKey) ||
|
|
80
|
+
(blocks[blockIndex]?.key === blockKey ? blocks[blockIndex] : null) ||
|
|
81
|
+
{}
|
|
81
82
|
|
|
82
83
|
this.currentContentBlockLocale = block.locale || currentLocale
|
|
84
|
+
this.currentContentBlockId = block.id || null
|
|
85
|
+
this.dispatch("block-loaded", { detail: { blockId: block.id } })
|
|
83
86
|
|
|
84
87
|
const contentType = String(block.content_type || "text").toLowerCase()
|
|
85
88
|
this.contentTypeTarget.value = contentType
|
|
@@ -139,12 +142,13 @@ export default class extends Controller {
|
|
|
139
142
|
this.modalTarget.classList.add("hidden")
|
|
140
143
|
this.currentContentBlockKey = null
|
|
141
144
|
this.currentContentBlockLocale = null
|
|
145
|
+
this.currentContentBlockId = null
|
|
142
146
|
this.currentBlockIndex = 0
|
|
143
147
|
|
|
144
148
|
document.removeEventListener("keydown", this.boundHandleEscape)
|
|
145
149
|
this.sendMessageToPreview({ type: "CLEAR_HIGHLIGHT" })
|
|
146
150
|
}
|
|
147
|
-
|
|
151
|
+
|
|
148
152
|
handleEscape(event) {
|
|
149
153
|
// Only handle Escape if modal is visible
|
|
150
154
|
if (event.key === "Escape" && !this.modalTarget.classList.contains("hidden")) {
|
|
@@ -156,7 +160,7 @@ export default class extends Controller {
|
|
|
156
160
|
|
|
157
161
|
changeContentType() {
|
|
158
162
|
const contentType = this.contentTypeTarget.value
|
|
159
|
-
|
|
163
|
+
|
|
160
164
|
if (contentType === "rich_text") {
|
|
161
165
|
this.textContainerTarget.style.display = "none"
|
|
162
166
|
this.richTextContainerTarget.classList.add("ruby_cms-visual-editor-modal__rich-text-container--visible")
|
|
@@ -164,14 +168,14 @@ export default class extends Controller {
|
|
|
164
168
|
this.textContainerTarget.style.display = "block"
|
|
165
169
|
this.richTextContainerTarget.classList.remove("ruby_cms-visual-editor-modal__rich-text-container--visible")
|
|
166
170
|
}
|
|
167
|
-
|
|
171
|
+
|
|
168
172
|
this.updateCharCount()
|
|
169
173
|
}
|
|
170
174
|
|
|
171
175
|
updateCharCount() {
|
|
172
176
|
const contentType = this.contentTypeTarget.value
|
|
173
177
|
let content = ""
|
|
174
|
-
|
|
178
|
+
|
|
175
179
|
if (contentType === "rich_text") {
|
|
176
180
|
const editor = this.richTextContainerTarget.querySelector("trix-editor")
|
|
177
181
|
if (editor && editor.editor) {
|
|
@@ -180,33 +184,33 @@ export default class extends Controller {
|
|
|
180
184
|
} else {
|
|
181
185
|
content = this.contentInputTarget.value
|
|
182
186
|
}
|
|
183
|
-
|
|
187
|
+
|
|
184
188
|
this.charCountTarget.textContent = `${content.length} characters`
|
|
185
189
|
}
|
|
186
190
|
|
|
187
191
|
async saveContent(event) {
|
|
188
192
|
event.preventDefault()
|
|
189
|
-
|
|
193
|
+
|
|
190
194
|
if (!this.currentContentBlockKey) return
|
|
191
|
-
|
|
195
|
+
|
|
192
196
|
const contentType = this.contentTypeTarget.value
|
|
193
197
|
const payload = {
|
|
194
198
|
key: this.currentContentBlockKey,
|
|
195
199
|
content_type: contentType,
|
|
196
200
|
locale: this.currentContentBlockLocale || null
|
|
197
201
|
}
|
|
198
|
-
|
|
202
|
+
|
|
199
203
|
if (contentType === "rich_text") {
|
|
200
204
|
const editor = this.richTextContainerTarget.querySelector("trix-editor")
|
|
201
205
|
payload.rich_content = editor ? editor.value : ""
|
|
202
206
|
} else {
|
|
203
207
|
payload.content = this.contentInputTarget.value
|
|
204
208
|
}
|
|
205
|
-
|
|
209
|
+
|
|
206
210
|
try {
|
|
207
211
|
this.saveButtonTarget.disabled = true
|
|
208
212
|
this.saveButtonTarget.textContent = "Saving..."
|
|
209
|
-
|
|
213
|
+
|
|
210
214
|
const response = await fetch("/admin/visual_editor/quick_update", {
|
|
211
215
|
method: "PATCH",
|
|
212
216
|
headers: {
|
|
@@ -216,16 +220,16 @@ export default class extends Controller {
|
|
|
216
220
|
},
|
|
217
221
|
body: JSON.stringify(payload)
|
|
218
222
|
})
|
|
219
|
-
|
|
223
|
+
|
|
220
224
|
const data = await response.json()
|
|
221
|
-
|
|
225
|
+
|
|
222
226
|
if (!response.ok || !data.success) {
|
|
223
227
|
throw new Error(data.message || "Failed to save")
|
|
224
228
|
}
|
|
225
|
-
|
|
229
|
+
|
|
226
230
|
// Update last updated time
|
|
227
231
|
this.lastUpdatedTarget.textContent = data.updated_at
|
|
228
|
-
|
|
232
|
+
|
|
229
233
|
// Update content in preview (same message format as app so page_preview_controller works)
|
|
230
234
|
const contentToDisplay = contentType === "rich_text"
|
|
231
235
|
? (data.rich_content_html || data.content || "")
|
|
@@ -237,13 +241,13 @@ export default class extends Controller {
|
|
|
237
241
|
content: contentToDisplay,
|
|
238
242
|
blockIndex: this.currentBlockIndex ?? 0
|
|
239
243
|
})
|
|
240
|
-
|
|
244
|
+
|
|
241
245
|
// Close modal
|
|
242
246
|
this.closeModal()
|
|
243
|
-
|
|
247
|
+
|
|
244
248
|
// Show success toast
|
|
245
249
|
this.showToast(data.message || "Content updated successfully")
|
|
246
|
-
|
|
250
|
+
|
|
247
251
|
} catch (error) {
|
|
248
252
|
console.error("Error saving content:", error)
|
|
249
253
|
alert(error.message || "Failed to save content")
|
|
@@ -258,12 +262,12 @@ export default class extends Controller {
|
|
|
258
262
|
if (event.key === "Enter") {
|
|
259
263
|
const isTextarea = event.target.matches("textarea")
|
|
260
264
|
const isTrixEditor = event.target.matches("trix-editor") || event.target.closest("trix-editor")
|
|
261
|
-
|
|
265
|
+
|
|
262
266
|
// Allow Shift+Enter for newlines in textareas/trix
|
|
263
267
|
if ((isTextarea || isTrixEditor) && event.shiftKey) {
|
|
264
268
|
return // Let the default behavior happen (newline)
|
|
265
269
|
}
|
|
266
|
-
|
|
270
|
+
|
|
267
271
|
// Enter without Shift submits the form
|
|
268
272
|
if (!isTextarea && !isTrixEditor) {
|
|
269
273
|
// Enter outside textarea/trix submits
|
|
@@ -275,7 +279,7 @@ export default class extends Controller {
|
|
|
275
279
|
this.saveContent(event)
|
|
276
280
|
}
|
|
277
281
|
}
|
|
278
|
-
|
|
282
|
+
|
|
279
283
|
// Ctrl/Cmd + Enter to save (alternative shortcut)
|
|
280
284
|
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
|
281
285
|
event.preventDefault()
|
|
@@ -298,7 +302,7 @@ export default class extends Controller {
|
|
|
298
302
|
showToast(message) {
|
|
299
303
|
this.toastMessageTarget.textContent = message
|
|
300
304
|
this.toastTarget.classList.remove("hidden")
|
|
301
|
-
|
|
305
|
+
|
|
302
306
|
setTimeout(() => {
|
|
303
307
|
this.toastTarget.classList.add("hidden")
|
|
304
308
|
}, 3000)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ContentBlock::Versionable # rubocop:disable Style/ClassAndModuleChildren
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
has_many :versions, class_name: "ContentBlockVersion",
|
|
8
|
+
dependent: :destroy
|
|
9
|
+
after_create :create_initial_version
|
|
10
|
+
after_update :create_update_version, if: :content_changed_meaningfully?
|
|
11
|
+
|
|
12
|
+
attr_accessor :_rollback_in_progress
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def rollback_to_version!(version, user: nil)
|
|
16
|
+
transaction do
|
|
17
|
+
self._rollback_in_progress = true
|
|
18
|
+
assign_attributes(
|
|
19
|
+
title: version.title,
|
|
20
|
+
content: version.content,
|
|
21
|
+
content_type: version.content_type,
|
|
22
|
+
published: version.published
|
|
23
|
+
)
|
|
24
|
+
restore_rich_content(version) if version.rich_content_html.present?
|
|
25
|
+
self.updated_by = user if user
|
|
26
|
+
save!
|
|
27
|
+
end
|
|
28
|
+
ensure
|
|
29
|
+
self._rollback_in_progress = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def current_version_number
|
|
33
|
+
versions.maximum(:version_number) || 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def content_changed_meaningfully?
|
|
39
|
+
saved_change_to_title? || saved_change_to_content? ||
|
|
40
|
+
saved_change_to_content_type? || saved_change_to_published?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def create_initial_version
|
|
44
|
+
create_version_record(event: "create")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def create_update_version
|
|
48
|
+
create_version_record(event: determine_event)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def determine_event
|
|
52
|
+
return "rollback" if _rollback_in_progress
|
|
53
|
+
return "publish" if saved_change_to_published? && published?
|
|
54
|
+
return "unpublish" if saved_change_to_published? && !published?
|
|
55
|
+
|
|
56
|
+
"update"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def create_version_record(event:)
|
|
60
|
+
rich_html = (rich_content.body&.to_html.to_s if respond_to?(:rich_content) && rich_content.respond_to?(:body))
|
|
61
|
+
|
|
62
|
+
versions.create!(
|
|
63
|
+
user: updated_by,
|
|
64
|
+
version_number: current_version_number + 1,
|
|
65
|
+
title: title,
|
|
66
|
+
content: content,
|
|
67
|
+
rich_content_html: rich_html,
|
|
68
|
+
content_type: content_type,
|
|
69
|
+
published: published?,
|
|
70
|
+
event: event,
|
|
71
|
+
metadata: { changed_fields: previous_changes.keys }
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def restore_rich_content(version)
|
|
76
|
+
return unless respond_to?(:rich_content=) && version.rich_content_html.present?
|
|
77
|
+
|
|
78
|
+
self.rich_content = version.rich_content_html
|
|
79
|
+
end
|
|
80
|
+
end
|
data/app/models/content_block.rb
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class ContentBlockVersion < ApplicationRecord
|
|
4
|
+
belongs_to :content_block
|
|
5
|
+
belongs_to :user, optional: true
|
|
6
|
+
|
|
7
|
+
EVENTS = %w[create update rollback publish unpublish visual_editor].freeze
|
|
8
|
+
|
|
9
|
+
validates :version_number, presence: true,
|
|
10
|
+
uniqueness: { scope: :content_block_id }
|
|
11
|
+
validates :event, inclusion: { in: EVENTS }
|
|
12
|
+
|
|
13
|
+
scope :chronologically, -> { order(version_number: :asc) }
|
|
14
|
+
scope :reverse_chronologically, -> { order(version_number: :desc) }
|
|
15
|
+
scope :preloaded, -> { includes(:user) }
|
|
16
|
+
|
|
17
|
+
def diff_from(other)
|
|
18
|
+
fields = %i[title content rich_content_html content_type published]
|
|
19
|
+
fields.each_with_object({}) do |field, changes|
|
|
20
|
+
old_val = other&.public_send(field)
|
|
21
|
+
new_val = public_send(field)
|
|
22
|
+
changes[field] = { old: old_val, new: new_val } if old_val != new_val
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def previous
|
|
27
|
+
content_block.versions.where(version_number: ...version_number)
|
|
28
|
+
.order(version_number: :desc).first
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def snapshot
|
|
32
|
+
{ title:, content:, rich_content_html:, content_type:, published: }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<h1>Versiegeschiedenis: <%= @content_block.key %></h1>
|
|
2
|
+
|
|
3
|
+
<%= link_to "Terug naar content block", ruby_cms_admin_content_block_path(@content_block),
|
|
4
|
+
class: "inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-4" %>
|
|
5
|
+
|
|
6
|
+
<div class="space-y-4">
|
|
7
|
+
<% @versions.each do |version| %>
|
|
8
|
+
<div class="rounded-lg border border-border/60 bg-card p-4">
|
|
9
|
+
<div class="flex items-center justify-between">
|
|
10
|
+
<div class="flex items-center gap-3">
|
|
11
|
+
<span class="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium
|
|
12
|
+
<%= case version.event
|
|
13
|
+
when 'create' then 'bg-blue-50 text-blue-700 ring-1 ring-blue-200'
|
|
14
|
+
when 'update' then 'bg-amber-50 text-amber-700 ring-1 ring-amber-200'
|
|
15
|
+
when 'rollback' then 'bg-purple-50 text-purple-700 ring-1 ring-purple-200'
|
|
16
|
+
when 'publish' then 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200'
|
|
17
|
+
when 'unpublish' then 'bg-red-50 text-red-700 ring-1 ring-red-200'
|
|
18
|
+
when 'visual_editor' then 'bg-cyan-50 text-cyan-700 ring-1 ring-cyan-200'
|
|
19
|
+
else 'bg-muted text-muted-foreground ring-1 ring-border/60'
|
|
20
|
+
end %>">
|
|
21
|
+
v<%= version.version_number %> — <%= version.event %>
|
|
22
|
+
</span>
|
|
23
|
+
<span class="text-sm text-muted-foreground">
|
|
24
|
+
<%= version.user&.name || version.user&.email || "System" %>
|
|
25
|
+
</span>
|
|
26
|
+
<span class="text-xs text-muted-foreground">
|
|
27
|
+
<%= version.created_at.strftime("%b %d, %Y %H:%M") %>
|
|
28
|
+
</span>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div class="flex items-center gap-2">
|
|
32
|
+
<%= link_to "Bekijk diff", ruby_cms_admin_content_block_version_path(@content_block, version),
|
|
33
|
+
class: "text-xs text-primary hover:underline" %>
|
|
34
|
+
|
|
35
|
+
<% unless version == @versions.first %>
|
|
36
|
+
<%= button_to "Rollback",
|
|
37
|
+
rollback_ruby_cms_admin_content_block_version_path(@content_block, version),
|
|
38
|
+
method: :post,
|
|
39
|
+
data: { turbo_confirm: "Weet je zeker dat je wilt terugdraaien naar versie #{version.version_number}?" },
|
|
40
|
+
class: "text-xs text-destructive hover:underline" %>
|
|
41
|
+
<% end %>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<% if version.metadata&.dig("changed_fields").present? %>
|
|
46
|
+
<p class="mt-2 text-xs text-muted-foreground">
|
|
47
|
+
Gewijzigde velden: <%= version.metadata["changed_fields"].join(", ") %>
|
|
48
|
+
</p>
|
|
49
|
+
<% end %>
|
|
50
|
+
</div>
|
|
51
|
+
<% end %>
|
|
52
|
+
</div>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<h1>Versie <%= @version.version_number %> — <%= @content_block.key %></h1>
|
|
2
|
+
|
|
3
|
+
<%= link_to "Terug naar versiegeschiedenis",
|
|
4
|
+
ruby_cms_admin_content_block_versions_path(@content_block),
|
|
5
|
+
class: "inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-4" %>
|
|
6
|
+
|
|
7
|
+
<% diff = @version.diff_from(@previous_version) %>
|
|
8
|
+
|
|
9
|
+
<% if diff.empty? %>
|
|
10
|
+
<p class="text-sm text-muted-foreground">Geen wijzigingen ten opzichte van de vorige versie.</p>
|
|
11
|
+
<% else %>
|
|
12
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
13
|
+
<% diff.each do |field, changes| %>
|
|
14
|
+
<div class="col-span-2 rounded-lg border border-border/60 bg-card p-4">
|
|
15
|
+
<h3 class="text-sm font-semibold text-foreground mb-2"><%= field.to_s.humanize %></h3>
|
|
16
|
+
<div class="grid grid-cols-2 gap-4">
|
|
17
|
+
<div>
|
|
18
|
+
<span class="text-xs font-medium text-muted-foreground">Vorige versie</span>
|
|
19
|
+
<div class="mt-1 rounded-md bg-red-50 p-3 text-sm text-red-800 whitespace-pre-wrap"><%= changes[:old] || "(leeg)" %></div>
|
|
20
|
+
</div>
|
|
21
|
+
<div>
|
|
22
|
+
<span class="text-xs font-medium text-muted-foreground">Deze versie</span>
|
|
23
|
+
<div class="mt-1 rounded-md bg-emerald-50 p-3 text-sm text-emerald-800 whitespace-pre-wrap"><%= changes[:new] || "(leeg)" %></div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<div class="mt-6">
|
|
32
|
+
<%= button_to "Rollback naar deze versie",
|
|
33
|
+
rollback_ruby_cms_admin_content_block_version_path(@content_block, @version),
|
|
34
|
+
method: :post,
|
|
35
|
+
data: { turbo_confirm: "Weet je zeker dat je wilt terugdraaien naar versie #{@version.version_number}?" },
|
|
36
|
+
class: "inline-flex items-center px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90" %>
|
|
37
|
+
</div>
|