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
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
#
|
|
13
13
|
# What a section looks like:
|
|
14
14
|
#
|
|
15
|
-
# { key: :
|
|
16
|
-
# locals: { ... }, requires: :
|
|
15
|
+
# { key: :name, title: "Name", page: :edit, partial: "studio/profiles/name_fields",
|
|
16
|
+
# locals: { ... }, requires: :first_name, admin: false }
|
|
17
17
|
#
|
|
18
18
|
# key — stable identifier. A host removes or replaces a default by key,
|
|
19
19
|
# so reordering the defaults never breaks a host's override.
|
|
@@ -48,27 +48,44 @@
|
|
|
48
48
|
# Studio.first_name_outstanding? already guards against with respond_to?.
|
|
49
49
|
#
|
|
50
50
|
# So a row DECLARES what it needs and is dropped when the host cannot serve it.
|
|
51
|
-
# mcritchie-industries gets a working
|
|
52
|
-
#
|
|
53
|
-
#
|
|
51
|
+
# mcritchie-industries gets a working edit page with an email field and no name
|
|
52
|
+
# or birthday fields until it installs the standard-profile-columns migration, at
|
|
53
|
+
# which point they appear with no code change. Silence, not a 500.
|
|
54
54
|
module Studio
|
|
55
55
|
module ProfileSections
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
#
|
|
56
|
+
# Which page a row belongs to. `:show` is the read page (/profile) — things
|
|
57
|
+
# you look at and occasionally act on; `:edit` is the form (/profile/edit).
|
|
58
|
+
PAGES = %i[show edit].freeze
|
|
59
|
+
|
|
60
|
+
# The rows every consumer gets for free.
|
|
61
|
+
#
|
|
62
|
+
# THE AVATAR IS NOT HERE, and that is a change rather than an omission: it
|
|
63
|
+
# moved into the identity header both pages render, where it sits with the
|
|
64
|
+
# display name and the address. It stopped being a row when it stopped
|
|
65
|
+
# looking like one.
|
|
61
66
|
DEFAULTS = [
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
#
|
|
67
|
-
|
|
68
|
-
# drawing this identical button on the login page.
|
|
69
|
-
{ key: :google, title: "Google account",
|
|
67
|
+
# --- the read page ------------------------------------------------------
|
|
68
|
+
# Google is an identity you CONNECT, not a field you type, which is why it
|
|
69
|
+
# reads rather than edits. Gated on the app OFFERING Google, not merely on
|
|
70
|
+
# having the columns — the same question app/views/sessions/new.html.erb
|
|
71
|
+
# already asks before drawing this identical button on the login page.
|
|
72
|
+
{ key: :google, title: "Google account", page: :show,
|
|
70
73
|
partial: "studio/profiles/google_section", requires: %i[provider uid],
|
|
71
|
-
if: -> { Studio.auth_method?(:google) } }
|
|
74
|
+
if: -> { Studio.auth_method?(:google) } },
|
|
75
|
+
|
|
76
|
+
# --- the edit page ------------------------------------------------------
|
|
77
|
+
# These are FIELDS in one form with one Save, so they carry no buttons of
|
|
78
|
+
# their own. Email included: it needed a separate action only while it was
|
|
79
|
+
# out-of-band, and now that it applies directly there is no reason for a
|
|
80
|
+
# second mechanism on the same page. Its side effects — the Google lock,
|
|
81
|
+
# notifying the old address, invalidating other sessions — are the server's
|
|
82
|
+
# business, not the form's.
|
|
83
|
+
{ key: :name, title: "Name", page: :edit,
|
|
84
|
+
partial: "studio/profiles/name_fields", requires: :first_name },
|
|
85
|
+
{ key: :email, title: "Email", page: :edit,
|
|
86
|
+
partial: "studio/profiles/email_fields", requires: :email },
|
|
87
|
+
{ key: :birthday, title: "Birthday", page: :edit,
|
|
88
|
+
partial: "studio/profiles/birthday_fields", requires: %i[birth_day birth_month birth_year] }
|
|
72
89
|
].freeze
|
|
73
90
|
|
|
74
91
|
module_function
|
|
@@ -86,7 +103,9 @@ module Studio
|
|
|
86
103
|
# `nil` meaning "the defaults" rather than "no sections" is the load-bearing
|
|
87
104
|
# choice here: it is what makes a brand-new app's profile page work with an
|
|
88
105
|
# empty initializer, which is the entire point of standardizing this.
|
|
89
|
-
|
|
106
|
+
# `page:` selects a subset. nil means every page, which is what the pre-split
|
|
107
|
+
# callers passed and what a host asking "all of them" wants.
|
|
108
|
+
def resolve(declared, view, page: nil)
|
|
90
109
|
sections = declared.respond_to?(:call) ? declared.call(view) : declared
|
|
91
110
|
sections = defaults if sections.nil?
|
|
92
111
|
|
|
@@ -95,6 +114,7 @@ module Studio
|
|
|
95
114
|
|
|
96
115
|
Array(sections)
|
|
97
116
|
.map { |section| symbolize(section) }
|
|
117
|
+
.reject { |section| page && page_of(section) != page.to_sym }
|
|
98
118
|
.reject { |section| section[:admin] && !admin }
|
|
99
119
|
.select { |section| enabled?(section[:if], view) }
|
|
100
120
|
.select { |section| served_by?(user, section[:requires]) }
|
|
@@ -116,9 +136,21 @@ module Studio
|
|
|
116
136
|
# that silently does nothing, in the same permissive direction as the bug
|
|
117
137
|
# this whole `if:` key was added to fix. A host would have had no signal.
|
|
118
138
|
if condition.is_a?(Symbol) || condition.is_a?(String)
|
|
119
|
-
|
|
139
|
+
unless view.respond_to?(condition)
|
|
140
|
+
# SILENT would repeat the bug this key was added to fix. The old
|
|
141
|
+
# coercion gave the host no signal; dropping the row without one only
|
|
142
|
+
# moves the silence somewhere safer. A typo'd gate now says so.
|
|
143
|
+
warn_gate("profile_sections: `if: #{condition.inspect}` names a method the view " \
|
|
144
|
+
"does not answer — the row was dropped. Check the spelling.")
|
|
145
|
+
return false
|
|
146
|
+
end
|
|
120
147
|
|
|
121
|
-
|
|
148
|
+
# Arity-aware, matching the lambda branch below: a predicate written as
|
|
149
|
+
# `def visible?(view)` is as natural as one written without an argument,
|
|
150
|
+
# and calling it wrong raises ArgumentError — which surfaces as a 500 on
|
|
151
|
+
# /profile rather than as a dropped row.
|
|
152
|
+
method = view.method(condition)
|
|
153
|
+
return !!(method.arity.zero? ? view.public_send(condition) : view.public_send(condition, view))
|
|
122
154
|
end
|
|
123
155
|
|
|
124
156
|
return !!condition unless condition.respond_to?(:call)
|
|
@@ -129,6 +161,12 @@ module Studio
|
|
|
129
161
|
# Can this host's user model serve the row? No requirement means yes — a row
|
|
130
162
|
# that reads nothing off the user (a static explainer, a link out) is always
|
|
131
163
|
# served. A nil user means we are rendering for nobody, and nothing is served.
|
|
164
|
+
# A row that never says defaults to :edit — someone adding a row is usually
|
|
165
|
+
# adding a field, and the read page is a deliberate, curated surface.
|
|
166
|
+
def page_of(section)
|
|
167
|
+
(section[:page] || :edit).to_sym
|
|
168
|
+
end
|
|
169
|
+
|
|
132
170
|
def served_by?(user, requires)
|
|
133
171
|
needed = Array(requires).compact
|
|
134
172
|
return true if needed.empty?
|
|
@@ -137,6 +175,15 @@ module Studio
|
|
|
137
175
|
needed.all? { |attribute| user.respond_to?(attribute) }
|
|
138
176
|
end
|
|
139
177
|
|
|
178
|
+
# Pure Ruby: this file loads without Rails, so it cannot assume a logger.
|
|
179
|
+
def warn_gate(message)
|
|
180
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
181
|
+
Rails.logger.warn(message)
|
|
182
|
+
else
|
|
183
|
+
Kernel.warn(message)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
140
187
|
def symbolize(hash)
|
|
141
188
|
out = hash.to_h.each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }
|
|
142
189
|
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
# Sections flagged admin: true resolve only for admin? viewers, so the
|
|
16
16
|
# trigger and panel stay invisible to everyone else even when the host
|
|
17
17
|
# declares nothing but admin links.
|
|
18
|
+
#
|
|
19
|
+
# THE ENGINE PREPENDS ONE SECTION OF ITS OWN — "You", linking to /profile. The
|
|
20
|
+
# engine ships that page, so it ships the way in rather than asking five apps to
|
|
21
|
+
# declare the same entry and watch them drift. See `standard` below for the two
|
|
22
|
+
# gates it carries.
|
|
18
23
|
module Studio
|
|
19
24
|
module SidebarSections
|
|
20
25
|
module_function
|
|
@@ -22,9 +27,48 @@ module Studio
|
|
|
22
27
|
def resolve(declared, view)
|
|
23
28
|
sections = declared.respond_to?(:call) ? declared.call(view) : declared
|
|
24
29
|
admin = view.respond_to?(:admin?) && view.admin?
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
(standard(view) + Array(sections))
|
|
31
|
+
.map { |section| symbolize(section) }
|
|
32
|
+
.reject { |section| section[:admin] && !admin }
|
|
33
|
+
.map { |section| section.merge(links: Array(section[:links]).map { |link| symbolize(link) }) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# What the ENGINE puts in every app's sidebar, ahead of whatever the host
|
|
37
|
+
# declared. The engine ships /profile, so it ships the way in — otherwise
|
|
38
|
+
# every consumer writes the same entry and they drift in wording and emoji.
|
|
39
|
+
#
|
|
40
|
+
# PREPENDED, not appended: it is the viewer's own account, the thing nearest
|
|
41
|
+
# to them, and the operator asked for it at the top.
|
|
42
|
+
#
|
|
43
|
+
# TWO GATES, and both are load-bearing:
|
|
44
|
+
#
|
|
45
|
+
# * the route must be drawn. An app that set draw_profile_routes = false
|
|
46
|
+
# would otherwise be handed a menu item pointing at a route that does not
|
|
47
|
+
# exist — a 404 from its own navigation.
|
|
48
|
+
# * the viewer must be signed in. /profile requires authentication, so
|
|
49
|
+
# offering it to a signed-out visitor bounces them to the login page from
|
|
50
|
+
# something that looked like navigation. Same shape as the `admin:` rule
|
|
51
|
+
# below, which already drops sections a viewer may not see.
|
|
52
|
+
#
|
|
53
|
+
# A view that answers neither predicate (a bare context in a unit test) gets
|
|
54
|
+
# nothing, which is the safe direction: a missing link is visible and
|
|
55
|
+
# fixable, a link to nowhere is the bug.
|
|
56
|
+
def standard(view)
|
|
57
|
+
return [] unless profile_link?(view)
|
|
58
|
+
|
|
59
|
+
[{ title: "You", links: [
|
|
60
|
+
{ label: "Profile", href: view.profile_path, emoji: "👤",
|
|
61
|
+
desc: "Your name, email and photo" }
|
|
62
|
+
] }]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def profile_link?(view)
|
|
66
|
+
return false unless defined?(Studio) && Studio.respond_to?(:draw_profile_routes)
|
|
67
|
+
return false unless Studio.draw_profile_routes
|
|
68
|
+
return false unless view.respond_to?(:profile_path)
|
|
69
|
+
return false unless view.respond_to?(:logged_in?) && view.logged_in?
|
|
70
|
+
|
|
71
|
+
true
|
|
28
72
|
end
|
|
29
73
|
|
|
30
74
|
def symbolize(hash)
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -613,8 +613,8 @@ module Studio
|
|
|
613
613
|
# the view, keys symbolize, admin-only rows drop for non-admin viewers, and
|
|
614
614
|
# rows this host's user model cannot serve drop entirely. `nil` config resolves
|
|
615
615
|
# to the standard page.
|
|
616
|
-
def self.profile_sections_for(view)
|
|
617
|
-
ProfileSections.resolve(profile_sections, view)
|
|
616
|
+
def self.profile_sections_for(view, page: nil)
|
|
617
|
+
ProfileSections.resolve(profile_sections, view, page: page)
|
|
618
618
|
end
|
|
619
619
|
|
|
620
620
|
def self.env_truthy?(value)
|
|
@@ -683,6 +683,7 @@ module Studio
|
|
|
683
683
|
# both would delete someone's photo every time they edited their name.
|
|
684
684
|
if Studio.draw_profile_routes
|
|
685
685
|
get "profile", to: "studio/profiles#show", as: :profile
|
|
686
|
+
get "profile/edit", to: "studio/profiles#edit", as: :edit_profile
|
|
686
687
|
patch "profile", to: "studio/profiles#update"
|
|
687
688
|
patch "profile/avatar", to: "studio/profiles#avatar", as: :profile_avatar
|
|
688
689
|
# DELETE, because unlinking removes an identity. Linking is not drawn
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.50.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -216,6 +216,7 @@ files:
|
|
|
216
216
|
- app/jobs/studio/email_delivery_job.rb
|
|
217
217
|
- app/mailers/application_mailer.rb
|
|
218
218
|
- app/mailers/studio/newsletter_mailer.rb
|
|
219
|
+
- app/mailers/studio/profile_mailer.rb
|
|
219
220
|
- app/mailers/user_mailer.rb
|
|
220
221
|
- app/models/concerns/sluggable.rb
|
|
221
222
|
- app/models/concerns/studio/board/rankable.rb
|
|
@@ -335,9 +336,21 @@ files:
|
|
|
335
336
|
- app/views/studio/models/show.html.erb
|
|
336
337
|
- app/views/studio/newsletter_mailer/subscribed.html.erb
|
|
337
338
|
- app/views/studio/newsletter_mailer/subscribed.text.erb
|
|
338
|
-
- app/views/studio/
|
|
339
|
-
- app/views/studio/
|
|
339
|
+
- app/views/studio/profile_mailer/email_change_notification.html.erb
|
|
340
|
+
- app/views/studio/profile_mailer/email_change_notification.text.erb
|
|
341
|
+
- app/views/studio/profiles/_birthday_fields.html.erb
|
|
342
|
+
- app/views/studio/profiles/_editable_identity.html.erb
|
|
343
|
+
- app/views/studio/profiles/_email_fields.html.erb
|
|
344
|
+
- app/views/studio/profiles/_form_script.html.erb
|
|
340
345
|
- app/views/studio/profiles/_google_section.html.erb
|
|
346
|
+
- app/views/studio/profiles/_identity.html.erb
|
|
347
|
+
- app/views/studio/profiles/_identity_body.html.erb
|
|
348
|
+
- app/views/studio/profiles/_identity_mini.html.erb
|
|
349
|
+
- app/views/studio/profiles/_identity_styles.html.erb
|
|
350
|
+
- app/views/studio/profiles/_name_fields.html.erb
|
|
351
|
+
- app/views/studio/profiles/_pencil_icon.html.erb
|
|
352
|
+
- app/views/studio/profiles/_save_bar.html.erb
|
|
353
|
+
- app/views/studio/profiles/edit.html.erb
|
|
341
354
|
- app/views/studio/profiles/show.html.erb
|
|
342
355
|
- app/views/style/_modal_specimen.html.erb
|
|
343
356
|
- app/views/style/_modals.html.erb
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
<%# Profile photo row — lifted from turf-monster's /account, which is the
|
|
2
|
-
interaction the operator wants standardized: click the avatar, hover reveals
|
|
3
|
-
an "Update" cap, pick a file, crop it square, and it saves immediately. No
|
|
4
|
-
Save button, no visible file field.
|
|
5
|
-
|
|
6
|
-
Locals: user (required).
|
|
7
|
-
|
|
8
|
-
HOW IT HANGS TOGETHER
|
|
9
|
-
imageUploadHost() the engine's crop-then-immediate-save x-data factory
|
|
10
|
-
(studio/modals/_image_upload). onFileSelected() reads
|
|
11
|
-
the picked file and opens the crop modal; the modal
|
|
12
|
-
dispatches the cropped Blob back on
|
|
13
|
-
'crop-photo-confirmed'; applyCrop() drops it into the
|
|
14
|
-
hidden form's file input and submits with a saving
|
|
15
|
-
card.
|
|
16
|
-
profileModals the PAGE-SCOPED Alpine store mounted by show.html.erb.
|
|
17
|
-
Not the shared "modals" store — see below.
|
|
18
|
-
profile_avatar_path its own endpoint, because an attachment param
|
|
19
|
-
submitted empty PURGES the attachment.
|
|
20
|
-
|
|
21
|
-
WHY THE PAGE-SCOPED STORE AND NOT `modals`. turf's copy opens on
|
|
22
|
-
Alpine.store('modals') — the app's shared modal host — which works there
|
|
23
|
-
because turf's layout mounts one and registers crop-photo in it. Two of the
|
|
24
|
-
five consumers (mcritchie-industries, moms-app) render NO shared host at all,
|
|
25
|
-
and the two that do would each have to add a crop-photo registration to their
|
|
26
|
-
layout before this row worked. An engine page cannot ask that. show.html.erb
|
|
27
|
-
mounts studio/modals/_scoped_host instead, so the page brings its own modals
|
|
28
|
-
and this row behaves identically in every app. /admin/emails already does
|
|
29
|
-
exactly this.
|
|
30
|
-
|
|
31
|
-
WHY onCropConfirmed AND NOT applyCrop DIRECTLY. turf's call site binds
|
|
32
|
-
`applyCrop($event.detail.blob)`, which predates the owner guard. Every
|
|
33
|
-
imageUploadHost on a page hears the same window event, so a page that later
|
|
34
|
-
grows a second uploader (a host row with its own image) would have both
|
|
35
|
-
hosts save the same crop. onCropConfirmed() checks the owner token first and
|
|
36
|
-
is a strict improvement at the same call cost.
|
|
37
|
-
|
|
38
|
-
ON THE INLINE PIXEL SIZES. Same reason turf gives: the exact circle geometry
|
|
39
|
-
is expressed inline rather than through w-24/h-24, because a size utility
|
|
40
|
-
only exists if something already emitted it into the compiled bundle. The
|
|
41
|
-
classes used here (group-hover:opacity-100, rounded-full, object-cover,
|
|
42
|
-
absolute inset-0) already ship in other engine partials.
|
|
43
|
-
%>
|
|
44
|
-
<div x-data="imageUploadHost({
|
|
45
|
-
store: 'profileModals',
|
|
46
|
-
aspectRatio: 1,
|
|
47
|
-
filename: 'avatar.png',
|
|
48
|
-
saving: 'Saving photo…',
|
|
49
|
-
toast: false
|
|
50
|
-
})"
|
|
51
|
-
@crop-photo-confirmed.window="onCropConfirmed($event.detail)"
|
|
52
|
-
class="flex flex-col items-center">
|
|
53
|
-
|
|
54
|
-
<%# Auto-submitted once a crop is confirmed. Hidden because the person never
|
|
55
|
-
touches it — applyCrop() writes the cropped File onto fileInput and calls
|
|
56
|
-
submitFormWithProgress on form. %>
|
|
57
|
-
<%= form_with url: profile_avatar_path, method: :patch, scope: :profile,
|
|
58
|
-
html: { multipart: true, "x-ref": "form", class: "hidden" } do %>
|
|
59
|
-
<input type="file" name="profile[avatar]" x-ref="fileInput">
|
|
60
|
-
<% end %>
|
|
61
|
-
|
|
62
|
-
<%# The real picker. Opened by the avatar button; never shown. %>
|
|
63
|
-
<input type="file" x-ref="filePicker" @change="onFileSelected($event)"
|
|
64
|
-
accept="<%= Studio::ProfileImage::ALLOWED_CONTENT_TYPES.join(",") %>" class="hidden">
|
|
65
|
-
|
|
66
|
-
<%# The clickable avatar. A BUTTON rather than turf's <div @click>: a div is
|
|
67
|
-
unreachable by keyboard and invisible to a screen reader, so the only way
|
|
68
|
-
to change your photo would be a mouse. Same visual, and it focuses. %>
|
|
69
|
-
<button type="button"
|
|
70
|
-
@click="$refs.filePicker.click()"
|
|
71
|
-
aria-label="Change your profile photo"
|
|
72
|
-
style="width: 96px; height: 96px;"
|
|
73
|
-
class="relative rounded-full cursor-pointer group overflow-hidden mb-3 flex-shrink-0 p-0 border-0 bg-transparent">
|
|
74
|
-
<% if user.avatar.attached? %>
|
|
75
|
-
<%= image_tag user.avatar, class: "rounded-full object-cover",
|
|
76
|
-
style: "width: 96px; height: 96px;", alt: user.display_name %>
|
|
77
|
-
<% else %>
|
|
78
|
-
<div class="rounded-full flex items-center justify-center font-bold text-white text-3xl"
|
|
79
|
-
style="width: 96px; height: 96px; background-color: <%= user.avatar_color %>">
|
|
80
|
-
<%= user.avatar_initials %>
|
|
81
|
-
</div>
|
|
82
|
-
<% end %>
|
|
83
|
-
|
|
84
|
-
<%# The hover cap. group-hover reveals it on pointer; focus-within is the
|
|
85
|
-
keyboard equivalent, so a tabbing user sees the same affordance. %>
|
|
86
|
-
<span class="absolute inset-0 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 group-focus:opacity-100 transition-opacity duration-200"
|
|
87
|
-
style="background: rgba(0,0,0,0.5)">
|
|
88
|
-
<span class="text-white text-xs font-bold">Update</span>
|
|
89
|
-
</span>
|
|
90
|
-
</button>
|
|
91
|
-
|
|
92
|
-
<div class="text-xl font-bold text-heading"><%= user.display_name %></div>
|
|
93
|
-
</div>
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
<%# First-name row.
|
|
2
|
-
|
|
3
|
-
Locals: user (required).
|
|
4
|
-
|
|
5
|
-
This row only renders when the host's user model actually answers
|
|
6
|
-
`first_name` — Studio::ProfileSections drops it otherwise, so an app that has
|
|
7
|
-
not installed the standard profile columns gets a page without this row
|
|
8
|
-
rather than a NoMethodError on every visit. Nothing here needs to re-check.
|
|
9
|
-
|
|
10
|
-
The same column is written by the onboarding step
|
|
11
|
-
(Studio::OnboardingController#first_name); this is the surface for changing it
|
|
12
|
-
later.
|
|
13
|
-
%>
|
|
14
|
-
<%= form_with url: profile_path, method: :patch, scope: :profile,
|
|
15
|
-
data: { turbo: false }, class: "flex flex-wrap items-end gap-3" do |form| %>
|
|
16
|
-
<div class="flex-1 min-w-0" style="min-width: 12rem;">
|
|
17
|
-
<label class="block text-sm text-secondary mb-2 font-medium" for="profile_first_name">First name</label>
|
|
18
|
-
<%= form.text_field :first_name,
|
|
19
|
-
value: user.first_name,
|
|
20
|
-
maxlength: Studio::FIRST_NAME_MAX_LENGTH,
|
|
21
|
-
autocomplete: "given-name",
|
|
22
|
-
placeholder: "What should we call you?",
|
|
23
|
-
class: "input-field" %>
|
|
24
|
-
</div>
|
|
25
|
-
<%= form.submit "Save", class: "btn btn-primary" %>
|
|
26
|
-
<% end %>
|