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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +1285 -0
- data/app/assets/javascripts/controllers/dashboard_controller.js +96 -0
- data/app/assets/javascripts/controllers/modal_controller.js +217 -0
- data/app/assets/javascripts/controllers/navigation_controller.js +117 -0
- data/app/assets/javascripts/controllers/notification_controller.js +85 -0
- data/app/assets/javascripts/controllers/search_controller.js +189 -0
- data/app/assets/javascripts/controllers/table_controller.js +272 -0
- data/app/assets/javascripts/solidcrud/application.js +9475 -0
- data/app/assets/stylesheets/solidcrud/_components.scss +267 -0
- data/app/assets/stylesheets/solidcrud/_forms.scss +69 -0
- data/app/assets/stylesheets/solidcrud/_layout.scss +149 -0
- data/app/assets/stylesheets/solidcrud/_tables.scss +90 -0
- data/app/assets/stylesheets/solidcrud/_variables.scss +21 -0
- data/app/assets/stylesheets/solidcrud/application.css +10 -0
- data/app/assets/stylesheets/solidcrud/application.css.map +1 -0
- data/app/assets/stylesheets/solidcrud/application.scss +10 -0
- data/app/assets/stylesheets/solidcrud/temp.css.map +1 -0
- data/app/assets/stylesheets/solidcrud/temp2.css.map +1 -0
- data/app/assets/stylesheets/solidcrud/webfonts/fa-brands-400.ttf +0 -0
- data/app/assets/stylesheets/solidcrud/webfonts/fa-brands-400.woff2 +0 -0
- data/app/assets/stylesheets/solidcrud/webfonts/fa-regular-400.ttf +0 -0
- data/app/assets/stylesheets/solidcrud/webfonts/fa-regular-400.woff2 +0 -0
- data/app/assets/stylesheets/solidcrud/webfonts/fa-solid-900.ttf +0 -0
- data/app/assets/stylesheets/solidcrud/webfonts/fa-solid-900.woff2 +0 -0
- data/app/assets/stylesheets/solidcrud/webfonts/fa-v4compatibility.ttf +0 -0
- data/app/assets/stylesheets/solidcrud/webfonts/fa-v4compatibility.woff2 +0 -0
- data/app/assets/stylesheets/webfonts/fa-brands-400.ttf +0 -0
- data/app/assets/stylesheets/webfonts/fa-brands-400.woff2 +0 -0
- data/app/assets/stylesheets/webfonts/fa-regular-400.ttf +0 -0
- data/app/assets/stylesheets/webfonts/fa-regular-400.woff2 +0 -0
- data/app/assets/stylesheets/webfonts/fa-solid-900.ttf +0 -0
- data/app/assets/stylesheets/webfonts/fa-solid-900.woff2 +0 -0
- data/app/assets/stylesheets/webfonts/fa-v4compatibility.ttf +0 -0
- data/app/assets/stylesheets/webfonts/fa-v4compatibility.woff2 +0 -0
- data/app/controllers/solidcrud/admin_controller.rb +215 -0
- data/app/controllers/solidcrud/application_controller.rb +19 -0
- data/app/controllers/solidcrud/assets_controller.rb +59 -0
- data/app/controllers/solidcrud/sessions_controller.rb +84 -0
- data/app/helpers/solidcrud/application_helper.rb +153 -0
- data/app/javascript/solidcrud/application.js +14 -0
- data/app/javascript/solidcrud/controllers/crud_controller.js +64 -0
- data/app/javascript/solidcrud/controllers/index.js +33 -0
- data/app/views/layouts/solidcrud/application.html.erb +70 -0
- data/app/views/solidcrud/admin/edit.html.erb +294 -0
- data/app/views/solidcrud/admin/index.html.erb +128 -0
- data/app/views/solidcrud/admin/model.html.erb +353 -0
- data/app/views/solidcrud/admin/new.html.erb +275 -0
- data/app/views/solidcrud/admin/shared/_dashboard_stats.html.erb +49 -0
- data/app/views/solidcrud/admin/shared/_edit_form_sidebar.html.erb +9 -0
- data/app/views/solidcrud/admin/shared/_flash_messages.html.erb +27 -0
- data/app/views/solidcrud/admin/shared/_full_sidebar.html.erb +56 -0
- data/app/views/solidcrud/admin/shared/_modal.html.erb +45 -0
- data/app/views/solidcrud/admin/shared/_new_form_sidebar.html.erb +6 -0
- data/app/views/solidcrud/admin/shared/_record_row.html.erb +35 -0
- data/app/views/solidcrud/admin/shared/_records_table.html.erb +85 -0
- data/app/views/solidcrud/sessions/new.html.erb +262 -0
- data/config/routes.rb +24 -0
- data/lib/generators/solidcrud/install/install_generator.rb +21 -0
- data/lib/generators/solidcrud/install/templates/INSTALL.md +80 -0
- data/lib/generators/solidcrud/install/templates/solidcrud.rb +31 -0
- data/lib/generators/solidcrud/install_generator.rb +17 -0
- data/lib/generators/solidcrud/templates/solidcrud.rb +4 -0
- data/lib/solidcrud/authentication.rb +143 -0
- data/lib/solidcrud/configuration.rb +64 -0
- data/lib/solidcrud/engine.rb +49 -0
- data/lib/solidcrud/version.rb +5 -0
- data/lib/solidcrud.rb +10 -0
- 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
|
+
}
|