studio-engine 0.49.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 +69 -5
- data/app/controllers/studio/profiles_controller.rb +148 -115
- data/app/mailers/studio/profile_mailer.rb +1 -1
- 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 +8 -61
- data/lib/studio/profile_sections.rb +46 -22
- data/lib/studio/sidebar_sections.rb +47 -3
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +3 -4
- metadata +13 -4
- data/app/views/studio/profiles/_avatar_section.html.erb +0 -93
- data/app/views/studio/profiles/_email_section.html.erb +0 -55
- data/app/views/studio/profiles/_first_name_section.html.erb +0 -42
|
@@ -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,14 +683,13 @@ 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
|
|
689
690
|
# here: it is OmniAuth's own /auth/:provider, which the middleware owns.
|
|
690
|
-
patch "profile/email", to: "studio/profiles#email", as: :profile_email
|
|
691
691
|
delete "profile/google", to: "studio/profiles#unlink_google",
|
|
692
692
|
as: :profile_unlink_google
|
|
693
|
-
|
|
694
693
|
end
|
|
695
694
|
|
|
696
695
|
resources :error_logs, only: [:index, :show]
|
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
|
|
@@ -338,10 +338,19 @@ files:
|
|
|
338
338
|
- app/views/studio/newsletter_mailer/subscribed.text.erb
|
|
339
339
|
- app/views/studio/profile_mailer/email_change_notification.html.erb
|
|
340
340
|
- app/views/studio/profile_mailer/email_change_notification.text.erb
|
|
341
|
-
- app/views/studio/profiles/
|
|
342
|
-
- app/views/studio/profiles/
|
|
343
|
-
- app/views/studio/profiles/
|
|
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
|
|
344
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
|
|
345
354
|
- app/views/studio/profiles/show.html.erb
|
|
346
355
|
- app/views/style/_modal_specimen.html.erb
|
|
347
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,55 +0,0 @@
|
|
|
1
|
-
<%# Email row.
|
|
2
|
-
|
|
3
|
-
Locals: user (required).
|
|
4
|
-
|
|
5
|
-
DIRECT CHANGE from any signed-in session (operator's call, 2026-08-14). The
|
|
6
|
-
earlier build mailed a confirmation link to the current address and applied
|
|
7
|
-
the change only when someone clicked it; the session is the authority now.
|
|
8
|
-
The row therefore saves what you type, and the copy says so plainly rather
|
|
9
|
-
than implying a pending step that no longer exists.
|
|
10
|
-
|
|
11
|
-
THE GOOGLE EXCEPTION. An account with a linked Google identity cannot change
|
|
12
|
-
its email here — Google is the authoritative source for that address, and
|
|
13
|
-
letting the two drift means the next OAuth sign-in either re-links to a
|
|
14
|
-
stranger's row or cannot find its own. The locked branch renders NO form at
|
|
15
|
-
all — there is nothing to type into and nothing to submit — and
|
|
16
|
-
ProfilesController#email refuses the request independently anyway, because a
|
|
17
|
-
missing form is a courtesy and anyone can POST.
|
|
18
|
-
|
|
19
|
-
Gated on a real delta for the same reason the name row is — an empty field,
|
|
20
|
-
or the address you already have, is not a change.
|
|
21
|
-
%>
|
|
22
|
-
<% locked = Studio::OauthIdentity.google_linked?(user) %>
|
|
23
|
-
<% current = user.email.to_s.strip %>
|
|
24
|
-
|
|
25
|
-
<% if locked %>
|
|
26
|
-
<p class="text-sm text-secondary mb-1">Current address</p>
|
|
27
|
-
<p class="text-heading font-semibold mb-3 break-all"><%= user.email %></p>
|
|
28
|
-
<div class="rounded-lg p-3 flex items-start gap-2" style="background: var(--color-surface-alt);">
|
|
29
|
-
<span aria-hidden="true">🔒</span>
|
|
30
|
-
<p class="text-muted text-xs">
|
|
31
|
-
This comes from your linked Google account. Unlink Google below if you want to change it.
|
|
32
|
-
</p>
|
|
33
|
-
</div>
|
|
34
|
-
<% else %>
|
|
35
|
-
<%= form_with url: profile_email_path, method: :patch, scope: :profile,
|
|
36
|
-
data: { turbo: false },
|
|
37
|
-
html: { "x-data": "{ current: #{current.downcase.to_json}, value: #{current.to_json} }" },
|
|
38
|
-
class: "flex flex-wrap items-end gap-3" do |form| %>
|
|
39
|
-
<div class="flex-1 min-w-0" style="min-width: 14rem;">
|
|
40
|
-
<label class="block text-sm text-secondary mb-2 font-medium" for="profile_email">Email</label>
|
|
41
|
-
<%= form.email_field :email, value: user.email, autocomplete: "email",
|
|
42
|
-
placeholder: "you@example.com", class: "input-field",
|
|
43
|
-
"x-model": "value" %>
|
|
44
|
-
</div>
|
|
45
|
-
<%= form.submit "Update",
|
|
46
|
-
class: "btn btn-primary",
|
|
47
|
-
":disabled": "value.trim() === '' || value.trim().toLowerCase() === current",
|
|
48
|
-
":class": "(value.trim() === '' || value.trim().toLowerCase() === current) && 'opacity-50 cursor-not-allowed'" %>
|
|
49
|
-
<% end %>
|
|
50
|
-
<% if current.present? %>
|
|
51
|
-
<p class="text-muted text-xs mt-2">
|
|
52
|
-
We'll let <span class="font-semibold"><%= user.email %></span> know it changed, and sign out your other devices.
|
|
53
|
-
</p>
|
|
54
|
-
<% end %>
|
|
55
|
-
<% end %>
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
<%# First-name row.
|
|
2
|
-
|
|
3
|
-
Locals: user (required).
|
|
4
|
-
|
|
5
|
-
PREFILLED, with the Update button to the right of the input and DISABLED
|
|
6
|
-
until the value actually differs (operator's call, 2026-08-14). A blank field
|
|
7
|
-
made you retype a name you had already set just to see what it was, and an
|
|
8
|
-
always-live Save invited a no-op write that flashed "Name updated." over a
|
|
9
|
-
change nobody made.
|
|
10
|
-
|
|
11
|
-
The delta is computed against the value the server rendered, and both sides
|
|
12
|
-
are trimmed — leading or trailing whitespace is not a change, and the
|
|
13
|
-
controller trims on the way in too, so the button agrees with what the server
|
|
14
|
-
would actually do.
|
|
15
|
-
|
|
16
|
-
NO-JS FALLBACK: the button carries no `disabled` attribute in the markup;
|
|
17
|
-
Alpine adds it on init. A page whose JS never ran therefore gets a working,
|
|
18
|
-
always-enabled Save rather than a control it can never turn on — the server
|
|
19
|
-
re-checks anyway.
|
|
20
|
-
|
|
21
|
-
This row only renders when the host's user model answers `first_name`
|
|
22
|
-
(Studio::ProfileSections drops it otherwise), so nothing here re-checks.
|
|
23
|
-
%>
|
|
24
|
-
<%= form_with url: profile_path, method: :patch, scope: :profile,
|
|
25
|
-
data: { turbo: false },
|
|
26
|
-
html: { "x-data": "{ initial: #{user.first_name.to_s.strip.to_json}, value: #{user.first_name.to_s.strip.to_json} }" },
|
|
27
|
-
class: "flex flex-wrap items-end gap-3" do |form| %>
|
|
28
|
-
<div class="flex-1 min-w-0" style="min-width: 12rem;">
|
|
29
|
-
<label class="block text-sm text-secondary mb-2 font-medium" for="profile_first_name">First name</label>
|
|
30
|
-
<%= form.text_field :first_name,
|
|
31
|
-
value: user.first_name,
|
|
32
|
-
maxlength: Studio::FIRST_NAME_MAX_LENGTH,
|
|
33
|
-
autocomplete: "given-name",
|
|
34
|
-
placeholder: "What should we call you?",
|
|
35
|
-
class: "input-field",
|
|
36
|
-
"x-model": "value" %>
|
|
37
|
-
</div>
|
|
38
|
-
<%= form.submit "Update",
|
|
39
|
-
class: "btn btn-primary",
|
|
40
|
-
":disabled": "value.trim() === initial.trim()",
|
|
41
|
-
":class": "value.trim() === initial.trim() && 'opacity-50 cursor-not-allowed'" %>
|
|
42
|
-
<% end %>
|