admin_suite 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/.gitignore +10 -0
- data/Gemfile +13 -0
- data/LICENSE.txt +22 -0
- data/README.md +7 -0
- data/Rakefile +11 -0
- data/app/assets/admin_suite.css +444 -0
- data/app/assets/admin_suite_tailwind.css +8 -0
- data/app/assets/builds/admin_suite_tailwind.css +8 -0
- data/app/assets/rouge.css +218 -0
- data/app/assets/tailwind/admin_suite.css +22 -0
- data/app/controllers/admin_suite/application_controller.rb +118 -0
- data/app/controllers/admin_suite/dashboard_controller.rb +258 -0
- data/app/controllers/admin_suite/docs_controller.rb +155 -0
- data/app/controllers/admin_suite/portals_controller.rb +22 -0
- data/app/controllers/admin_suite/resources_controller.rb +238 -0
- data/app/helpers/admin_suite/base_helper.rb +1199 -0
- data/app/helpers/admin_suite/icon_helper.rb +61 -0
- data/app/helpers/admin_suite/panels_helper.rb +52 -0
- data/app/helpers/admin_suite/resources_helper.rb +15 -0
- data/app/helpers/admin_suite/theme_helper.rb +99 -0
- data/app/javascript/controllers/admin_suite/click_actions_controller.js +73 -0
- data/app/javascript/controllers/admin_suite/clipboard_controller.js +57 -0
- data/app/javascript/controllers/admin_suite/code_editor_controller.js +45 -0
- data/app/javascript/controllers/admin_suite/file_upload_controller.js +238 -0
- data/app/javascript/controllers/admin_suite/json_editor_controller.js +62 -0
- data/app/javascript/controllers/admin_suite/live_filter_controller.js +71 -0
- data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +67 -0
- data/app/javascript/controllers/admin_suite/searchable_select_controller.js +171 -0
- data/app/javascript/controllers/admin_suite/sidebar_controller.js +33 -0
- data/app/javascript/controllers/admin_suite/tag_select_controller.js +193 -0
- data/app/javascript/controllers/admin_suite/toggle_switch_controller.js +66 -0
- data/app/views/admin_suite/dashboard/index.html.erb +21 -0
- data/app/views/admin_suite/docs/index.html.erb +86 -0
- data/app/views/admin_suite/panels/_cards.html.erb +107 -0
- data/app/views/admin_suite/panels/_chart.html.erb +47 -0
- data/app/views/admin_suite/panels/_health.html.erb +44 -0
- data/app/views/admin_suite/panels/_recent.html.erb +56 -0
- data/app/views/admin_suite/panels/_stat.html.erb +64 -0
- data/app/views/admin_suite/panels/_table.html.erb +36 -0
- data/app/views/admin_suite/portals/show.html.erb +75 -0
- data/app/views/admin_suite/resources/_form.html.erb +32 -0
- data/app/views/admin_suite/resources/edit.html.erb +24 -0
- data/app/views/admin_suite/resources/index.html.erb +315 -0
- data/app/views/admin_suite/resources/new.html.erb +22 -0
- data/app/views/admin_suite/resources/show.html.erb +184 -0
- data/app/views/admin_suite/shared/_flash.html.erb +30 -0
- data/app/views/admin_suite/shared/_form.html.erb +60 -0
- data/app/views/admin_suite/shared/_json_editor_field.html.erb +52 -0
- data/app/views/admin_suite/shared/_sidebar.html.erb +94 -0
- data/app/views/admin_suite/shared/_toggle_cell.html.erb +34 -0
- data/app/views/admin_suite/shared/_topbar.html.erb +47 -0
- data/app/views/layouts/admin_suite/application.html.erb +79 -0
- data/lib/admin/base/action_executor.rb +155 -0
- data/lib/admin/base/action_handler.rb +31 -0
- data/lib/admin/base/filter_builder.rb +121 -0
- data/lib/admin/base/resource.rb +541 -0
- data/lib/admin_suite/configuration.rb +42 -0
- data/lib/admin_suite/engine.rb +101 -0
- data/lib/admin_suite/markdown_renderer.rb +115 -0
- data/lib/admin_suite/portal_definition.rb +64 -0
- data/lib/admin_suite/portal_registry.rb +32 -0
- data/lib/admin_suite/theme_palette.rb +36 -0
- data/lib/admin_suite/ui/dashboard_definition.rb +69 -0
- data/lib/admin_suite/ui/field_renderer_registry.rb +119 -0
- data/lib/admin_suite/ui/form_field_renderer.rb +48 -0
- data/lib/admin_suite/ui/show_formatter_registry.rb +120 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +70 -0
- data/lib/admin_suite/version.rb +10 -0
- data/lib/admin_suite.rb +54 -0
- data/lib/generators/admin_suite/install/install_generator.rb +23 -0
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +60 -0
- data/lib/generators/admin_suite/resource/resource_generator.rb +83 -0
- data/lib/generators/admin_suite/resource/templates/resource.rb.tt +47 -0
- data/lib/generators/admin_suite/scaffold/scaffold_generator.rb +28 -0
- data/lib/tasks/admin_suite_tailwind.rake +28 -0
- data/lib/tasks/admin_suite_test.rake +11 -0
- data/test/dummy/Gemfile +21 -0
- data/test/dummy/README.md +24 -0
- data/test/dummy/Rakefile +6 -0
- data/test/dummy/app/assets/stylesheets/application.css +10 -0
- data/test/dummy/app/controllers/application_controller.rb +4 -0
- data/test/dummy/app/helpers/application_helper.rb +2 -0
- data/test/dummy/app/models/application_record.rb +2 -0
- data/test/dummy/app/views/layouts/application.html.erb +28 -0
- data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
- data/test/dummy/app/views/pwa/service-worker.js +26 -0
- data/test/dummy/bin/ci +6 -0
- data/test/dummy/bin/dev +2 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/bin/setup +35 -0
- data/test/dummy/config/application.rb +43 -0
- data/test/dummy/config/boot.rb +3 -0
- data/test/dummy/config/ci.rb +19 -0
- data/test/dummy/config/database.yml +31 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/environments/development.rb +57 -0
- data/test/dummy/config/environments/production.rb +67 -0
- data/test/dummy/config/environments/test.rb +42 -0
- data/test/dummy/config/initializers/assets.rb +7 -0
- data/test/dummy/config/initializers/content_security_policy.rb +29 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +8 -0
- data/test/dummy/config/initializers/inflections.rb +16 -0
- data/test/dummy/config/locales/en.yml +31 -0
- data/test/dummy/config/puma.rb +39 -0
- data/test/dummy/config/routes.rb +16 -0
- data/test/dummy/config.ru +6 -0
- data/test/dummy/db/seeds.rb +9 -0
- data/test/dummy/log/test.log +441 -0
- data/test/dummy/public/400.html +135 -0
- data/test/dummy/public/404.html +135 -0
- data/test/dummy/public/406-unsupported-browser.html +135 -0
- data/test/dummy/public/422.html +135 -0
- data/test/dummy/public/500.html +135 -0
- data/test/dummy/public/icon.png +0 -0
- data/test/dummy/public/icon.svg +3 -0
- data/test/dummy/public/robots.txt +1 -0
- data/test/dummy/test/test_helper.rb +15 -0
- data/test/dummy/tmp/local_secret.txt +1 -0
- data/test/fixtures/docs/progress/PROGRESS_REPORT.md +6 -0
- data/test/integration/dashboard_test.rb +13 -0
- data/test/integration/docs_test.rb +46 -0
- data/test/integration/theme_test.rb +27 -0
- data/test/lib/markdown_renderer_test.rb +20 -0
- data/test/lib/theme_palette_test.rb +24 -0
- data/test/test_helper.rb +11 -0
- metadata +264 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// JSON Editor Controller (Admin Suite)
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ["input", "error"]
|
|
6
|
+
|
|
7
|
+
validate() {
|
|
8
|
+
if (!this.hasInputTarget) return
|
|
9
|
+
|
|
10
|
+
const value = this.inputTarget.value.trim()
|
|
11
|
+
|
|
12
|
+
if (value === "") {
|
|
13
|
+
this.clearError()
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
JSON.parse(value)
|
|
19
|
+
this.clearError()
|
|
20
|
+
this.inputTarget.classList.remove("border-red-500", "dark:border-red-500")
|
|
21
|
+
this.inputTarget.classList.add("border-slate-300", "dark:border-slate-600")
|
|
22
|
+
} catch (e) {
|
|
23
|
+
this.showError(e.message)
|
|
24
|
+
this.inputTarget.classList.remove("border-slate-300", "dark:border-slate-600")
|
|
25
|
+
this.inputTarget.classList.add("border-red-500", "dark:border-red-500")
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
format(event) {
|
|
30
|
+
event?.preventDefault()
|
|
31
|
+
if (!this.hasInputTarget) return
|
|
32
|
+
|
|
33
|
+
const value = this.inputTarget.value.trim()
|
|
34
|
+
if (value === "") return
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(value)
|
|
38
|
+
const formatted = JSON.stringify(parsed, null, 2)
|
|
39
|
+
this.inputTarget.value = formatted
|
|
40
|
+
this.clearError()
|
|
41
|
+
this.inputTarget.classList.remove("border-red-500", "dark:border-red-500")
|
|
42
|
+
this.inputTarget.classList.add("border-slate-300", "dark:border-slate-600")
|
|
43
|
+
} catch (e) {
|
|
44
|
+
this.showError(e.message)
|
|
45
|
+
this.inputTarget.classList.remove("border-slate-300", "dark:border-slate-600")
|
|
46
|
+
this.inputTarget.classList.add("border-red-500", "dark:border-red-500")
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
showError(message) {
|
|
51
|
+
if (!this.hasErrorTarget) return
|
|
52
|
+
this.errorTarget.textContent = `Invalid JSON: ${message}`
|
|
53
|
+
this.errorTarget.classList.remove("hidden")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
clearError() {
|
|
57
|
+
if (!this.hasErrorTarget) return
|
|
58
|
+
this.errorTarget.textContent = ""
|
|
59
|
+
this.errorTarget.classList.add("hidden")
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Live Filter Controller (Admin Suite)
|
|
5
|
+
*
|
|
6
|
+
* Automatically submits filter forms on input change with debouncing.
|
|
7
|
+
* Works with Turbo Frames to provide smooth, live-updating results.
|
|
8
|
+
*/
|
|
9
|
+
export default class extends Controller {
|
|
10
|
+
static targets = ["input"]
|
|
11
|
+
static values = {
|
|
12
|
+
debounce: { type: Number, default: 300 },
|
|
13
|
+
minLength: { type: Number, default: 3 },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
connect() {
|
|
17
|
+
this.timeout = null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
disconnect() {
|
|
21
|
+
if (this.timeout) {
|
|
22
|
+
clearTimeout(this.timeout)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
submit() {
|
|
27
|
+
if (this.timeout) {
|
|
28
|
+
clearTimeout(this.timeout)
|
|
29
|
+
}
|
|
30
|
+
this.element.requestSubmit()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
debounce() {
|
|
34
|
+
if (this.timeout) {
|
|
35
|
+
clearTimeout(this.timeout)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
this.timeout = setTimeout(() => {
|
|
39
|
+
this.element.requestSubmit()
|
|
40
|
+
}, this.debounceValue)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
debounceWithMinLength(event) {
|
|
44
|
+
if (this.timeout) {
|
|
45
|
+
clearTimeout(this.timeout)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const value = event.target.value
|
|
49
|
+
const minLength =
|
|
50
|
+
parseInt(event.target.dataset.adminSuiteLiveFilterMinLengthValue) ||
|
|
51
|
+
this.minLengthValue
|
|
52
|
+
|
|
53
|
+
if (value.length === 0 || value.length >= minLength) {
|
|
54
|
+
this.timeout = setTimeout(() => {
|
|
55
|
+
this.element.requestSubmit()
|
|
56
|
+
}, this.debounceValue)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
clear() {
|
|
61
|
+
this.inputTargets.forEach((input) => {
|
|
62
|
+
if (input.type === "checkbox") {
|
|
63
|
+
input.checked = false
|
|
64
|
+
} else {
|
|
65
|
+
input.value = ""
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
this.submit()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Markdown Editor Controller (Admin Suite)
|
|
5
|
+
*
|
|
6
|
+
* Initializes EasyMDE on a textarea element for rich markdown editing.
|
|
7
|
+
* EasyMDE is loaded globally via script tag in the admin layout.
|
|
8
|
+
*/
|
|
9
|
+
export default class extends Controller {
|
|
10
|
+
static targets = ["textarea"]
|
|
11
|
+
|
|
12
|
+
connect() {
|
|
13
|
+
this.initEditor()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
initEditor() {
|
|
17
|
+
if (typeof window.EasyMDE === "undefined") {
|
|
18
|
+
setTimeout(() => this.initEditor(), 100)
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (this.editor) return
|
|
23
|
+
|
|
24
|
+
this.editor = new window.EasyMDE({
|
|
25
|
+
element: this.textareaTarget,
|
|
26
|
+
spellChecker: false,
|
|
27
|
+
autofocus: false,
|
|
28
|
+
autosave: { enabled: false },
|
|
29
|
+
status: ["lines", "words", "cursor"],
|
|
30
|
+
placeholder: "Write your content in Markdown...",
|
|
31
|
+
toolbar: [
|
|
32
|
+
"bold",
|
|
33
|
+
"italic",
|
|
34
|
+
"heading",
|
|
35
|
+
"|",
|
|
36
|
+
"quote",
|
|
37
|
+
"unordered-list",
|
|
38
|
+
"ordered-list",
|
|
39
|
+
"|",
|
|
40
|
+
"link",
|
|
41
|
+
"image",
|
|
42
|
+
"code",
|
|
43
|
+
"|",
|
|
44
|
+
"preview",
|
|
45
|
+
"side-by-side",
|
|
46
|
+
"fullscreen",
|
|
47
|
+
"|",
|
|
48
|
+
"guide",
|
|
49
|
+
],
|
|
50
|
+
minHeight: "400px",
|
|
51
|
+
renderingConfig: { codeSyntaxHighlighting: true },
|
|
52
|
+
forceSync: true,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
this.editor.codemirror.on("change", () => {
|
|
56
|
+
this.textareaTarget.value = this.editor.value()
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
disconnect() {
|
|
61
|
+
if (this.editor) {
|
|
62
|
+
this.editor.toTextArea()
|
|
63
|
+
this.editor = null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Searchable Select Controller (Admin Suite)
|
|
5
|
+
*
|
|
6
|
+
* Provides a searchable dropdown for select fields with optional AJAX search.
|
|
7
|
+
*/
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
static targets = ["input", "search", "dropdown"]
|
|
10
|
+
static values = {
|
|
11
|
+
options: { type: Array, default: [] },
|
|
12
|
+
creatable: { type: Boolean, default: false },
|
|
13
|
+
searchUrl: { type: String, default: "" },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
connect() {
|
|
17
|
+
this.isOpen = false
|
|
18
|
+
this.selectedIndex = -1
|
|
19
|
+
this.filteredOptions = [...this.optionsValue]
|
|
20
|
+
|
|
21
|
+
this.clickOutside = this.clickOutside.bind(this)
|
|
22
|
+
document.addEventListener("click", this.clickOutside)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
disconnect() {
|
|
26
|
+
document.removeEventListener("click", this.clickOutside)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
open() {
|
|
30
|
+
this.isOpen = true
|
|
31
|
+
this.filteredOptions = [...this.optionsValue]
|
|
32
|
+
this.renderDropdown()
|
|
33
|
+
this.dropdownTarget.classList.remove("hidden")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
close() {
|
|
37
|
+
this.isOpen = false
|
|
38
|
+
this.dropdownTarget.classList.add("hidden")
|
|
39
|
+
this.selectedIndex = -1
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
clickOutside(event) {
|
|
43
|
+
if (!this.element.contains(event.target)) {
|
|
44
|
+
this.close()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
search() {
|
|
49
|
+
const query = this.searchTarget.value.toLowerCase().trim()
|
|
50
|
+
|
|
51
|
+
if (this.searchUrlValue) {
|
|
52
|
+
this.fetchOptions(query)
|
|
53
|
+
} else {
|
|
54
|
+
this.filteredOptions = this.optionsValue.filter((opt) =>
|
|
55
|
+
opt.label.toLowerCase().includes(query),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if (
|
|
59
|
+
this.creatableValue &&
|
|
60
|
+
query &&
|
|
61
|
+
!this.filteredOptions.some((o) => o.value === query)
|
|
62
|
+
) {
|
|
63
|
+
this.filteredOptions.push({
|
|
64
|
+
value: query,
|
|
65
|
+
label: `Create "${query}"`,
|
|
66
|
+
isNew: true,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.renderDropdown()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!this.isOpen) this.open()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async fetchOptions(query) {
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetch(
|
|
79
|
+
`${this.searchUrlValue}?q=${encodeURIComponent(query)}`,
|
|
80
|
+
)
|
|
81
|
+
const data = await response.json()
|
|
82
|
+
this.filteredOptions = data.map((item) => ({
|
|
83
|
+
value: item.id || item.value,
|
|
84
|
+
label: item.name || item.label,
|
|
85
|
+
}))
|
|
86
|
+
|
|
87
|
+
if (
|
|
88
|
+
this.creatableValue &&
|
|
89
|
+
query &&
|
|
90
|
+
!this.filteredOptions.some((o) => o.value === query)
|
|
91
|
+
) {
|
|
92
|
+
this.filteredOptions.push({
|
|
93
|
+
value: query,
|
|
94
|
+
label: `Create "${query}"`,
|
|
95
|
+
isNew: true,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this.renderDropdown()
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error("Search failed:", error)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
renderDropdown() {
|
|
106
|
+
if (!this.filteredOptions.length) {
|
|
107
|
+
this.dropdownTarget.innerHTML = `
|
|
108
|
+
<div class="px-3 py-2 text-sm text-slate-400 dark:text-slate-500">No results found</div>
|
|
109
|
+
`
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.dropdownTarget.innerHTML = this.filteredOptions
|
|
114
|
+
.map(
|
|
115
|
+
(opt, index) => `
|
|
116
|
+
<button type="button"
|
|
117
|
+
class="block w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700 ${index === this.selectedIndex ? "bg-slate-100 dark:bg-slate-700" : ""} ${opt.isNew ? "text-indigo-600 dark:text-indigo-400 font-medium" : "text-slate-700 dark:text-slate-200"}"
|
|
118
|
+
data-action="click->admin-suite--searchable-select#select"
|
|
119
|
+
data-value="${opt.value}"
|
|
120
|
+
data-label="${opt.label}">
|
|
121
|
+
${opt.label}
|
|
122
|
+
</button>
|
|
123
|
+
`,
|
|
124
|
+
)
|
|
125
|
+
.join("")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
select(event) {
|
|
129
|
+
const value = event.currentTarget.dataset.value
|
|
130
|
+
const label = event.currentTarget.dataset.label
|
|
131
|
+
.replace(/^Create "/, "")
|
|
132
|
+
.replace(/"$/, "")
|
|
133
|
+
|
|
134
|
+
this.inputTarget.value = value
|
|
135
|
+
this.searchTarget.value = label
|
|
136
|
+
this.close()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
keydown(event) {
|
|
140
|
+
switch (event.key) {
|
|
141
|
+
case "ArrowDown":
|
|
142
|
+
event.preventDefault()
|
|
143
|
+
this.selectedIndex = Math.min(
|
|
144
|
+
this.selectedIndex + 1,
|
|
145
|
+
this.filteredOptions.length - 1,
|
|
146
|
+
)
|
|
147
|
+
this.renderDropdown()
|
|
148
|
+
break
|
|
149
|
+
case "ArrowUp":
|
|
150
|
+
event.preventDefault()
|
|
151
|
+
this.selectedIndex = Math.max(this.selectedIndex - 1, 0)
|
|
152
|
+
this.renderDropdown()
|
|
153
|
+
break
|
|
154
|
+
case "Enter":
|
|
155
|
+
event.preventDefault()
|
|
156
|
+
if (this.selectedIndex >= 0 && this.filteredOptions[this.selectedIndex]) {
|
|
157
|
+
const opt = this.filteredOptions[this.selectedIndex]
|
|
158
|
+
this.inputTarget.value = opt.value
|
|
159
|
+
this.searchTarget.value = opt.label
|
|
160
|
+
.replace(/^Create "/, "")
|
|
161
|
+
.replace(/"$/, "")
|
|
162
|
+
this.close()
|
|
163
|
+
}
|
|
164
|
+
break
|
|
165
|
+
case "Escape":
|
|
166
|
+
this.close()
|
|
167
|
+
break
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="admin-suite--sidebar"
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ["overlay", "mobileSidebar"]
|
|
6
|
+
|
|
7
|
+
toggle(event) {
|
|
8
|
+
event.preventDefault()
|
|
9
|
+
|
|
10
|
+
if (this.hasOverlayTarget) {
|
|
11
|
+
this.overlayTarget.classList.toggle("hidden")
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (this.hasMobileSidebarTarget) {
|
|
15
|
+
this.mobileSidebarTarget.classList.toggle("hidden")
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
close(event) {
|
|
20
|
+
if (event) {
|
|
21
|
+
event.preventDefault()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (this.hasOverlayTarget) {
|
|
25
|
+
this.overlayTarget.classList.add("hidden")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (this.hasMobileSidebarTarget) {
|
|
29
|
+
this.mobileSidebarTarget.classList.add("hidden")
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tag Select Controller (Admin Suite)
|
|
5
|
+
*
|
|
6
|
+
* Provides a tag/multi-select input with inline tag creation.
|
|
7
|
+
*/
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
static targets = ["tags", "input", "dropdown", "placeholder"]
|
|
10
|
+
static values = {
|
|
11
|
+
creatable: { type: Boolean, default: true },
|
|
12
|
+
suggestions: { type: Array, default: [] },
|
|
13
|
+
fieldName: { type: String, default: "" },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
connect() {
|
|
17
|
+
this.selectedTags = this.getExistingTags()
|
|
18
|
+
|
|
19
|
+
this.clickOutside = this.clickOutside.bind(this)
|
|
20
|
+
document.addEventListener("click", this.clickOutside)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
disconnect() {
|
|
24
|
+
document.removeEventListener("click", this.clickOutside)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getExistingTags() {
|
|
28
|
+
const tags = []
|
|
29
|
+
this.tagsTarget
|
|
30
|
+
.querySelectorAll("span[class*='bg-indigo']")
|
|
31
|
+
.forEach((el) => {
|
|
32
|
+
const hidden = el.querySelector("input[type='hidden']")
|
|
33
|
+
if (hidden) tags.push(hidden.value)
|
|
34
|
+
})
|
|
35
|
+
return tags
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
clickOutside(event) {
|
|
39
|
+
if (!this.element.contains(event.target)) {
|
|
40
|
+
this.closeDropdown()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
search() {
|
|
45
|
+
const query = this.inputTarget.value.toLowerCase().trim()
|
|
46
|
+
|
|
47
|
+
if (!query) {
|
|
48
|
+
this.closeDropdown()
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (this.hasDropdownTarget) {
|
|
53
|
+
const buttons = this.dropdownTarget.querySelectorAll("button")
|
|
54
|
+
let hasVisible = false
|
|
55
|
+
|
|
56
|
+
buttons.forEach((btn) => {
|
|
57
|
+
const value = btn.dataset.value.toLowerCase()
|
|
58
|
+
if (
|
|
59
|
+
value.includes(query) &&
|
|
60
|
+
!this.selectedTags.includes(btn.dataset.value)
|
|
61
|
+
) {
|
|
62
|
+
btn.classList.remove("hidden")
|
|
63
|
+
hasVisible = true
|
|
64
|
+
} else {
|
|
65
|
+
btn.classList.add("hidden")
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
if (hasVisible) {
|
|
70
|
+
this.dropdownTarget.classList.remove("hidden")
|
|
71
|
+
} else if (this.creatableValue) {
|
|
72
|
+
this.dropdownTarget.classList.remove("hidden")
|
|
73
|
+
} else {
|
|
74
|
+
this.closeDropdown()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
closeDropdown() {
|
|
80
|
+
if (this.hasDropdownTarget) {
|
|
81
|
+
this.dropdownTarget.classList.add("hidden")
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
keydown(event) {
|
|
86
|
+
const value = this.inputTarget.value.trim()
|
|
87
|
+
|
|
88
|
+
switch (event.key) {
|
|
89
|
+
case "Enter":
|
|
90
|
+
case ",":
|
|
91
|
+
event.preventDefault()
|
|
92
|
+
if (value && this.creatableValue) {
|
|
93
|
+
this.addTag(value)
|
|
94
|
+
}
|
|
95
|
+
break
|
|
96
|
+
case "Backspace":
|
|
97
|
+
if (!value && this.selectedTags.length > 0) {
|
|
98
|
+
this.removeLastTag()
|
|
99
|
+
}
|
|
100
|
+
break
|
|
101
|
+
case "Escape":
|
|
102
|
+
this.closeDropdown()
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
select(event) {
|
|
108
|
+
event.preventDefault()
|
|
109
|
+
const value = event.currentTarget.dataset.value
|
|
110
|
+
this.addTag(value)
|
|
111
|
+
this.closeDropdown()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
addTag(value) {
|
|
115
|
+
if (this.selectedTags.includes(value)) return
|
|
116
|
+
|
|
117
|
+
this.selectedTags.push(value)
|
|
118
|
+
|
|
119
|
+
const tagEl = document.createElement("span")
|
|
120
|
+
tagEl.className =
|
|
121
|
+
"inline-flex items-center gap-1 px-2 py-1 bg-indigo-100 dark:bg-indigo-900/50 text-indigo-700 dark:text-indigo-300 rounded text-sm"
|
|
122
|
+
tagEl.innerHTML = `
|
|
123
|
+
${this.escapeHtml(value)}
|
|
124
|
+
<input type="hidden" name="${this.getFieldName()}" value="${this.escapeHtml(value)}">
|
|
125
|
+
<button type="button" class="text-indigo-500 hover:text-indigo-700 font-bold" data-action="admin-suite--tag-select#remove">×</button>
|
|
126
|
+
`
|
|
127
|
+
|
|
128
|
+
this.inputTarget.parentNode.insertBefore(tagEl, this.inputTarget)
|
|
129
|
+
this.inputTarget.value = ""
|
|
130
|
+
this.inputTarget.focus()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
remove(event) {
|
|
134
|
+
event.preventDefault()
|
|
135
|
+
const tagEl = event.currentTarget.closest("span")
|
|
136
|
+
const hidden = tagEl.querySelector("input[type='hidden']")
|
|
137
|
+
|
|
138
|
+
if (hidden) {
|
|
139
|
+
const index = this.selectedTags.indexOf(hidden.value)
|
|
140
|
+
if (index > -1) this.selectedTags.splice(index, 1)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
tagEl.remove()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
removeLastTag() {
|
|
147
|
+
const tags = this.tagsTarget.querySelectorAll("span[class*='bg-indigo']")
|
|
148
|
+
if (tags.length > 0) {
|
|
149
|
+
const lastTag = tags[tags.length - 1]
|
|
150
|
+
const hidden = lastTag.querySelector("input[type='hidden']")
|
|
151
|
+
|
|
152
|
+
if (hidden) {
|
|
153
|
+
const index = this.selectedTags.indexOf(hidden.value)
|
|
154
|
+
if (index > -1) this.selectedTags.splice(index, 1)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
lastTag.remove()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
getFieldName() {
|
|
162
|
+
if (this.fieldNameValue) return this.fieldNameValue
|
|
163
|
+
|
|
164
|
+
const existing = this.tagsTarget.querySelector(
|
|
165
|
+
"input[type='hidden']:not([data-admin-suite--tag-select-target='placeholder'])",
|
|
166
|
+
)
|
|
167
|
+
if (existing && existing.name) return existing.name
|
|
168
|
+
|
|
169
|
+
if (this.hasPlaceholderTarget && this.placeholderTarget.name) {
|
|
170
|
+
return this.placeholderTarget.name
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const form = this.element.closest("form")
|
|
174
|
+
if (form) {
|
|
175
|
+
const anyHidden = form.querySelector("input[type='hidden'][name*='[']")
|
|
176
|
+
if (anyHidden) {
|
|
177
|
+
const match = anyHidden.name.match(/^([^\[]+)\[/)
|
|
178
|
+
if (match) {
|
|
179
|
+
return `${match[1]}[tag_list]`
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return "tag_list"
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
escapeHtml(text) {
|
|
188
|
+
const div = document.createElement("div")
|
|
189
|
+
div.textContent = text
|
|
190
|
+
return div.innerHTML
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Toggle Switch Controller (Admin Suite)
|
|
5
|
+
*/
|
|
6
|
+
export default class extends Controller {
|
|
7
|
+
static targets = ["button", "thumb", "input", "label"]
|
|
8
|
+
static values = {
|
|
9
|
+
activeClass: String,
|
|
10
|
+
inactiveClasses: String
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
connect() {
|
|
14
|
+
this.checked = this.inputTarget?.value === "1" || this.inputTarget?.value === "true"
|
|
15
|
+
this.updateVisual()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get activeClass() {
|
|
19
|
+
return this.hasActiveClassValue ? this.activeClassValue : "bg-indigo-600"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get inactiveClasses() {
|
|
23
|
+
return this.hasInactiveClassesValue ? this.inactiveClassesValue : "bg-slate-200 dark:bg-slate-700"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
toggle(event) {
|
|
27
|
+
event.preventDefault()
|
|
28
|
+
this.checked = !this.checked
|
|
29
|
+
this.updateVisual()
|
|
30
|
+
this.updateInput()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
updateVisual() {
|
|
34
|
+
if (this.hasButtonTarget) {
|
|
35
|
+
if (this.checked) {
|
|
36
|
+
this.buttonTarget.classList.remove(...this.inactiveClasses.split(" "))
|
|
37
|
+
this.buttonTarget.classList.add(this.activeClass)
|
|
38
|
+
} else {
|
|
39
|
+
this.buttonTarget.classList.remove(this.activeClass)
|
|
40
|
+
this.buttonTarget.classList.add(...this.inactiveClasses.split(" "))
|
|
41
|
+
}
|
|
42
|
+
this.buttonTarget.setAttribute("aria-checked", this.checked.toString())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (this.hasThumbTarget) {
|
|
46
|
+
if (this.checked) {
|
|
47
|
+
this.thumbTarget.classList.remove("translate-x-0")
|
|
48
|
+
this.thumbTarget.classList.add("translate-x-5")
|
|
49
|
+
} else {
|
|
50
|
+
this.thumbTarget.classList.remove("translate-x-5")
|
|
51
|
+
this.thumbTarget.classList.add("translate-x-0")
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (this.hasLabelTarget) {
|
|
56
|
+
this.labelTarget.textContent = this.checked ? "Enabled" : "Disabled"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
updateInput() {
|
|
61
|
+
if (this.hasInputTarget) {
|
|
62
|
+
this.inputTarget.value = this.checked ? "1" : "0"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<% content_for :title, "Developer Portal" %>
|
|
2
|
+
|
|
3
|
+
<div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
|
4
|
+
<div class="mb-6">
|
|
5
|
+
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">Developer Portal</h1>
|
|
6
|
+
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
|
7
|
+
Admin framework for managing all application resources across three specialized portals.
|
|
8
|
+
</p>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<% @dashboard_sections.each do |section| %>
|
|
12
|
+
<% if section[:title].present? %>
|
|
13
|
+
<div class="mt-8 mb-3 flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
|
|
14
|
+
<%= admin_suite_icon("activity", class: "w-4 h-4 text-slate-400") %>
|
|
15
|
+
<span><%= section[:title] %></span>
|
|
16
|
+
</div>
|
|
17
|
+
<% end %>
|
|
18
|
+
|
|
19
|
+
<%= render_dashboard_rows(section[:rows]) %>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|