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.
Files changed (128) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/Gemfile +13 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +7 -0
  6. data/Rakefile +11 -0
  7. data/app/assets/admin_suite.css +444 -0
  8. data/app/assets/admin_suite_tailwind.css +8 -0
  9. data/app/assets/builds/admin_suite_tailwind.css +8 -0
  10. data/app/assets/rouge.css +218 -0
  11. data/app/assets/tailwind/admin_suite.css +22 -0
  12. data/app/controllers/admin_suite/application_controller.rb +118 -0
  13. data/app/controllers/admin_suite/dashboard_controller.rb +258 -0
  14. data/app/controllers/admin_suite/docs_controller.rb +155 -0
  15. data/app/controllers/admin_suite/portals_controller.rb +22 -0
  16. data/app/controllers/admin_suite/resources_controller.rb +238 -0
  17. data/app/helpers/admin_suite/base_helper.rb +1199 -0
  18. data/app/helpers/admin_suite/icon_helper.rb +61 -0
  19. data/app/helpers/admin_suite/panels_helper.rb +52 -0
  20. data/app/helpers/admin_suite/resources_helper.rb +15 -0
  21. data/app/helpers/admin_suite/theme_helper.rb +99 -0
  22. data/app/javascript/controllers/admin_suite/click_actions_controller.js +73 -0
  23. data/app/javascript/controllers/admin_suite/clipboard_controller.js +57 -0
  24. data/app/javascript/controllers/admin_suite/code_editor_controller.js +45 -0
  25. data/app/javascript/controllers/admin_suite/file_upload_controller.js +238 -0
  26. data/app/javascript/controllers/admin_suite/json_editor_controller.js +62 -0
  27. data/app/javascript/controllers/admin_suite/live_filter_controller.js +71 -0
  28. data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +67 -0
  29. data/app/javascript/controllers/admin_suite/searchable_select_controller.js +171 -0
  30. data/app/javascript/controllers/admin_suite/sidebar_controller.js +33 -0
  31. data/app/javascript/controllers/admin_suite/tag_select_controller.js +193 -0
  32. data/app/javascript/controllers/admin_suite/toggle_switch_controller.js +66 -0
  33. data/app/views/admin_suite/dashboard/index.html.erb +21 -0
  34. data/app/views/admin_suite/docs/index.html.erb +86 -0
  35. data/app/views/admin_suite/panels/_cards.html.erb +107 -0
  36. data/app/views/admin_suite/panels/_chart.html.erb +47 -0
  37. data/app/views/admin_suite/panels/_health.html.erb +44 -0
  38. data/app/views/admin_suite/panels/_recent.html.erb +56 -0
  39. data/app/views/admin_suite/panels/_stat.html.erb +64 -0
  40. data/app/views/admin_suite/panels/_table.html.erb +36 -0
  41. data/app/views/admin_suite/portals/show.html.erb +75 -0
  42. data/app/views/admin_suite/resources/_form.html.erb +32 -0
  43. data/app/views/admin_suite/resources/edit.html.erb +24 -0
  44. data/app/views/admin_suite/resources/index.html.erb +315 -0
  45. data/app/views/admin_suite/resources/new.html.erb +22 -0
  46. data/app/views/admin_suite/resources/show.html.erb +184 -0
  47. data/app/views/admin_suite/shared/_flash.html.erb +30 -0
  48. data/app/views/admin_suite/shared/_form.html.erb +60 -0
  49. data/app/views/admin_suite/shared/_json_editor_field.html.erb +52 -0
  50. data/app/views/admin_suite/shared/_sidebar.html.erb +94 -0
  51. data/app/views/admin_suite/shared/_toggle_cell.html.erb +34 -0
  52. data/app/views/admin_suite/shared/_topbar.html.erb +47 -0
  53. data/app/views/layouts/admin_suite/application.html.erb +79 -0
  54. data/lib/admin/base/action_executor.rb +155 -0
  55. data/lib/admin/base/action_handler.rb +31 -0
  56. data/lib/admin/base/filter_builder.rb +121 -0
  57. data/lib/admin/base/resource.rb +541 -0
  58. data/lib/admin_suite/configuration.rb +42 -0
  59. data/lib/admin_suite/engine.rb +101 -0
  60. data/lib/admin_suite/markdown_renderer.rb +115 -0
  61. data/lib/admin_suite/portal_definition.rb +64 -0
  62. data/lib/admin_suite/portal_registry.rb +32 -0
  63. data/lib/admin_suite/theme_palette.rb +36 -0
  64. data/lib/admin_suite/ui/dashboard_definition.rb +69 -0
  65. data/lib/admin_suite/ui/field_renderer_registry.rb +119 -0
  66. data/lib/admin_suite/ui/form_field_renderer.rb +48 -0
  67. data/lib/admin_suite/ui/show_formatter_registry.rb +120 -0
  68. data/lib/admin_suite/ui/show_value_formatter.rb +70 -0
  69. data/lib/admin_suite/version.rb +10 -0
  70. data/lib/admin_suite.rb +54 -0
  71. data/lib/generators/admin_suite/install/install_generator.rb +23 -0
  72. data/lib/generators/admin_suite/install/templates/admin_suite.rb +60 -0
  73. data/lib/generators/admin_suite/resource/resource_generator.rb +83 -0
  74. data/lib/generators/admin_suite/resource/templates/resource.rb.tt +47 -0
  75. data/lib/generators/admin_suite/scaffold/scaffold_generator.rb +28 -0
  76. data/lib/tasks/admin_suite_tailwind.rake +28 -0
  77. data/lib/tasks/admin_suite_test.rake +11 -0
  78. data/test/dummy/Gemfile +21 -0
  79. data/test/dummy/README.md +24 -0
  80. data/test/dummy/Rakefile +6 -0
  81. data/test/dummy/app/assets/stylesheets/application.css +10 -0
  82. data/test/dummy/app/controllers/application_controller.rb +4 -0
  83. data/test/dummy/app/helpers/application_helper.rb +2 -0
  84. data/test/dummy/app/models/application_record.rb +2 -0
  85. data/test/dummy/app/views/layouts/application.html.erb +28 -0
  86. data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
  87. data/test/dummy/app/views/pwa/service-worker.js +26 -0
  88. data/test/dummy/bin/ci +6 -0
  89. data/test/dummy/bin/dev +2 -0
  90. data/test/dummy/bin/rails +4 -0
  91. data/test/dummy/bin/rake +4 -0
  92. data/test/dummy/bin/setup +35 -0
  93. data/test/dummy/config/application.rb +43 -0
  94. data/test/dummy/config/boot.rb +3 -0
  95. data/test/dummy/config/ci.rb +19 -0
  96. data/test/dummy/config/database.yml +31 -0
  97. data/test/dummy/config/environment.rb +5 -0
  98. data/test/dummy/config/environments/development.rb +57 -0
  99. data/test/dummy/config/environments/production.rb +67 -0
  100. data/test/dummy/config/environments/test.rb +42 -0
  101. data/test/dummy/config/initializers/assets.rb +7 -0
  102. data/test/dummy/config/initializers/content_security_policy.rb +29 -0
  103. data/test/dummy/config/initializers/filter_parameter_logging.rb +8 -0
  104. data/test/dummy/config/initializers/inflections.rb +16 -0
  105. data/test/dummy/config/locales/en.yml +31 -0
  106. data/test/dummy/config/puma.rb +39 -0
  107. data/test/dummy/config/routes.rb +16 -0
  108. data/test/dummy/config.ru +6 -0
  109. data/test/dummy/db/seeds.rb +9 -0
  110. data/test/dummy/log/test.log +441 -0
  111. data/test/dummy/public/400.html +135 -0
  112. data/test/dummy/public/404.html +135 -0
  113. data/test/dummy/public/406-unsupported-browser.html +135 -0
  114. data/test/dummy/public/422.html +135 -0
  115. data/test/dummy/public/500.html +135 -0
  116. data/test/dummy/public/icon.png +0 -0
  117. data/test/dummy/public/icon.svg +3 -0
  118. data/test/dummy/public/robots.txt +1 -0
  119. data/test/dummy/test/test_helper.rb +15 -0
  120. data/test/dummy/tmp/local_secret.txt +1 -0
  121. data/test/fixtures/docs/progress/PROGRESS_REPORT.md +6 -0
  122. data/test/integration/dashboard_test.rb +13 -0
  123. data/test/integration/docs_test.rb +46 -0
  124. data/test/integration/theme_test.rb +27 -0
  125. data/test/lib/markdown_renderer_test.rb +20 -0
  126. data/test/lib/theme_palette_test.rb +24 -0
  127. data/test/test_helper.rb +11 -0
  128. 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>