studio-engine 0.48.0 → 0.50.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/CHANGELOG.md +94 -0
- data/app/controllers/studio/profiles_controller.rb +174 -54
- data/app/mailers/studio/profile_mailer.rb +55 -0
- data/app/services/studio/email_catalog.rb +21 -0
- data/app/views/studio/profile_mailer/email_change_notification.html.erb +30 -0
- data/app/views/studio/profile_mailer/email_change_notification.text.erb +11 -0
- data/app/views/studio/profiles/_birthday_fields.html.erb +31 -0
- data/app/views/studio/profiles/_editable_identity.html.erb +35 -0
- data/app/views/studio/profiles/_email_fields.html.erb +37 -0
- data/app/views/studio/profiles/_form_script.html.erb +65 -0
- data/app/views/studio/profiles/_identity.html.erb +41 -0
- data/app/views/studio/profiles/_identity_body.html.erb +44 -0
- data/app/views/studio/profiles/_identity_mini.html.erb +106 -0
- data/app/views/studio/profiles/_identity_styles.html.erb +96 -0
- data/app/views/studio/profiles/_name_fields.html.erb +34 -0
- data/app/views/studio/profiles/_pencil_icon.html.erb +7 -0
- data/app/views/studio/profiles/_save_bar.html.erb +39 -0
- data/app/views/studio/profiles/edit.html.erb +111 -0
- data/app/views/studio/profiles/show.html.erb +13 -58
- data/lib/studio/profile_sections.rb +69 -22
- data/lib/studio/sidebar_sections.rb +47 -3
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +3 -2
- metadata +16 -3
- data/app/views/studio/profiles/_avatar_section.html.erb +0 -93
- data/app/views/studio/profiles/_first_name_section.html.erb +0 -26
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<%# The edit form's Alpine component.
|
|
2
|
+
|
|
3
|
+
Its own partial so the page above stays readable and this can be reasoned
|
|
4
|
+
about as behaviour rather than markup.
|
|
5
|
+
%>
|
|
6
|
+
<script>
|
|
7
|
+
window.studioProfileForm = function (initial) {
|
|
8
|
+
return {
|
|
9
|
+
alpine: true,
|
|
10
|
+
fields: Object.assign({}, initial),
|
|
11
|
+
initial: Object.assign({}, initial),
|
|
12
|
+
|
|
13
|
+
// Trimmed on both sides: whitespace is not a change, and the controller
|
|
14
|
+
// trims on the way in too — so the bar agrees with what saving would do.
|
|
15
|
+
changed(key) {
|
|
16
|
+
return String(this.fields[key] || "").trim() !== String(this.initial[key] || "").trim();
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
get changeCount() {
|
|
20
|
+
var self = this;
|
|
21
|
+
return Object.keys(this.fields).filter(function (k) { return self.changed(k); }).length;
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
get dirty() { return this.changeCount > 0; },
|
|
25
|
+
|
|
26
|
+
discard() {
|
|
27
|
+
this.fields = Object.assign({}, this.initial);
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
init() {
|
|
31
|
+
var self = this;
|
|
32
|
+
|
|
33
|
+
// Guard the exit BOTH ways. beforeunload covers a hard navigation or a
|
|
34
|
+
// closed tab; turbo:before-visit covers a Turbo Drive visit, which never
|
|
35
|
+
// fires beforeunload — an app with Turbo on would otherwise drop edits
|
|
36
|
+
// silently on any in-app link.
|
|
37
|
+
this._beforeUnload = function (e) {
|
|
38
|
+
if (!self.dirty) return;
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
e.returnValue = "";
|
|
41
|
+
};
|
|
42
|
+
window.addEventListener("beforeunload", this._beforeUnload);
|
|
43
|
+
|
|
44
|
+
this._beforeVisit = function (e) {
|
|
45
|
+
if (!self.dirty) return;
|
|
46
|
+
if (!window.confirm("You have unsaved changes. Leave without saving?")) {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
document.addEventListener("turbo:before-visit", this._beforeVisit);
|
|
51
|
+
|
|
52
|
+
// Submitting is not leaving. Drop both guards before the form goes, or
|
|
53
|
+
// saving prompts you about the very changes you just chose to save.
|
|
54
|
+
this.$refs.form.addEventListener("submit", function () { self.teardown(); });
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
teardown() {
|
|
58
|
+
window.removeEventListener("beforeunload", this._beforeUnload);
|
|
59
|
+
document.removeEventListener("turbo:before-visit", this._beforeVisit);
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
destroy() { this.teardown(); }
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
</script>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<%# The "you at a glance" header — avatar, name, address — at the top of BOTH
|
|
2
|
+
/profile and /profile/edit.
|
|
3
|
+
|
|
4
|
+
Locals:
|
|
5
|
+
user (required)
|
|
6
|
+
editable (Boolean) — true on the edit page, where the avatar badge opens
|
|
7
|
+
the crop modal.
|
|
8
|
+
|
|
9
|
+
ON THE READ PAGE THE WHOLE CARD IS THE LINK to /profile/edit (operator's
|
|
10
|
+
call). It began as a badge-only link, then the page heading and the Edit
|
|
11
|
+
button were removed, and a 28px target became the single route to editing —
|
|
12
|
+
so the target grew to the thing people actually aim at.
|
|
13
|
+
|
|
14
|
+
That is also why the badge is a <span> there: an <a> inside an <a> is invalid,
|
|
15
|
+
and browsers repair it by closing the outer link early, which would quietly
|
|
16
|
+
shrink the card's clickable area to whatever preceded the badge.
|
|
17
|
+
|
|
18
|
+
THE AVATAR IS OPTIONAL — the guard the registry used to carry. As a ROW the
|
|
19
|
+
avatar declared `requires: :avatar` and was dropped whole on a host whose
|
|
20
|
+
model has no attachment; moving it into this header lost that, and the first
|
|
21
|
+
render against such a model raised `undefined method 'avatar'`.
|
|
22
|
+
%>
|
|
23
|
+
<% editable = local_assigns.fetch(:editable, false) %>
|
|
24
|
+
<% attachable = user.respond_to?(:avatar) && user.avatar.respond_to?(:attached?) %>
|
|
25
|
+
|
|
26
|
+
<%= render "studio/profiles/identity_styles" %>
|
|
27
|
+
|
|
28
|
+
<%= render "studio/profiles/identity_mini", user: user %>
|
|
29
|
+
|
|
30
|
+
<% if editable %>
|
|
31
|
+
<div data-studio-identity-full class="card p-6 mb-6 text-center">
|
|
32
|
+
<%= render "studio/profiles/identity_body", user: user, editable: true, attachable: attachable %>
|
|
33
|
+
</div>
|
|
34
|
+
<% else %>
|
|
35
|
+
<%= link_to edit_profile_path,
|
|
36
|
+
class: "studio-identity-card card p-6 mb-6 text-center block no-underline",
|
|
37
|
+
data: { studio_identity_full: true },
|
|
38
|
+
aria: { label: "Edit your profile" } do %>
|
|
39
|
+
<%= render "studio/profiles/identity_body", user: user, editable: false, attachable: attachable %>
|
|
40
|
+
<% end %>
|
|
41
|
+
<% end %>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<%# The identity block's contents — avatar, name, address.
|
|
2
|
+
|
|
3
|
+
Its own partial because the READ page wraps it in a link and the EDIT page
|
|
4
|
+
does not, and duplicating the markup to achieve that is how the two drift.
|
|
5
|
+
|
|
6
|
+
Locals: user, editable, attachable.
|
|
7
|
+
%>
|
|
8
|
+
<div class="relative mb-3 studio-avatar mx-auto" style="width: 128px; height: 128px;">
|
|
9
|
+
<% if attachable && user.avatar.attached? %>
|
|
10
|
+
<%= image_tag user.avatar, class: "rounded-full object-cover",
|
|
11
|
+
style: "width: 128px; height: 128px;", alt: user.display_name %>
|
|
12
|
+
<% else %>
|
|
13
|
+
<div class="rounded-full flex items-center justify-center font-bold text-white text-4xl"
|
|
14
|
+
style="width: 128px; height: 128px; background-color: <%= user.avatar_color %>">
|
|
15
|
+
<%= user.avatar_initials %>
|
|
16
|
+
</div>
|
|
17
|
+
<% end %>
|
|
18
|
+
|
|
19
|
+
<% badge_class = "studio-avatar-badge absolute bottom-0 right-0 flex items-center justify-center rounded-full border-2 border-page bg-primary text-white transition" %>
|
|
20
|
+
<% badge_style = "width: 28px; height: 28px;" %>
|
|
21
|
+
|
|
22
|
+
<% if editable && attachable %>
|
|
23
|
+
<%# A real button: on the edit page this opens the cropper. %>
|
|
24
|
+
<button type="button" @click="$refs.filePicker.click()"
|
|
25
|
+
aria-label="Change your profile photo"
|
|
26
|
+
class="<%= badge_class %>" style="<%= badge_style %>">
|
|
27
|
+
<%= render "studio/profiles/pencil_icon" %>
|
|
28
|
+
</button>
|
|
29
|
+
<% elsif !editable %>
|
|
30
|
+
<%# DECORATIVE on the read page, and it has to be: the whole card is a link
|
|
31
|
+
now, and an <a> inside an <a> is invalid HTML that browsers repair by
|
|
32
|
+
closing the outer one early — which would silently cut the card's
|
|
33
|
+
clickable area down to whatever came before the badge. The card carries
|
|
34
|
+
the destination and the accessible name; this is just the glyph. %>
|
|
35
|
+
<span class="<%= badge_class %>" style="<%= badge_style %>" aria-hidden="true">
|
|
36
|
+
<%= render "studio/profiles/pencil_icon" %>
|
|
37
|
+
</span>
|
|
38
|
+
<% end %>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<p class="text-3xl font-extrabold text-heading"><%= user.display_name %></p>
|
|
42
|
+
<% if user.respond_to?(:email) && user.email.present? %>
|
|
43
|
+
<p class="text-secondary text-sm break-all"><%= user.email %></p>
|
|
44
|
+
<% end %>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<%# The compact identity that takes over when the full card scrolls away.
|
|
2
|
+
|
|
3
|
+
Locals: user (required).
|
|
4
|
+
|
|
5
|
+
WHY IT EXISTS: on a long profile the header is the thing that tells you whose
|
|
6
|
+
page you are on, and it is the first thing to disappear. This keeps that
|
|
7
|
+
answer on screen — the same move the navbar makes when it shrinks on scroll.
|
|
8
|
+
|
|
9
|
+
FIXED, NOT STICKY, and that is the whole layout decision. A sticky element
|
|
10
|
+
stays in flow, so it would reserve its own height under the card forever and
|
|
11
|
+
push every row down by 56px whether or not it is showing. Fixed takes it out
|
|
12
|
+
of flow: nothing moves, and the bar simply is not there until it is.
|
|
13
|
+
|
|
14
|
+
Pinned to `var(--nav-h, 0px)` — the height the host navbar publishes, which
|
|
15
|
+
/admin/style already uses to stick its section nav. The 0px fallback means an
|
|
16
|
+
app that publishes nothing gets a bar at the top of the viewport rather than
|
|
17
|
+
a broken one.
|
|
18
|
+
|
|
19
|
+
NOT INTERACTIVE, and aria-hidden. It duplicates content that is already on
|
|
20
|
+
the page and already reachable: the full card above is the link to editing.
|
|
21
|
+
A second link here would be a duplicate tab stop and a second announcement of
|
|
22
|
+
the same name for no gain — this is an orientation aid for people who can see
|
|
23
|
+
the screen, so it says so.
|
|
24
|
+
%>
|
|
25
|
+
<div data-studio-identity-mini
|
|
26
|
+
class="studio-identity-mini"
|
|
27
|
+
aria-hidden="true">
|
|
28
|
+
<div class="px-4">
|
|
29
|
+
<div class="flex items-center gap-3 py-2">
|
|
30
|
+
<% if user.respond_to?(:avatar) && user.avatar.respond_to?(:attached?) && user.avatar.attached? %>
|
|
31
|
+
<%= image_tag user.avatar, class: "rounded-full object-cover flex-shrink-0",
|
|
32
|
+
style: "width: 32px; height: 32px;", alt: "" %>
|
|
33
|
+
<% else %>
|
|
34
|
+
<div class="rounded-full flex items-center justify-center font-bold text-white text-xs flex-shrink-0"
|
|
35
|
+
style="width: 32px; height: 32px; background-color: <%= user.avatar_color %>">
|
|
36
|
+
<%= user.avatar_initials %>
|
|
37
|
+
</div>
|
|
38
|
+
<% end %>
|
|
39
|
+
|
|
40
|
+
<div class="min-w-0">
|
|
41
|
+
<p class="text-sm font-bold text-heading truncate"><%= user.display_name %></p>
|
|
42
|
+
<% if user.respond_to?(:email) && user.email.present? %>
|
|
43
|
+
<p class="text-xs text-muted truncate"><%= user.email %></p>
|
|
44
|
+
<% end %>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<script>
|
|
51
|
+
(function () {
|
|
52
|
+
var CLASS = "is-visible";
|
|
53
|
+
|
|
54
|
+
function wire() {
|
|
55
|
+
var full = document.querySelector("[data-studio-identity-full]");
|
|
56
|
+
var mini = document.querySelector("[data-studio-identity-mini]");
|
|
57
|
+
if (!full || !mini) return;
|
|
58
|
+
|
|
59
|
+
// No IntersectionObserver: leave the bar hidden rather than showing a
|
|
60
|
+
// duplicate header permanently. The page is fully usable without it.
|
|
61
|
+
if (!("IntersectionObserver" in window)) return;
|
|
62
|
+
|
|
63
|
+
if (mini._studioObserver) mini._studioObserver.disconnect();
|
|
64
|
+
|
|
65
|
+
// HAND OVER PART-WAY, not once the card has gone. Waiting for the last
|
|
66
|
+
// pixel left a gap where neither header was on screen, which is the moment
|
|
67
|
+
// the bar exists to cover. It appears once most of the card has scrolled
|
|
68
|
+
// past, so the two overlap briefly instead.
|
|
69
|
+
//
|
|
70
|
+
// Ratio, not a pixel offset: the card's height changes with the name's
|
|
71
|
+
// wrap and with whether there is an email under it, so a fixed offset
|
|
72
|
+
// would mean something different on every account.
|
|
73
|
+
var SHOW_BELOW_RATIO = 0.6;
|
|
74
|
+
|
|
75
|
+
var observer = new IntersectionObserver(function (entries) {
|
|
76
|
+
var entry = entries[0];
|
|
77
|
+
// A card taller than the viewport can never reach ratio 1, so "not
|
|
78
|
+
// intersecting at all" has to count as gone in its own right.
|
|
79
|
+
var gone = !entry.isIntersecting || entry.intersectionRatio < SHOW_BELOW_RATIO;
|
|
80
|
+
mini.classList.toggle(CLASS, gone);
|
|
81
|
+
}, {
|
|
82
|
+
// The navbar covers the top of the viewport, so pixels behind it are not
|
|
83
|
+
// visible pixels — without this the card reads as on-screen while it is
|
|
84
|
+
// hidden under the nav.
|
|
85
|
+
rootMargin: "-72px 0px 0px 0px",
|
|
86
|
+
// A spread of thresholds, or the callback only fires at fully-in and
|
|
87
|
+
// fully-out and the ratio test above never gets a chance to run.
|
|
88
|
+
threshold: [0, 0.2, 0.4, 0.5, 0.6, 0.8, 1]
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
observer.observe(full);
|
|
92
|
+
mini._studioObserver = observer;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
document.addEventListener("turbo:load", wire);
|
|
96
|
+
document.addEventListener("turbo:before-cache", function () {
|
|
97
|
+
var mini = document.querySelector("[data-studio-identity-mini]");
|
|
98
|
+
if (mini) {
|
|
99
|
+
mini.classList.remove(CLASS);
|
|
100
|
+
if (mini._studioObserver) mini._studioObserver.disconnect();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
if (document.readyState !== "loading") wire();
|
|
104
|
+
else document.addEventListener("DOMContentLoaded", wire);
|
|
105
|
+
})();
|
|
106
|
+
</script>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<%# The avatar badge's reveal.
|
|
2
|
+
|
|
3
|
+
The badge sits empty until you hover the picture, then the pencil fades in —
|
|
4
|
+
the operator's call, and it keeps the resting state quiet.
|
|
5
|
+
|
|
6
|
+
WRITTEN AS CSS RATHER THAN TAILWIND UTILITIES on purpose. The engine ships a
|
|
7
|
+
prebuilt bundle to consumers, so a utility only exists if something already
|
|
8
|
+
emitted it; `group-focus-within:opacity-100` is exactly the kind of class
|
|
9
|
+
that is absent in one app and present in another, and the failure mode is
|
|
10
|
+
silent — a keyboard user simply never sees the icon. Owning the selector
|
|
11
|
+
removes that uncertainty.
|
|
12
|
+
|
|
13
|
+
THREE REVEAL PATHS, and the last two are not decoration. Since the operator
|
|
14
|
+
removed the page heading and the Edit button, this badge is the ONLY route
|
|
15
|
+
from /profile to /profile/edit, so an affordance that exists only on hover
|
|
16
|
+
would strand two whole groups of people:
|
|
17
|
+
|
|
18
|
+
hover — the intended interaction on a pointer
|
|
19
|
+
focus — the same affordance for a keyboard, which never hovers
|
|
20
|
+
(hover: none) — touch has no hover at all, so the icon simply stays
|
|
21
|
+
visible there rather than never appearing
|
|
22
|
+
|
|
23
|
+
The badge circle itself is always visible in every case; it is only the
|
|
24
|
+
glyph inside it that waits.
|
|
25
|
+
%>
|
|
26
|
+
<style>
|
|
27
|
+
.studio-avatar-badge-icon {
|
|
28
|
+
opacity: 0;
|
|
29
|
+
transition: opacity 200ms ease;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* The read page: the whole card is the link, so hovering ANYWHERE on it
|
|
33
|
+
reveals the glyph. The avatar and badge selectors stay for the edit page,
|
|
34
|
+
where the badge is its own button. */
|
|
35
|
+
.studio-identity-card:hover .studio-avatar-badge-icon,
|
|
36
|
+
.studio-identity-card:focus .studio-avatar-badge-icon,
|
|
37
|
+
.studio-identity-card:focus-visible .studio-avatar-badge-icon,
|
|
38
|
+
.studio-avatar:hover .studio-avatar-badge-icon,
|
|
39
|
+
.studio-avatar-badge:hover .studio-avatar-badge-icon,
|
|
40
|
+
.studio-avatar-badge:focus .studio-avatar-badge-icon,
|
|
41
|
+
.studio-avatar-badge:focus-visible .studio-avatar-badge-icon {
|
|
42
|
+
opacity: 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* The card reads as clickable before you click it. Kept subtle — this is a
|
|
46
|
+
whole card lighting up, not a button. */
|
|
47
|
+
.studio-identity-card {
|
|
48
|
+
transition: border-color 200ms ease, background-color 200ms ease;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.studio-identity-card:hover,
|
|
52
|
+
.studio-identity-card:focus-visible {
|
|
53
|
+
border-color: var(--color-primary);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* No hover to give: show it and be done. */
|
|
57
|
+
@media (hover: none) {
|
|
58
|
+
.studio-avatar-badge-icon { opacity: 1; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* The compact header that takes over on scroll. FIXED so it costs no layout
|
|
62
|
+
height while hidden; pinned to the navbar height the host publishes. */
|
|
63
|
+
.studio-identity-mini {
|
|
64
|
+
position: fixed;
|
|
65
|
+
/* Sits just under the host navbar, with a little air so it reads as a card
|
|
66
|
+
floating over the page rather than a second navbar welded to the first. */
|
|
67
|
+
top: calc(var(--nav-h, 0px) + 0.5rem);
|
|
68
|
+
/* CARD WIDTH, not full bleed. 42rem is max-w-2xl — the page container's
|
|
69
|
+
width — and the calc keeps it inside the viewport's gutters on a narrow
|
|
70
|
+
screen, where a fixed 42rem would run off the edge. Centred with the same
|
|
71
|
+
translate the entry animation uses, so the two must stay in one transform:
|
|
72
|
+
a second transform property would silently replace the first. */
|
|
73
|
+
left: 50%;
|
|
74
|
+
width: min(42rem, calc(100% - 2rem));
|
|
75
|
+
z-index: 30;
|
|
76
|
+
background: var(--color-surface);
|
|
77
|
+
border: 1px solid var(--color-border-subtle);
|
|
78
|
+
border-radius: 0.75rem;
|
|
79
|
+
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.25);
|
|
80
|
+
opacity: 0;
|
|
81
|
+
transform: translateX(-50%) translateY(-6px);
|
|
82
|
+
pointer-events: none;
|
|
83
|
+
transition: opacity 200ms ease, transform 200ms ease;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.studio-identity-mini.is-visible {
|
|
87
|
+
opacity: 1;
|
|
88
|
+
transform: translateX(-50%);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@media (prefers-reduced-motion: reduce) {
|
|
92
|
+
.studio-avatar-badge-icon,
|
|
93
|
+
.studio-identity-card,
|
|
94
|
+
.studio-identity-mini { transition: none; }
|
|
95
|
+
}
|
|
96
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<%# Name fields — first and last, side by side on a wide row.
|
|
2
|
+
|
|
3
|
+
Locals: user (required).
|
|
4
|
+
|
|
5
|
+
NO BUTTON. These are fields in the edit page's single form; the sticky bar
|
|
6
|
+
saves them. `x-model` feeds the dirty check that makes the bar appear.
|
|
7
|
+
|
|
8
|
+
LAST NAME IS OPTIONAL AT THE COLUMN LEVEL. mcritchie-studio and turf-monster
|
|
9
|
+
have it; mcritchie-industries, moms-app and acquisition-studio do not, and
|
|
10
|
+
they get a first-name-only row rather than a broken one. The engine's
|
|
11
|
+
standard-columns migration does not yet carry last_name — making it universal
|
|
12
|
+
is a coordinated fleet change (roll-out-last-name-column), and gating here is
|
|
13
|
+
what lets this page ship without waiting on it.
|
|
14
|
+
%>
|
|
15
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
16
|
+
<div>
|
|
17
|
+
<label class="block text-sm text-secondary mb-2 font-medium" for="profile_first_name">First name</label>
|
|
18
|
+
<input type="text" name="profile[first_name]" id="profile_first_name"
|
|
19
|
+
value="<%= user.first_name %>" class="input-field"
|
|
20
|
+
maxlength="<%= Studio::FIRST_NAME_MAX_LENGTH %>"
|
|
21
|
+
autocomplete="given-name" placeholder="What should we call you?"
|
|
22
|
+
x-model="fields.first_name">
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<% if user.respond_to?(:last_name) %>
|
|
26
|
+
<div>
|
|
27
|
+
<label class="block text-sm text-secondary mb-2 font-medium" for="profile_last_name">Last name</label>
|
|
28
|
+
<input type="text" name="profile[last_name]" id="profile_last_name"
|
|
29
|
+
value="<%= user.last_name %>" class="input-field"
|
|
30
|
+
maxlength="<%= Studio::FIRST_NAME_MAX_LENGTH %>"
|
|
31
|
+
autocomplete="family-name" x-model="fields.last_name">
|
|
32
|
+
</div>
|
|
33
|
+
<% end %>
|
|
34
|
+
</div>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<%# The edit glyph on the identity avatar. Its own partial so the read page's
|
|
2
|
+
link and the edit page's button cannot drift into different icons. %>
|
|
3
|
+
<svg class="w-4 h-4 studio-avatar-badge-icon" fill="none" stroke="currentColor" stroke-width="2"
|
|
4
|
+
viewBox="0 0 24 24" aria-hidden="true">
|
|
5
|
+
<path stroke-linecap="round" stroke-linejoin="round"
|
|
6
|
+
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z"/>
|
|
7
|
+
</svg>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<%# The edit form's save bar — fixed to the viewport bottom, present only while
|
|
2
|
+
the form is dirty.
|
|
3
|
+
|
|
4
|
+
No locals. It reads `dirty` and `changeCount` off the enclosing
|
|
5
|
+
studioProfileForm component, so it must be rendered INSIDE that x-data scope.
|
|
6
|
+
|
|
7
|
+
ITS OWN PARTIAL so the browser lane can mount it. The lab page that drives
|
|
8
|
+
this in a real browser may set up a partial's locals and nothing else — it may
|
|
9
|
+
not hand-roll the markup — and while this lived inline in edit.html.erb there
|
|
10
|
+
was no name for it to render. A template needs a controller, a session and a
|
|
11
|
+
user; a partial needs none of those. See test/lib/e2e_lane_contract_test.rb.
|
|
12
|
+
|
|
13
|
+
Four details that make or break it:
|
|
14
|
+
|
|
15
|
+
* fixed, not sticky — it must not reserve height while it is hidden;
|
|
16
|
+
* env(safe-area-inset-bottom), or it sits under the iPhone home indicator;
|
|
17
|
+
* role="status" + aria-live="polite" announces "unsaved changes" without
|
|
18
|
+
stealing focus;
|
|
19
|
+
* the enclosing form carries bottom padding while the bar is up, so the bar
|
|
20
|
+
never covers the last field. That padding lives on the x-data element in
|
|
21
|
+
edit.html.erb, because it is the scroll container's business, not the
|
|
22
|
+
bar's.
|
|
23
|
+
%>
|
|
24
|
+
<div x-show="dirty" x-cloak x-transition.opacity
|
|
25
|
+
data-studio-save-bar
|
|
26
|
+
role="status" aria-live="polite"
|
|
27
|
+
class="fixed inset-x-0 bottom-0 z-40 border-t border-subtle"
|
|
28
|
+
style="background: var(--color-surface); padding-bottom: env(safe-area-inset-bottom);">
|
|
29
|
+
<div class="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between gap-4">
|
|
30
|
+
<p class="text-sm text-secondary">
|
|
31
|
+
<span class="font-semibold text-heading" x-text="changeCount"></span>
|
|
32
|
+
<span x-text="changeCount === 1 ? 'unsaved change' : 'unsaved changes'"></span>
|
|
33
|
+
</p>
|
|
34
|
+
<div class="flex items-center gap-2">
|
|
35
|
+
<button type="button" @click="discard()" class="btn btn-outline btn-sm">Discard</button>
|
|
36
|
+
<button type="submit" class="btn btn-primary btn-sm">Save changes</button>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<%# /profile/edit — ONE form, one Save, and a save bar that does not depend on
|
|
2
|
+
where you are in the page.
|
|
3
|
+
|
|
4
|
+
THE SAVE BAR is fixed to the viewport bottom and exists only while the form
|
|
5
|
+
is dirty. Rejected alternatives, so nobody re-litigates them: a sticky TOP bar
|
|
6
|
+
stacks under the navbar and eats mobile viewport; a floating button has no
|
|
7
|
+
room for a Discard or a change count; an always-present footer costs vertical
|
|
8
|
+
space permanently on a page that is usually short.
|
|
9
|
+
|
|
10
|
+
Four details that make or break it, all present below:
|
|
11
|
+
|
|
12
|
+
* the form carries bottom padding while the bar is up, so the bar never
|
|
13
|
+
covers the last field;
|
|
14
|
+
* env(safe-area-inset-bottom), or it sits under the iPhone home indicator;
|
|
15
|
+
* role="status" + aria-live="polite" announces "unsaved changes" without
|
|
16
|
+
stealing focus;
|
|
17
|
+
* beforeunload AND turbo:before-visit guard the exit, so navigating away
|
|
18
|
+
does not silently drop edits.
|
|
19
|
+
|
|
20
|
+
THE DIRTY CHECK compares against what the server rendered, trimmed on both
|
|
21
|
+
sides — whitespace is not a change, and the controller trims on the way in
|
|
22
|
+
too, so the bar agrees with what saving would actually do.
|
|
23
|
+
|
|
24
|
+
NO-JS FALLBACK: the form has a plain submit at the end. A page whose JS never
|
|
25
|
+
ran still saves; it simply never gets the bar.
|
|
26
|
+
%>
|
|
27
|
+
<% content_for(:title, "Edit profile") %>
|
|
28
|
+
|
|
29
|
+
<% initial = {
|
|
30
|
+
first_name: current_user.try(:first_name).to_s,
|
|
31
|
+
last_name: current_user.try(:last_name).to_s,
|
|
32
|
+
email: current_user.try(:email).to_s,
|
|
33
|
+
birthday: (if current_user.respond_to?(:birth_year) && current_user.birth_year.present? &&
|
|
34
|
+
current_user.birth_month.present? && current_user.birth_day.present?
|
|
35
|
+
format("%04d-%02d-%02d", current_user.birth_year, current_user.birth_month, current_user.birth_day)
|
|
36
|
+
else
|
|
37
|
+
""
|
|
38
|
+
end)
|
|
39
|
+
} %>
|
|
40
|
+
|
|
41
|
+
<%# THE CROPPER COMES FROM THE IDENTITY HEADER, not from a row. It used to be a
|
|
42
|
+
row carrying `modals: true`, and this page kept gating on that flag after the
|
|
43
|
+
avatar moved — so nothing declared it, the host never mounted, and clicking
|
|
44
|
+
the badge opened nothing at all. Gate on the same thing the badge does. %>
|
|
45
|
+
<% avatar_editable = current_user.respond_to?(:avatar) && current_user.avatar.respond_to?(:attached?) %>
|
|
46
|
+
|
|
47
|
+
<% if avatar_editable %>
|
|
48
|
+
<%= render "studio/cropper_assets" %>
|
|
49
|
+
<% end %>
|
|
50
|
+
|
|
51
|
+
<div x-data="studioProfileForm(<%= initial.to_json %>)"
|
|
52
|
+
x-bind:style="dirty ? 'padding-bottom: 96px' : ''">
|
|
53
|
+
<div class="max-w-2xl mx-auto py-8">
|
|
54
|
+
<%# The way back. The page heading and the Done button came off on the
|
|
55
|
+
operator's call, which left browser-back as the only exit for someone who
|
|
56
|
+
opened this and changed nothing — so a small explicit one goes here.
|
|
57
|
+
Deliberately quiet: it is an escape hatch, not an action, and the save bar
|
|
58
|
+
owns the bottom of the screen when there is anything to save. %>
|
|
59
|
+
<%= link_to profile_path,
|
|
60
|
+
class: "inline-flex items-center gap-1 text-sm text-secondary hover:text-heading transition mb-4",
|
|
61
|
+
aria: { label: "Back to your profile" } do %>
|
|
62
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2"
|
|
63
|
+
viewBox="0 0 24 24" aria-hidden="true">
|
|
64
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/>
|
|
65
|
+
</svg>
|
|
66
|
+
Back
|
|
67
|
+
<% end %>
|
|
68
|
+
|
|
69
|
+
<%# Header + uploader in ONE Alpine scope — the badge calls $refs.filePicker,
|
|
70
|
+
and refs do not cross components. %>
|
|
71
|
+
<%= render "studio/profiles/editable_identity", user: current_user %>
|
|
72
|
+
|
|
73
|
+
<%= form_with url: profile_path, method: :patch, scope: :profile,
|
|
74
|
+
data: { turbo: false }, html: { "x-ref": "form" } do %>
|
|
75
|
+
<div class="card p-0 overflow-hidden">
|
|
76
|
+
<% @profile_sections.each_with_index do |section, index| %>
|
|
77
|
+
<section class="p-6 <%= "border-t border-subtle" unless index.zero? %>"
|
|
78
|
+
data-profile-section="<%= section[:key] %>">
|
|
79
|
+
<% if section[:title].present? %>
|
|
80
|
+
<h2 class="text-sm font-bold text-heading mb-4"><%= section[:title] %></h2>
|
|
81
|
+
<% end %>
|
|
82
|
+
<%= render section[:partial], **section.fetch(:locals, {}).merge(user: current_user) %>
|
|
83
|
+
</section>
|
|
84
|
+
<% end %>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<%# The JS-less path. Hidden once Alpine boots, because with the bar up
|
|
88
|
+
there would be two Saves. %>
|
|
89
|
+
<div class="mt-6" x-show="!alpine" x-cloak>
|
|
90
|
+
<button type="submit" class="btn btn-primary">Save changes</button>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<%= render "studio/profiles/save_bar" %>
|
|
94
|
+
<% end %>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<%= render "studio/profiles/form_script" %>
|
|
99
|
+
|
|
100
|
+
<% if avatar_editable %>
|
|
101
|
+
<%# Mounted ONCE per page. Two hosts sharing a store name would register the
|
|
102
|
+
same modal ids twice and render duplicate cards. %>
|
|
103
|
+
<%= render "studio/modals/scoped_host", store: "profileModals" do %>
|
|
104
|
+
<template x-if="$store.profileModals.current()?.id === 'crop-photo'">
|
|
105
|
+
<div><%= render "studio/modals/crop_photo", store: "profileModals" %></div>
|
|
106
|
+
</template>
|
|
107
|
+
<template x-if="$store.profileModals.current()?.id === 'saving'">
|
|
108
|
+
<div><%= render "studio/modals/saving", store: "profileModals" %></div>
|
|
109
|
+
</template>
|
|
110
|
+
<% end %>
|
|
111
|
+
<% end %>
|
|
@@ -1,56 +1,24 @@
|
|
|
1
|
-
<%#
|
|
1
|
+
<%# /profile — the READ page.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
"You at a glance" (the operator's framing, and the shape iOS Settings uses):
|
|
4
|
+
an identity block you look at, then the things that are read-level but need a
|
|
5
|
+
way to act — Google being the one that exists today.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
cannot serve, and admin rows for non-admins, are gone before we get here — so
|
|
10
|
-
this template makes no decisions of its own beyond an empty state.
|
|
7
|
+
Editing lives at /profile/edit. The avatar's badge links there, so the same
|
|
8
|
+
control in the same place means "change this" on both pages.
|
|
11
9
|
%>
|
|
12
10
|
<% content_for(:title, "Profile") %>
|
|
13
11
|
|
|
14
|
-
<%# Does any resolved row need the crop/saving modals? A row declares
|
|
15
|
-
`modals: true` and the page mounts them once, here.
|
|
16
|
-
|
|
17
|
-
The page owns its modal host DELIBERATELY. The obvious alternative — render
|
|
18
|
-
"studio/modals/host" and let the app supply it — fails in both directions:
|
|
19
|
-
mcritchie-industries and moms-app render no shared host at all, so there
|
|
20
|
-
would be nowhere to open a modal from; and mcritchie-studio and turf-monster
|
|
21
|
-
each ship their OWN app/views/studio/modals/_host.html.erb, which shadows the
|
|
22
|
-
engine's in this non-isolated engine, so the page would silently get their
|
|
23
|
-
fork and its registrations. studio/modals/_scoped_host is unforked in every
|
|
24
|
-
app. /admin/emails reached the same conclusion first.
|
|
25
|
-
|
|
26
|
-
Mounted once per page rather than once per row: two hosts sharing a store
|
|
27
|
-
name would register the same modal ids twice and render duplicate cards. %>
|
|
28
|
-
<% profile_needs_modals = Array(@profile_sections).any? { |section| section[:modals] } %>
|
|
29
|
-
|
|
30
|
-
<% if profile_needs_modals %>
|
|
31
|
-
<%# cropper.js + the imageUploadHost / cropPhotoModal / submitFormWithProgress
|
|
32
|
-
factories. Loaded only when a row can actually open the cropper, which is
|
|
33
|
-
the whole reason this is a partial and not a global include. %>
|
|
34
|
-
<%= render "studio/cropper_assets" %>
|
|
35
|
-
<% end %>
|
|
36
|
-
|
|
37
12
|
<div class="max-w-2xl mx-auto py-8">
|
|
38
|
-
|
|
13
|
+
<%= render "studio/profiles/identity", user: current_user, editable: false %>
|
|
39
14
|
|
|
40
|
-
<% if @profile_sections.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<%= render "components/empty_state",
|
|
46
|
-
message: "Nothing to edit yet",
|
|
47
|
-
detail: "This app has not enabled any profile fields." %>
|
|
48
|
-
<% else %>
|
|
49
|
-
<div class="space-y-6">
|
|
50
|
-
<% @profile_sections.each do |section| %>
|
|
51
|
-
<section class="card p-6" data-profile-section="<%= section[:key] %>">
|
|
15
|
+
<% if @profile_sections.present? %>
|
|
16
|
+
<div class="card p-0 overflow-hidden">
|
|
17
|
+
<% @profile_sections.each_with_index do |section, index| %>
|
|
18
|
+
<section class="p-6 <%= "border-t border-subtle" unless index.zero? %>"
|
|
19
|
+
data-profile-section="<%= section[:key] %>">
|
|
52
20
|
<% if section[:title].present? %>
|
|
53
|
-
<h2 class="text-
|
|
21
|
+
<h2 class="text-sm font-bold text-heading mb-4"><%= section[:title] %></h2>
|
|
54
22
|
<% end %>
|
|
55
23
|
<%= render section[:partial], **section.fetch(:locals, {}).merge(user: current_user) %>
|
|
56
24
|
</section>
|
|
@@ -58,16 +26,3 @@
|
|
|
58
26
|
</div>
|
|
59
27
|
<% end %>
|
|
60
28
|
</div>
|
|
61
|
-
|
|
62
|
-
<% if profile_needs_modals %>
|
|
63
|
-
<%# Optional chaining on current() is load-bearing: the outer template unmounts
|
|
64
|
-
one tick AFTER the stack empties, so a bare .id throws on every close. %>
|
|
65
|
-
<%= render "studio/modals/scoped_host", store: "profileModals" do %>
|
|
66
|
-
<template x-if="$store.profileModals.current()?.id === 'crop-photo'">
|
|
67
|
-
<div><%= render "studio/modals/crop_photo", store: "profileModals" %></div>
|
|
68
|
-
</template>
|
|
69
|
-
<template x-if="$store.profileModals.current()?.id === 'saving'">
|
|
70
|
-
<div><%= render "studio/modals/saving", store: "profileModals" %></div>
|
|
71
|
-
</template>
|
|
72
|
-
<% end %>
|
|
73
|
-
<% end %>
|