trek 2.2.0 → 2.3.1

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.slim-lint.yml +0 -3
  3. data/AGENTS.md +9 -2
  4. data/CHANGELOG.md +73 -0
  5. data/Gemfile +4 -0
  6. data/Gemfile.lock +23 -3
  7. data/Makefile +11 -9
  8. data/README.md +1 -1
  9. data/app/assets/icons/trek/chevron-down.svg +1 -0
  10. data/app/assets/icons/trek/chevron-up.svg +1 -0
  11. data/app/assets/icons/trek/external.svg +1 -0
  12. data/app/assets/icons/trek/theme-auto.svg +1 -0
  13. data/app/assets/icons/trek/theme-dark.svg +1 -0
  14. data/app/assets/icons/trek/theme-light.svg +1 -0
  15. data/app/assets/stylesheets/trek/base.css +13 -0
  16. data/app/components/trek/button_component/button_component.css +9 -0
  17. data/app/components/trek/button_component.rb +13 -1
  18. data/app/components/trek/dashboard/tile_component/tile_component.html.slim +1 -1
  19. data/app/components/trek/dashboard/tile_component.rb +14 -0
  20. data/app/components/trek/dialog_component/dialog_component.html.slim +6 -2
  21. data/app/components/trek/dialog_component/dialog_component.js +62 -2
  22. data/app/components/trek/dropdown_component/dropdown_component.css +69 -0
  23. data/app/components/trek/dropdown_component/dropdown_component.html.slim +31 -0
  24. data/app/components/trek/dropdown_component/dropdown_component.js +114 -0
  25. data/app/components/trek/dropdown_component.rb +34 -0
  26. data/app/components/trek/form/group_component/group_component.css +34 -0
  27. data/app/components/trek/header_component/header_component.css +9 -4
  28. data/app/components/trek/header_component/header_component.html.slim +17 -10
  29. data/app/components/trek/header_component/header_component.js +29 -5
  30. data/app/components/trek/header_component.rb +24 -3
  31. data/app/components/trek/menu_component/menu_component.css +6 -0
  32. data/app/components/trek/menu_component.rb +29 -4
  33. data/app/components/trek/toaster/toast_component.rb +1 -1
  34. data/app/controllers/concerns/trek/authorization.rb +12 -0
  35. data/app/controllers/concerns/trek/filters.rb +18 -1
  36. data/app/helpers/trek/page_path_helper.rb +4 -3
  37. data/app/helpers/trek/theme_helper.rb +16 -1
  38. data/app/models/concerns/trek/phonable.rb +15 -3
  39. data/app/models/concerns/trek/sectionable.rb +5 -0
  40. data/app/models/concerns/trek/users/validations.rb +15 -6
  41. data/app/uploaders/audio_uploader.rb +11 -0
  42. data/app/uploaders/record_image_uploader.rb +3 -0
  43. data/config/locales/trek.en.yml +3 -2
  44. data/config/locales/trek.fr.yml +1 -0
  45. data/docs/.vitepress/config.mjs +20 -1
  46. data/docs/.vitepress/theme/style.css +36 -8
  47. data/docs/changelog.md +9 -0
  48. data/docs/customize.md +18 -2
  49. data/docs/deploy.md +7 -2
  50. data/docs/getting-started.md +1 -1
  51. data/docs/index.md +8 -8
  52. data/docs/public/apple-touch-icon.png +0 -0
  53. data/docs/public/favicon.ico +0 -0
  54. data/docs/public/favicon.png +0 -0
  55. data/docs/public/trek-mark.webp +0 -0
  56. data/docs/reference/components.md +15 -0
  57. data/docs/reference/concerns/phonable.md +27 -1
  58. data/docs/reference/controllers.md +8 -3
  59. data/docs/reference/environment-variables.md +2 -0
  60. data/docs/reference/forms.md +2 -0
  61. data/docs/reference/generators/install.md +4 -1
  62. data/docs/reference/icons.md +1 -1
  63. data/docs/reference/models/tag.md +1 -1
  64. data/docs/reference/models/user.md +6 -4
  65. data/docs/reference/uploaders.md +31 -6
  66. data/docs/what-is-trek.md +1 -1
  67. data/lib/generators/trek/install/admin/routes_generator.rb +7 -1
  68. data/lib/generators/trek/install/appsignal_generator.rb +102 -0
  69. data/lib/generators/trek/install/model_specs_generator.rb +64 -0
  70. data/lib/generators/trek/install/postmark_generator.rb +3 -0
  71. data/lib/generators/trek/install/ruby_generator.rb +1 -1
  72. data/lib/generators/trek/install/scalingo_generator.rb +1 -1
  73. data/lib/generators/trek/install/uploaders_generator.rb +2 -0
  74. data/lib/generators/trek/install_generator.rb +5 -0
  75. data/lib/generators/trek/taxonomies_generator.rb +7 -0
  76. data/lib/generators/trek/templates/.gitlab-ci.yml +1 -1
  77. data/lib/generators/trek/templates/config/appsignal.rb.tt +25 -0
  78. data/lib/generators/trek/templates/controllers/admin/uploads_controller.rb +4 -0
  79. data/lib/generators/trek/templates/controllers/admin/user_password_resets_controller.rb +7 -0
  80. data/lib/generators/trek/templates/controllers/admin/users_controller.rb +4 -1
  81. data/lib/generators/trek/templates/controllers/concerns/appsignal_context.rb +37 -0
  82. data/lib/generators/trek/templates/db/seeds/setup/fragments.rb +3 -0
  83. data/lib/generators/trek/templates/db/seeds/setup/users.rb +6 -0
  84. data/lib/generators/trek/templates/db/seeds/test/cases/page_tree.rb +5 -0
  85. data/lib/generators/trek/templates/initializers/appsignal_context.rb +5 -0
  86. data/lib/generators/trek/templates/locales/scaffold/model.en.yml.tt +1 -1
  87. data/lib/generators/trek/templates/migrations/fragments.rb.erb +1 -1
  88. data/lib/generators/trek/templates/migrations/menu_nodes.rb.erb +0 -1
  89. data/lib/generators/trek/templates/migrations/tag_categories.rb.erb +1 -1
  90. data/lib/generators/trek/templates/migrations/taggings.rb.erb +1 -1
  91. data/lib/generators/trek/templates/migrations/users.rb.erb +2 -1
  92. data/lib/generators/trek/templates/models/tag.rb +6 -1
  93. data/lib/generators/trek/templates/policies/admin/tag_policy.rb +1 -1
  94. data/lib/generators/trek/templates/policies/external_link_policy.rb +1 -1
  95. data/lib/generators/trek/templates/spec/fixtures/files/image.png +0 -0
  96. data/lib/generators/trek/templates/spec/fixtures/files/sound.mp3 +0 -0
  97. data/lib/generators/trek/templates/spec/models/page_spec.rb +24 -0
  98. data/lib/generators/trek/templates/spec/models/user_spec.rb +109 -0
  99. data/lib/generators/trek/templates/spec/policies/admin/external_link_policy_spec.rb +17 -0
  100. data/lib/generators/trek/templates/spec/policies/admin/fragment_policy_spec.rb +33 -0
  101. data/lib/generators/trek/templates/spec/policies/admin/page_policy_spec.rb +39 -0
  102. data/lib/generators/trek/templates/spec/policies/admin/preview_policy_spec.rb +9 -0
  103. data/lib/generators/trek/templates/spec/policies/admin/tag_category_policy_spec.rb +31 -0
  104. data/lib/generators/trek/templates/spec/policies/admin/tag_policy_spec.rb +31 -0
  105. data/lib/generators/trek/templates/spec/policies/admin/user_policy_spec.rb +58 -0
  106. data/lib/generators/trek/templates/spec/requests/admin/fragments_spec.rb +50 -0
  107. data/lib/generators/trek/templates/spec/requests/admin/pages_spec.rb +47 -0
  108. data/lib/generators/trek/templates/spec/requests/admin/uploads_spec.rb +22 -0
  109. data/lib/generators/trek/templates/spec/requests/admin/user_password_resets_spec.rb +78 -0
  110. data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +51 -0
  111. data/lib/generators/trek/templates/spec/requests/admin/users_spec.rb +99 -0
  112. data/lib/generators/trek/templates/views/admin/user_password_resets/edit.html.slim +1 -1
  113. data/lib/generators/trek/templates/views/admin/user_password_resets/new.html.slim +1 -1
  114. data/lib/generators/trek/templates/views/admin/user_sessions/new.html.slim +2 -2
  115. data/lib/generators/trek/templates/views/admin/users/theme.turbo_stream.slim +2 -0
  116. data/lib/generators/trek/upgrade/v2_generator.rb +3 -0
  117. data/lib/tasks/trek/attachments.rake +18 -0
  118. data/lib/trek/cached_attachments.rb +53 -0
  119. data/lib/trek/saved_attachments_reload.rb +44 -0
  120. data/lib/trek/version.rb +1 -1
  121. data/lib/trek.rb +4 -0
  122. data/package.json +1 -1
  123. data/postcss.config.js +36 -0
  124. data/trek.gemspec +6 -0
  125. metadata +89 -3
  126. data/app/assets/icons/trek/theme.svg +0 -11
@@ -0,0 +1,69 @@
1
+ /* Use component-local class names and add them to HTML via #class_for(name) helper */
2
+
3
+ .root {
4
+ display: inline-flex;
5
+ position: relative;
6
+ }
7
+
8
+ /* Inherits its context's color and font, so it reads as part of the surrounding text or link (and
9
+ follows its hover color). Style the trigger itself, not the root, with `trigger_classnames:`. */
10
+ .trigger {
11
+ align-items: center;
12
+ background: none;
13
+ border: none;
14
+ color: inherit;
15
+ cursor: pointer;
16
+ display: inline-flex;
17
+ font: inherit;
18
+ gap: 0.4rem;
19
+ }
20
+
21
+ .indicator {
22
+ transition: transform 0.2s ease-in-out;
23
+ }
24
+
25
+ .trigger[aria-expanded="true"] .indicator {
26
+ transform: rotate(180deg);
27
+ }
28
+
29
+ .menu {
30
+ background-color: var(--slate-2);
31
+ border: 1px solid var(--slate-6);
32
+ border-radius: 0.8rem;
33
+ box-shadow: 0 4px 12px rgb(0 0 0 / 12%);
34
+ list-style: none;
35
+ margin: 0;
36
+ min-width: 14rem;
37
+ padding: 0.4rem;
38
+ position: absolute;
39
+ right: 0;
40
+ top: calc(100% + 0.4rem);
41
+ z-index: 10;
42
+ }
43
+
44
+ .menu[hidden] {
45
+ display: none;
46
+ }
47
+
48
+ .-item-root {
49
+ background: none;
50
+ border: none;
51
+ border-radius: 0.6rem;
52
+ color: var(--slate-12);
53
+ cursor: pointer;
54
+ display: inline-flex;
55
+ font-size: 1.3rem;
56
+ gap: 1rem;
57
+ line-height: 1.23;
58
+ padding: 0.8rem 1.2rem;
59
+ text-align: left;
60
+ text-decoration: none;
61
+ width: 100%;
62
+ }
63
+
64
+ .-item-root:hover,
65
+ .-item-root:focus {
66
+ background-color: var(--accent-3);
67
+ color: var(--accent-11);
68
+ outline: none;
69
+ }
@@ -0,0 +1,31 @@
1
+ div(class=classes data-controller=identifier)
2
+ button(
3
+ type="button"
4
+ class=trigger_classes
5
+ aria-haspopup="true"
6
+ aria-expanded="false"
7
+ aria-controls=menu_id
8
+ aria-label=label
9
+ data=stimulus_target_hash("trigger")
10
+ data-action=stimulus_action(click: :toggle, keydown: :on_trigger_keydown)
11
+ )
12
+ = trigger
13
+ - unless hide_chevron
14
+ = render Trek::IconComponent.new( \
15
+ "trek/chevron-down",
16
+ classnames: class_for("indicator"),
17
+ aria_hidden: true,
18
+ )
19
+ ul(
20
+ id=menu_id
21
+ class=class_for("menu")
22
+ role="menu"
23
+ aria-label=label
24
+ hidden=true
25
+ data=stimulus_target_hash("menu")
26
+ data-action=stimulus_action( \
27
+ keydown: :on_menu_keydown,
28
+ click: :on_menu_click,
29
+ )
30
+ )
31
+ = safe_join(items)
@@ -0,0 +1,114 @@
1
+ import "./dropdown_component.css";
2
+
3
+ import { Controller as BaseController } from "@hotwired/stimulus";
4
+
5
+ export class Controller extends BaseController {
6
+ static targets = ["trigger", "menu"];
7
+
8
+ connect() {
9
+ this.onDocumentClick = this.onDocumentClick.bind(this);
10
+ }
11
+
12
+ disconnect() {
13
+ document.removeEventListener("click", this.onDocumentClick);
14
+ }
15
+
16
+ get items() {
17
+ return Array.from(this.menuTarget.querySelectorAll('[role="menuitem"]'));
18
+ }
19
+
20
+ get expanded() {
21
+ return this.triggerTarget.getAttribute("aria-expanded") === "true";
22
+ }
23
+
24
+ toggle(event) {
25
+ event.preventDefault();
26
+ this.expanded ? this.collapse() : this.open();
27
+ }
28
+
29
+ onTriggerKeydown(event) {
30
+ switch (event.key) {
31
+ case "ArrowDown":
32
+ case "Enter":
33
+ case " ":
34
+ event.preventDefault();
35
+ this.open();
36
+ this.focusItem(0);
37
+ break;
38
+ case "ArrowUp":
39
+ event.preventDefault();
40
+ this.open();
41
+ this.focusItem(this.items.length - 1);
42
+ break;
43
+ }
44
+ }
45
+
46
+ onMenuKeydown(event) {
47
+ const items = this.items;
48
+ const current = items.indexOf(document.activeElement);
49
+ switch (event.key) {
50
+ case "ArrowDown":
51
+ event.preventDefault();
52
+ this.focusItem((current + 1) % items.length);
53
+ break;
54
+ case "ArrowUp":
55
+ event.preventDefault();
56
+ this.focusItem((current - 1 + items.length) % items.length);
57
+ break;
58
+ case "Home":
59
+ event.preventDefault();
60
+ this.focusItem(0);
61
+ break;
62
+ case "End":
63
+ event.preventDefault();
64
+ this.focusItem(items.length - 1);
65
+ break;
66
+ case "Escape":
67
+ event.preventDefault();
68
+ this.collapse();
69
+ this.triggerTarget.focus();
70
+ break;
71
+ case "Enter":
72
+ case " ":
73
+ // Menu items are not native buttons: activate the focused one as a
74
+ // click so it runs its own action, then onMenuClick closes the menu.
75
+ if (current === -1) break;
76
+ event.preventDefault();
77
+ items[current].click();
78
+ break;
79
+ case "Tab":
80
+ this.collapse();
81
+ break;
82
+ }
83
+ }
84
+
85
+ onMenuClick(event) {
86
+ if (event.target.closest('[role="menuitem"]')) {
87
+ this.collapse();
88
+ this.triggerTarget.focus();
89
+ }
90
+ }
91
+
92
+ open() {
93
+ if (this.expanded) return;
94
+ this.menuTarget.hidden = false;
95
+ this.triggerTarget.setAttribute("aria-expanded", "true");
96
+ document.addEventListener("click", this.onDocumentClick);
97
+ }
98
+
99
+ collapse() {
100
+ if (!this.expanded) return;
101
+ this.menuTarget.hidden = true;
102
+ this.triggerTarget.setAttribute("aria-expanded", "false");
103
+ document.removeEventListener("click", this.onDocumentClick);
104
+ }
105
+
106
+ focusItem(index) {
107
+ const item = this.items[index];
108
+ if (item) item.focus();
109
+ }
110
+
111
+ onDocumentClick(event) {
112
+ if (!this.element.contains(event.target)) this.collapse();
113
+ }
114
+ }
@@ -0,0 +1,34 @@
1
+ module Trek
2
+ class DropdownComponent < Trek::Component
3
+ option :label, optional: true # accessible name (aria-label on trigger + menu)
4
+ option :classnames, optional: true # extra classes on root
5
+ option :trigger_classnames, optional: true # extra classes on the trigger button, the clickable element
6
+ option :hide_chevron, default: proc { false }
7
+
8
+ renders_one :trigger # inner content of the trigger button (e.g. an icon)
9
+ renders_many :items, "ItemComponent"
10
+
11
+ def classes
12
+ class_names_for(root_class, classnames)
13
+ end
14
+
15
+ def trigger_classes
16
+ class_names_for(class_for("trigger"), trigger_classnames)
17
+ end
18
+
19
+ def menu_id
20
+ @menu_id ||= "#{identifier}-menu-#{SecureRandom.hex(4)}"
21
+ end
22
+
23
+ class ItemComponent < Trek::Component
24
+ option :data, default: -> { {} }
25
+
26
+ def call
27
+ attrs = { class: class_for("root"), role: "menuitem", tabindex: -1, data: }
28
+ tag.li(**attrs) do
29
+ content
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -92,6 +92,40 @@
92
92
  color: var(--slate-12);
93
93
  }
94
94
 
95
+ .root input[type="file"] {
96
+ color: var(--slate-12);
97
+ font-size: 1.4rem;
98
+ }
99
+
100
+ /* Mirrors Trek::ButtonComponent with size: :narrow — keep in sync with
101
+ button_component/button_component.css (the scoped class can't be reused here) */
102
+ .root input[type="file"]::file-selector-button {
103
+ appearance: none;
104
+ background-color: transparent;
105
+ border: 1px solid var(--slate-6);
106
+ border-radius: 0.8rem;
107
+ color: var(--slate-12);
108
+ cursor: pointer;
109
+ font-size: 1.6rem;
110
+ font-weight: var(--semibold);
111
+ line-height: 1.25;
112
+ margin-right: 1.6rem;
113
+ padding: 1rem 2.8rem;
114
+ transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out,
115
+ color 0.2s ease-in-out;
116
+ }
117
+
118
+ .root input[type="file"]::file-selector-button:hover {
119
+ background-color: var(--accent-3);
120
+ border-color: var(--accent-5);
121
+ }
122
+
123
+ .root input[type="file"]::file-selector-button:active {
124
+ background-color: var(--accent-4);
125
+ border-color: var(--accent-6);
126
+ color: var(--accent-12);
127
+ }
128
+
95
129
  .root input:disabled,
96
130
  .root select:disabled,
97
131
  .root textarea:disabled {
@@ -40,6 +40,7 @@
40
40
  align-items: center;
41
41
  border-radius: 0.8rem;
42
42
  display: flex;
43
+ gap: 0.8rem;
43
44
  padding: 1rem 1.6rem;
44
45
  transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out;
45
46
  }
@@ -52,10 +53,6 @@
52
53
  cursor: pointer;
53
54
  }
54
55
 
55
- .nav-item-link .nav-item-icon {
56
- margin-left: 0.8rem;
57
- }
58
-
59
56
  .nav-item-link:hover,
60
57
  .nav-item-button:hover {
61
58
  background-color: var(--accent-3);
@@ -69,6 +66,14 @@
69
66
  text-overflow: ellipsis;
70
67
  }
71
68
 
69
+ /* The theme dropdown's trigger: a nav item button (the button itself, so the whole pill is clickable),
70
+ with a larger icon and less vertical padding. */
71
+ .nav-theme-trigger {
72
+ --icon-size: 2rem;
73
+
74
+ padding: 0.8rem 1.6rem;
75
+ }
76
+
72
77
  /* Media queries */
73
78
 
74
79
  @media (max-width: 768px) {
@@ -1,4 +1,5 @@
1
- div(class=classes data-controller=identifier)
1
+ div(class=classes data-controller=identifier \
2
+ data=stimulus_value_hash("url", theme_admin_users_path))
2
3
  = render Trek::BrandComponent.new
3
4
  nav(class=class_for("nav"))
4
5
  ul(class=class_for("nav-items"))
@@ -31,12 +32,18 @@ div(class=classes data-controller=identifier)
31
32
  "trek/profile",
32
33
  classnames: class_for("nav-item-icon"),
33
34
  )
34
- li(class=class_for("nav-item"))
35
- button(
36
- class=class_for("nav-item-button")
37
- data-action=stimulus_action("toggleTheme")
38
- )
39
- = render Trek::IconComponent.new( \
40
- "trek/theme",
41
- classnames: class_for("nav-item-icon"),
42
- )
35
+ li(class=class_for("nav-item"))
36
+ = render Trek::DropdownComponent.new( \
37
+ label: User.human_attribute_name(:theme),
38
+ trigger_classnames: theme_trigger_classes,
39
+ hide_chevron: true,
40
+ ) do |dropdown|
41
+ - dropdown.with_trigger do
42
+ = helpers.theme_icon(current_user.theme)
43
+ - %w[auto light dark].each do |theme|
44
+ = dropdown.with_item(data: theme_item_data(theme)) do
45
+ = render Trek::IconComponent.new( \
46
+ "trek/theme-#{theme}",
47
+ classnames: class_for("nav-item-icon"),
48
+ )
49
+ span = t("user.theme.#{theme}")
@@ -3,11 +3,35 @@ import "./header_component.css";
3
3
  import { Controller as BaseController } from "@hotwired/stimulus";
4
4
 
5
5
  export class Controller extends BaseController {
6
- toggleTheme() {
7
- const dark = document.documentElement.classList.toggle("dark-theme");
8
- document.documentElement.classList.toggle("light-theme");
6
+ static values = { url: String };
9
7
 
10
- // TODO: save theme to user account
11
- console.log(dark ? "dark" : "light");
8
+ setTheme(event) {
9
+ const theme = event.currentTarget.dataset.theme;
10
+ if (!theme) return;
11
+
12
+ const root = document.documentElement;
13
+ root.classList.remove("auto-theme", "light-theme", "dark-theme");
14
+ root.classList.add(`${theme}-theme`);
15
+
16
+ this.persist(theme);
17
+ }
18
+
19
+ async persist(theme) {
20
+ if (!this.hasUrlValue) return;
21
+
22
+ const token = document.querySelector('meta[name="csrf-token"]')?.content;
23
+ const response = await fetch(this.urlValue, {
24
+ method: "PATCH",
25
+ headers: {
26
+ "Content-Type": "application/json",
27
+ "X-CSRF-Token": token,
28
+ Accept: "text/vnd.turbo-stream.html",
29
+ },
30
+ body: JSON.stringify({ theme }),
31
+ });
32
+
33
+ // A manual fetch is not processed by Turbo automatically; render the
34
+ // returned stream ourselves to refresh the server-rendered theme icon.
35
+ if (response.ok) window.Turbo.renderStreamMessage(await response.text());
12
36
  }
13
37
  }
@@ -4,6 +4,7 @@ module Trek
4
4
  class HeaderComponent < Trek::Component
5
5
  extend Dry::Initializer
6
6
  include ActionPolicy::Behaviour
7
+ include Trek::PagePathHelper
7
8
 
8
9
  option :current_object, optional: true
9
10
  option :current_user, optional: true
@@ -25,14 +26,36 @@ module Trek
25
26
  current_object&.persisted? && (
26
27
  allowed_to?(:preview?, current_object, namespace: Admin, strict_namespace: true) ||
27
28
  (current_object_page? || current_object.respond_to?(:page))
28
- )
29
+ ) && preview_path.present?
30
+ end
31
+
32
+ def theme_trigger_classes
33
+ class_names_for("nav-item-button", "nav-theme-trigger", map_args: true)
34
+ end
35
+
36
+ def theme_item_data(theme)
37
+ { action: stimulus_action("click", "setTheme"), theme: }
29
38
  end
30
39
 
31
40
  def can_preview_home?
32
41
  Admin::PreviewPolicy.new(user: current_user).apply(:home?)
33
42
  end
34
43
 
44
+ # Where the current object shows on the website, or nil when the app has no front-office route
45
+ # for it (a freshly generated app has none): the link is then left out rather than raising.
35
46
  def preview_path
47
+ return @preview_path if defined?(@preview_path)
48
+
49
+ @preview_path = website_path
50
+ rescue NoMethodError => e
51
+ raise unless e.receiver.equal?(self) && e.name.to_s.end_with?("_path", "_url")
52
+
53
+ @preview_path = nil
54
+ end
55
+
56
+ private
57
+
58
+ def website_path
36
59
  return current_object unless defined?(Page) && defined?(PageVersion)
37
60
 
38
61
  case current_object
@@ -50,8 +73,6 @@ module Trek
50
73
  end
51
74
  end
52
75
 
53
- private
54
-
55
76
  def current_object_page?
56
77
  defined?(Page) && current_object.is_a?(Page)
57
78
  end
@@ -38,6 +38,12 @@
38
38
  padding: 1rem 2.4rem;
39
39
  }
40
40
 
41
+ .-section--item-icon {
42
+ --icon-size: 1.2rem;
43
+
44
+ margin-left: 0.6rem;
45
+ }
46
+
41
47
  .-section--item-link:hover {
42
48
  background-color: var(--accent-3);
43
49
  color: var(--accent-11);
@@ -62,13 +62,16 @@ module Trek
62
62
  # against it, a boolean forces the state. By default the item is active when its href
63
63
  # matches the current controller.
64
64
  option :active, optional: true
65
+ option :target, optional: true
65
66
 
66
67
  def call
67
68
  tag.li(
68
69
  link_to(
69
- text,
70
+ rendered_text,
70
71
  href,
71
- class: class_for("link")
72
+ class: class_for("link"),
73
+ target:,
74
+ rel:
72
75
  ),
73
76
  class: class_names_for(
74
77
  root_class,
@@ -83,6 +86,24 @@ module Trek
83
86
 
84
87
  private
85
88
 
89
+ def rendered_text
90
+ safe_join([text, icon].compact)
91
+ end
92
+
93
+ def icon
94
+ return unless new_tab?
95
+
96
+ render IconComponent.new("trek/external", classnames: class_for("icon"))
97
+ end
98
+
99
+ def rel
100
+ "noopener" if new_tab?
101
+ end
102
+
103
+ def new_tab?
104
+ target == "_blank"
105
+ end
106
+
86
107
  def active?
87
108
  case active
88
109
  when nil then href_path == controller_path
@@ -93,8 +114,12 @@ module Trek
93
114
  end
94
115
 
95
116
  def href_path
96
- Array.wrap(href).map do |part|
97
- part.respond_to?(:model_name) ? part.model_name.collection : part
117
+ Array.wrap(href).inject([]) do |parts, part|
118
+ segments = part.respond_to?(:model_name) ? part.model_name.collection.split("/") : [part.to_s]
119
+ # A namespaced model's collection already contains the namespace symbols the
120
+ # route helper needs before it — drop the overlap instead of doubling it.
121
+ overlap = (segments.length - 1).downto(0).find { |n| parts.last(n) == segments.first(n) }
122
+ parts + segments.drop(overlap)
98
123
  end.join("/").sub("root", "dashboards")
99
124
  end
100
125
  end
@@ -52,7 +52,7 @@ module Trek
52
52
  def icon
53
53
  return if target != "_blank"
54
54
 
55
- render IconComponent.new("external", classnames: class_for("icon"))
55
+ render IconComponent.new("trek/external", classnames: class_for("icon"))
56
56
  end
57
57
 
58
58
  def params
@@ -4,6 +4,8 @@ module Trek
4
4
 
5
5
  included do
6
6
  helper_method :policy_for, :policy_permitted_attribute?
7
+
8
+ rescue_from ActionPolicy::Unauthorized, with: :deny_access
7
9
  end
8
10
 
9
11
  private
@@ -11,5 +13,15 @@ module Trek
11
13
  def policy_permitted_attribute?(name)
12
14
  policy_for(record: @object).permitted_attribute?(name)
13
15
  end
16
+
17
+ # A signed-in user opening what their role doesn't allow (an editor on the users panel…) goes back
18
+ # where they came from, or to the dashboard, with an alert, rather than to an error page. Other
19
+ # formats (Turbo Streams, JSON) get a plain 403.
20
+ def deny_access
21
+ respond_to do |format|
22
+ format.html { redirect_back_or_to admin_root_path, alert: t("notices.unauthorized") }
23
+ format.any { head :forbidden }
24
+ end
25
+ end
14
26
  end
15
27
  end
@@ -25,8 +25,13 @@ module Trek
25
25
  def apply_filters(scope, filters)
26
26
  return scope if filters.blank?
27
27
 
28
+ # Only the controller's own filters: the keys come from the request and become column names
29
+ allowed = available_filters.map(&:to_s)
30
+
28
31
  filters.each do |filter, value|
29
32
  next if value.blank?
33
+ next unless allowed.include?(filter.to_s)
34
+
30
35
  value = nil if value == "nil"
31
36
 
32
37
  scope = scope.where(filter => value)
@@ -37,7 +42,19 @@ module Trek
37
42
 
38
43
  def apply_filters!
39
44
  @filters_applied = true
40
- @objects = apply_filters(@objects, params[:filters])
45
+ @objects = apply_filters(@objects, filter_params)
46
+ end
47
+
48
+ # The requested filters, permitted to the available ones with scalar values only; nil when
49
+ # params[:filters] isn't a hash
50
+ def filter_params
51
+ filters = params[:filters]
52
+ filters.permit(*available_filters) if filters.respond_to?(:permit)
53
+ end
54
+
55
+ # The columns a request may filter on; override in the controller.
56
+ def available_filters
57
+ []
41
58
  end
42
59
 
43
60
  private
@@ -13,10 +13,11 @@ module Trek
13
13
  raise "#{object} is not a page" unless object.is_a?(Page)
14
14
 
15
15
  if object.pageable.present?
16
- klass = object.pageable.class.name.underscore
16
+ # Same key as Rails' polymorphic routes: `shop_product` for Shop::Product, `post` for an isolated engine's Blog::Post
17
+ route_key = object.pageable.model_name.singular_route_key
17
18
  id = object.slug.presence || object.to_param
18
19
 
19
- id ? send(:"#{klass}_#{suffix}", id:, **options) : nil
20
+ id ? send(:"#{route_key}_#{suffix}", id:, **options) : nil
20
21
  else
21
22
  begin
22
23
  key = object.key.presence
@@ -27,7 +28,7 @@ module Trek
27
28
  send(:"localized_root_#{suffix}", locale:, **options)
28
29
  end
29
30
  else
30
- send(:"#{key}_path", **options)
31
+ send(:"#{key}_#{suffix}", **options)
31
32
  end
32
33
  rescue NoMethodError
33
34
  I18n.with_locale(locale) do
@@ -1,10 +1,25 @@
1
1
  module Trek
2
2
  module ThemeHelper
3
+ # DOM id of the header's theme indicator, targeted by the theme turbo_stream.
4
+ THEME_ICON_ID = "header-theme-icon".freeze
5
+
3
6
  def class_for_theme
4
7
  if authenticated?
5
8
  "#{Current.user.theme}-theme"
6
9
  else
7
- "light-theme"
10
+ "auto-theme"
11
+ end
12
+ end
13
+
14
+ # Renders the theme indicator used in the header trigger. Wrapped in a
15
+ # stable-id element so the +theme+ turbo_stream can replace it after a
16
+ # theme change. Used both on initial render and in the stream response.
17
+ def theme_icon(theme)
18
+ tag.span(id: THEME_ICON_ID) do
19
+ render Trek::IconComponent.new(
20
+ "trek/theme-#{theme}",
21
+ classnames: "c-trek--header-nav-item-icon"
22
+ )
8
23
  end
9
24
  end
10
25
  end
@@ -10,14 +10,26 @@ module Trek
10
10
  module Phonable
11
11
  extend ActiveSupport::Concern
12
12
 
13
+ STRICTNESSES = %i[possible valid].freeze
14
+
13
15
  class_methods do
14
- def phonable_attributes(*attrs)
16
+ # `strictness:` picks how hard Phonelib looks. `:possible`, the default, only asks whether the
17
+ # digits could form a number for the country; `:valid` asks whether they fall in a range the
18
+ # regulator has actually assigned. The gap is not academic: with a French default country,
19
+ # "0012345678" is possible, so it is accepted and normalized to "+12345678" — the stray leading
20
+ # zero read as an international prefix, the number silently rehomed to country code +1. An app
21
+ # that intends to call the numbers it collects wants `:valid`.
22
+ def phonable_attributes(*attrs, strictness: :possible)
23
+ unless STRICTNESSES.include?(strictness)
24
+ raise ArgumentError, "unknown strictness #{strictness.inspect}, expected one of #{STRICTNESSES.inspect}"
25
+ end
26
+
15
27
  @__phonable_attributes ||= []
16
28
  @__phonable_attributes += attrs
17
29
 
18
30
  attrs.each do |attr|
19
31
  validates attr,
20
- phone: { possible: true, allow_blank: true }
32
+ phone: { possible: strictness == :possible, allow_blank: true }
21
33
 
22
34
  define_method :"formatted_#{attr}" do
23
35
  return if send(attr).blank?
@@ -42,7 +54,7 @@ module Trek
42
54
 
43
55
  def normalize_phone
44
56
  self.class.instance_variable_get(:@__phonable_attributes).each do |attr|
45
- send(:"#{attr}=", Phonelib.parse(phone).full_e164.presence)
57
+ send(:"#{attr}=", Phonelib.parse(send(attr)).full_e164.presence)
46
58
  end
47
59
  end
48
60
  end