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