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.
@@ -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
- Array(sections).map { |section| symbolize(section) }
26
- .reject { |section| section[:admin] && !admin }
27
- .map { |section| section.merge(links: Array(section[:links]).map { |link| symbolize(link) }) }
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)
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.49.0"
2
+ VERSION = "0.50.0"
3
3
  end
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.49.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/_avatar_section.html.erb
342
- - app/views/studio/profiles/_email_section.html.erb
343
- - app/views/studio/profiles/_first_name_section.html.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
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 %>