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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +440 -58
  4. data/app/controllers/ruby_cms/admin/base_controller.rb +33 -12
  5. data/app/controllers/ruby_cms/admin/content_block_versions_controller.rb +62 -0
  6. data/app/controllers/ruby_cms/admin/dashboard_controller.rb +40 -0
  7. data/app/helpers/ruby_cms/admin/dashboard_helper.rb +20 -0
  8. data/app/javascript/controllers/ruby_cms/content_block_history_controller.js +91 -0
  9. data/app/javascript/controllers/ruby_cms/index.js +4 -0
  10. data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +33 -29
  11. data/app/models/concerns/content_block/versionable.rb +80 -0
  12. data/app/models/content_block.rb +1 -0
  13. data/app/models/content_block_version.rb +34 -0
  14. data/app/views/admin/content_block_versions/index.html.erb +52 -0
  15. data/app/views/admin/content_block_versions/show.html.erb +37 -0
  16. data/app/views/ruby_cms/admin/content_block_versions/index.html.erb +52 -0
  17. data/app/views/ruby_cms/admin/content_block_versions/show.html.erb +37 -0
  18. data/app/views/ruby_cms/admin/content_blocks/show.html.erb +12 -0
  19. data/app/views/ruby_cms/admin/dashboard/blocks/_analytics_overview.html.erb +53 -0
  20. data/app/views/ruby_cms/admin/dashboard/blocks/_content_blocks_stats.html.erb +17 -0
  21. data/app/views/ruby_cms/admin/dashboard/blocks/_permissions_stats.html.erb +17 -0
  22. data/app/views/ruby_cms/admin/dashboard/blocks/_quick_actions.html.erb +62 -0
  23. data/app/views/ruby_cms/admin/dashboard/blocks/_recent_errors.html.erb +39 -0
  24. data/app/views/ruby_cms/admin/dashboard/blocks/_users_stats.html.erb +17 -0
  25. data/app/views/ruby_cms/admin/dashboard/blocks/_visitor_errors_stats.html.erb +24 -0
  26. data/app/views/ruby_cms/admin/dashboard/index.html.erb +22 -180
  27. data/config/routes.rb +8 -0
  28. data/db/migrate/20260328000001_create_content_block_versions.rb +22 -0
  29. data/lib/generators/ruby_cms/admin_page_generator.rb +126 -0
  30. data/lib/generators/ruby_cms/templates/admin_page/controller.rb.tt +8 -0
  31. data/lib/generators/ruby_cms/templates/admin_page/index.html.erb.tt +11 -0
  32. data/lib/ruby_cms/dashboard_blocks.rb +91 -0
  33. data/lib/ruby_cms/engine/admin_permissions.rb +69 -0
  34. data/lib/ruby_cms/engine/content_blocks_tasks.rb +66 -0
  35. data/lib/ruby_cms/engine/css.rb +14 -0
  36. data/lib/ruby_cms/engine/dashboard_registration.rb +66 -0
  37. data/lib/ruby_cms/engine/navigation_registration.rb +80 -0
  38. data/lib/ruby_cms/engine.rb +23 -278
  39. data/lib/ruby_cms/icons.rb +118 -0
  40. data/lib/ruby_cms/version.rb +1 -1
  41. data/lib/ruby_cms.rb +36 -10
  42. 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
- blocks.find(b => b.key === blockKey) ||
79
- (blocks[blockIndex]?.key === blockKey ? blocks[blockIndex] : null) ||
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
@@ -4,6 +4,7 @@
4
4
  class ContentBlock < ApplicationRecord
5
5
  include Publishable
6
6
  include Searchable
7
+ include Versionable
7
8
 
8
9
  self.table_name = "content_blocks"
9
10
 
@@ -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>