katalyst-koi 4.0.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 (206) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +23 -0
  4. data/Upgrade.md +6 -0
  5. data/app/assets/builds/koi/admin.css +1 -0
  6. data/app/assets/builds/koi/nav_items.css +1 -0
  7. data/app/assets/config/koi.js +10 -0
  8. data/app/assets/images/koi/application/chevron-right.svg +10 -0
  9. data/app/assets/images/koi/application/glyphicons-halflings-white.png +0 -0
  10. data/app/assets/images/koi/application/glyphicons-halflings.png +0 -0
  11. data/app/assets/images/koi/application/icon-collapse-down.png +0 -0
  12. data/app/assets/images/koi/application/icon-collapse-up.png +0 -0
  13. data/app/assets/images/koi/application/icon-file-doc.png +0 -0
  14. data/app/assets/images/koi/application/icon-file-img.png +0 -0
  15. data/app/assets/images/koi/application/icon-file-pdf.png +0 -0
  16. data/app/assets/images/koi/application/icon-file-ppt.png +0 -0
  17. data/app/assets/images/koi/application/icon-file-unknown.png +0 -0
  18. data/app/assets/images/koi/application/icon-file-xls.png +0 -0
  19. data/app/assets/images/koi/application/icon-file-zip.png +0 -0
  20. data/app/assets/images/koi/application/icon-form-date-picker.png +0 -0
  21. data/app/assets/images/koi/application/icon-form-error.png +0 -0
  22. data/app/assets/images/koi/application/icon-index-sort-ascending.png +0 -0
  23. data/app/assets/images/koi/application/icon-index-sort-descending.png +0 -0
  24. data/app/assets/images/koi/application/icon-index-sort.png +0 -0
  25. data/app/assets/images/koi/application/icon-index-sortable.png +0 -0
  26. data/app/assets/images/koi/application/icon-menu-cursor.png +0 -0
  27. data/app/assets/images/koi/application/icon-overlay-add.png +0 -0
  28. data/app/assets/images/koi/application/icon-overlay-close.png +0 -0
  29. data/app/assets/images/koi/application/icon-sortable.png +0 -0
  30. data/app/assets/images/koi/application/jcrop.gif +0 -0
  31. data/app/assets/images/koi/application/loading.gif +0 -0
  32. data/app/assets/images/koi/application/select-arrow.svg +3 -0
  33. data/app/assets/images/koi/application/select_arrow.png +0 -0
  34. data/app/assets/images/koi/application/sort-ascending.png +0 -0
  35. data/app/assets/images/koi/application/sort-descending.png +0 -0
  36. data/app/assets/javascripts/koi/admin.js +4 -0
  37. data/app/assets/javascripts/koi/controllers/application.js +11 -0
  38. data/app/assets/javascripts/koi/controllers/document_field_controller.js +26 -0
  39. data/app/assets/javascripts/koi/controllers/file_field_controller.js +143 -0
  40. data/app/assets/javascripts/koi/controllers/flash_controller.js +12 -0
  41. data/app/assets/javascripts/koi/controllers/form_request_submit_controller.js +11 -0
  42. data/app/assets/javascripts/koi/controllers/image_field_controller.js +24 -0
  43. data/app/assets/javascripts/koi/controllers/index.js +6 -0
  44. data/app/assets/javascripts/koi/controllers/index_actions_controller.js +61 -0
  45. data/app/assets/javascripts/koi/controllers/keyboard_controller.js +149 -0
  46. data/app/assets/javascripts/koi/controllers/navigation_controller.js +84 -0
  47. data/app/assets/javascripts/koi/controllers/navigation_toggle_controller.js +7 -0
  48. data/app/assets/javascripts/koi/controllers/show_hide_controller.js +25 -0
  49. data/app/assets/javascripts/koi/controllers/sluggable_controller.js +30 -0
  50. data/app/assets/javascripts/koi/controllers/webauthn_authentication_controller.js +23 -0
  51. data/app/assets/javascripts/koi/controllers/webauthn_registration_controller.js +30 -0
  52. data/app/assets/javascripts/koi/utils/transition.js +220 -0
  53. data/app/assets/stylesheets/koi/admin.scss +27 -0
  54. data/app/assets/stylesheets/koi/base/_button.scss +122 -0
  55. data/app/assets/stylesheets/koi/base/_icon.scss +29 -0
  56. data/app/assets/stylesheets/koi/base/_index.scss +18 -0
  57. data/app/assets/stylesheets/koi/base/_input.scss +13 -0
  58. data/app/assets/stylesheets/koi/base/_link.scss +26 -0
  59. data/app/assets/stylesheets/koi/base/_list.scss +11 -0
  60. data/app/assets/stylesheets/koi/base/_typography.scss +160 -0
  61. data/app/assets/stylesheets/koi/components/_actions-group.scss +7 -0
  62. data/app/assets/stylesheets/koi/components/_image-field.scss +33 -0
  63. data/app/assets/stylesheets/koi/components/_index-actions.scss +69 -0
  64. data/app/assets/stylesheets/koi/components/_index-table.scss +91 -0
  65. data/app/assets/stylesheets/koi/components/_index.scss +6 -0
  66. data/app/assets/stylesheets/koi/components/_item-table.scss +33 -0
  67. data/app/assets/stylesheets/koi/components/_pagy.scss +41 -0
  68. data/app/assets/stylesheets/koi/layouts/_banner.scss +7 -0
  69. data/app/assets/stylesheets/koi/layouts/_content.scss +40 -0
  70. data/app/assets/stylesheets/koi/layouts/_flash.scss +41 -0
  71. data/app/assets/stylesheets/koi/layouts/_header.scss +62 -0
  72. data/app/assets/stylesheets/koi/layouts/_index.scss +48 -0
  73. data/app/assets/stylesheets/koi/layouts/_main.scss +23 -0
  74. data/app/assets/stylesheets/koi/layouts/_navigation.scss +156 -0
  75. data/app/assets/stylesheets/koi/layouts/_stack.scss +13 -0
  76. data/app/assets/stylesheets/koi/pages/_index.scss +1 -0
  77. data/app/assets/stylesheets/koi/pages/_login.scss +40 -0
  78. data/app/assets/stylesheets/koi/themes/_content.scss +5 -0
  79. data/app/assets/stylesheets/koi/themes/_govuk.scss +52 -0
  80. data/app/assets/stylesheets/koi/themes/_index.scss +5 -0
  81. data/app/assets/stylesheets/koi/themes/_kpop.scss +5 -0
  82. data/app/assets/stylesheets/koi/themes/_navigation.scss +5 -0
  83. data/app/assets/stylesheets/koi/themes/_trix.scss +32 -0
  84. data/app/assets/stylesheets/koi/utils/_breakpoints.scss +13 -0
  85. data/app/assets/stylesheets/koi/utils/_hide.scss +11 -0
  86. data/app/assets/stylesheets/koi/utils/_index.scss +2 -0
  87. data/app/assets/stylesheets/koi/utils/_typography.scss +24 -0
  88. data/app/components/koi/header/edit_component.rb +58 -0
  89. data/app/components/koi/header/index_component.rb +23 -0
  90. data/app/components/koi/header/new_component.rb +40 -0
  91. data/app/components/koi/header/show_component.rb +51 -0
  92. data/app/components/koi/header_component.html.erb +16 -0
  93. data/app/components/koi/header_component.rb +28 -0
  94. data/app/components/koi/index_table_component.rb +21 -0
  95. data/app/controllers/admin/admin_users_controller.rb +88 -0
  96. data/app/controllers/admin/application_controller.rb +9 -0
  97. data/app/controllers/admin/caches_controller.rb +11 -0
  98. data/app/controllers/admin/credentials_controller.rb +64 -0
  99. data/app/controllers/admin/dashboards_controller.rb +7 -0
  100. data/app/controllers/admin/sessions_controller.rb +78 -0
  101. data/app/controllers/admin/url_rewrites_controller.rb +87 -0
  102. data/app/controllers/concerns/koi/controller/has_admin_users.rb +49 -0
  103. data/app/controllers/concerns/koi/controller/has_webauthn.rb +45 -0
  104. data/app/controllers/concerns/koi/controller/is_admin_controller.rb +52 -0
  105. data/app/helpers/katalyst/content/editor/errors.rb +21 -0
  106. data/app/helpers/katalyst/navigation/editor/errors.rb +21 -0
  107. data/app/helpers/koi/application_helper.rb +7 -0
  108. data/app/helpers/koi/date_helper.rb +36 -0
  109. data/app/helpers/koi/definition_list_helper.rb +92 -0
  110. data/app/helpers/koi/index_actions_helper.rb +99 -0
  111. data/app/jobs/koi/application_job.rb +6 -0
  112. data/app/mailers/koi/application_mailer.rb +8 -0
  113. data/app/models/admin/credential.rb +14 -0
  114. data/app/models/admin/user.rb +51 -0
  115. data/app/models/application_record.rb +5 -0
  116. data/app/models/concerns/koi/model/archivable.rb +55 -0
  117. data/app/models/url_rewrite.rb +25 -0
  118. data/app/views/admin/admin_users/_admin.html+row.erb +4 -0
  119. data/app/views/admin/admin_users/_authentication.html.erb +15 -0
  120. data/app/views/admin/admin_users/_fields.html.erb +4 -0
  121. data/app/views/admin/admin_users/edit.html.erb +11 -0
  122. data/app/views/admin/admin_users/index.html.erb +9 -0
  123. data/app/views/admin/admin_users/new.html.erb +11 -0
  124. data/app/views/admin/admin_users/show.html.erb +22 -0
  125. data/app/views/admin/credentials/new.html.erb +14 -0
  126. data/app/views/admin/dashboards/show.html.erb +1 -0
  127. data/app/views/admin/sessions/new.html.erb +19 -0
  128. data/app/views/admin/shared/icons/_close.html.erb +8 -0
  129. data/app/views/admin/shared/icons/_cross.html.erb +3 -0
  130. data/app/views/admin/shared/icons/_menu.html.erb +3 -0
  131. data/app/views/admin/shared/icons/_refresh.html.erb +8 -0
  132. data/app/views/admin/url_rewrites/_form_fields.html.erb +3 -0
  133. data/app/views/admin/url_rewrites/_url_rewrite.html+row.erb +7 -0
  134. data/app/views/admin/url_rewrites/edit.html.erb +12 -0
  135. data/app/views/admin/url_rewrites/index.html.erb +10 -0
  136. data/app/views/admin/url_rewrites/new.html.erb +11 -0
  137. data/app/views/admin/url_rewrites/show.html.erb +16 -0
  138. data/app/views/katalyst/content/asides/_aside.html+form.erb +18 -0
  139. data/app/views/katalyst/content/columns/_column.html+form.erb +18 -0
  140. data/app/views/katalyst/content/contents/_content.html+form.erb +20 -0
  141. data/app/views/katalyst/content/figures/_figure.html+form.erb +17 -0
  142. data/app/views/katalyst/content/groups/_group.html+form.erb +18 -0
  143. data/app/views/katalyst/content/items/_item.html+form.erb +18 -0
  144. data/app/views/katalyst/content/sections/_section.html+form.erb +18 -0
  145. data/app/views/katalyst/navigation/items/_button.html.erb +15 -0
  146. data/app/views/katalyst/navigation/items/_heading.html.erb +11 -0
  147. data/app/views/katalyst/navigation/items/_link.html.erb +13 -0
  148. data/app/views/katalyst/navigation/menus/edit.html.erb +12 -0
  149. data/app/views/katalyst/navigation/menus/new.html.erb +9 -0
  150. data/app/views/katalyst/navigation/menus/show.html.erb +18 -0
  151. data/app/views/layouts/koi/_environment.html.erb +4 -0
  152. data/app/views/layouts/koi/_flash.html.erb +8 -0
  153. data/app/views/layouts/koi/_header.html.erb +11 -0
  154. data/app/views/layouts/koi/_navigation.html.erb +13 -0
  155. data/app/views/layouts/koi/_navigation_collapse.html.erb +3 -0
  156. data/app/views/layouts/koi/_navigation_header.html.erb +6 -0
  157. data/app/views/layouts/koi/_navigation_item.html.erb +12 -0
  158. data/app/views/layouts/koi/application.html.erb +59 -0
  159. data/app/views/layouts/koi/login.html.erb +29 -0
  160. data/config/importmap.rb +9 -0
  161. data/config/initializers/flipper.rb +13 -0
  162. data/config/initializers/pagy.rb +1 -0
  163. data/config/initializers/time_formats.rb +5 -0
  164. data/config/locales/koi.en.yml +18 -0
  165. data/config/locales/pagy.en.yml +6 -0
  166. data/config/routes.rb +25 -0
  167. data/db/migrate/20120220130849_devise_create_admins.rb +56 -0
  168. data/db/migrate/20130509235316_add_url_rewriter.rb +13 -0
  169. data/db/migrate/20230213053854_convert_devise_admins_to_rails.rb +7 -0
  170. data/db/migrate/20230412023411_create_admin_user_credentials.rb +20 -0
  171. data/db/migrate/20230531063707_update_admin_users.rb +37 -0
  172. data/db/migrate/20230602033610_add_archived_to_admin_users.rb +7 -0
  173. data/db/seeds.rb +9 -0
  174. data/lib/generators/koi/active_record/active_record_generator.rb +43 -0
  175. data/lib/generators/koi/admin/USAGE +8 -0
  176. data/lib/generators/koi/admin/admin_generator.rb +20 -0
  177. data/lib/generators/koi/admin_controller/USAGE +17 -0
  178. data/lib/generators/koi/admin_controller/admin_controller_generator.rb +51 -0
  179. data/lib/generators/koi/admin_controller/templates/controller.rb.tt +81 -0
  180. data/lib/generators/koi/admin_controller/templates/controller_spec.rb.tt +135 -0
  181. data/lib/generators/koi/admin_route/admin_route_generator.rb +62 -0
  182. data/lib/generators/koi/admin_views/USAGE +12 -0
  183. data/lib/generators/koi/admin_views/admin_views_generator.rb +54 -0
  184. data/lib/generators/koi/admin_views/templates/_fields.html.erb.tt +3 -0
  185. data/lib/generators/koi/admin_views/templates/_record.html+row.erb.tt +10 -0
  186. data/lib/generators/koi/admin_views/templates/edit.html.erb.tt +12 -0
  187. data/lib/generators/koi/admin_views/templates/index.html.erb.tt +7 -0
  188. data/lib/generators/koi/admin_views/templates/new.html.erb.tt +11 -0
  189. data/lib/generators/koi/admin_views/templates/show.html.erb.tt +18 -0
  190. data/lib/govuk_design_system_formbuilder/concerns/file_element.rb +115 -0
  191. data/lib/govuk_design_system_formbuilder/elements/document.rb +59 -0
  192. data/lib/govuk_design_system_formbuilder/elements/image.rb +86 -0
  193. data/lib/katalyst/koi.rb +3 -0
  194. data/lib/koi/caching.rb +15 -0
  195. data/lib/koi/config.rb +11 -0
  196. data/lib/koi/engine.rb +40 -0
  197. data/lib/koi/form_builder.rb +76 -0
  198. data/lib/koi/menu/builder.rb +68 -0
  199. data/lib/koi/menu.rb +46 -0
  200. data/lib/koi/middleware/url_redirect.rb +44 -0
  201. data/lib/koi/release.rb +52 -0
  202. data/lib/koi/version.rb +5 -0
  203. data/lib/koi.rb +37 -0
  204. data/spec/factories/admins.rb +9 -0
  205. data/spec/factories/url_rewrites.rb +9 -0
  206. metadata +430 -0
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 13">
2
+ <path d="M.541 0l11.125 12.573a.5.5 0 00.749 0L23.541 0h-23z" fill="#000" fill-rule="evenodd"/>
3
+ </svg>
@@ -0,0 +1,4 @@
1
+ import "koi/controllers";
2
+
3
+ /** Let GOVUK know that we've got JS enabled */
4
+ document.body.classList.add("js-enabled");
@@ -0,0 +1,11 @@
1
+ import { Application } from "@hotwired/stimulus";
2
+
3
+ // Stimulus controllers. This should ultimately be moved to koi/admin.js
4
+ import "@hotwired/turbo-rails";
5
+ import "@rails/actiontext";
6
+
7
+ const application = Application.start();
8
+
9
+ window.Stimulus = application;
10
+
11
+ export { application };
@@ -0,0 +1,26 @@
1
+ import FileFieldController from "koi/controllers/file_field_controller";
2
+
3
+ export default class DocumentFieldController extends FileFieldController {
4
+ connect() {
5
+ this.initialPreviewContent = this.filenameTag.text;
6
+ }
7
+
8
+ setPreviewContent(content) {
9
+ this.filenameTag.innerText = content;
10
+ }
11
+
12
+ showPreview(file) {
13
+ const reader = new FileReader();
14
+
15
+ reader.onload = (e) => {
16
+ if (this.filenameTag) {
17
+ this.filenameTag.innerText = file.name;
18
+ }
19
+ };
20
+ reader.readAsDataURL(file);
21
+ }
22
+
23
+ get filenameTag() {
24
+ return this.previewTarget.querySelector("p.preview-filename");
25
+ }
26
+ }
@@ -0,0 +1,143 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class FileFieldController extends Controller {
4
+ static targets = ["preview", "destroy"];
5
+ static values = {
6
+ mimeTypes: Array,
7
+ };
8
+
9
+ connect() {
10
+ this.counter = 0;
11
+ this.initialPreviewContent = null;
12
+ this.onUploadFlag = false;
13
+ }
14
+
15
+ onUpload(event) {
16
+ this.onUploadFlag = true;
17
+
18
+ // Set the file to be destroyed only if it is already persisted
19
+ if (this.hasDestroyTarget) {
20
+ this.destroyTarget.value = false;
21
+ }
22
+ this.previewTarget.classList.remove("hidden");
23
+
24
+ // Show preview only if a file has been selected in the file picker popup. If cancelled, show previous file or do
25
+ // not show preview at all
26
+ if (this.hasPreviewTarget) {
27
+ if (event.currentTarget.files.length > 0) {
28
+ this.showPreview(event.currentTarget.files[0]);
29
+ } else {
30
+ this.setPreviewContent(this.initialPreviewContent);
31
+ }
32
+ }
33
+ }
34
+
35
+ setDestroy(event) {
36
+ event.preventDefault();
37
+
38
+ // If the data is already persisted and another image has been picked from the file picker popup, but the new image
39
+ // is removed, show the original image
40
+ if (this.initialPreviewContent && this.onUploadFlag) {
41
+ this.onUploadFlag = false;
42
+ this.setPreviewContent(this.initialPreviewContent);
43
+ } else {
44
+ // Set image to be destroyed, hide preview and remove image url
45
+ if (this.hasDestroyTarget) {
46
+ this.destroyTarget.value = true;
47
+ }
48
+ if (this.hasPreviewTarget) {
49
+ this.previewTarget.classList.add("hidden");
50
+ this.setPreviewContent("");
51
+ }
52
+ }
53
+
54
+ this.fileInput.value = "";
55
+ }
56
+
57
+ setPreviewContent(content) {
58
+ if (this.filenameTag) {
59
+ this.filenameTag.innerText = text;
60
+ }
61
+ }
62
+
63
+ drop(event) {
64
+ event.preventDefault();
65
+
66
+ const file = this.fileForEvent(event, this.mimeTypesValue);
67
+ if (file) {
68
+ const dT = new DataTransfer();
69
+ dT.items.add(file);
70
+ this.fileInput.files = dT.files;
71
+ this.fileInput.dispatchEvent(new Event("change"));
72
+ }
73
+
74
+ this.counter = 0;
75
+ this.element.classList.remove("droppable");
76
+ }
77
+
78
+ dragover(event) {
79
+ event.preventDefault();
80
+ }
81
+
82
+ dragenter(event) {
83
+ event.preventDefault();
84
+
85
+ if (this.counter === 0) {
86
+ this.element.classList.add("droppable");
87
+ }
88
+ this.counter++;
89
+ }
90
+
91
+ dragleave(event) {
92
+ event.preventDefault();
93
+
94
+ this.counter--;
95
+ if (this.counter === 0) {
96
+ this.element.classList.remove("droppable");
97
+ }
98
+ }
99
+
100
+ get fileInput() {
101
+ return this.element.querySelector("input[type='file']");
102
+ }
103
+
104
+ get filenameTag() {
105
+ if (!this.hasPreviewTarget) return null;
106
+
107
+ return this.previewTarget.querySelector("p.preview-filename");
108
+ }
109
+
110
+ showPreview(file) {
111
+ const reader = new FileReader();
112
+
113
+ reader.onload = (e) => {
114
+ if (this.filenameTag) {
115
+ this.filenameTag.innerText = file.name;
116
+ }
117
+ };
118
+ reader.readAsDataURL(file);
119
+ }
120
+
121
+ /**
122
+ * Given a drop event, find the first acceptable file.
123
+ * @param event {DropEvent}
124
+ * @param mimeTypes {String[]}
125
+ * @returns {File}
126
+ */
127
+ fileForEvent(event, mimeTypes) {
128
+ const accept = (file) => mimeTypes.indexOf(file.type) > -1;
129
+
130
+ let file;
131
+
132
+ if (event.dataTransfer.items) {
133
+ const item = [...event.dataTransfer.items].find(accept);
134
+ if (item) {
135
+ file = item.getAsFile();
136
+ }
137
+ } else {
138
+ file = [...event.dataTransfer.files].find(accept);
139
+ }
140
+
141
+ return file;
142
+ }
143
+ }
@@ -0,0 +1,12 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class FlashController extends Controller {
4
+ close(e) {
5
+ e.target.closest("li").remove();
6
+
7
+ // remove the flash container if there are no more flashes
8
+ if (this.element.children.length === 0) {
9
+ this.element.remove();
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,11 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ /**
4
+ A stimulus controller to request form submissions.
5
+ This controller should be attached to a form element.
6
+ */
7
+ export default class extends Controller {
8
+ requestSubmit() {
9
+ this.element.requestSubmit();
10
+ }
11
+ }
@@ -0,0 +1,24 @@
1
+ import FileFieldController from "koi/controllers/file_field_controller";
2
+
3
+ export default class ImageFieldController extends FileFieldController {
4
+ connect() {
5
+ this.initialPreviewContent = this.imageTag.getAttribute("src");
6
+ }
7
+
8
+ setPreviewContent(content) {
9
+ this.imageTag.src = content;
10
+ }
11
+
12
+ showPreview(file) {
13
+ const reader = new FileReader();
14
+
15
+ reader.onload = (e) => {
16
+ this.imageTag.src = e.target.result;
17
+ };
18
+ reader.readAsDataURL(file);
19
+ }
20
+
21
+ get imageTag() {
22
+ return this.previewTarget.querySelector("img");
23
+ }
24
+ }
@@ -0,0 +1,6 @@
1
+ import { application } from "koi/controllers/application";
2
+
3
+ // Eager load all controllers defined in the import map under controllers/**/*_controller
4
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading";
5
+ eagerLoadControllersFrom("controllers", application);
6
+ eagerLoadControllersFrom("koi/controllers", application);
@@ -0,0 +1,61 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class IndexActionsController extends Controller {
4
+ static targets = ["create", "search", "sort"];
5
+
6
+ initialize() {
7
+ // debounce search
8
+ this.update = debounce(this, this.update);
9
+ }
10
+
11
+ disconnect() {
12
+ clearTimeout(this.timer);
13
+ }
14
+
15
+ create() {
16
+ this.createTarget.click();
17
+ }
18
+
19
+ search() {
20
+ this.searchTarget.focus();
21
+ }
22
+
23
+ clear() {
24
+ this.searchTarget.value = "";
25
+ this.searchTarget.closest("form").requestSubmit();
26
+ }
27
+
28
+ update() {
29
+ this.searchTarget.closest("form").requestSubmit();
30
+ }
31
+
32
+ submit() {
33
+ if (this.searchTarget.value === "") {
34
+ this.searchTarget.disabled = true;
35
+ }
36
+ if (this.sortTarget.value === "") {
37
+ this.sortTarget.disabled = true;
38
+ }
39
+ setTimeout(() => {
40
+ this.searchTarget.disabled = false;
41
+ this.sortTarget.disabled = false;
42
+ });
43
+ }
44
+
45
+ nextPage() {
46
+ this.element.parentElement.querySelector(".pagination .next a").click();
47
+ }
48
+
49
+ prevPage() {
50
+ this.element.parentElement.querySelector(".pagination .prev a").click();
51
+ }
52
+ }
53
+
54
+ function debounce(self, f) {
55
+ return (...args) => {
56
+ clearTimeout(self.timer);
57
+ self.timer = setTimeout(() => {
58
+ f.apply(self, ...args);
59
+ }, 300);
60
+ };
61
+ }
@@ -0,0 +1,149 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ const DEBUG = false;
4
+
5
+ export default class KeyboardController extends Controller {
6
+ static values = {
7
+ mapping: String,
8
+ depth: { type: Number, default: 2 },
9
+ };
10
+
11
+ event(cause) {
12
+ if (isFormField(cause.target) || this.#ignore(cause)) return;
13
+
14
+ const key = this.describeEvent(cause);
15
+
16
+ this.buffer = [...(this.buffer || []), key].slice(0 - this.depthValue);
17
+
18
+ if (DEBUG) console.debug("[keyboard] buffer:", ...this.buffer);
19
+
20
+ // test whether the tail of the buffer matches any of the configured chords
21
+ const action = this.buffer.reduceRight((mapping, key) => {
22
+ if (typeof mapping === "string" || typeof mapping === "undefined") {
23
+ return mapping;
24
+ } else {
25
+ return mapping[key];
26
+ }
27
+ }, this.mappings);
28
+
29
+ // if we don't have a string we may have a miss or an incomplete chord
30
+ if (typeof action !== "string") return;
31
+
32
+ // clear the buffer and prevent the key from being consumed elsewhere
33
+ this.buffer = [];
34
+ cause.preventDefault();
35
+
36
+ if (DEBUG) console.debug("[keyboard] event: %s", action);
37
+
38
+ // fire the configured event
39
+ const event = new CustomEvent(action, {
40
+ detail: { cause: cause },
41
+ bubbles: true,
42
+ });
43
+ cause.target.dispatchEvent(event);
44
+ }
45
+
46
+ /**
47
+ * @param event KeyboardEvent input event to describe
48
+ * @return String description of keyboard event, e.g. 'C-KeyV' (CTRL+V)
49
+ */
50
+ describeEvent(event) {
51
+ return [
52
+ event.ctrlKey && "C",
53
+ event.metaKey && "M",
54
+ event.altKey && "A",
55
+ event.shiftKey && "S",
56
+ event.code,
57
+ ]
58
+ .filter((w) => w)
59
+ .join("-");
60
+ }
61
+
62
+ /**
63
+ * Build a tree for efficiently looking up key chords, where the last key in the sequence
64
+ * is the first key in tree.
65
+ */
66
+ get mappings() {
67
+ const inputs = this.mappingValue
68
+ .replaceAll(/\s+/g, " ")
69
+ .split(" ")
70
+ .filter((f) => f.length > 0);
71
+ const mappings = {};
72
+
73
+ inputs.forEach((mapping) => this.#parse(mappings, mapping));
74
+
75
+ // memoize the result
76
+ Object.defineProperty(this, "mappings", {
77
+ value: mappings,
78
+ writable: false,
79
+ });
80
+
81
+ return mappings;
82
+ }
83
+
84
+ /**
85
+ * Parse a key chord pattern and an event and store it in the inverted tree lookup structure.
86
+ *
87
+ * @param mappings inverted tree lookup for key chords
88
+ * @param mapping input definition, e.g. "C-KeyC+C-KeyV->paste"
89
+ */
90
+ #parse(mappings, mapping) {
91
+ const [pattern, event] = mapping.split("->");
92
+ const keys = pattern.split("+");
93
+ const first = keys.shift();
94
+
95
+ mappings = keys.reduceRight(
96
+ (mappings, key) => (mappings[key] ||= {}),
97
+ mappings,
98
+ );
99
+ mappings[first] = event;
100
+ }
101
+
102
+ /**
103
+ * Ignore modifier keys, as they will be captured in normal key presses.
104
+ *
105
+ * @param event KeyboardEvent
106
+ * @returns {boolean} true if key event should be ignored
107
+ */
108
+ #ignore(event) {
109
+ switch (event.code) {
110
+ case "ControlLeft":
111
+ case "ControlRight":
112
+ case "MetaLeft":
113
+ case "MetaRight":
114
+ case "ShiftLeft":
115
+ case "ShiftRight":
116
+ case "AltLeft":
117
+ case "AltRight":
118
+ return true;
119
+ default:
120
+ return false;
121
+ }
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Detect input nodes where we should not listen for events.
127
+ *
128
+ * Credit: github.com
129
+ */
130
+ function isFormField(element) {
131
+ if (!(element instanceof HTMLElement)) {
132
+ return false;
133
+ }
134
+
135
+ const name = element.nodeName.toLowerCase();
136
+ const type = (element.getAttribute("type") || "").toLowerCase();
137
+ return (
138
+ name === "select" ||
139
+ name === "textarea" ||
140
+ name === "trix-editor" ||
141
+ (name === "input" &&
142
+ type !== "submit" &&
143
+ type !== "reset" &&
144
+ type !== "checkbox" &&
145
+ type !== "radio" &&
146
+ type !== "file") ||
147
+ element.isContentEditable
148
+ );
149
+ }
@@ -0,0 +1,84 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class NavigationController extends Controller {
4
+ static targets = ["filter"];
5
+
6
+ focus() {
7
+ this.filterTarget.focus();
8
+ }
9
+
10
+ filter() {
11
+ const filter = this.filterTarget.value;
12
+ this.clearFilter(filter);
13
+
14
+ if (filter.length > 0) {
15
+ this.applyFilter(filter);
16
+ }
17
+ }
18
+
19
+ go() {
20
+ this.element.querySelector("li:not([hidden]) > a").click();
21
+ }
22
+
23
+ applyFilter(filter) {
24
+ // hide items that don't match the search filter
25
+ this.links
26
+ .filter(
27
+ (li) =>
28
+ !this.prefixSearch(filter.toLowerCase(), li.innerText.toLowerCase()),
29
+ )
30
+ .forEach((li) => {
31
+ li.toggleAttribute("hidden", true);
32
+ });
33
+
34
+ this.menus
35
+ .filter((li) => !li.matches("li:has(li:not([hidden]) > a)"))
36
+ .forEach((li) => {
37
+ li.toggleAttribute("hidden", true);
38
+ });
39
+ }
40
+
41
+ clearFilter(filter) {
42
+ this.element.querySelectorAll("li").forEach((li) => {
43
+ li.toggleAttribute("hidden", false);
44
+ });
45
+ }
46
+
47
+ prefixSearch(needle, haystack) {
48
+ const haystackLength = haystack.length;
49
+ const needleLength = needle.length;
50
+ if (needleLength > haystackLength) {
51
+ return false;
52
+ }
53
+ if (needleLength === haystackLength) {
54
+ return needle === haystack;
55
+ }
56
+ outer: for (let i = 0, j = 0; i < needleLength; i++) {
57
+ const needleChar = needle.charCodeAt(i);
58
+ if (needleChar === 32) {
59
+ // skip ahead to next space in the haystack
60
+ while (j < haystackLength && haystack.charCodeAt(j++) !== 32) {}
61
+ continue;
62
+ }
63
+ while (j < haystackLength) {
64
+ if (haystack.charCodeAt(j++) === needleChar) continue outer;
65
+ // skip ahead to the next space in the haystack
66
+ while (j < haystackLength && haystack.charCodeAt(j++) !== 32) {}
67
+ }
68
+ return false;
69
+ }
70
+ return true;
71
+ }
72
+
73
+ toggle() {
74
+ this.element.toggleAttribute("aria-expanded");
75
+ }
76
+
77
+ get links() {
78
+ return Array.from(this.element.querySelectorAll("li:has(> a)"));
79
+ }
80
+
81
+ get menus() {
82
+ return Array.from(this.element.querySelectorAll("li:has(> ul)"));
83
+ }
84
+ }
@@ -0,0 +1,7 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class NavigationToggleController extends Controller {
4
+ trigger() {
5
+ this.dispatch("toggle", { prefix: "navigation", bubbles: true });
6
+ }
7
+ }
@@ -0,0 +1,25 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import { Transition } from "koi/utils/transition";
3
+
4
+ export default class ShowHideController extends Controller {
5
+ static targets = ["content"];
6
+
7
+ toggle() {
8
+ const element = this.contentTarget;
9
+ const hide = element.toggleAttribute("data-collapsed");
10
+
11
+ // cancel previous animation, if any
12
+ if (this.transition) this.transition.cancel();
13
+
14
+ const transition = (this.transition = new Transition(element)
15
+ .addCallback("starting", function () {
16
+ element.setAttribute("data-collapsed-transitioning", "true");
17
+ })
18
+ .addCallback("complete", function () {
19
+ element.removeAttribute("data-collapsed-transitioning");
20
+ }));
21
+ hide ? transition.collapse() : transition.expand();
22
+
23
+ transition.start();
24
+ }
25
+ }
@@ -0,0 +1,30 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ /**
4
+ * Connect an input (e.g. title) to slug.
5
+ */
6
+ export default class SluggableController extends Controller {
7
+ static targets = ["source", "slug"];
8
+ static values = {
9
+ slug: String,
10
+ };
11
+
12
+ sourceChanged(e) {
13
+ if (this.slugValue === "") {
14
+ this.slugTarget.value = parameterize(this.sourceTarget.value);
15
+ }
16
+ }
17
+
18
+ slugChanged(e) {
19
+ this.slugValue = this.slugTarget.value;
20
+ }
21
+ }
22
+
23
+ function parameterize(input) {
24
+ return input
25
+ .toLowerCase()
26
+ .replace(/'/g, "-")
27
+ .replace(/[^-\w\s]/g, "")
28
+ .replace(/[^a-z0-9]+/g, "-")
29
+ .replace(/(^-|-$)/g, "");
30
+ }