katalyst-koi 4.0.0

Sign up to get free protection for your applications and to get access to all the features.
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
+ }