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.
- checksums.yaml +4 -4
- data/.slim-lint.yml +0 -3
- data/AGENTS.md +9 -2
- data/CHANGELOG.md +73 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +23 -3
- data/Makefile +11 -9
- data/README.md +1 -1
- data/app/assets/icons/trek/chevron-down.svg +1 -0
- data/app/assets/icons/trek/chevron-up.svg +1 -0
- data/app/assets/icons/trek/external.svg +1 -0
- data/app/assets/icons/trek/theme-auto.svg +1 -0
- data/app/assets/icons/trek/theme-dark.svg +1 -0
- data/app/assets/icons/trek/theme-light.svg +1 -0
- data/app/assets/stylesheets/trek/base.css +13 -0
- data/app/components/trek/button_component/button_component.css +9 -0
- data/app/components/trek/button_component.rb +13 -1
- data/app/components/trek/dashboard/tile_component/tile_component.html.slim +1 -1
- data/app/components/trek/dashboard/tile_component.rb +14 -0
- data/app/components/trek/dialog_component/dialog_component.html.slim +6 -2
- data/app/components/trek/dialog_component/dialog_component.js +62 -2
- data/app/components/trek/dropdown_component/dropdown_component.css +69 -0
- data/app/components/trek/dropdown_component/dropdown_component.html.slim +31 -0
- data/app/components/trek/dropdown_component/dropdown_component.js +114 -0
- data/app/components/trek/dropdown_component.rb +34 -0
- data/app/components/trek/form/group_component/group_component.css +34 -0
- data/app/components/trek/header_component/header_component.css +9 -4
- data/app/components/trek/header_component/header_component.html.slim +17 -10
- data/app/components/trek/header_component/header_component.js +29 -5
- data/app/components/trek/header_component.rb +24 -3
- data/app/components/trek/menu_component/menu_component.css +6 -0
- data/app/components/trek/menu_component.rb +29 -4
- data/app/components/trek/toaster/toast_component.rb +1 -1
- data/app/controllers/concerns/trek/authorization.rb +12 -0
- data/app/controllers/concerns/trek/filters.rb +18 -1
- data/app/helpers/trek/page_path_helper.rb +4 -3
- data/app/helpers/trek/theme_helper.rb +16 -1
- data/app/models/concerns/trek/phonable.rb +15 -3
- data/app/models/concerns/trek/sectionable.rb +5 -0
- data/app/models/concerns/trek/users/validations.rb +15 -6
- data/app/uploaders/audio_uploader.rb +11 -0
- data/app/uploaders/record_image_uploader.rb +3 -0
- data/config/locales/trek.en.yml +3 -2
- data/config/locales/trek.fr.yml +1 -0
- data/docs/.vitepress/config.mjs +20 -1
- data/docs/.vitepress/theme/style.css +36 -8
- data/docs/changelog.md +9 -0
- data/docs/customize.md +18 -2
- data/docs/deploy.md +7 -2
- data/docs/getting-started.md +1 -1
- data/docs/index.md +8 -8
- data/docs/public/apple-touch-icon.png +0 -0
- data/docs/public/favicon.ico +0 -0
- data/docs/public/favicon.png +0 -0
- data/docs/public/trek-mark.webp +0 -0
- data/docs/reference/components.md +15 -0
- data/docs/reference/concerns/phonable.md +27 -1
- data/docs/reference/controllers.md +8 -3
- data/docs/reference/environment-variables.md +2 -0
- data/docs/reference/forms.md +2 -0
- data/docs/reference/generators/install.md +4 -1
- data/docs/reference/icons.md +1 -1
- data/docs/reference/models/tag.md +1 -1
- data/docs/reference/models/user.md +6 -4
- data/docs/reference/uploaders.md +31 -6
- data/docs/what-is-trek.md +1 -1
- data/lib/generators/trek/install/admin/routes_generator.rb +7 -1
- data/lib/generators/trek/install/appsignal_generator.rb +102 -0
- data/lib/generators/trek/install/model_specs_generator.rb +64 -0
- data/lib/generators/trek/install/postmark_generator.rb +3 -0
- data/lib/generators/trek/install/ruby_generator.rb +1 -1
- data/lib/generators/trek/install/scalingo_generator.rb +1 -1
- data/lib/generators/trek/install/uploaders_generator.rb +2 -0
- data/lib/generators/trek/install_generator.rb +5 -0
- data/lib/generators/trek/taxonomies_generator.rb +7 -0
- data/lib/generators/trek/templates/.gitlab-ci.yml +1 -1
- data/lib/generators/trek/templates/config/appsignal.rb.tt +25 -0
- data/lib/generators/trek/templates/controllers/admin/uploads_controller.rb +4 -0
- data/lib/generators/trek/templates/controllers/admin/user_password_resets_controller.rb +7 -0
- data/lib/generators/trek/templates/controllers/admin/users_controller.rb +4 -1
- data/lib/generators/trek/templates/controllers/concerns/appsignal_context.rb +37 -0
- data/lib/generators/trek/templates/db/seeds/setup/fragments.rb +3 -0
- data/lib/generators/trek/templates/db/seeds/setup/users.rb +6 -0
- data/lib/generators/trek/templates/db/seeds/test/cases/page_tree.rb +5 -0
- data/lib/generators/trek/templates/initializers/appsignal_context.rb +5 -0
- data/lib/generators/trek/templates/locales/scaffold/model.en.yml.tt +1 -1
- data/lib/generators/trek/templates/migrations/fragments.rb.erb +1 -1
- data/lib/generators/trek/templates/migrations/menu_nodes.rb.erb +0 -1
- data/lib/generators/trek/templates/migrations/tag_categories.rb.erb +1 -1
- data/lib/generators/trek/templates/migrations/taggings.rb.erb +1 -1
- data/lib/generators/trek/templates/migrations/users.rb.erb +2 -1
- data/lib/generators/trek/templates/models/tag.rb +6 -1
- data/lib/generators/trek/templates/policies/admin/tag_policy.rb +1 -1
- data/lib/generators/trek/templates/policies/external_link_policy.rb +1 -1
- data/lib/generators/trek/templates/spec/fixtures/files/image.png +0 -0
- data/lib/generators/trek/templates/spec/fixtures/files/sound.mp3 +0 -0
- data/lib/generators/trek/templates/spec/models/page_spec.rb +24 -0
- data/lib/generators/trek/templates/spec/models/user_spec.rb +109 -0
- data/lib/generators/trek/templates/spec/policies/admin/external_link_policy_spec.rb +17 -0
- data/lib/generators/trek/templates/spec/policies/admin/fragment_policy_spec.rb +33 -0
- data/lib/generators/trek/templates/spec/policies/admin/page_policy_spec.rb +39 -0
- data/lib/generators/trek/templates/spec/policies/admin/preview_policy_spec.rb +9 -0
- data/lib/generators/trek/templates/spec/policies/admin/tag_category_policy_spec.rb +31 -0
- data/lib/generators/trek/templates/spec/policies/admin/tag_policy_spec.rb +31 -0
- data/lib/generators/trek/templates/spec/policies/admin/user_policy_spec.rb +58 -0
- data/lib/generators/trek/templates/spec/requests/admin/fragments_spec.rb +50 -0
- data/lib/generators/trek/templates/spec/requests/admin/pages_spec.rb +47 -0
- data/lib/generators/trek/templates/spec/requests/admin/uploads_spec.rb +22 -0
- data/lib/generators/trek/templates/spec/requests/admin/user_password_resets_spec.rb +78 -0
- data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +51 -0
- data/lib/generators/trek/templates/spec/requests/admin/users_spec.rb +99 -0
- data/lib/generators/trek/templates/views/admin/user_password_resets/edit.html.slim +1 -1
- data/lib/generators/trek/templates/views/admin/user_password_resets/new.html.slim +1 -1
- data/lib/generators/trek/templates/views/admin/user_sessions/new.html.slim +2 -2
- data/lib/generators/trek/templates/views/admin/users/theme.turbo_stream.slim +2 -0
- data/lib/generators/trek/upgrade/v2_generator.rb +3 -0
- data/lib/tasks/trek/attachments.rake +18 -0
- data/lib/trek/cached_attachments.rb +53 -0
- data/lib/trek/saved_attachments_reload.rb +44 -0
- data/lib/trek/version.rb +1 -1
- data/lib/trek.rb +4 -0
- data/package.json +1 -1
- data/postcss.config.js +36 -0
- data/trek.gemspec +6 -0
- metadata +89 -3
- 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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
7
|
-
const dark = document.documentElement.classList.toggle("dark-theme");
|
|
8
|
-
document.documentElement.classList.toggle("light-theme");
|
|
6
|
+
static values = { url: String };
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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).
|
|
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
|
|
@@ -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,
|
|
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
|
-
|
|
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(:"#{
|
|
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}
|
|
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
|
-
"
|
|
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
|
-
|
|
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:
|
|
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(
|
|
57
|
+
send(:"#{attr}=", Phonelib.parse(send(attr)).full_e164.presence)
|
|
46
58
|
end
|
|
47
59
|
end
|
|
48
60
|
end
|