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,73 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Auto-saves preference changes via JSON PATCH.
4
+ export default class extends Controller {
5
+ static values = {
6
+ preferenceKey: String,
7
+ settingsUrl: String,
8
+ tab: String,
9
+ };
10
+
11
+ async save(event) {
12
+ const target = event.target;
13
+ const value = target.type === "checkbox" ? target.checked : target.value;
14
+ const key = this.preferenceKeyValue;
15
+ const url = this.settingsUrlValue || "/admin/settings";
16
+
17
+ if (!key) {
18
+ console.error("Preference key not found");
19
+ return;
20
+ }
21
+
22
+ try {
23
+ const csrf = document.querySelector('meta[name="csrf-token"]');
24
+ const response = await fetch(url, {
25
+ method: "PATCH",
26
+ headers: {
27
+ "Content-Type": "application/json",
28
+ "X-CSRF-Token": csrf ? csrf.content : "",
29
+ Accept: "application/json",
30
+ },
31
+ body: JSON.stringify({
32
+ key,
33
+ value,
34
+ tab: this.tabValue || null,
35
+ }),
36
+ });
37
+
38
+ if (!response.ok) {
39
+ this.showNotification(`Failed to save ${key}`, "error");
40
+ return;
41
+ }
42
+
43
+ const payload = await response.json();
44
+ const updatedKey = (payload.updated_keys && payload.updated_keys[0]) || key;
45
+ const displayName = updatedKey
46
+ .replace(/^nav_show_/, "")
47
+ .replace(/_per_page$/, "")
48
+ .replace(/_/g, " ");
49
+
50
+ this.showNotification(`Saved: ${displayName}`, "success");
51
+ } catch (error) {
52
+ console.error("Error saving preference:", error);
53
+ this.showNotification(`Error saving ${key}`, "error");
54
+ }
55
+ }
56
+
57
+ showNotification(message, type = "info") {
58
+ const toast = document.createElement("div");
59
+ toast.className = `fixed top-4 right-4 z-50 px-4 py-2 rounded-md shadow-lg text-white max-w-sm ${
60
+ type === "success"
61
+ ? "bg-green-600"
62
+ : type === "error"
63
+ ? "bg-red-600"
64
+ : "bg-blue-600"
65
+ }`;
66
+ toast.textContent = message;
67
+ document.body.appendChild(toast);
68
+
69
+ setTimeout(() => {
70
+ toast.remove();
71
+ }, 3000);
72
+ }
73
+ }
@@ -0,0 +1,553 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Connects to data-controller="ruby-cms--bulk-action-table"
4
+ export default class extends Controller {
5
+ static targets = [
6
+ "bulkBar",
7
+ "selectedCount",
8
+ "selectAllCheckbox",
9
+ "itemCheckbox",
10
+ "selectAllButton",
11
+ "dialogOverlay",
12
+ "dialogConfirmButton",
13
+ "dialogContent",
14
+ "dialogTitle",
15
+ "dialogMessage",
16
+ ];
17
+ static values = {
18
+ csrfToken: String,
19
+ bulkActionUrl: String,
20
+ itemName: { type: String, default: "item" },
21
+ };
22
+
23
+ connect() {
24
+ this.currentAction = null;
25
+ this.currentItemId = null;
26
+ this.isProcessing = false;
27
+
28
+ this.clearItemIdsFromUrl();
29
+ this.clearSelection();
30
+
31
+ if (this.hasSelectAllCheckboxTarget) {
32
+ this.updateBulkBar();
33
+ }
34
+
35
+ // Handle ESC key to close dialog
36
+ this.boundHandleKeydown = this.handleKeydown.bind(this);
37
+ document.addEventListener("keydown", this.boundHandleKeydown);
38
+ }
39
+
40
+ disconnect() {
41
+ if (this.boundHandleKeydown) {
42
+ document.removeEventListener("keydown", this.boundHandleKeydown);
43
+ }
44
+ }
45
+
46
+ handleKeydown(event) {
47
+ // Close dialog on ESC key
48
+ if (
49
+ event.key === "Escape" &&
50
+ this.hasDialogOverlayTarget &&
51
+ !this.dialogOverlayTarget.classList.contains("hidden")
52
+ ) {
53
+ event.preventDefault();
54
+ this.closeDialog();
55
+ }
56
+ }
57
+
58
+ toggleSelectAll(event) {
59
+ if (!this.hasSelectAllCheckboxTarget) return;
60
+
61
+ const checked = event.target.checked;
62
+ this.itemCheckboxTargets.forEach((checkbox) => {
63
+ checkbox.checked = checked;
64
+ });
65
+ this.updateBulkBar();
66
+ }
67
+
68
+ selectAll() {
69
+ if (!this.hasSelectAllCheckboxTarget) return;
70
+
71
+ this.selectAllCheckboxTarget.checked = true;
72
+ this.itemCheckboxTargets.forEach((checkbox) => {
73
+ checkbox.checked = true;
74
+ });
75
+ this.updateBulkBar();
76
+ }
77
+
78
+ clearSelection() {
79
+ if (!this.hasSelectAllCheckboxTarget) return;
80
+
81
+ this.selectAllCheckboxTarget.checked = false;
82
+ this.itemCheckboxTargets.forEach((checkbox) => {
83
+ checkbox.checked = false;
84
+ });
85
+ this.updateBulkBar();
86
+ }
87
+
88
+ updateSelection() {
89
+ this.updateBulkBar();
90
+ }
91
+
92
+ updateBulkBar() {
93
+ // Find targets manually if Stimulus targets aren't available
94
+ const controllerName = "ruby-cms--bulk-action-table";
95
+ const bulkBar = this.hasBulkBarTarget
96
+ ? this.bulkBarTarget
97
+ : this.element.querySelector(`[data-${controllerName}-target="bulkBar"]`);
98
+
99
+ const selectedCount = this.hasSelectedCountTarget
100
+ ? this.selectedCountTarget
101
+ : this.element.querySelector(
102
+ `[data-${controllerName}-target="selectedCount"]`,
103
+ );
104
+
105
+ if (!bulkBar || !selectedCount) {
106
+ // Silently return if targets aren't found (component might not have bulk actions)
107
+ return;
108
+ }
109
+
110
+ const selectedItems = this.getSelectedIds();
111
+ const count = selectedItems.length;
112
+
113
+ if (count > 0) {
114
+ bulkBar.classList.remove("hidden");
115
+ const itemName = this.itemNameValue || "item";
116
+ selectedCount.textContent = `${count} ${itemName}${count === 1 ? "" : "s"} selected:`;
117
+ } else {
118
+ bulkBar.classList.add("hidden");
119
+ }
120
+
121
+ this.updateRowHighlighting(selectedItems);
122
+
123
+ if (this.hasSelectAllCheckboxTarget) {
124
+ if (count === 0) {
125
+ this.selectAllCheckboxTarget.indeterminate = false;
126
+ this.selectAllCheckboxTarget.checked = false;
127
+ } else if (count === this.itemCheckboxTargets.length) {
128
+ this.selectAllCheckboxTarget.indeterminate = false;
129
+ this.selectAllCheckboxTarget.checked = true;
130
+ } else {
131
+ this.selectAllCheckboxTarget.indeterminate = true;
132
+ this.selectAllCheckboxTarget.checked = false;
133
+ }
134
+ }
135
+ }
136
+
137
+ updateRowHighlighting(selectedIds) {
138
+ const rows = this.element.querySelectorAll("tr[data-item-id]");
139
+ // Convert selectedIds to strings for comparison
140
+ const selectedIdsStr = selectedIds.map((id) => String(id));
141
+
142
+ rows.forEach((row) => {
143
+ const itemId = String(row.getAttribute("data-item-id") || "");
144
+ if (selectedIdsStr.includes(itemId)) {
145
+ row.setAttribute("data-state", "selected");
146
+ row.classList.add("bg-gray-50");
147
+ } else {
148
+ row.removeAttribute("data-state");
149
+ row.classList.remove("bg-gray-50");
150
+ }
151
+ });
152
+ }
153
+
154
+ getSelectedIds() {
155
+ // Get checkboxes - try Stimulus targets first, then fallback to querySelector
156
+ const controllerName = "ruby-cms--bulk-action-table";
157
+ const checkboxes = this.hasItemCheckboxTarget
158
+ ? this.itemCheckboxTargets
159
+ : Array.from(
160
+ this.element.querySelectorAll(
161
+ `input[type="checkbox"][data-${controllerName}-target="itemCheckbox"]`,
162
+ ),
163
+ );
164
+
165
+ return checkboxes
166
+ .filter((checkbox) => checkbox.checked)
167
+ .map((checkbox) => {
168
+ // Try dataset.itemId first, then value, ensuring string conversion
169
+ return String(
170
+ checkbox.dataset.itemId ||
171
+ checkbox.getAttribute("data-item-id") ||
172
+ checkbox.value ||
173
+ "",
174
+ );
175
+ })
176
+ .filter((id) => id !== ""); // Remove empty IDs
177
+ }
178
+
179
+ showActionDialog(event) {
180
+ const count = this.getSelectedIds().length;
181
+ if (count === 0) {
182
+ this.showNotification("Please select at least one item.", "error");
183
+ return;
184
+ }
185
+
186
+ const button = event.currentTarget;
187
+ const actionName = event.params.actionName || button.dataset.actionName;
188
+ const actionUrl =
189
+ event.params.actionUrl ||
190
+ button.dataset.actionUrl ||
191
+ button.dataset.deleteUrl ||
192
+ this.bulkActionUrlValue;
193
+ const actionLabel =
194
+ button.dataset.actionLabel || this.getDefaultActionLabel(actionName);
195
+ const actionConfirm =
196
+ button.dataset.actionConfirm || this.getDefaultActionConfirm(actionName);
197
+
198
+ if (
199
+ button.dataset.actionType === "redirect" ||
200
+ actionName === "bulk_edit"
201
+ ) {
202
+ this.redirectToBulkAction(actionUrl, actionName);
203
+ return;
204
+ }
205
+
206
+ // Direct action: perform immediately without confirmation
207
+ if (button.dataset.actionType === "direct") {
208
+ this.currentAction = actionName;
209
+ this.currentActionUrl = actionUrl;
210
+ this.performDirectAction();
211
+ return;
212
+ }
213
+
214
+ this.currentAction = actionName;
215
+ this.currentActionUrl = actionUrl;
216
+ this.currentActionLabel = actionLabel;
217
+ this.currentActionConfirm = actionConfirm;
218
+
219
+ this.showDialog();
220
+ }
221
+
222
+ async performDirectAction() {
223
+ const itemIds = this.getSelectedIds();
224
+ if (itemIds.length === 0) return;
225
+
226
+ await this.performBulkAction(
227
+ null,
228
+ this.currentAction,
229
+ this.currentActionUrl,
230
+ itemIds,
231
+ );
232
+ }
233
+
234
+ getDefaultActionLabel(actionName) {
235
+ if (actionName === "delete") return "Delete Selected";
236
+ return actionName
237
+ ? actionName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
238
+ : "Confirm";
239
+ }
240
+
241
+ getDefaultActionConfirm(actionName) {
242
+ if (actionName === "delete") {
243
+ return "Are you sure you want to delete the selected items? This action cannot be undone.";
244
+ }
245
+ return "Are you sure you want to proceed?";
246
+ }
247
+
248
+ redirectToBulkAction(url, actionName) {
249
+ const selectedIds = this.getSelectedIds();
250
+ if (selectedIds.length === 0) {
251
+ this.showNotification("Please select at least one item.", "error");
252
+ return;
253
+ }
254
+
255
+ const urlObj = new URL(url, window.location.origin);
256
+ selectedIds.forEach((id) => {
257
+ urlObj.searchParams.append("item_ids[]", id);
258
+ });
259
+
260
+ this.clearSelection();
261
+
262
+ window.location.href = urlObj.toString();
263
+ }
264
+
265
+ showDialog() {
266
+ const label = this.currentActionLabel || "Confirm";
267
+ const message =
268
+ this.currentActionConfirm || "Are you sure you want to proceed?";
269
+
270
+ if (this.hasDialogTitleTarget) {
271
+ this.dialogTitleTarget.textContent = label;
272
+ }
273
+ if (this.hasDialogMessageTarget) {
274
+ this.dialogMessageTarget.innerHTML = message
275
+ .split("\n")
276
+ .map((p) => `<p>${p}</p>`)
277
+ .join("");
278
+ }
279
+ if (this.hasDialogConfirmButtonTarget) {
280
+ this.dialogConfirmButtonTarget.disabled = false;
281
+ this.dialogConfirmButtonTarget.textContent = label;
282
+ }
283
+
284
+ if (this.hasDialogOverlayTarget) {
285
+ this.dialogOverlayTarget.classList.remove("hidden");
286
+ document.body.classList.add("overflow-hidden");
287
+ if (this.hasDialogContentTarget) {
288
+ this.dialogContentTarget.focus();
289
+ }
290
+ }
291
+ }
292
+
293
+ closeDialog() {
294
+ // Hide the dialog overlay
295
+ if (this.hasDialogOverlayTarget) {
296
+ this.dialogOverlayTarget.classList.add("hidden");
297
+ // Allow body scroll
298
+ document.body.classList.remove("overflow-hidden");
299
+ }
300
+ this.currentAction = null;
301
+ this.currentItemId = null;
302
+ this.isProcessing = false;
303
+ }
304
+
305
+ stopPropagation(event) {
306
+ event.stopPropagation();
307
+ }
308
+
309
+ async showIndividualDeleteDialog(event) {
310
+ const deleteButton = event.currentTarget;
311
+ const requireConfirm = deleteButton.dataset.requireConfirm !== "false";
312
+
313
+ if (!requireConfirm) {
314
+ const itemId =
315
+ event.params.itemId || event.params.rubyCmsBulkActionTableItemIdParam;
316
+
317
+ if (!itemId) {
318
+ this.showNotification("Item ID not found for deletion.", "error");
319
+ return;
320
+ }
321
+
322
+ if (this.isProcessing) {
323
+ event?.preventDefault();
324
+ event?.stopPropagation();
325
+ return;
326
+ }
327
+
328
+ this.isProcessing = true;
329
+
330
+ try {
331
+ const deletePath =
332
+ deleteButton.dataset.deletePath ||
333
+ this.getFallbackPath(this.itemNameValue, itemId);
334
+
335
+ if (!deletePath) {
336
+ this.showNotification("Delete path not found.", "error");
337
+ return;
338
+ }
339
+
340
+ await this.performBulkAction(null, "delete", deletePath, [itemId]);
341
+ } finally {
342
+ this.isProcessing = false;
343
+ }
344
+ return;
345
+ }
346
+
347
+ const itemId =
348
+ event.params.itemId || event.params.rubyCmsBulkActionTableItemIdParam;
349
+
350
+ const deletePath =
351
+ deleteButton.dataset.deletePath ||
352
+ this.getFallbackPath(this.itemNameValue, itemId);
353
+
354
+ this.currentAction = "delete";
355
+ this.currentItemId = itemId;
356
+ this.currentActionUrl = deletePath;
357
+
358
+ this.showDialog();
359
+ }
360
+
361
+ getFallbackPath(itemName, itemId) {
362
+ if (
363
+ !itemId ||
364
+ itemId === "" ||
365
+ itemId === "undefined" ||
366
+ itemId === "null"
367
+ ) {
368
+ return null;
369
+ }
370
+ const routeName = itemName
371
+ .toLowerCase()
372
+ .replace(/\s+/g, "_")
373
+ .replace(/_+/g, "_");
374
+ const pluralName = routeName.endsWith("s") ? routeName : `${routeName}s`;
375
+ return `/admin/${pluralName}/${itemId}`;
376
+ }
377
+
378
+ async confirmAction(event) {
379
+ if (this.isProcessing) {
380
+ event?.preventDefault();
381
+ event?.stopPropagation();
382
+ return;
383
+ }
384
+
385
+ this.isProcessing = true;
386
+
387
+ if (this.hasDialogConfirmButtonTarget) {
388
+ this.dialogConfirmButtonTarget.disabled = true;
389
+ this.dialogConfirmButtonTarget.textContent = "Processing...";
390
+ }
391
+
392
+ try {
393
+ if (this.currentAction) {
394
+ const actionUrl =
395
+ this.currentActionUrl ||
396
+ event?.currentTarget?.dataset?.actionUrl ||
397
+ event?.currentTarget?.dataset?.deleteUrl ||
398
+ this.bulkActionUrlValue;
399
+
400
+ const itemIds = this.currentItemId
401
+ ? [this.currentItemId]
402
+ : this.getSelectedIds();
403
+
404
+ await this.performBulkAction(
405
+ event,
406
+ this.currentAction,
407
+ actionUrl,
408
+ itemIds,
409
+ );
410
+ }
411
+ } finally {
412
+ this.isProcessing = false;
413
+ this.currentAction = null;
414
+ this.currentItemId = null;
415
+ this.currentActionUrl = null;
416
+ this.closeDialog();
417
+ }
418
+ }
419
+
420
+ async performBulkAction(
421
+ event = null,
422
+ actionName = null,
423
+ customUrl = null,
424
+ itemIds = null,
425
+ ) {
426
+ const selectedIds = itemIds || this.getSelectedIds();
427
+
428
+ if (selectedIds.length === 0) {
429
+ this.showNotification("Please select at least one item.", "error");
430
+ return;
431
+ }
432
+
433
+ const action =
434
+ actionName ||
435
+ event?.currentTarget?.dataset?.actionName ||
436
+ this.currentAction ||
437
+ "delete";
438
+
439
+ const actionUrl =
440
+ customUrl ||
441
+ event?.currentTarget?.dataset?.actionUrl ||
442
+ event?.currentTarget?.dataset?.deleteUrl ||
443
+ this.currentActionUrl ||
444
+ this.bulkActionUrlValue;
445
+
446
+ if (!actionUrl) {
447
+ this.showNotification(
448
+ "Action URL not configured. Please configure an action URL for this page.",
449
+ "error",
450
+ );
451
+ return;
452
+ }
453
+
454
+ try {
455
+ const method =
456
+ actionUrl.includes("bulk_delete") ||
457
+ action === "delete" ||
458
+ actionUrl.match(/\/\d+\/?$/)
459
+ ? "DELETE"
460
+ : "PATCH";
461
+
462
+ const response = await fetch(actionUrl, {
463
+ method: method,
464
+ headers: {
465
+ "X-CSRF-Token":
466
+ this.csrfTokenValue ||
467
+ document.querySelector('meta[name="csrf-token"]').content,
468
+ "Content-Type": "application/json",
469
+ Accept: "application/json",
470
+ },
471
+ body: JSON.stringify({
472
+ item_ids: selectedIds,
473
+ }),
474
+ });
475
+
476
+ if (response.ok || response.redirected) {
477
+ this.clearSelection();
478
+ this.clearItemIdsFromUrl();
479
+
480
+ if (window.Turbo) {
481
+ window.Turbo.visit(window.location.href, { action: "replace" });
482
+ } else {
483
+ window.location.reload();
484
+ }
485
+ } else {
486
+ const contentType = response.headers.get("content-type");
487
+ let errorMessage = `An error occurred while performing ${action}.`;
488
+
489
+ if (contentType && contentType.includes("application/json")) {
490
+ try {
491
+ const error = await response.json();
492
+ errorMessage = error.error || error.message || errorMessage;
493
+ } catch (e) {
494
+ const errorText = await response.text();
495
+ if (errorText && !errorText.includes("<!DOCTYPE")) {
496
+ errorMessage = errorText;
497
+ }
498
+ }
499
+ } else {
500
+ const errorText = await response.text();
501
+ if (errorText.includes("<!DOCTYPE")) {
502
+ errorMessage = `Server error occurred during ${action}. Please try again or refresh the page.`;
503
+ } else {
504
+ errorMessage = errorText.substring(0, 200);
505
+ }
506
+ }
507
+ this.showNotification(errorMessage, "error");
508
+ }
509
+ } catch (error) {
510
+ console.error(`Error performing bulk action ${action}:`, error);
511
+ this.showNotification(
512
+ `An error occurred while performing ${action}.`,
513
+ "error",
514
+ );
515
+ }
516
+ }
517
+
518
+ showNotification(message, type = "info") {
519
+ const toast = document.createElement("div");
520
+ toast.className = `fixed top-4 right-4 z-50 px-4 py-2 rounded-md shadow-lg text-white max-w-sm ${
521
+ type === "success"
522
+ ? "bg-green-600"
523
+ : type === "error"
524
+ ? "bg-red-600"
525
+ : "bg-blue-600"
526
+ }`;
527
+ toast.textContent = message;
528
+
529
+ document.body.appendChild(toast);
530
+
531
+ setTimeout(() => {
532
+ toast.remove();
533
+ }, 5000);
534
+ }
535
+
536
+ clearItemIdsFromUrl() {
537
+ const url = new URL(window.location);
538
+ const hasItemIds =
539
+ url.searchParams.has("item_ids[]") ||
540
+ Array.from(url.searchParams.keys()).some((key) =>
541
+ key.startsWith("item_ids"),
542
+ );
543
+
544
+ if (hasItemIds) {
545
+ url.searchParams.delete("item_ids[]");
546
+ Array.from(url.searchParams.keys())
547
+ .filter((key) => key.startsWith("item_ids"))
548
+ .forEach((key) => url.searchParams.delete(key));
549
+
550
+ window.history.replaceState({}, "", url.toString());
551
+ }
552
+ }
553
+ }
@@ -0,0 +1,28 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="clickable-row"
4
+ export default class extends Controller {
5
+ static values = {
6
+ clickUrl: String
7
+ }
8
+
9
+ navigate(event) {
10
+ // Don't navigate if clicking on a checkbox, button, or link
11
+ const target = event.target
12
+ if (
13
+ target.matches('input[type="checkbox"]') ||
14
+ target.closest('input[type="checkbox"]') ||
15
+ target.matches('button') ||
16
+ target.closest('button') ||
17
+ target.matches('a') ||
18
+ target.closest('a')
19
+ ) {
20
+ return
21
+ }
22
+
23
+ // Navigate to the URL
24
+ if (this.clickUrlValue) {
25
+ window.location.href = this.clickUrlValue
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,29 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="ruby-cms--flash-messages"
4
+ export default class extends Controller {
5
+ static targets = ["message"]
6
+
7
+ connect() {
8
+ // Auto-dismiss flash messages after 5 seconds
9
+ this.messageTargets.forEach((message) => {
10
+ setTimeout(() => {
11
+ this.dismissMessage(message)
12
+ }, 5000)
13
+ })
14
+ }
15
+
16
+ dismiss(event) {
17
+ const message = event.currentTarget.closest(".flash-message")
18
+ if (message) {
19
+ this.dismissMessage(message)
20
+ }
21
+ }
22
+
23
+ dismissMessage(message) {
24
+ message.classList.add("opacity-0", "-translate-y-1")
25
+ setTimeout(() => {
26
+ message.remove()
27
+ }, 200)
28
+ }
29
+ }