trek 2.2.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.
- checksums.yaml +4 -4
- data/.slim-lint.yml +0 -3
- data/AGENTS.md +8 -1
- data/CHANGELOG.md +62 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +20 -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 +66 -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 +29 -0
- data/app/components/trek/form/group_component/group_component.css +34 -0
- data/app/components/trek/header_component/header_component.css +10 -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 +20 -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/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 +1 -1
- 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/customize.md +18 -2
- data/docs/deploy.md +7 -2
- data/docs/getting-started.md +1 -1
- data/docs/index.md +1 -1
- data/docs/reference/components.md +15 -0
- data/docs/reference/concerns/phonable.md +27 -1
- 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 +4 -2
- 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/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 +76 -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 +71 -0
- data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +45 -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 +3 -0
- data/package.json +1 -1
- data/postcss.config.js +36 -0
- data/trek.gemspec +5 -0
- metadata +70 -3
- data/app/assets/icons/trek/theme.svg +0 -11
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
div(class=classes data-controller=identifier)
|
|
2
|
+
button(
|
|
3
|
+
type="button"
|
|
4
|
+
class=class_for("trigger")
|
|
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,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
|
|
@@ -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
|
-
|
|
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
|
+
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
|
-
|
|
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,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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class AudioUploader < Shrine
|
|
2
|
+
plugin :determine_mime_type
|
|
3
|
+
plugin :validation_helpers
|
|
4
|
+
# The admin's sound field accepts files up to 200 MB (`Trek::Form::SoundFieldComponent`), above
|
|
5
|
+
# the 20 MB the Shrine initializer allows images.
|
|
6
|
+
plugin :upload_endpoint, url: true, max_size: 200.megabytes
|
|
7
|
+
|
|
8
|
+
Attacher.validate do
|
|
9
|
+
validate_mime_type %w[audio/mpeg audio/mp4 audio/aac audio/ogg audio/wav audio/x-wav audio/webm audio/flac]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
class RecordImageUploader < ImageUploader
|
|
2
2
|
plugin :activerecord
|
|
3
|
+
# Keeps a file attached during a save that reloads the record (closure_tree reorders siblings
|
|
4
|
+
# that way) pending, so it is still promoted out of the cache storage on commit.
|
|
5
|
+
plugin Trek::SavedAttachmentsReload
|
|
3
6
|
end
|
data/config/locales/trek.en.yml
CHANGED
|
@@ -69,8 +69,8 @@ en:
|
|
|
69
69
|
self: the same tab
|
|
70
70
|
prompt:
|
|
71
71
|
targets:
|
|
72
|
-
blank:
|
|
73
|
-
self:
|
|
72
|
+
blank: a new tab
|
|
73
|
+
self: the same tab
|
|
74
74
|
contents: Contents
|
|
75
75
|
dashboard: Dashboard
|
|
76
76
|
dashboards:
|
|
@@ -264,3 +264,4 @@ en:
|
|
|
264
264
|
notices:
|
|
265
265
|
authentication_needed: Please log in
|
|
266
266
|
rate_limited: Too many attempts, please try again later
|
|
267
|
+
unauthorized: You are not allowed to do that
|
data/config/locales/trek.fr.yml
CHANGED