solidcrud 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 (70) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +1285 -0
  4. data/app/assets/javascripts/controllers/dashboard_controller.js +96 -0
  5. data/app/assets/javascripts/controllers/modal_controller.js +217 -0
  6. data/app/assets/javascripts/controllers/navigation_controller.js +117 -0
  7. data/app/assets/javascripts/controllers/notification_controller.js +85 -0
  8. data/app/assets/javascripts/controllers/search_controller.js +189 -0
  9. data/app/assets/javascripts/controllers/table_controller.js +272 -0
  10. data/app/assets/javascripts/solidcrud/application.js +9475 -0
  11. data/app/assets/stylesheets/solidcrud/_components.scss +267 -0
  12. data/app/assets/stylesheets/solidcrud/_forms.scss +69 -0
  13. data/app/assets/stylesheets/solidcrud/_layout.scss +149 -0
  14. data/app/assets/stylesheets/solidcrud/_tables.scss +90 -0
  15. data/app/assets/stylesheets/solidcrud/_variables.scss +21 -0
  16. data/app/assets/stylesheets/solidcrud/application.css +10 -0
  17. data/app/assets/stylesheets/solidcrud/application.css.map +1 -0
  18. data/app/assets/stylesheets/solidcrud/application.scss +10 -0
  19. data/app/assets/stylesheets/solidcrud/temp.css.map +1 -0
  20. data/app/assets/stylesheets/solidcrud/temp2.css.map +1 -0
  21. data/app/assets/stylesheets/solidcrud/webfonts/fa-brands-400.ttf +0 -0
  22. data/app/assets/stylesheets/solidcrud/webfonts/fa-brands-400.woff2 +0 -0
  23. data/app/assets/stylesheets/solidcrud/webfonts/fa-regular-400.ttf +0 -0
  24. data/app/assets/stylesheets/solidcrud/webfonts/fa-regular-400.woff2 +0 -0
  25. data/app/assets/stylesheets/solidcrud/webfonts/fa-solid-900.ttf +0 -0
  26. data/app/assets/stylesheets/solidcrud/webfonts/fa-solid-900.woff2 +0 -0
  27. data/app/assets/stylesheets/solidcrud/webfonts/fa-v4compatibility.ttf +0 -0
  28. data/app/assets/stylesheets/solidcrud/webfonts/fa-v4compatibility.woff2 +0 -0
  29. data/app/assets/stylesheets/webfonts/fa-brands-400.ttf +0 -0
  30. data/app/assets/stylesheets/webfonts/fa-brands-400.woff2 +0 -0
  31. data/app/assets/stylesheets/webfonts/fa-regular-400.ttf +0 -0
  32. data/app/assets/stylesheets/webfonts/fa-regular-400.woff2 +0 -0
  33. data/app/assets/stylesheets/webfonts/fa-solid-900.ttf +0 -0
  34. data/app/assets/stylesheets/webfonts/fa-solid-900.woff2 +0 -0
  35. data/app/assets/stylesheets/webfonts/fa-v4compatibility.ttf +0 -0
  36. data/app/assets/stylesheets/webfonts/fa-v4compatibility.woff2 +0 -0
  37. data/app/controllers/solidcrud/admin_controller.rb +215 -0
  38. data/app/controllers/solidcrud/application_controller.rb +19 -0
  39. data/app/controllers/solidcrud/assets_controller.rb +59 -0
  40. data/app/controllers/solidcrud/sessions_controller.rb +84 -0
  41. data/app/helpers/solidcrud/application_helper.rb +153 -0
  42. data/app/javascript/solidcrud/application.js +14 -0
  43. data/app/javascript/solidcrud/controllers/crud_controller.js +64 -0
  44. data/app/javascript/solidcrud/controllers/index.js +33 -0
  45. data/app/views/layouts/solidcrud/application.html.erb +70 -0
  46. data/app/views/solidcrud/admin/edit.html.erb +294 -0
  47. data/app/views/solidcrud/admin/index.html.erb +128 -0
  48. data/app/views/solidcrud/admin/model.html.erb +353 -0
  49. data/app/views/solidcrud/admin/new.html.erb +275 -0
  50. data/app/views/solidcrud/admin/shared/_dashboard_stats.html.erb +49 -0
  51. data/app/views/solidcrud/admin/shared/_edit_form_sidebar.html.erb +9 -0
  52. data/app/views/solidcrud/admin/shared/_flash_messages.html.erb +27 -0
  53. data/app/views/solidcrud/admin/shared/_full_sidebar.html.erb +56 -0
  54. data/app/views/solidcrud/admin/shared/_modal.html.erb +45 -0
  55. data/app/views/solidcrud/admin/shared/_new_form_sidebar.html.erb +6 -0
  56. data/app/views/solidcrud/admin/shared/_record_row.html.erb +35 -0
  57. data/app/views/solidcrud/admin/shared/_records_table.html.erb +85 -0
  58. data/app/views/solidcrud/sessions/new.html.erb +262 -0
  59. data/config/routes.rb +24 -0
  60. data/lib/generators/solidcrud/install/install_generator.rb +21 -0
  61. data/lib/generators/solidcrud/install/templates/INSTALL.md +80 -0
  62. data/lib/generators/solidcrud/install/templates/solidcrud.rb +31 -0
  63. data/lib/generators/solidcrud/install_generator.rb +17 -0
  64. data/lib/generators/solidcrud/templates/solidcrud.rb +4 -0
  65. data/lib/solidcrud/authentication.rb +143 -0
  66. data/lib/solidcrud/configuration.rb +64 -0
  67. data/lib/solidcrud/engine.rb +49 -0
  68. data/lib/solidcrud/version.rb +5 -0
  69. data/lib/solidcrud.rb +10 -0
  70. metadata +177 -0
@@ -0,0 +1,96 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="dashboard"
4
+ export default class extends Controller {
5
+ static targets = ["stats", "modelCount", "railsVersion", "databaseType"]
6
+
7
+ connect() {
8
+ console.log("Dashboard controller connected")
9
+ this.startRealTimeUpdates()
10
+ }
11
+
12
+ disconnect() {
13
+ this.stopRealTimeUpdates()
14
+ }
15
+
16
+ startRealTimeUpdates() {
17
+ // Update dashboard stats every 30 seconds
18
+ this.updateInterval = setInterval(() => {
19
+ this.updateStats()
20
+ }, 30000)
21
+
22
+ // Listen for Turbo navigation events to refresh data
23
+ document.addEventListener("turbo:load", this.handleTurboLoad.bind(this))
24
+ }
25
+
26
+ stopRealTimeUpdates() {
27
+ if (this.updateInterval) {
28
+ clearInterval(this.updateInterval)
29
+ }
30
+ document.removeEventListener("turbo:load", this.handleTurboLoad.bind(this))
31
+ }
32
+
33
+ handleTurboLoad() {
34
+ // Refresh dashboard data when navigating back to dashboard
35
+ if (window.location.pathname === "/admin") {
36
+ this.updateStats()
37
+ }
38
+ }
39
+
40
+ async updateStats() {
41
+ try {
42
+ const response = await fetch("/admin/dashboard_stats", {
43
+ headers: {
44
+ "Accept": "text/vnd.turbo-stream.html",
45
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
46
+ }
47
+ })
48
+
49
+ if (response.ok) {
50
+ const html = await response.text()
51
+ // Update the stats section with Turbo Stream
52
+ this.updateStatsSection(html)
53
+ }
54
+ } catch (error) {
55
+ console.error("Failed to update dashboard stats:", error)
56
+ }
57
+ }
58
+
59
+ updateStatsSection(html) {
60
+ // Parse and apply Turbo Stream updates
61
+ const parser = new DOMParser()
62
+ const doc = parser.parseFromString(html, "text/html")
63
+ const turboStreams = doc.querySelectorAll("turbo-stream")
64
+
65
+ turboStreams.forEach(stream => {
66
+ const target = stream.getAttribute("target")
67
+ const action = stream.getAttribute("action")
68
+ const template = stream.querySelector("template")
69
+
70
+ if (target && template) {
71
+ const targetElement = document.getElementById(target)
72
+ if (targetElement) {
73
+ const newContent = template.content.cloneNode(true)
74
+
75
+ switch (action) {
76
+ case "replace":
77
+ targetElement.replaceWith(newContent)
78
+ break
79
+ case "update":
80
+ targetElement.innerHTML = ""
81
+ targetElement.appendChild(newContent)
82
+ break
83
+ case "append":
84
+ targetElement.appendChild(newContent)
85
+ break
86
+ }
87
+ }
88
+ }
89
+ })
90
+ }
91
+
92
+ // Manual refresh method that can be called from UI
93
+ refresh() {
94
+ this.updateStats()
95
+ }
96
+ }
@@ -0,0 +1,217 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="modal"
4
+ export default class extends Controller {
5
+ static targets = ["container", "content", "title", "body", "confirmButton", "cancelButton", "closeButton"]
6
+
7
+ connect() {
8
+ console.log("Modal controller connected")
9
+ this.setupKeyboardListeners()
10
+ }
11
+
12
+ disconnect() {
13
+ this.removeKeyboardListeners()
14
+ }
15
+
16
+ // Show modal with custom content
17
+ show(event) {
18
+ event?.preventDefault()
19
+
20
+ const title = event?.params?.title || "Confirm Action"
21
+ const body = event?.params?.body || "Are you sure you want to proceed?"
22
+ const confirmText = event?.params?.confirmText || "Confirm"
23
+ const cancelText = event?.params?.cancelText || "Cancel"
24
+ const confirmClass = event?.params?.confirmClass || "bg-red-500 hover:bg-red-600"
25
+
26
+ this.titleTarget.textContent = title
27
+ this.bodyTarget.innerHTML = body
28
+ this.confirmButtonTarget.textContent = confirmText
29
+ this.cancelButtonTarget.textContent = cancelText
30
+
31
+ // Update confirm button classes
32
+ this.confirmButtonTarget.className = `inline-flex items-center px-4 py-2 text-sm font-medium text-white rounded-lg transition-all duration-200 ${confirmClass}`
33
+
34
+ // Store the original link/button that triggered the modal
35
+ this.triggerElement = event?.target?.closest("a, button") || event?.currentTarget
36
+
37
+ this.containerTarget.classList.remove("hidden")
38
+ this.containerTarget.classList.add("flex")
39
+
40
+ // Focus management
41
+ this.previousActiveElement = document.activeElement
42
+ this.confirmButtonTarget.focus()
43
+
44
+ // Prevent body scroll
45
+ document.body.style.overflow = "hidden"
46
+ }
47
+
48
+ // Hide modal
49
+ hide() {
50
+ this.containerTarget.classList.add("hidden")
51
+ this.containerTarget.classList.remove("flex")
52
+
53
+ // Restore body scroll
54
+ document.body.style.overflow = ""
55
+
56
+ // Restore focus
57
+ if (this.previousActiveElement) {
58
+ this.previousActiveElement.focus()
59
+ }
60
+ }
61
+
62
+ // Confirm delete action
63
+ confirmDelete(event) {
64
+ event?.preventDefault()
65
+
66
+ const element = event.currentTarget
67
+ const title = element.dataset.modalTitle || "Confirm Delete"
68
+ const message = element.dataset.modalMessage || "Are you sure you want to delete this item?"
69
+ const confirmText = element.dataset.modalConfirmText || "Delete"
70
+ const cancelText = element.dataset.modalCancelText || "Cancel"
71
+ const url = element.dataset.modalUrl || element.href
72
+
73
+ this.titleTarget.textContent = title
74
+ this.bodyTarget.innerHTML = `<p class="text-slate-600">${message}</p>`
75
+ this.confirmButtonTarget.textContent = confirmText
76
+ this.cancelButtonTarget.textContent = cancelText
77
+
78
+ // Update confirm button for delete (red styling)
79
+ this.confirmButtonTarget.className = "inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-all duration-200"
80
+
81
+ // Store the delete URL
82
+ this.deleteUrl = url
83
+
84
+ this.containerTarget.classList.remove("hidden")
85
+ this.containerTarget.classList.add("flex")
86
+
87
+ // Focus management
88
+ this.previousActiveElement = document.activeElement
89
+ this.confirmButtonTarget.focus()
90
+
91
+ // Prevent body scroll
92
+ document.body.style.overflow = "hidden"
93
+ }
94
+
95
+ // Perform delete action
96
+ performDelete() {
97
+ if (this.deleteUrl) {
98
+ // Create a form to submit the delete request
99
+ const form = document.createElement("form")
100
+ form.method = "POST"
101
+ form.action = this.deleteUrl
102
+ form.style.display = "none"
103
+
104
+ // Add authenticity token if available
105
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')
106
+ if (csrfToken) {
107
+ const tokenInput = document.createElement("input")
108
+ tokenInput.type = "hidden"
109
+ tokenInput.name = "authenticity_token"
110
+ tokenInput.value = csrfToken.content
111
+ form.appendChild(tokenInput)
112
+ }
113
+
114
+ // Add method override for DELETE
115
+ const methodInput = document.createElement("input")
116
+ methodInput.type = "hidden"
117
+ methodInput.name = "_method"
118
+ methodInput.value = "delete"
119
+ form.appendChild(methodInput)
120
+
121
+ document.body.appendChild(form)
122
+ form.submit()
123
+ }
124
+ }
125
+
126
+ // Confirm action
127
+ confirm(event) {
128
+ event?.preventDefault()
129
+
130
+ // Handle delete confirmation
131
+ if (this.deleteUrl) {
132
+ this.performDelete()
133
+ this.hide()
134
+ return
135
+ }
136
+
137
+ if (this.triggerElement) {
138
+ // If it's a link, navigate to it
139
+ if (this.triggerElement.tagName === "A") {
140
+ window.location.href = this.triggerElement.href
141
+ } else if (this.triggerElement.type === "submit") {
142
+ // If it's a submit button, submit the form
143
+ const form = this.triggerElement.closest("form")
144
+ if (form) {
145
+ form.submit()
146
+ }
147
+ } else {
148
+ // Trigger click on the original element
149
+ this.triggerElement.click()
150
+ }
151
+ }
152
+
153
+ this.hide()
154
+ }
155
+
156
+ // Cancel action
157
+ cancel(event) {
158
+ event?.preventDefault()
159
+ this.hide()
160
+ }
161
+
162
+ // Close on backdrop click
163
+ closeOnBackdrop(event) {
164
+ if (event.target === this.containerTarget) {
165
+ this.hide()
166
+ }
167
+ }
168
+
169
+ // Keyboard listeners
170
+ setupKeyboardListeners() {
171
+ this.keydownHandler = this.handleKeydown.bind(this)
172
+ document.addEventListener("keydown", this.keydownHandler)
173
+ }
174
+
175
+ removeKeyboardListeners() {
176
+ if (this.keydownHandler) {
177
+ document.removeEventListener("keydown", this.keydownHandler)
178
+ }
179
+ }
180
+
181
+ handleKeydown(event) {
182
+ if (!this.containerTarget.classList.contains("flex")) return
183
+
184
+ switch (event.key) {
185
+ case "Escape":
186
+ event.preventDefault()
187
+ this.hide()
188
+ break
189
+ case "Enter":
190
+ if (document.activeElement === this.confirmButtonTarget) {
191
+ event.preventDefault()
192
+ this.confirm()
193
+ }
194
+ break
195
+ case "Tab":
196
+ // Trap focus within modal
197
+ const focusableElements = this.containerTarget.querySelectorAll(
198
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
199
+ )
200
+ const firstElement = focusableElements[0]
201
+ const lastElement = focusableElements[focusableElements.length - 1]
202
+
203
+ if (event.shiftKey) {
204
+ if (document.activeElement === firstElement) {
205
+ event.preventDefault()
206
+ lastElement.focus()
207
+ }
208
+ } else {
209
+ if (document.activeElement === lastElement) {
210
+ event.preventDefault()
211
+ firstElement.focus()
212
+ }
213
+ }
214
+ break
215
+ }
216
+ }
217
+ }
@@ -0,0 +1,117 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="navigation"
4
+ export default class extends Controller {
5
+ static targets = ["sidebar", "mobileMenu", "overlay"]
6
+
7
+ connect() {
8
+ console.log("Navigation controller connected")
9
+ this.setupKeyboardShortcuts()
10
+ }
11
+
12
+ toggleMobileMenu() {
13
+ const isOpen = this.mobileMenuTarget.classList.contains("open")
14
+
15
+ if (isOpen) {
16
+ this.closeMobileMenu()
17
+ } else {
18
+ this.openMobileMenu()
19
+ }
20
+ }
21
+
22
+ openMobileMenu() {
23
+ this.mobileMenuTarget.classList.add("open")
24
+ this.overlayTarget?.classList.add("active")
25
+
26
+ // Prevent body scroll when mobile menu is open
27
+ document.body.style.overflow = "hidden"
28
+ }
29
+
30
+ closeMobileMenu() {
31
+ this.mobileMenuTarget.classList.remove("open")
32
+ this.overlayTarget?.classList.remove("active")
33
+
34
+ // Restore body scroll
35
+ document.body.style.overflow = ""
36
+ }
37
+
38
+ // Close mobile menu when clicking overlay
39
+ closeOnOverlayClick(event) {
40
+ if (event.target === this.overlayTarget) {
41
+ this.closeMobileMenu()
42
+ }
43
+ }
44
+
45
+ // Keyboard shortcuts
46
+ setupKeyboardShortcuts() {
47
+ document.addEventListener("keydown", (event) => {
48
+ // Escape key closes mobile menu
49
+ if (event.key === "Escape" && this.mobileMenuTarget.classList.contains("open")) {
50
+ this.closeMobileMenu()
51
+ }
52
+
53
+ // Ctrl/Cmd + B toggles sidebar (if exists)
54
+ if ((event.ctrlKey || event.metaKey) && event.key === "b") {
55
+ event.preventDefault()
56
+ this.toggleSidebar()
57
+ }
58
+ })
59
+ }
60
+
61
+ toggleSidebar() {
62
+ if (this.hasSidebarTarget) {
63
+ this.sidebarTarget.classList.toggle("collapsed")
64
+ // Save preference to localStorage
65
+ const isCollapsed = this.sidebarTarget.classList.contains("collapsed")
66
+ localStorage.setItem("solidcrud_sidebar_collapsed", isCollapsed)
67
+ }
68
+ }
69
+
70
+ // Handle navigation link clicks for SPA behavior
71
+ handleNavClick(event) {
72
+ const link = event.target.closest("a")
73
+ if (!link) return
74
+
75
+ // Close mobile menu after navigation on mobile
76
+ if (window.innerWidth < 768) {
77
+ this.closeMobileMenu()
78
+ }
79
+
80
+ // Add loading state
81
+ this.showNavigationLoading()
82
+ }
83
+
84
+ showNavigationLoading() {
85
+ // Add a subtle loading indicator
86
+ const loader = document.createElement("div")
87
+ loader.id = "navigation-loader"
88
+ loader.className = "fixed top-0 left-0 right-0 h-1 bg-blue-500 animate-pulse z-50"
89
+ loader.style.animation = "loading 1s ease-in-out infinite"
90
+
91
+ document.body.appendChild(loader)
92
+
93
+ // Remove loader after navigation completes
94
+ document.addEventListener("turbo:load", () => {
95
+ const existingLoader = document.getElementById("navigation-loader")
96
+ if (existingLoader) {
97
+ existingLoader.remove()
98
+ }
99
+ }, { once: true })
100
+ }
101
+
102
+ // Highlight active navigation item
103
+ highlightActiveNav() {
104
+ const currentPath = window.location.pathname
105
+
106
+ // Remove active class from all nav items
107
+ this.element.querySelectorAll(".nav-item").forEach(item => {
108
+ item.classList.remove("active")
109
+ })
110
+
111
+ // Add active class to current nav item
112
+ const activeItem = this.element.querySelector(`.nav-item[data-path="${currentPath}"]`)
113
+ if (activeItem) {
114
+ activeItem.classList.add("active")
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,85 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="notification"
4
+ export default class extends Controller {
5
+ static targets = ["container"]
6
+
7
+ connect() {
8
+ console.log("Notification controller connected")
9
+ // Handle notifications that are already in the DOM
10
+ this.handleExistingNotifications()
11
+ // Setup mutation observer for dynamically added notifications
12
+ this.setupMutationObserver()
13
+ }
14
+
15
+ disconnect() {
16
+ if (this.observer) {
17
+ this.observer.disconnect()
18
+ }
19
+ }
20
+
21
+ // Setup mutation observer to handle dynamically added notifications
22
+ setupMutationObserver() {
23
+ this.observer = new MutationObserver((mutations) => {
24
+ mutations.forEach((mutation) => {
25
+ mutation.addedNodes.forEach((node) => {
26
+ if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('notification')) {
27
+ this.setupNotification(node)
28
+ }
29
+ })
30
+ })
31
+ })
32
+
33
+ this.observer.observe(this.element, {
34
+ childList: true,
35
+ subtree: true
36
+ })
37
+ }
38
+
39
+ // Handle notifications that are dynamically added via Turbo Stream
40
+ handleExistingNotifications() {
41
+ const notifications = this.element.querySelectorAll('.notification')
42
+ notifications.forEach(notification => {
43
+ this.setupNotification(notification)
44
+ })
45
+ }
46
+
47
+ // Setup notification behavior
48
+ setupNotification(notification) {
49
+ const autoDismiss = notification.dataset.notificationAutoDismiss === 'true'
50
+ const dismissButton = notification.querySelector('button[data-action="notification#dismiss"]')
51
+
52
+ if (dismissButton) {
53
+ dismissButton.addEventListener('click', () => {
54
+ this.dismiss(notification)
55
+ })
56
+ }
57
+
58
+ // Auto-dismiss after 5 seconds if enabled
59
+ if (autoDismiss) {
60
+ setTimeout(() => {
61
+ this.dismiss(notification)
62
+ }, 5000)
63
+ }
64
+
65
+ // Animate in
66
+ setTimeout(() => {
67
+ notification.classList.remove("translate-x-full", "opacity-0")
68
+ notification.classList.add("translate-x-0", "opacity-100")
69
+ }, 10)
70
+ }
71
+
72
+ // Dismiss notification
73
+ dismiss(notification) {
74
+ // Animate out
75
+ notification.classList.remove("translate-x-0", "opacity-100")
76
+ notification.classList.add("translate-x-full", "opacity-0")
77
+
78
+ // Remove from DOM after animation
79
+ setTimeout(() => {
80
+ if (notification.parentNode) {
81
+ notification.parentNode.removeChild(notification)
82
+ }
83
+ }, 300)
84
+ }
85
+ }
@@ -0,0 +1,189 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="search"
4
+ export default class extends Controller {
5
+ static targets = ["input", "results", "loading", "clear"]
6
+
7
+ connect() {
8
+ console.log("Search controller connected")
9
+ this.setupDebouncedSearch()
10
+ this.restoreSearchState()
11
+ }
12
+
13
+ setupDebouncedSearch() {
14
+ this.searchTimeout = null
15
+ this.inputTarget.addEventListener("input", this.debouncedSearch.bind(this))
16
+ }
17
+
18
+ debouncedSearch() {
19
+ clearTimeout(this.searchTimeout)
20
+ this.searchTimeout = setTimeout(() => {
21
+ this.performSearch()
22
+ }, 300) // 300ms debounce
23
+ }
24
+
25
+ async performSearch() {
26
+ const query = this.inputTarget.value.trim()
27
+
28
+ if (query.length === 0) {
29
+ this.clearSearch()
30
+ return
31
+ }
32
+
33
+ this.showLoading()
34
+
35
+ try {
36
+ const url = new URL(window.location)
37
+ url.searchParams.set("search", query)
38
+ url.searchParams.set("page", "1") // Reset to first page on new search
39
+
40
+ const response = await fetch(url.toString(), {
41
+ headers: {
42
+ "Accept": "text/vnd.turbo-stream.html",
43
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
44
+ }
45
+ })
46
+
47
+ if (response.ok) {
48
+ const html = await response.text()
49
+ this.updateResults(html)
50
+ this.saveSearchState(query)
51
+ } else {
52
+ this.showError("Search failed. Please try again.")
53
+ }
54
+ } catch (error) {
55
+ console.error("Search error:", error)
56
+ this.showError("Network error. Please check your connection.")
57
+ } finally {
58
+ this.hideLoading()
59
+ }
60
+ }
61
+
62
+ updateResults(html) {
63
+ // Parse Turbo Stream response and update the table/results
64
+ const parser = new DOMParser()
65
+ const doc = parser.parseFromString(html, "text/html")
66
+ const turboStreams = doc.querySelectorAll("turbo-stream")
67
+
68
+ turboStreams.forEach(stream => {
69
+ const target = stream.getAttribute("target")
70
+ const action = stream.getAttribute("action")
71
+ const template = stream.querySelector("template")
72
+
73
+ if (target && template) {
74
+ const targetElement = document.getElementById(target)
75
+ if (targetElement) {
76
+ const newContent = template.content.cloneNode(true)
77
+
78
+ switch (action) {
79
+ case "replace":
80
+ targetElement.replaceWith(newContent)
81
+ break
82
+ case "update":
83
+ targetElement.innerHTML = ""
84
+ targetElement.appendChild(newContent)
85
+ break
86
+ case "append":
87
+ targetElement.appendChild(newContent)
88
+ break
89
+ }
90
+ }
91
+ }
92
+ })
93
+ }
94
+
95
+ clearSearch() {
96
+ this.inputTarget.value = ""
97
+ this.hideClearButton()
98
+ this.saveSearchState("")
99
+
100
+ // Reload the page without search parameters
101
+ const url = new URL(window.location)
102
+ url.searchParams.delete("search")
103
+ url.searchParams.set("page", "1")
104
+
105
+ // Use Turbo to navigate without full page reload
106
+ Turbo.visit(url.toString(), { action: "replace" })
107
+ }
108
+
109
+ showLoading() {
110
+ if (this.hasLoadingTarget) {
111
+ this.loadingTarget.classList.remove("hidden")
112
+ }
113
+ }
114
+
115
+ hideLoading() {
116
+ if (this.hasLoadingTarget) {
117
+ this.loadingTarget.classList.add("hidden")
118
+ }
119
+ }
120
+
121
+ showClearButton() {
122
+ if (this.hasClearTarget) {
123
+ this.clearTarget.classList.remove("hidden")
124
+ }
125
+ }
126
+
127
+ hideClearButton() {
128
+ if (this.hasClearTarget) {
129
+ this.clearTarget.classList.add("hidden")
130
+ }
131
+ }
132
+
133
+ showError(message) {
134
+ // Create and show error message
135
+ const errorDiv = document.createElement("div")
136
+ errorDiv.className = "bg-red-50 border border-red-200 rounded-xl p-4 mt-4"
137
+ errorDiv.innerHTML = `
138
+ <div class="flex items-center space-x-3">
139
+ <div class="flex-shrink-0">
140
+ <i class="fas fa-exclamation-circle text-red-500 text-lg"></i>
141
+ </div>
142
+ <div class="flex-1">
143
+ <p class="text-red-800 font-medium">${message}</p>
144
+ </div>
145
+ </div>
146
+ `
147
+
148
+ // Insert error message after the search form
149
+ const form = this.element.closest("form") || this.element
150
+ form.parentNode.insertBefore(errorDiv, form.nextSibling)
151
+
152
+ // Auto-remove error after 5 seconds
153
+ setTimeout(() => {
154
+ if (errorDiv.parentNode) {
155
+ errorDiv.remove()
156
+ }
157
+ }, 5000)
158
+ }
159
+
160
+ saveSearchState(query) {
161
+ sessionStorage.setItem(`solidcrud_search_${this.getModelName()}`, query)
162
+ }
163
+
164
+ restoreSearchState() {
165
+ const savedQuery = sessionStorage.getItem(`solidcrud_search_${this.getModelName()}`)
166
+ if (savedQuery) {
167
+ this.inputTarget.value = savedQuery
168
+ if (savedQuery.length > 0) {
169
+ this.showClearButton()
170
+ }
171
+ }
172
+ }
173
+
174
+ getModelName() {
175
+ // Extract model name from URL or data attribute
176
+ const urlMatch = window.location.pathname.match(/\/admin\/([^\/]+)/)
177
+ return urlMatch ? urlMatch[1] : ""
178
+ }
179
+
180
+ // Handle input changes to show/hide clear button
181
+ handleInput() {
182
+ const hasValue = this.inputTarget.value.trim().length > 0
183
+ if (hasValue) {
184
+ this.showClearButton()
185
+ } else {
186
+ this.hideClearButton()
187
+ }
188
+ }
189
+ }