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,104 @@
1
+ // RubyCms Stimulus Controllers
2
+ // This file exports all controllers for registration in the host application
3
+
4
+ import VisualEditorController from "ruby_cms/visual_editor_controller";
5
+ import PagePreviewController from "ruby_cms/page_preview_controller";
6
+ import MobileMenuController from "ruby_cms/mobile_menu_controller";
7
+ import FlashMessagesController from "ruby_cms/flash_messages_controller";
8
+ import BulkActionTableController from "ruby_cms/bulk_action_table_controller";
9
+ import ToggleController from "ruby_cms/toggle_controller";
10
+ import LocaleTabsController from "ruby_cms/locale_tabs_controller";
11
+ import ClickableRowController from "ruby_cms/clickable_row_controller";
12
+ import AutoSavePreferenceController from "ruby_cms/auto_save_preference_controller";
13
+ import NavOrderSortableController from "ruby_cms/nav_order_sortable_controller";
14
+
15
+ export {
16
+ VisualEditorController,
17
+ PagePreviewController,
18
+ MobileMenuController,
19
+ FlashMessagesController,
20
+ BulkActionTableController,
21
+ ToggleController,
22
+ LocaleTabsController,
23
+ ClickableRowController,
24
+ AutoSavePreferenceController,
25
+ NavOrderSortableController,
26
+ };
27
+
28
+ // Helper function to register all RubyCms controllers with a Stimulus application
29
+ export function registerRubyCmsControllers(application) {
30
+ application.register("ruby-cms--visual-editor", VisualEditorController);
31
+ application.register("ruby-cms--page-preview", PagePreviewController);
32
+ application.register("ruby-cms--mobile-menu", MobileMenuController);
33
+ application.register("ruby-cms--flash-messages", FlashMessagesController);
34
+ application.register(
35
+ "ruby-cms--bulk-action-table",
36
+ BulkActionTableController,
37
+ );
38
+ application.register("ruby-cms--toggle", ToggleController);
39
+ application.register("ruby-cms--locale-tabs", LocaleTabsController);
40
+ application.register("clickable-row", ClickableRowController);
41
+ application.register(
42
+ "ruby-cms--auto-save-preference",
43
+ AutoSavePreferenceController,
44
+ );
45
+ application.register(
46
+ "ruby-cms--nav-order-sortable",
47
+ NavOrderSortableController,
48
+ );
49
+ }
50
+
51
+ // Auto-register controllers when this module is imported
52
+ // Works with Rails 7+ importmap setup where Stimulus is loaded via @hotwired/stimulus
53
+ if (typeof window !== "undefined") {
54
+ let registered = false;
55
+
56
+ const registerControllers = (app) => {
57
+ if (registered || !app || typeof app.register !== "function") return false;
58
+ registerRubyCmsControllers(app);
59
+ registered = true;
60
+ return true;
61
+ };
62
+
63
+ // Try to get Stimulus from the standard Rails 7+ location
64
+ const tryRegister = () => {
65
+ // Check window.Stimulus (standard Rails 7+ pattern with @hotwired/stimulus-loading)
66
+ if (window.Stimulus) {
67
+ return registerControllers(window.Stimulus);
68
+ }
69
+ // Fallback: check for application export
70
+ if (window.application) {
71
+ return registerControllers(window.application);
72
+ }
73
+ return false;
74
+ };
75
+
76
+ // Try immediately
77
+ if (!tryRegister()) {
78
+ // Wait for turbo:load which fires after Stimulus is initialized
79
+ const onTurboLoad = () => {
80
+ if (tryRegister()) {
81
+ document.removeEventListener("turbo:load", onTurboLoad);
82
+ }
83
+ };
84
+ document.addEventListener("turbo:load", onTurboLoad);
85
+
86
+ // Also try on DOMContentLoaded as fallback
87
+ const onDOMReady = () => {
88
+ setTimeout(() => {
89
+ if (tryRegister()) {
90
+ document.removeEventListener("DOMContentLoaded", onDOMReady);
91
+ }
92
+ }, 50);
93
+ };
94
+
95
+ if (document.readyState === "loading") {
96
+ document.addEventListener("DOMContentLoaded", onDOMReady);
97
+ } else {
98
+ // DOM already loaded, try after a short delay
99
+ setTimeout(tryRegister, 50);
100
+ setTimeout(tryRegister, 200);
101
+ setTimeout(tryRegister, 500);
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,34 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["tab"];
5
+
6
+ switchTab(event) {
7
+ const tab = event.currentTarget;
8
+ const panelId = tab.dataset.panelId;
9
+ if (!panelId) return;
10
+
11
+ const hideClass = "is-hidden";
12
+
13
+ // Hide all panels
14
+ this.tabTargets.forEach((t) => {
15
+ const pid = t.dataset.panelId;
16
+ if (pid) {
17
+ const panel = document.getElementById(pid);
18
+ if (panel) panel.classList.add(hideClass);
19
+ }
20
+ t.classList.remove("is-active");
21
+ });
22
+
23
+ // Show selected panel
24
+ const panel = document.getElementById(panelId);
25
+ if (panel) panel.classList.remove(hideClass);
26
+
27
+ // Highlight selected tab
28
+ tab.classList.add("is-active");
29
+ tab.setAttribute("aria-selected", "true");
30
+ this.tabTargets
31
+ .filter((t) => t !== tab)
32
+ .forEach((t) => t.setAttribute("aria-selected", "false"));
33
+ }
34
+ }
@@ -0,0 +1,55 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="ruby-cms--mobile-menu"
4
+ export default class extends Controller {
5
+ static targets = ["sidebar", "overlay"]
6
+
7
+ connect() {
8
+ // Close menu when clicking outside (only on mobile)
9
+ if (window.innerWidth < 1024) {
10
+ document.addEventListener("click", this.handleOutsideClick.bind(this))
11
+ }
12
+ }
13
+
14
+ disconnect() {
15
+ document.removeEventListener("click", this.handleOutsideClick.bind(this))
16
+ }
17
+
18
+ toggle() {
19
+ const isOpen = this.hasSidebarTarget && this.sidebarTarget.classList.contains("translate-x-0")
20
+ isOpen ? this.close() : this.open()
21
+ }
22
+
23
+ close() {
24
+ if (this.hasSidebarTarget) {
25
+ this.sidebarTarget.classList.remove("translate-x-0")
26
+ this.sidebarTarget.classList.add("-translate-x-full")
27
+ }
28
+ if (this.hasOverlayTarget) {
29
+ this.overlayTarget.classList.add("hidden")
30
+ }
31
+ }
32
+
33
+ open() {
34
+ if (this.hasSidebarTarget) {
35
+ this.sidebarTarget.classList.remove("-translate-x-full")
36
+ this.sidebarTarget.classList.add("translate-x-0")
37
+ }
38
+ if (this.hasOverlayTarget) {
39
+ this.overlayTarget.classList.remove("hidden")
40
+ }
41
+ }
42
+
43
+ handleOutsideClick(event) {
44
+ if (!this.hasSidebarTarget || !this.sidebarTarget.classList.contains("translate-x-0")) {
45
+ return
46
+ }
47
+
48
+ // Don't close if clicking inside sidebar or toggle button
49
+ if (this.sidebarTarget.contains(event.target) || this.element.contains(event.target)) {
50
+ return
51
+ }
52
+
53
+ this.close()
54
+ }
55
+ }
@@ -0,0 +1,192 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Makes the nav order list sortable via native HTML5 drag and drop.
4
+ // Shows a blue indicator line for drop position; supports dropping at the top.
5
+ export default class extends Controller {
6
+ static values = {
7
+ settingsUrl: { type: String, default: "/admin/settings/nav_order" }
8
+ }
9
+
10
+ connect() {
11
+ this.list = this.element
12
+ this.items = () => Array.from(this.list.querySelectorAll("[draggable='true']"))
13
+ this.boundDragstart = this.dragstart.bind(this)
14
+ this.boundDragend = this.dragend.bind(this)
15
+ this.boundDragover = this.dragover.bind(this)
16
+ this.boundDrop = this.drop.bind(this)
17
+ this.boundDragleave = this.dragleave.bind(this)
18
+ this.boundListDragover = this.listDragover.bind(this)
19
+ this.boundListDrop = this.listDrop.bind(this)
20
+ this.list.addEventListener("dragover", this.boundListDragover)
21
+ this.list.addEventListener("drop", this.boundListDrop)
22
+ this.list.addEventListener("dragleave", this.boundDragleave)
23
+ this.items().forEach((item) => {
24
+ item.addEventListener("dragstart", this.boundDragstart)
25
+ item.addEventListener("dragend", this.boundDragend)
26
+ item.addEventListener("dragover", this.boundDragover)
27
+ item.addEventListener("dragleave", this.boundDragleave)
28
+ item.addEventListener("drop", this.boundDrop)
29
+ })
30
+ }
31
+
32
+ disconnect() {
33
+ this.removeIndicator()
34
+ this.list.removeEventListener("dragover", this.boundListDragover)
35
+ this.list.removeEventListener("drop", this.boundListDrop)
36
+ this.list.removeEventListener("dragleave", this.boundDragleave)
37
+ this.items().forEach((item) => {
38
+ item.removeEventListener("dragstart", this.boundDragstart)
39
+ item.removeEventListener("dragend", this.boundDragend)
40
+ item.removeEventListener("dragover", this.boundDragover)
41
+ item.removeEventListener("dragleave", this.boundDragleave)
42
+ item.removeEventListener("drop", this.boundDrop)
43
+ })
44
+ }
45
+
46
+ getIndicator() {
47
+ if (!this._indicator) {
48
+ this._indicator = document.createElement("div")
49
+ this._indicator.setAttribute("data-nav-order-indicator", "")
50
+ this._indicator.className = "h-0.5 w-full bg-blue-500 flex-shrink-0 pointer-events-none"
51
+ this._indicator.setAttribute("aria-hidden", "true")
52
+ }
53
+ return this._indicator
54
+ }
55
+
56
+ removeIndicator() {
57
+ this._indicator?.remove()
58
+ this._indicator = null
59
+ this.dropIndex = null
60
+ this.items().forEach((item) => item.classList.remove("ring-2", "ring-inset", "ring-blue-400"))
61
+ }
62
+
63
+ placeIndicator(insertIndex) {
64
+ const items = this.items()
65
+ if (insertIndex < 0 || insertIndex > items.length) return
66
+ const indicator = this.getIndicator()
67
+ if (insertIndex === 0) {
68
+ this.list.insertBefore(indicator, items[0])
69
+ } else {
70
+ this.list.insertBefore(indicator, items[insertIndex])
71
+ }
72
+ this.dropIndex = insertIndex
73
+ }
74
+
75
+ dragleave(e) {
76
+ // Only remove ring from items; indicator stays until drop/dragend or leave list
77
+ if (e.currentTarget.getAttribute("draggable") === "true") {
78
+ e.currentTarget.classList.remove("ring-2", "ring-inset", "ring-blue-400")
79
+ }
80
+ const list = this.list
81
+ const related = e.relatedTarget
82
+ if (!related || !list.contains(related)) {
83
+ this.removeIndicator()
84
+ }
85
+ }
86
+
87
+ dragstart(e) {
88
+ this.dragged = e.currentTarget
89
+ e.dataTransfer.effectAllowed = "move"
90
+ e.dataTransfer.setData("text/html", e.currentTarget.innerHTML)
91
+ e.currentTarget.classList.add("opacity-50")
92
+ }
93
+
94
+ dragend(e) {
95
+ e.currentTarget.classList.remove("opacity-50")
96
+ this.dragged = null
97
+ this.removeIndicator()
98
+ this.saveOrder()
99
+ }
100
+
101
+ listDragover(e) {
102
+ if (!this.dragged) return
103
+ e.preventDefault()
104
+ e.dataTransfer.dropEffect = "move"
105
+ const items = this.items()
106
+ const first = items[0]
107
+ if (!first) return
108
+ const firstRect = first.getBoundingClientRect()
109
+ if (e.clientY < firstRect.top + firstRect.height / 2) {
110
+ this.placeIndicator(0)
111
+ }
112
+ }
113
+
114
+ listDrop(e) {
115
+ e.preventDefault()
116
+ if (!this.dragged || this.dropIndex == null) return
117
+ const items = this.items()
118
+ const insertBefore = items[this.dropIndex]
119
+ if (insertBefore && insertBefore !== this.dragged) {
120
+ this.list.insertBefore(this.dragged, insertBefore)
121
+ } else if (!insertBefore) {
122
+ this.list.appendChild(this.dragged)
123
+ }
124
+ this.removeIndicator()
125
+ this.saveOrder()
126
+ }
127
+
128
+ dragover(e) {
129
+ e.preventDefault()
130
+ e.dataTransfer.dropEffect = "move"
131
+ const target = e.currentTarget
132
+ if (target === this.dragged) return
133
+ const items = this.items()
134
+ const index = items.indexOf(target)
135
+ if (index === -1) return
136
+ const rect = target.getBoundingClientRect()
137
+ const mid = rect.top + rect.height / 2
138
+ const insertIndex = e.clientY < mid ? index : index + 1
139
+ this.placeIndicator(insertIndex)
140
+ }
141
+
142
+ drop(e) {
143
+ e.preventDefault()
144
+ if (!this.dragged || this.dragged === e.currentTarget) return
145
+ const items = this.items()
146
+ const insertBefore = this.dropIndex != null ? items[this.dropIndex] : e.currentTarget.nextElementSibling
147
+ if (insertBefore && insertBefore !== this.dragged) {
148
+ this.list.insertBefore(this.dragged, insertBefore)
149
+ } else if (!insertBefore) {
150
+ this.list.appendChild(this.dragged)
151
+ }
152
+ this.removeIndicator()
153
+ this.saveOrder()
154
+ }
155
+
156
+ saveOrder() {
157
+ const form = this.element.closest("form")
158
+ if (!form) return
159
+ const mainList = form.querySelector("[data-list-type='main']")
160
+ const bottomList = form.querySelector("[data-list-type='bottom']")
161
+ const mainOrder = mainList ? Array.from(mainList.querySelectorAll("input[name='nav_order_main[]']")).map((i) => i.value) : []
162
+ const bottomOrder = bottomList ? Array.from(bottomList.querySelectorAll("input[name='nav_order_bottom[]']")).map((i) => i.value) : []
163
+ const url = this.settingsUrlValue || "/admin/settings/nav_order"
164
+ const csrf = document.querySelector('meta[name="csrf-token"]')
165
+ fetch(url, {
166
+ method: "POST",
167
+ headers: {
168
+ "Content-Type": "application/json",
169
+ "X-CSRF-Token": csrf ? csrf.content : "",
170
+ Accept: "application/json"
171
+ },
172
+ body: JSON.stringify({
173
+ tab: "navigation",
174
+ nav_order_main: mainOrder,
175
+ nav_order_bottom: bottomOrder
176
+ })
177
+ })
178
+ .then((res) => (res.ok ? res.json() : Promise.reject(res)))
179
+ .then(() => this.showToast("Order saved.", "success"))
180
+ .catch(() => this.showToast("Failed to save order.", "error"))
181
+ }
182
+
183
+ showToast(message, type = "info") {
184
+ const toast = document.createElement("div")
185
+ toast.className = `fixed top-4 right-4 z-50 rounded-lg px-4 py-2.5 text-sm font-medium text-white shadow-lg ${
186
+ type === "success" ? "bg-emerald-600" : type === "error" ? "bg-red-600" : "bg-gray-800"
187
+ }`
188
+ toast.textContent = message
189
+ document.body.appendChild(toast)
190
+ setTimeout(() => toast.remove(), 2500)
191
+ }
192
+ }
@@ -0,0 +1,135 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ page: String,
6
+ editMode: Boolean,
7
+ };
8
+
9
+ connect() {
10
+ if (this.editModeValue) {
11
+ this.enableEditMode();
12
+ }
13
+
14
+ this.boundHandleMessage = this.handleMessage.bind(this);
15
+ window.addEventListener("message", this.boundHandleMessage);
16
+ }
17
+
18
+ disconnect() {
19
+ window.removeEventListener("message", this.boundHandleMessage);
20
+ this.disableEditMode();
21
+ }
22
+
23
+ enableEditMode() {
24
+ this.boundHandleBlockClick = this.handleBlockClick.bind(this);
25
+ this.element.addEventListener("click", this.boundHandleBlockClick);
26
+ }
27
+
28
+ disableEditMode() {
29
+ if (this.boundHandleBlockClick) {
30
+ this.element.removeEventListener("click", this.boundHandleBlockClick);
31
+ }
32
+ }
33
+
34
+ handleBlockClick(event) {
35
+ const blockElement =
36
+ event.target.closest(".ruby_cms-content-block") ||
37
+ event.target.closest(".content-block");
38
+ if (!blockElement) return;
39
+
40
+ event.preventDefault();
41
+ event.stopPropagation();
42
+
43
+ const blockId =
44
+ blockElement.dataset.blockId || blockElement.dataset.contentKey;
45
+ if (!blockId) {
46
+ console.warn(
47
+ "Content block found but no blockId or contentKey data attribute",
48
+ );
49
+ return;
50
+ }
51
+
52
+ const allWithSameId = this.element.querySelectorAll(
53
+ `[data-block-id="${blockId}"], [data-content-key="${blockId}"]`,
54
+ );
55
+ const blockIndex = Array.from(allWithSameId).indexOf(blockElement);
56
+
57
+ if (window.parent && window.parent !== window) {
58
+ window.parent.postMessage(
59
+ {
60
+ type: "CONTENT_BLOCK_CLICKED",
61
+ blockId,
62
+ blockIndex,
63
+ page: this.getCurrentPage(),
64
+ },
65
+ window.location.origin,
66
+ );
67
+ }
68
+ }
69
+
70
+ handleMessage(event) {
71
+ if (event.origin !== window.location.origin) return;
72
+ const { type, blockId, blockIndex = 0 } = event.data;
73
+
74
+ if (type === "HIGHLIGHT_BLOCK") {
75
+ this.highlightBlock(blockId, blockIndex);
76
+ } else if (type === "CLEAR_HIGHLIGHT") {
77
+ this.clearHighlight();
78
+ } else if (type === "content-updated") {
79
+ this.handleContentUpdate(event.data);
80
+ } else if (type === "UPDATE_BLOCK_CONTENT") {
81
+ this.updateBlockContent(event.data);
82
+ }
83
+ }
84
+
85
+ highlightBlock(blockId, blockIndex = 0) {
86
+ this.clearHighlight();
87
+
88
+ const selector = `[data-block-id="${blockId}"], [data-content-key="${blockId}"]`;
89
+ const matching = this.element.querySelectorAll(selector);
90
+ const target = matching[blockIndex] || matching[0];
91
+ if (target) {
92
+ target.classList.add("editing");
93
+ target.scrollIntoView({ behavior: "smooth", block: "center" });
94
+ }
95
+ }
96
+
97
+ clearHighlight() {
98
+ this.element
99
+ .querySelectorAll(".content-block.editing, .ruby_cms-content-block.editing")
100
+ .forEach((el) => el.classList.remove("editing"));
101
+ }
102
+
103
+ handleContentUpdate(data) {
104
+ const blockIndex = data.blockIndex ?? 0;
105
+ const key = data.key;
106
+ const elements = this.element.querySelectorAll(
107
+ `[data-content-key="${key}"], [data-block-id="${key}"]`,
108
+ );
109
+ const element = elements[blockIndex] || elements[0];
110
+ if (element) {
111
+ const contentEl =
112
+ element.querySelector("[data-content-target]") || element;
113
+ contentEl.innerHTML = data.content ?? "";
114
+ }
115
+ }
116
+
117
+ updateBlockContent({ blockId, content, contentType }) {
118
+ const blockElement = this.element.querySelector(
119
+ `[data-block-id="${blockId}"], [data-content-key="${blockId}"]`,
120
+ );
121
+ if (blockElement) {
122
+ if (contentType === "rich_text") {
123
+ blockElement.innerHTML = content;
124
+ } else {
125
+ blockElement.textContent = content;
126
+ }
127
+ }
128
+ }
129
+
130
+ getCurrentPage() {
131
+ if (this.hasPageValue) return this.pageValue;
132
+ const urlParams = new URLSearchParams(window.location.search);
133
+ return urlParams.get("page") || "home";
134
+ }
135
+ }
@@ -0,0 +1,39 @@
1
+ // Toggle visibility controller for simple show/hide functionality
2
+ import { Controller } from "@hotwired/stimulus"
3
+
4
+ export default class extends Controller {
5
+ static targets = ["toggleable"]
6
+
7
+ toggle(event) {
8
+ event.preventDefault()
9
+ const targetId = event.currentTarget.dataset.toggleTargetId
10
+ if (targetId) {
11
+ const element = document.getElementById(targetId)
12
+ if (element) {
13
+ element.classList.toggle("hidden")
14
+ }
15
+ }
16
+ }
17
+
18
+ hide(event) {
19
+ event.preventDefault()
20
+ const targetId = event.currentTarget.dataset.toggleTargetId
21
+ if (targetId) {
22
+ const element = document.getElementById(targetId)
23
+ if (element) {
24
+ element.classList.add("hidden")
25
+ }
26
+ }
27
+ }
28
+
29
+ show(event) {
30
+ event.preventDefault()
31
+ const targetId = event.currentTarget.dataset.toggleTargetId
32
+ if (targetId) {
33
+ const element = document.getElementById(targetId)
34
+ if (element) {
35
+ element.classList.remove("hidden")
36
+ }
37
+ }
38
+ }
39
+ }