trek 2.1.0 → 2.3.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/.slim-lint.yml +0 -3
  3. data/AGENTS.md +8 -1
  4. data/CHANGELOG.md +75 -0
  5. data/Gemfile +4 -0
  6. data/Gemfile.lock +20 -3
  7. data/Makefile +11 -9
  8. data/README.md +1 -1
  9. data/app/assets/icons/trek/audit.svg +1 -0
  10. data/app/assets/icons/trek/chevron-down.svg +1 -0
  11. data/app/assets/icons/trek/chevron-up.svg +1 -0
  12. data/app/assets/icons/trek/external.svg +1 -0
  13. data/app/assets/icons/trek/print.svg +1 -0
  14. data/app/assets/icons/trek/theme-auto.svg +1 -0
  15. data/app/assets/icons/trek/theme-dark.svg +1 -0
  16. data/app/assets/icons/trek/theme-light.svg +1 -0
  17. data/app/assets/stylesheets/trek/base.css +13 -0
  18. data/app/components/trek/button_component/button_component.css +9 -0
  19. data/app/components/trek/button_component.rb +13 -1
  20. data/app/components/trek/dashboard/analytics_component/analytics_component.html.slim +2 -0
  21. data/app/components/trek/dashboard/analytics_component.rb +1 -0
  22. data/app/components/trek/dashboard/chart_component/chart_component.css +192 -0
  23. data/app/components/trek/dashboard/chart_component/chart_component.html.slim +6 -0
  24. data/app/components/trek/dashboard/chart_component/chart_component.js +1 -0
  25. data/app/components/trek/dashboard/chart_component.rb +27 -0
  26. data/app/components/trek/dashboard/tile_component/tile_component.css +40 -0
  27. data/app/components/trek/dashboard/tile_component/tile_component.html.slim +14 -4
  28. data/app/components/trek/dashboard/tile_component.rb +20 -2
  29. data/app/components/trek/dialog_component/dialog_component.html.slim +6 -2
  30. data/app/components/trek/dialog_component/dialog_component.js +62 -2
  31. data/app/components/trek/dropdown_component/dropdown_component.css +66 -0
  32. data/app/components/trek/dropdown_component/dropdown_component.html.slim +31 -0
  33. data/app/components/trek/dropdown_component/dropdown_component.js +114 -0
  34. data/app/components/trek/dropdown_component.rb +29 -0
  35. data/app/components/trek/form/actions_component/actions_component.css +6 -7
  36. data/app/components/trek/form/content_editor_component/content_editor_component.html.slim +10 -12
  37. data/app/components/trek/form/content_editor_component/content_editor_component.js +4 -0
  38. data/app/components/trek/form/group_component/group_component.css +34 -0
  39. data/app/components/trek/header_component/header_component.css +10 -4
  40. data/app/components/trek/header_component/header_component.html.slim +17 -10
  41. data/app/components/trek/header_component/header_component.js +29 -5
  42. data/app/components/trek/header_component.rb +20 -3
  43. data/app/components/trek/layout_component/layout_component.css +20 -0
  44. data/app/components/trek/layout_component/layout_component.html.slim +16 -0
  45. data/app/components/trek/layout_component/layout_component.js +35 -6
  46. data/app/components/trek/menu_component/menu_component.css +6 -0
  47. data/app/components/trek/menu_component.rb +29 -4
  48. data/app/components/trek/toaster/toast_component.rb +1 -1
  49. data/app/controllers/concerns/trek/authorization.rb +12 -0
  50. data/app/formatters/prosemirror_to_html/global_id_to_links_formatter.rb +3 -1
  51. data/app/helpers/trek/page_path_helper.rb +4 -3
  52. data/app/helpers/trek/theme_helper.rb +16 -1
  53. data/app/models/concerns/trek/phonable.rb +15 -3
  54. data/app/models/concerns/trek/sectionable.rb +5 -0
  55. data/app/models/concerns/trek/users/validations.rb +1 -1
  56. data/app/models/prosemirror.rb +5 -2
  57. data/app/uploaders/audio_uploader.rb +11 -0
  58. data/app/uploaders/record_image_uploader.rb +3 -0
  59. data/app/views/trek/panels/images/_form.html.slim +1 -1
  60. data/app/views/trek/panels/images/create.turbo_stream.slim +3 -3
  61. data/app/views/trek/panels/images/new.html.slim +1 -1
  62. data/app/views/trek/panels/links/_form.html.slim +1 -1
  63. data/app/views/trek/panels/links/create.turbo_stream.slim +3 -3
  64. data/app/views/trek/panels/links/new.html.slim +1 -1
  65. data/app/views/trek/panels/prompts/_form.html.slim +1 -1
  66. data/app/views/trek/panels/prompts/create.turbo_stream.slim +3 -3
  67. data/app/views/trek/panels/prompts/new.html.slim +1 -1
  68. data/config/locales/trek.en.yml +3 -2
  69. data/config/locales/trek.fr.yml +1 -0
  70. data/docs/customize.md +18 -2
  71. data/docs/deploy.md +7 -2
  72. data/docs/getting-started.md +1 -1
  73. data/docs/index.md +1 -1
  74. data/docs/reference/components.md +15 -0
  75. data/docs/reference/concerns/phonable.md +27 -1
  76. data/docs/reference/environment-variables.md +2 -0
  77. data/docs/reference/forms.md +2 -0
  78. data/docs/reference/generators/install.md +4 -1
  79. data/docs/reference/icons.md +1 -1
  80. data/docs/reference/models/tag.md +1 -1
  81. data/docs/reference/models/user.md +4 -2
  82. data/docs/reference/uploaders.md +31 -6
  83. data/docs/what-is-trek.md +1 -1
  84. data/lib/generators/trek/install/admin/routes_generator.rb +7 -1
  85. data/lib/generators/trek/install/appsignal_generator.rb +102 -0
  86. data/lib/generators/trek/install/model_specs_generator.rb +64 -0
  87. data/lib/generators/trek/install/postmark_generator.rb +3 -0
  88. data/lib/generators/trek/install/ruby_generator.rb +1 -1
  89. data/lib/generators/trek/install/scalingo_generator.rb +1 -1
  90. data/lib/generators/trek/install/uploaders_generator.rb +2 -0
  91. data/lib/generators/trek/install_generator.rb +5 -0
  92. data/lib/generators/trek/taxonomies_generator.rb +7 -0
  93. data/lib/generators/trek/templates/.gitlab-ci.yml +1 -1
  94. data/lib/generators/trek/templates/config/appsignal.rb.tt +25 -0
  95. data/lib/generators/trek/templates/controllers/admin/uploads_controller.rb +4 -0
  96. data/lib/generators/trek/templates/controllers/admin/users_controller.rb +4 -1
  97. data/lib/generators/trek/templates/controllers/concerns/appsignal_context.rb +37 -0
  98. data/lib/generators/trek/templates/db/seeds/setup/fragments.rb +3 -0
  99. data/lib/generators/trek/templates/db/seeds/setup/users.rb +6 -0
  100. data/lib/generators/trek/templates/db/seeds/test/cases/page_tree.rb +5 -0
  101. data/lib/generators/trek/templates/initializers/appsignal_context.rb +5 -0
  102. data/lib/generators/trek/templates/locales/scaffold/model.en.yml.tt +1 -1
  103. data/lib/generators/trek/templates/migrations/fragments.rb.erb +1 -1
  104. data/lib/generators/trek/templates/migrations/menu_nodes.rb.erb +0 -1
  105. data/lib/generators/trek/templates/migrations/tag_categories.rb.erb +1 -1
  106. data/lib/generators/trek/templates/migrations/taggings.rb.erb +1 -1
  107. data/lib/generators/trek/templates/migrations/users.rb.erb +2 -1
  108. data/lib/generators/trek/templates/models/tag.rb +6 -1
  109. data/lib/generators/trek/templates/policies/admin/tag_policy.rb +1 -1
  110. data/lib/generators/trek/templates/policies/external_link_policy.rb +1 -1
  111. data/lib/generators/trek/templates/spec/fixtures/files/image.png +0 -0
  112. data/lib/generators/trek/templates/spec/fixtures/files/sound.mp3 +0 -0
  113. data/lib/generators/trek/templates/spec/models/page_spec.rb +24 -0
  114. data/lib/generators/trek/templates/spec/models/user_spec.rb +76 -0
  115. data/lib/generators/trek/templates/spec/policies/admin/external_link_policy_spec.rb +17 -0
  116. data/lib/generators/trek/templates/spec/policies/admin/fragment_policy_spec.rb +33 -0
  117. data/lib/generators/trek/templates/spec/policies/admin/page_policy_spec.rb +39 -0
  118. data/lib/generators/trek/templates/spec/policies/admin/preview_policy_spec.rb +9 -0
  119. data/lib/generators/trek/templates/spec/policies/admin/tag_category_policy_spec.rb +31 -0
  120. data/lib/generators/trek/templates/spec/policies/admin/tag_policy_spec.rb +31 -0
  121. data/lib/generators/trek/templates/spec/policies/admin/user_policy_spec.rb +58 -0
  122. data/lib/generators/trek/templates/spec/requests/admin/fragments_spec.rb +50 -0
  123. data/lib/generators/trek/templates/spec/requests/admin/pages_spec.rb +47 -0
  124. data/lib/generators/trek/templates/spec/requests/admin/uploads_spec.rb +22 -0
  125. data/lib/generators/trek/templates/spec/requests/admin/user_password_resets_spec.rb +71 -0
  126. data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +45 -0
  127. data/lib/generators/trek/templates/spec/requests/admin/users_spec.rb +99 -0
  128. data/lib/generators/trek/templates/views/admin/user_password_resets/edit.html.slim +1 -1
  129. data/lib/generators/trek/templates/views/admin/user_password_resets/new.html.slim +1 -1
  130. data/lib/generators/trek/templates/views/admin/user_sessions/new.html.slim +2 -2
  131. data/lib/generators/trek/templates/views/admin/users/theme.turbo_stream.slim +2 -0
  132. data/lib/generators/trek/upgrade/v2_generator.rb +3 -0
  133. data/lib/tasks/trek/attachments.rake +18 -0
  134. data/lib/trek/cached_attachments.rb +53 -0
  135. data/lib/trek/saved_attachments_reload.rb +44 -0
  136. data/lib/trek/version.rb +1 -1
  137. data/lib/trek.rb +3 -0
  138. data/package.json +1 -1
  139. data/postcss.config.js +36 -0
  140. data/trek.gemspec +5 -0
  141. metadata +76 -3
  142. data/app/assets/icons/trek/theme.svg +0 -11
@@ -0,0 +1,29 @@
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 :hide_chevron, default: proc { false }
6
+
7
+ renders_one :trigger # inner content of the trigger button (e.g. an icon)
8
+ renders_many :items, "ItemComponent"
9
+
10
+ def classes
11
+ class_names_for(root_class, classnames)
12
+ end
13
+
14
+ def menu_id
15
+ @menu_id ||= "#{identifier}-menu-#{SecureRandom.hex(4)}"
16
+ end
17
+
18
+ class ItemComponent < Trek::Component
19
+ option :data, default: -> { {} }
20
+
21
+ def call
22
+ attrs = { class: class_for("root"), role: "menuitem", tabindex: -1, data: }
23
+ tag.li(**attrs) do
24
+ content
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -2,14 +2,8 @@
2
2
 
3
3
  .root {
4
4
  align-items: center;
5
- background-color: var(--accent-1);
6
- bottom: 0;
7
5
  display: flex;
8
6
  max-width: 120rem;
9
- padding: 4rem 0;
10
- position: sticky;
11
- width: 100%;
12
- z-index: 2;
13
7
  }
14
8
 
15
9
  .buttons {
@@ -36,9 +30,14 @@
36
30
 
37
31
  /* aspect */
38
32
  .root.aspect-default {
33
+ background-color: var(--accent-1);
39
34
  border-top: 1px solid var(--slate-6);
35
+ bottom: 0;
40
36
  margin-top: 3.2rem;
41
- padding-top: 3.2rem;
37
+ padding: 3.2rem 0 4rem;
38
+ position: sticky;
39
+ width: 100%;
40
+ z-index: 2;
42
41
  }
43
42
 
44
43
  /* .root.aspect-seamless {
@@ -29,16 +29,14 @@ div(
29
29
  / data-action=stimulus_action("toggleCode")
30
30
  / ) code
31
31
 
32
- / slim-lint:disable ControlStatementSpacing
32
+ / The panel opening is handled by the hidden button's
33
+ openStackedPanelWhenLoaded action — a direct openPanel here would
34
+ pop the (possibly empty) primary panel alongside the stacked one.
33
35
  button.link(
34
36
  class=class_for("button")
35
37
  type="button"
36
- data-action=[ \
37
- stimulus_action("toggleLink"),
38
- "trek--layout#openPanel",
39
- ].join(" ")
38
+ data-action=stimulus_action("toggleLink")
40
39
  ) = render Trek::IconComponent.new("trek/link", title: t(".link"))
41
- / slim-lint:enable ControlStatementSpacing
42
40
 
43
41
  = link_to "",
44
42
  new_link_path,
@@ -46,8 +44,8 @@ div(
46
44
  hidden: true,
47
45
  data: stimulus_target_hash("hiddenLinkButton").merge( \
48
46
  'base-path': new_link_path,
49
- action: "trek--layout#openPanelWhenLoaded",
50
- 'turbo-frame': "trek--layout-panel",
47
+ action: "trek--layout#openStackedPanelWhenLoaded",
48
+ 'turbo-frame': "trek--layout-stacked-panel",
51
49
  )
52
50
 
53
51
  button(
@@ -239,8 +237,8 @@ div(
239
237
  hidden: true,
240
238
  data: stimulus_target_hash("hiddenPromptsButton").merge( \
241
239
  'base-path': new_prompt_path,
242
- action: "trek--layout#openPanelWhenLoaded",
243
- 'turbo-frame': "trek--layout-panel",
240
+ action: "trek--layout#openStackedPanelWhenLoaded",
241
+ 'turbo-frame': "trek--layout-stacked-panel",
244
242
  )
245
243
  = link_to "",
246
244
  new_image_path,
@@ -248,6 +246,6 @@ div(
248
246
  hidden: true,
249
247
  data: stimulus_target_hash("hiddenImageButton").merge( \
250
248
  'base-path': new_image_path,
251
- action: "trek--layout#openPanelWhenLoaded",
252
- 'turbo-frame': "trek--layout-panel",
249
+ action: "trek--layout#openStackedPanelWhenLoaded",
250
+ 'turbo-frame': "trek--layout-stacked-panel",
253
251
  )
@@ -41,6 +41,10 @@ export class Controller extends BaseController {
41
41
  // }),
42
42
  Link.configure({
43
43
  openOnClick: false,
44
+ // Internal links are stored as GlobalIDs (resolved to URLs at render
45
+ // time); without this the gid:// scheme fails Link's URI validation
46
+ // and setLink silently does nothing.
47
+ protocols: ["gid"],
44
48
  HTMLAttributes: {
45
49
  "data-action": "trek--form--content-editor#selectLink",
46
50
  target: null,
@@ -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,15 @@
69
66
  text-overflow: ellipsis;
70
67
  }
71
68
 
69
+ /* Theme Dropdown */
70
+ .nav-item-link:global(.c-trek--dropdown-root) {
71
+ padding: 0.8rem 1.6rem;
72
+ }
73
+
74
+ .nav-item-link:global(.c-trek--dropdown-root .c-trek--icon-root) {
75
+ --icon-size: 2rem;
76
+ }
77
+
72
78
  /* Media queries */
73
79
 
74
80
  @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
+ classnames: class_for("nav-item-link"),
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,32 @@ 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_item_data(theme)
33
+ { action: stimulus_action("click", "setTheme"), theme: }
29
34
  end
30
35
 
31
36
  def can_preview_home?
32
37
  Admin::PreviewPolicy.new(user: current_user).apply(:home?)
33
38
  end
34
39
 
40
+ # Where the current object shows on the website, or nil when the app has no front-office route
41
+ # for it (a freshly generated app has none): the link is then left out rather than raising.
35
42
  def preview_path
43
+ return @preview_path if defined?(@preview_path)
44
+
45
+ @preview_path = website_path
46
+ rescue NoMethodError => e
47
+ raise unless e.receiver.equal?(self) && e.name.to_s.end_with?("_path", "_url")
48
+
49
+ @preview_path = nil
50
+ end
51
+
52
+ private
53
+
54
+ def website_path
36
55
  return current_object unless defined?(Page) && defined?(PageVersion)
37
56
 
38
57
  case current_object
@@ -50,8 +69,6 @@ module Trek
50
69
  end
51
70
  end
52
71
 
53
- private
54
-
55
72
  def current_object_page?
56
73
  defined?(Page) && current_object.is_a?(Page)
57
74
  end
@@ -34,6 +34,18 @@
34
34
  transform: translateX(0);
35
35
  }
36
36
 
37
+ /* Stacked panel: overlays the primary panel. */
38
+ .panel-stacked {
39
+ box-shadow: 0 0 16px var(--slate-a8);
40
+ z-index: 3;
41
+ }
42
+
43
+ /* Only when it actually stacks on an open primary panel, make it 1rem
44
+ narrower so a sliver of the panel underneath stays visible. */
45
+ .panel.open + .panel-stacked {
46
+ width: 41rem;
47
+ }
48
+
37
49
  .panel-close {
38
50
  --icon-size: 1.6rem;
39
51
 
@@ -65,4 +77,12 @@
65
77
  .content {
66
78
  margin: 6rem 3rem;
67
79
  }
80
+
81
+ .panel {
82
+ width: 100vw;
83
+ }
84
+
85
+ .panel.open + .panel-stacked {
86
+ width: calc(100vw - 1rem);
87
+ }
68
88
  }
@@ -18,3 +18,19 @@ div(
18
18
  data-action=stimulus_action("closePanel")
19
19
  )
20
20
  = render(Trek::IconComponent.new("trek/close"))
21
+ / Stacked panel: opens on top of the primary panel, so a panel-hosted form
22
+ (e.g. a content editor in a section panel) survives sub-panels like the
23
+ link/image/prompt inserters.
24
+ aside(
25
+ class=[class_for("panel"), class_for("panel-stacked")]
26
+ data=stimulus_target_hash("stackedPanel")
27
+ )
28
+ = turbo_frame_tag( \
29
+ "#{identifier}-stacked-panel",
30
+ data: stimulus_target_hash("stackedPanelFrame"),
31
+ )
32
+ button(
33
+ class=class_for("panel-close")
34
+ data-action=stimulus_action("closeStackedPanel")
35
+ )
36
+ = render(Trek::IconComponent.new("trek/close"))
@@ -5,7 +5,14 @@ import { useClickOutside } from "stimulus-use";
5
5
 
6
6
  export class Controller extends BaseController {
7
7
  static classes = ["open"];
8
- static targets = ["panel", "panelFrame", "panelCloser"];
8
+ static targets = [
9
+ "panel",
10
+ "panelFrame",
11
+ "panelCloser",
12
+ "stackedPanel",
13
+ "stackedPanelFrame",
14
+ "stackedPanelCloser",
15
+ ];
9
16
 
10
17
  connect() {
11
18
  useClickOutside(this, { element: this.panelTarget });
@@ -17,31 +24,53 @@ export class Controller extends BaseController {
17
24
  this.closePanel();
18
25
  }
19
26
 
27
+ stackedPanelCloserTargetConnected() {
28
+ this.closeStackedPanel();
29
+ }
30
+
20
31
  openPanelWhenLoaded() {
21
32
  this.panelFrameTarget.loaded.then(() => this.openPanel());
22
33
  }
23
34
 
35
+ openStackedPanelWhenLoaded() {
36
+ this.stackedPanelFrameTarget.loaded.then(() => this.openStackedPanel());
37
+ }
38
+
24
39
  openPanel() {
25
40
  this.panelTarget.classList.add(this.openClass);
26
41
  }
27
42
 
43
+ openStackedPanel() {
44
+ this.stackedPanelTarget.classList.add(this.openClass);
45
+ }
46
+
28
47
  closePanel() {
48
+ this.closeStackedPanel();
29
49
  this.panelTarget.classList.remove(this.openClass);
30
- this.emptyFrame();
50
+ this.emptyFrame(this.panelFrameTarget);
51
+ }
52
+
53
+ closeStackedPanel() {
54
+ this.stackedPanelTarget.classList.remove(this.openClass);
55
+ this.emptyFrame(this.stackedPanelFrameTarget);
31
56
  }
32
57
 
33
58
  clickOutside(e) {
34
59
  if (
35
60
  e.target.dataset.action &&
36
- e.target.dataset.action.includes("trek--layout#openPanel")
61
+ (e.target.dataset.action.includes("trek--layout#openPanel") ||
62
+ e.target.dataset.action.includes("trek--layout#openStackedPanel"))
37
63
  )
38
64
  return;
65
+ // Clicks inside the stacked panel are outside the primary panel element,
66
+ // but must not close anything.
67
+ if (this.stackedPanelTarget.contains(e.target)) return;
39
68
  this.closePanel();
40
69
  }
41
70
 
42
- emptyFrame() {
43
- while (this.panelFrameTarget.firstChild) {
44
- this.panelFrameTarget.removeChild(this.panelFrameTarget.firstChild);
71
+ emptyFrame(frame) {
72
+ while (frame.firstChild) {
73
+ frame.removeChild(frame.firstChild);
45
74
  }
46
75
  }
47
76
  }
@@ -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
@@ -2,7 +2,9 @@ module ProsemirrorToHtml
2
2
  class GlobalIdToLinksFormatter
3
3
  include Rails.application.routes.url_helpers
4
4
 
5
- GLOBAL_ID_REGEX = %r{(gid://[[:alnum:]]+/[\w:]+/[\w-]+)}
5
+ # The app segment allows hyphens and dots: GlobalID derives it from the
6
+ # Rails application name (e.g. "au-poste").
7
+ GLOBAL_ID_REGEX = %r{(gid://[\w.-]+/[\w:]+/[\w-]+)}
6
8
 
7
9
  def initialize(str)
8
10
  @str = str
@@ -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
@@ -9,6 +9,11 @@ module Trek
9
9
  end
10
10
 
11
11
  included do
12
+ # Sections are the host app's own `Section` model, which Trek doesn't generate: without one, a
13
+ # record has no sections, and declaring the association anyway would make `destroy` raise
14
+ # (`dependent: :destroy`). `const_defined?` also sees a model Zeitwerk hasn't loaded yet.
15
+ next unless Object.const_defined?(:Section)
16
+
12
17
  has_many :sections,
13
18
  -> { ordered },
14
19
  as: :sectionable,