studio-engine 0.113.0 → 0.114.0.rc1
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 +116 -0
- data/README.md +139 -0
- data/app/assets/tailwind/studio_engine/engine.css +52 -0
- data/app/helpers/studio_navbar_helper.rb +47 -2
- data/app/javascript/studio/alpine_scopes.js +9 -3
- data/app/javascript/studio/host_fetchers.js +16 -0
- data/app/javascript/studio/modal_countdown.js +95 -0
- data/app/javascript/studio/newsletter_modal.js +252 -0
- data/app/views/components/_sidebar_panel.html.erb +22 -3
- data/app/views/components/_user_nav.html.erb +37 -4
- data/app/views/layouts/_navbar.html.erb +60 -10
- data/app/views/studio/_alpine_scopes_guard.html.erb +9 -7
- data/app/views/studio/_cropper_assets.html.erb +15 -3
- data/app/views/studio/modals/blocks/_birthday.html.erb +18 -0
- data/app/views/studio/modals/blocks/_change_username.html.erb +39 -1
- data/app/views/studio/modals/blocks/_error_card.html.erb +57 -1
- data/app/views/studio/modals/blocks/_success_card.html.erb +45 -4
- data/app/views/studio/profiles/_newsletter_modals.html.erb +281 -19
- data/app/views/style/_modals.html.erb +65 -0
- data/lib/studio/navbar_account_sidebar.rb +128 -0
- data/lib/studio/navbar_brand.rb +84 -0
- data/lib/studio/navbar_signed_out_cta.rb +77 -8
- data/lib/studio/navbar_slots.rb +36 -14
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +129 -0
- metadata +6 -1
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// studio/newsletter_modal: the newsletter cards' JSON mode
|
|
2
|
+
// (studio/profiles/_newsletter_modals with submit_mode: :json).
|
|
3
|
+
//
|
|
4
|
+
// x-data="studioNewsletterModal({ action: 'subscribe', url, store, needsEmail, requiresConsent, nextId, event })"
|
|
5
|
+
//
|
|
6
|
+
// One scope for every card, so each binds the same names: email, consent,
|
|
7
|
+
// saving, error, props, ready and submit(). A card that only shows (the success
|
|
8
|
+
// and goodbye beats) binds props and nothing else.
|
|
9
|
+
//
|
|
10
|
+
// WHAT submit() DOES, in order:
|
|
11
|
+
// 1. Refuses quietly while a request is in flight, or while a consent box the
|
|
12
|
+
// card carries is unticked (the button is disabled then too).
|
|
13
|
+
// 2. When the card asks for an address, refuses an address with no "@" past
|
|
14
|
+
// its first character, on the card's own error line.
|
|
15
|
+
// 3. HANDS OFF when the opener passed props.onSubmit: closes the card and
|
|
16
|
+
// calls it with the address (or null). The opener owns the request then;
|
|
17
|
+
// this is how a page that already knows how to subscribe asks only for the
|
|
18
|
+
// address.
|
|
19
|
+
// 4. Otherwise POSTs JSON to the host's url (DELETE or another verb by
|
|
20
|
+
// opts.method) with the page's CSRF token, and reads the answer: a 2xx, not by redirect, whose
|
|
21
|
+
// body does not say success: false is a success; anything else shows
|
|
22
|
+
// body.error, or a plain line, on the card. The request goes through
|
|
23
|
+
// plain fetch, or through the host's own fetcher when opts.fetcher names
|
|
24
|
+
// one (THE HOST'S FETCHER, below).
|
|
25
|
+
// 5. On success: dispatches opts.event on window with
|
|
26
|
+
// { subscribed, email, response }, calls props.onSuccess(response) when the
|
|
27
|
+
// opener passed one, then swaps to opts.nextId (the success or goodbye card)
|
|
28
|
+
// with the response as its props, or closes when there is none.
|
|
29
|
+
//
|
|
30
|
+
// No page reload anywhere: the host's listener updates whatever the page shows.
|
|
31
|
+
//
|
|
32
|
+
// THE HOST'S FETCHER. A host whose requests go through its own wrapper (Turf
|
|
33
|
+
// Monster's authedFetch: a 401 opens the sign-in card, a 429 the rate-limit
|
|
34
|
+
// card) names it with opts.fetcher, a JS identifier. The card resolves it as a
|
|
35
|
+
// named export of the importmap module "studio/host_fetchers": the engine pins
|
|
36
|
+
// that name to an empty module, and a host that pins the same name to its own
|
|
37
|
+
// file wins (FRONT_END_STANDARD.md, "The engine's modules"). Never a window
|
|
38
|
+
// global: the standard forbids a window.* API between files. The host's module
|
|
39
|
+
// is imported on the first press that needs it, not at boot.
|
|
40
|
+
//
|
|
41
|
+
// # the host's config/importmap.rb
|
|
42
|
+
// pin "studio/host_fetchers", to: "newsletter_fetchers.js"
|
|
43
|
+
// // app/javascript/newsletter_fetchers.js
|
|
44
|
+
// export { authedFetch } from "solana_utils"
|
|
45
|
+
//
|
|
46
|
+
// The fetcher takes fetch's arguments and returns a Response, or null when it
|
|
47
|
+
// has answered the request itself (authedFetch opens its own card and returns
|
|
48
|
+
// null): the card then stops its spinner and says nothing more. A name the
|
|
49
|
+
// module does not export as a function, or a module that does not load, fails
|
|
50
|
+
// the press with the card's plain line and one console error; it never falls
|
|
51
|
+
// back to plain fetch, which would quietly drop the host's 401 handling.
|
|
52
|
+
//
|
|
53
|
+
// studio/alpine_scopes publishes the factory on window, statically: Alpine
|
|
54
|
+
// evaluates the x-data as the modal host mounts the card. It imports nothing
|
|
55
|
+
// statically (the host's fetcher module is a dynamic import);
|
|
56
|
+
// test/javascript/newsletter_modal.test.mjs loads it as a data: module.
|
|
57
|
+
|
|
58
|
+
export const INVALID_EMAIL = "Enter a valid email address."
|
|
59
|
+
export const SUBSCRIBE_FAILED = "Could not join the newsletter."
|
|
60
|
+
export const UNSUBSCRIBE_FAILED = "Could not unsubscribe."
|
|
61
|
+
export const CHANGED_EVENT = "studio:newsletter-changed"
|
|
62
|
+
export const HOST_FETCHERS = "studio/host_fetchers"
|
|
63
|
+
export const FETCHER_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
64
|
+
|
|
65
|
+
// The host's fetcher named +name+, as a function, or null when the module does
|
|
66
|
+
// not load or does not export a function by that name. +importer+ loads
|
|
67
|
+
// studio/host_fetchers (the tests pass their own).
|
|
68
|
+
export async function resolveFetcher(name, importer) {
|
|
69
|
+
if (!FETCHER_NAME.test(String(name || ""))) return null
|
|
70
|
+
try {
|
|
71
|
+
const mod = await importer()
|
|
72
|
+
const fn = mod && mod[name]
|
|
73
|
+
return typeof fn === "function" ? fn : null
|
|
74
|
+
} catch (_) {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The address trimmed, or "" when it cannot be one: an "@" with nothing before
|
|
80
|
+
// it, or none at all. The server is the real check; this only saves a round trip
|
|
81
|
+
// for the address nobody meant.
|
|
82
|
+
export function cleanEmail(value) {
|
|
83
|
+
const s = String(value == null ? "" : value).trim()
|
|
84
|
+
return s.indexOf("@") > 0 ? s : ""
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// What the host's answer means. ok: a 2xx whose body does not say
|
|
88
|
+
// success: false, and that fetch did not reach by a redirect (an expired session
|
|
89
|
+
// redirected to an HTML sign-in page answers 200, and joined nobody). error:
|
|
90
|
+
// the body's own words, else the fallback.
|
|
91
|
+
export function outcomeOf(response, body, fallback) {
|
|
92
|
+
body = body && typeof body === "object" ? body : {}
|
|
93
|
+
const ok = !!(response && response.ok && !response.redirected) && body.success !== false
|
|
94
|
+
return ok ? { ok: true, body: body } : { ok: false, body: body, error: body.error || fallback }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The page's CSRF token, or "" on a page without one.
|
|
98
|
+
export function csrfToken(doc) {
|
|
99
|
+
try {
|
|
100
|
+
const meta = doc && doc.querySelector('meta[name="csrf-token"]')
|
|
101
|
+
return (meta && meta.content) || ""
|
|
102
|
+
} catch (_) {
|
|
103
|
+
return ""
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// x-data for a newsletter card.
|
|
108
|
+
//
|
|
109
|
+
// opts:
|
|
110
|
+
// action "subscribe" (default) or "unsubscribe".
|
|
111
|
+
// url the host's JSON endpoint for the action.
|
|
112
|
+
// method the verb (default "POST").
|
|
113
|
+
// store the Alpine store the card is mounted in (default "modals").
|
|
114
|
+
// needsEmail the card asks for an address.
|
|
115
|
+
// requiresConsent the card carries a consent box (ticked to start).
|
|
116
|
+
// nextId the card a success swaps to; blank closes instead.
|
|
117
|
+
// event the window event a success dispatches (CHANGED_EVENT).
|
|
118
|
+
// fetcher the host's fetcher, by its export name in
|
|
119
|
+
// studio/host_fetchers; blank sends through plain fetch.
|
|
120
|
+
//
|
|
121
|
+
// env: { win, importer } for the tests.
|
|
122
|
+
export function studioNewsletterModal(opts, env) {
|
|
123
|
+
opts = opts || {}
|
|
124
|
+
const win = (env && env.win) || (typeof window !== "undefined" ? window : {})
|
|
125
|
+
const action = opts.action === "unsubscribe" ? "unsubscribe" : "subscribe"
|
|
126
|
+
const importer = (env && env.importer) || (() => import("studio/host_fetchers"))
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
email: "",
|
|
130
|
+
consent: true,
|
|
131
|
+
saving: false,
|
|
132
|
+
error: "",
|
|
133
|
+
action: action,
|
|
134
|
+
url: opts.url || "",
|
|
135
|
+
method: String(opts.method || "POST").toUpperCase(),
|
|
136
|
+
store: opts.store || "modals",
|
|
137
|
+
needsEmail: !!opts.needsEmail,
|
|
138
|
+
requiresConsent: !!opts.requiresConsent,
|
|
139
|
+
nextId: opts.nextId || "",
|
|
140
|
+
event: opts.event || CHANGED_EVENT,
|
|
141
|
+
fetcher: opts.fetcher || "",
|
|
142
|
+
|
|
143
|
+
_store() {
|
|
144
|
+
try { return win.Alpine.store(this.store) } catch (_) { return null }
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
// The open card's props, or {} with nothing open.
|
|
148
|
+
get props() {
|
|
149
|
+
const store = this._store()
|
|
150
|
+
const entry = store && store.current && store.current()
|
|
151
|
+
return (entry && entry.props) || {}
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
get ready() {
|
|
155
|
+
return !this.saving && (!this.requiresConsent || !!this.consent)
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async submit() {
|
|
159
|
+
if (!this.ready) return
|
|
160
|
+
let address = null
|
|
161
|
+
if (this.needsEmail) {
|
|
162
|
+
address = cleanEmail(this.email)
|
|
163
|
+
if (!address) {
|
|
164
|
+
this.error = INVALID_EMAIL
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
this.error = ""
|
|
169
|
+
|
|
170
|
+
const props = this.props
|
|
171
|
+
if (action === "subscribe" && typeof props.onSubmit === "function") {
|
|
172
|
+
const handOff = props.onSubmit
|
|
173
|
+
this._close()
|
|
174
|
+
handOff(address)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.saving = true
|
|
179
|
+
const fallback = action === "unsubscribe" ? UNSUBSCRIBE_FAILED : SUBSCRIBE_FAILED
|
|
180
|
+
let outcome
|
|
181
|
+
try {
|
|
182
|
+
const init = {
|
|
183
|
+
method: this.method,
|
|
184
|
+
credentials: "same-origin",
|
|
185
|
+
headers: {
|
|
186
|
+
"Content-Type": "application/json",
|
|
187
|
+
"Accept": "application/json",
|
|
188
|
+
"X-CSRF-Token": csrfToken(win.document)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (action === "subscribe") init.body = JSON.stringify({ email: address })
|
|
192
|
+
const send = await this._sender()
|
|
193
|
+
if (!send) {
|
|
194
|
+
outcome = { ok: false, body: {}, error: fallback }
|
|
195
|
+
} else {
|
|
196
|
+
const response = await send(this.url, init)
|
|
197
|
+
if (response == null && this.fetcher) {
|
|
198
|
+
// The host's fetcher answered it (its own sign-in or wait card).
|
|
199
|
+
this.saving = false
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
let body = {}
|
|
203
|
+
try { body = await response.json() } catch (_) {}
|
|
204
|
+
outcome = outcomeOf(response, body, fallback)
|
|
205
|
+
}
|
|
206
|
+
} catch (_) {
|
|
207
|
+
outcome = { ok: false, body: {}, error: fallback }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.saving = false
|
|
211
|
+
if (!outcome.ok) {
|
|
212
|
+
this.error = outcome.error
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
this._succeed(outcome.body, address, props)
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
// What sends the request: plain fetch, or the host's named fetcher; null
|
|
219
|
+
// when a named one cannot be found.
|
|
220
|
+
async _sender() {
|
|
221
|
+
if (!this.fetcher) return (url, init) => win.fetch(url, init)
|
|
222
|
+
const fn = await resolveFetcher(this.fetcher, importer)
|
|
223
|
+
if (!fn) {
|
|
224
|
+
try {
|
|
225
|
+
win.console.error(`studio/newsletter_modal: ${HOST_FETCHERS} exports no function named ${this.fetcher}`)
|
|
226
|
+
} catch (_) {}
|
|
227
|
+
}
|
|
228
|
+
return fn
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
_succeed(body, address, props) {
|
|
232
|
+
const subscribed = action === "subscribe"
|
|
233
|
+
try {
|
|
234
|
+
win.dispatchEvent(new win.CustomEvent(this.event, {
|
|
235
|
+
detail: { subscribed: subscribed, email: address, response: body }
|
|
236
|
+
}))
|
|
237
|
+
} catch (_) {}
|
|
238
|
+
if (typeof props.onSuccess === "function") {
|
|
239
|
+
try { props.onSuccess(body) } catch (_) {}
|
|
240
|
+
}
|
|
241
|
+
const store = this._store()
|
|
242
|
+
if (!store) return
|
|
243
|
+
if (this.nextId) store.swap(this.nextId, body)
|
|
244
|
+
else store.close()
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
_close() {
|
|
248
|
+
const store = this._store()
|
|
249
|
+
if (store) store.close()
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -17,9 +17,23 @@
|
|
|
17
17
|
data-pin-edge: on a header on the scroll timeline the stack publishes the
|
|
18
18
|
PINNED bar and nothing per frame, so at the top of the page this fixed panel
|
|
19
19
|
would cover the expanded header. engine.css (THE PINNED EDGE ON THE SCROLL
|
|
20
|
-
TIMELINE) moves it with the header on the scroll timeline instead.
|
|
20
|
+
TIMELINE) moves it with the header on the scroll timeline instead.
|
|
21
|
+
|
|
22
|
+
Two opt-in locals (unset, the panel renders as it did in 0.113.0):
|
|
23
|
+
title_html the header's LEAD SLOT: HTML in the title's place, for a panel
|
|
24
|
+
whose lead is a live status line rather than a label. It is
|
|
25
|
+
content, NOT a name: it never feeds aria-labelledby or
|
|
26
|
+
aria-label, so a caller that passes it instead of `title` must
|
|
27
|
+
pass aria_label, or the panel has no accessible name. `title`
|
|
28
|
+
wins when both are passed. Escaped unless html_safe.
|
|
29
|
+
close_hook true stamps the close button data-sidebar-panel-close="<id>"
|
|
30
|
+
too, so a host's own document handler can match the button
|
|
31
|
+
to its panel by id. Needs `id`; without one it adds nothing.
|
|
32
|
+
data-link-sidebar-close stays, and studio/link_sidebar counts
|
|
33
|
+
it only inside its own panels. %>
|
|
21
34
|
<% id = local_assigns.fetch(:id, nil) %>
|
|
22
35
|
<% title = local_assigns.fetch(:title, nil) %>
|
|
36
|
+
<% title_html = local_assigns.fetch(:title_html, nil) %>
|
|
23
37
|
<% open = local_assigns.fetch(:open) %>
|
|
24
38
|
<% actions_html = local_assigns.fetch(:actions_html, nil) %>
|
|
25
39
|
<% body_html = local_assigns.fetch(:body_html, nil) %>
|
|
@@ -34,6 +48,7 @@
|
|
|
34
48
|
<% role = local_assigns.fetch(:role, "complementary") %>
|
|
35
49
|
<% aria_label = local_assigns.fetch(:aria_label, title) %>
|
|
36
50
|
<% labelledby = id.present? && title.present? ? "#{id}-title" : nil %>
|
|
51
|
+
<% close_hook = local_assigns.fetch(:close_hook, false) && id.present? %>
|
|
37
52
|
|
|
38
53
|
<aside
|
|
39
54
|
<% if id.present? %>id="<%= id %>"<% end %>
|
|
@@ -64,10 +79,14 @@
|
|
|
64
79
|
role="<%= role %>"
|
|
65
80
|
<% if labelledby %>aria-labelledby="<%= labelledby %>"<% elsif aria_label.present? %>aria-label="<%= aria_label %>"<% end %>>
|
|
66
81
|
<div class="flex flex-col h-full">
|
|
67
|
-
<% if title.present? || actions_html.present? || close_action.present? %>
|
|
82
|
+
<% if title.present? || title_html.present? || actions_html.present? || close_action.present? %>
|
|
68
83
|
<div class="flex items-center justify-between p-4 border-b border-subtle">
|
|
69
84
|
<% if title.present? %>
|
|
70
85
|
<h3 <% if labelledby %>id="<%= labelledby %>"<% end %> class="text-lg font-bold text-heading"><%= title %></h3>
|
|
86
|
+
<% elsif title_html.present? %>
|
|
87
|
+
<%# min-w-0 so a long status line truncates in its own box instead of
|
|
88
|
+
pushing the close button off the panel's edge on a phone. %>
|
|
89
|
+
<div class="min-w-0 flex-1" data-sidebar-panel-lead><%= title_html %></div>
|
|
71
90
|
<% end %>
|
|
72
91
|
<div class="flex items-center gap-2 ml-auto">
|
|
73
92
|
<%= actions_html if actions_html.present? %>
|
|
@@ -76,7 +95,7 @@
|
|
|
76
95
|
@click="<%= close_action %>"
|
|
77
96
|
class="flex items-center justify-center w-8 h-8 rounded-full text-secondary hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 transition"
|
|
78
97
|
aria-label="Close sidebar"
|
|
79
|
-
data-link-sidebar-close="true"
|
|
98
|
+
data-link-sidebar-close="true"<% if close_hook %> data-sidebar-panel-close="<%= id %>"<% end %>>
|
|
80
99
|
<span class="text-xl leading-none">×</span>
|
|
81
100
|
</button>
|
|
82
101
|
<% end %>
|
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
(default display_name) and the signed-out button reads
|
|
16
16
|
Studio.sign_in_label (default "Log in"); lib/studio/navbar_identity.rb.
|
|
17
17
|
Studio.navbar_avatar_badge (default nil) draws one partial over the
|
|
18
|
-
avatar
|
|
18
|
+
avatar, and Studio.navbar_menu_trigger (default nil) replaces the admin
|
|
19
|
+
dropdown in the icon rail; lib/studio/navbar_slots.rb.
|
|
20
|
+
Studio.navbar_account_sidebar (default nil) makes the account control
|
|
21
|
+
open a host sidebar; lib/studio/navbar_account_sidebar.rb.
|
|
19
22
|
|
|
20
23
|
Legacy string locals (deprecated): balance_html, extra_icons_html,
|
|
21
24
|
div2_html — pre-rendered HTML injected via raw. Still honored when
|
|
@@ -63,6 +66,9 @@
|
|
|
63
66
|
of it. A link that goes nowhere looks identical to one that works until it is
|
|
64
67
|
clicked, which is why nobody reported it.
|
|
65
68
|
%>
|
|
69
|
+
<%# Studio.navbar_menu_trigger: the host's menu control in the admin
|
|
70
|
+
dropdown's seat (the icon rail from md, and the signed-out rail). %>
|
|
71
|
+
<% menu_trigger = respond_to?(:studio_navbar_menu_trigger) ? studio_navbar_menu_trigger : nil %>
|
|
66
72
|
<% studio_nav_destination =
|
|
67
73
|
if defined?(account_path)
|
|
68
74
|
account_path
|
|
@@ -102,7 +108,11 @@
|
|
|
102
108
|
glyphs side by side read as a double gear. Desktop only, like
|
|
103
109
|
the theme toggle: below md the navbar's phone row draws the cog,
|
|
104
110
|
so a second one here put two on an admin's phone. %>
|
|
111
|
+
<% if menu_trigger %>
|
|
112
|
+
<span class="hidden md:block" data-nav-menu-trigger><%= studio_navbar_slot_tag(menu_trigger) %></span>
|
|
113
|
+
<% else %>
|
|
105
114
|
<%= render "components/admin_dropdown", display_class: "hidden md:block" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
|
|
115
|
+
<% end %>
|
|
106
116
|
</div>
|
|
107
117
|
</div>
|
|
108
118
|
<% if div2_content.present? %>
|
|
@@ -219,15 +229,34 @@
|
|
|
219
229
|
dialog" rather than a link that goes nowhere it says. studio/link_sidebar
|
|
220
230
|
claims its click from the document, before Alpine binds, as it does the
|
|
221
231
|
gear's. An app with no sidebar keeps the link to the account page. %>
|
|
222
|
-
|
|
232
|
+
<%# Studio.navbar_account_sidebar: a HOST sidebar the control opens
|
|
233
|
+
instead (a button toggling the host's store flag, built by the engine
|
|
234
|
+
from validated names). It wins over the link sidebar and the account
|
|
235
|
+
link. It carries data-profile-image-toggle, so a host handler that
|
|
236
|
+
claims its triggers in the capture phase, before Alpine binds, claims
|
|
237
|
+
this one; the guarded @click is the path when nothing claims it. Its
|
|
238
|
+
controls may not name the engine's own link sidebar ids, refused at
|
|
239
|
+
assignment (lib/studio/navbar_account_sidebar.rb). %>
|
|
240
|
+
<% account_sidebar = respond_to?(:studio_navbar_account_sidebar) ? studio_navbar_account_sidebar : nil %>
|
|
241
|
+
<% account_opens_sidebar = !account_sidebar && respond_to?(:studio_account_opens_sidebar?) && studio_account_opens_sidebar? %>
|
|
223
242
|
<% account_classes = "group flex items-center gap-2 flex-shrink-0" %>
|
|
243
|
+
<%# Studio.navbar_phone_name: the name shows below md too, capped, and
|
|
244
|
+
fades at its edge when it overflows (engine.css, THE PHONE NAME). %>
|
|
245
|
+
<% phone_name = Studio.navbar_phone_name %>
|
|
224
246
|
<% account_inner = capture do %>
|
|
225
|
-
|
|
247
|
+
<% if phone_name %><span class="min-w-0 max-w-[4.5rem] min-[400px]:max-w-[6rem] md:max-w-40" data-nav-name-phone><% else %><span class="sr-only md:not-sr-only md:max-w-40"><% end %><span class="block truncate text-right text-heading font-semibold text-base leading-none transition<%= " group-hover:text-primary" if account_sidebar || account_opens_sidebar || studio_nav_destination %>" data-nav-name><%= Studio.navbar_user_name_for(current_user, self) %></span></span>
|
|
226
248
|
<span class="flex-shrink-0 flex items-center" aria-hidden="true">
|
|
227
249
|
<%= render "components/avatar", user: current_user, size: "nav" %>
|
|
228
250
|
</span>
|
|
229
251
|
<% end %>
|
|
230
|
-
<% if
|
|
252
|
+
<% if account_sidebar %>
|
|
253
|
+
<% account_js = studio_navbar_account_sidebar_expressions(account_sidebar) %>
|
|
254
|
+
<%= tag.button type: "button", class: "#{account_classes} cursor-pointer rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
|
|
255
|
+
data: { nav_account: "", profile_image_toggle: "" },
|
|
256
|
+
aria: { controls: account_sidebar[:controls], haspopup: "dialog" },
|
|
257
|
+
":aria-expanded": account_js[:expanded], "@click.stop": account_js[:toggle],
|
|
258
|
+
"x-bind:style": "$store.devMode && 'background: lightgreen'" do %><%= account_inner %><% end %>
|
|
259
|
+
<% elsif account_opens_sidebar %>
|
|
231
260
|
<button type="button" class="<%= account_classes %> cursor-pointer rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40" data-nav-account data-profile-image-toggle
|
|
232
261
|
aria-controls="studio-link-sidebar studio-link-sidebar-mobile" aria-haspopup="dialog"
|
|
233
262
|
:aria-expanded="$store.sidebars && $store.sidebars.linkTreeOpen ? 'true' : 'false'"
|
|
@@ -251,7 +280,11 @@
|
|
|
251
280
|
<div data-nav-avatar-badge><%= studio_navbar_slot_tag(avatar_badge) %></div>
|
|
252
281
|
<% end %>
|
|
253
282
|
<% else %>
|
|
283
|
+
<% if menu_trigger %>
|
|
284
|
+
<span class="hidden md:block" data-nav-menu-trigger><%= studio_navbar_slot_tag(menu_trigger) %></span>
|
|
285
|
+
<% else %>
|
|
254
286
|
<%= render "components/admin_dropdown", display_class: "hidden md:block" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
|
|
287
|
+
<% end %>
|
|
255
288
|
<%# Desktop only, as above: the phone row carries the toggle below md. %>
|
|
256
289
|
<span class="hidden md:flex" data-nav-theme-toggle="desktop"><%= render "components/theme_toggle_morph" %></span>
|
|
257
290
|
<div class="flex items-center gap-3 ml-auto">
|
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
preview — if true, disables scroll handler and sticky positioning
|
|
5
5
|
balance_html — raw HTML for balance display (passed through to _user_nav)
|
|
6
6
|
extra_icons_html — raw HTML for extra icon buttons (passed through to _user_nav)
|
|
7
|
+
balance_slot, extra_icons_slot, div2_slot, div2_html
|
|
8
|
+
— components/_user_nav's slot locals (a partial path, or
|
|
9
|
+
{ partial:, locals: }), forwarded to it as given. A
|
|
10
|
+
balance_slot reserves the balance column as balance_html
|
|
11
|
+
does. extra_icons_slot is rendered here, so the link
|
|
12
|
+
sidebar's trigger still follows it; like the user nav,
|
|
13
|
+
a slot wins over its legacy _html string.
|
|
7
14
|
brand_heading — boolean, draw the brand title as the page's h1 (default: true).
|
|
8
15
|
Pass false on a page that carries its own h1: the brand is
|
|
9
16
|
drawn as a div with the same classes and contents.
|
|
@@ -20,6 +27,13 @@
|
|
|
20
27
|
<% pin_header = Studio.smooth_load && !is_preview %>
|
|
21
28
|
<% balance_html = local_assigns.fetch(:balance_html, nil) %>
|
|
22
29
|
<% extra_icons_html = local_assigns.fetch(:extra_icons_html, nil) %>
|
|
30
|
+
<%# The user nav's slot locals, forwarded only when passed, so a navbar that
|
|
31
|
+
passes none renders the user nav exactly as before. %>
|
|
32
|
+
<% user_nav_slots = local_assigns.slice(:balance_slot, :div2_slot, :div2_html).compact %>
|
|
33
|
+
<% extra_icons_slot = local_assigns[:extra_icons_slot] %>
|
|
34
|
+
<% if extra_icons_slot.present? %>
|
|
35
|
+
<% extra_icons_html = extra_icons_slot.is_a?(Hash) ? render(extra_icons_slot[:partial], **extra_icons_slot.fetch(:locals, {})) : render(extra_icons_slot.to_s) %>
|
|
36
|
+
<% end %>
|
|
23
37
|
<% brand_tag = local_assigns.fetch(:brand_heading, true) ? "h1" : "div" %>
|
|
24
38
|
<%# Out-of-the-box navigation (engine 0.30): when the host declares
|
|
25
39
|
Studio.sidebar_sections, the navbar mounts the link-sidebar trigger in its
|
|
@@ -50,6 +64,13 @@
|
|
|
50
64
|
prefix = words.join(" ")
|
|
51
65
|
logo_path = Studio.logo_for("Navbar Logo")
|
|
52
66
|
wordmark = Studio.navbar_wordmark
|
|
67
|
+
# Studio.navbar_brand: the host's logo, its size and alt, and the title as
|
|
68
|
+
# words with the host's classes. nil (the default) draws as before.
|
|
69
|
+
brand = Studio.navbar_brand
|
|
70
|
+
logo_path = brand[:logo] if brand && brand[:logo]
|
|
71
|
+
logo_alt = (brand && brand[:alt]) || Studio.app_name
|
|
72
|
+
logo_size = brand && brand[:logo_size]
|
|
73
|
+
brand_words = brand && brand[:words]
|
|
53
74
|
%>
|
|
54
75
|
|
|
55
76
|
<%# nav-shell is the --nav-p scope: the engine's nav-collapse controller (studio/nav_collapse) writes
|
|
@@ -76,7 +97,10 @@
|
|
|
76
97
|
or a scroll_timeline: local for one render; engine.css, THE SCROLL-DRIVEN
|
|
77
98
|
COLLAPSE). Never on a preview: the /navbar page drives --nav-p itself. %>
|
|
78
99
|
<% scroll_timeline = local_assigns.fetch(:scroll_timeline, Studio.navbar_scroll_timeline) %>
|
|
79
|
-
|
|
100
|
+
<%# Studio.navbar_root_hook puts data-navbar-root on the header, live and
|
|
101
|
+
preview; Studio.navbar_z_tier layers the live header at --z-nav on the
|
|
102
|
+
shared scale (engine.css) instead of z-50. Both off renders as before. %>
|
|
103
|
+
<header data-pin="nav"<%= " data-navbar-root".html_safe if Studio.navbar_root_hook %><% unless is_preview %> x-data data-studio-controller="nav-collapse" data-nav-collapse-scrolled-class="shadow-lg border-b border-subtle is-scrolled"<%= " data-nav-timeline" if scroll_timeline %><% end %>
|
|
80
104
|
<%# top-0, a STATIC value, and never a custom property. Any bars render as
|
|
81
105
|
this header's sibling in normal flow (studio/banners/_stack), so they
|
|
82
106
|
already occupy their own height above it and there is nothing to
|
|
@@ -85,7 +109,7 @@
|
|
|
85
109
|
measurement landed, moved again on a webfont swap, and drew twice
|
|
86
110
|
during a view transition composited at two different tops. Only CSS
|
|
87
111
|
can set this top now, so none of that is reachable. %>
|
|
88
|
-
class="nav-shell <%= is_preview ? 'bg-page' : "#{'vt-pinned-header ' if pin_header}sticky top-0 z-50 bg-page transition-shadow duration-300" %>"
|
|
112
|
+
class="nav-shell <%= is_preview ? 'bg-page' : "#{'vt-pinned-header ' if pin_header}sticky top-0 #{Studio.navbar_z_tier ? 'z-[var(--z-nav)]' : 'z-50'} bg-page transition-shadow duration-300" %>"
|
|
89
113
|
<%= %(:class="scrolled && 'shadow-lg border-b border-subtle is-scrolled'").html_safe if is_preview %>>
|
|
90
114
|
<style>
|
|
91
115
|
/* THE PHONE STEP IS A SHARE OF THE VIEWPORT, NOT A CONSTANT, and that is the
|
|
@@ -144,6 +168,18 @@
|
|
|
144
168
|
title swap matters most: it is DISCRETE, which is the 1px reverse lurch
|
|
145
169
|
the primitive exists to delete. Full accounting in engine.css. */
|
|
146
170
|
</style>
|
|
171
|
+
<%# Studio.navbar_header_bars: bars INSIDE the pinned header, above its row,
|
|
172
|
+
so they never scroll away: a control an admin must not lose (an
|
|
173
|
+
impersonation bar's JS-free "Return to <admin>" button). An ambient
|
|
174
|
+
label belongs in the bar stack above the header, which scrolls. Being in
|
|
175
|
+
the header costs one thing, stated: the header is a stacking context, so
|
|
176
|
+
these never rise over an open modal. On the scroll timeline engine.css
|
|
177
|
+
(THE IN-HEADER BARS) carries them down by the band so they hold at the
|
|
178
|
+
window's top. Never on a preview, and [] (the default) renders nothing. %>
|
|
179
|
+
<% header_bars = !is_preview && respond_to?(:studio_navbar_header_bars) ? studio_navbar_header_bars : [] %>
|
|
180
|
+
<% if header_bars.any? %>
|
|
181
|
+
<div data-nav-header-bars><% header_bars.each do |bar| %><%= studio_navbar_slot_tag(bar) %><% end %></div>
|
|
182
|
+
<% end %>
|
|
147
183
|
<div class="nav-row flex items-center">
|
|
148
184
|
<%# Left side: logo + nav %>
|
|
149
185
|
<%# min-w-0 THREE TIMES, on every flex item between the row and the title.
|
|
@@ -174,14 +210,16 @@
|
|
|
174
210
|
<%# No w-*/h-* utility here: .nav-shell .nav-logo sizes it off --nav-p.
|
|
175
211
|
transition-transform, NOT transition-all — the hover lift still
|
|
176
212
|
eases, while width/height follow the finger with no clock. %>
|
|
177
|
-
<img src="<%= logo_path %>" alt="<%=
|
|
213
|
+
<img src="<%= logo_path %>" alt="<%= logo_alt %>"<% if logo_size %> width="<%= logo_size %>" height="<%= logo_size %>"<% end %> class="nav-logo rounded-full shadow-lg <%= "group-hover:scale-105 transition-transform duration-300" unless is_preview %>">
|
|
178
214
|
<% end %>
|
|
179
215
|
<%# truncate on the SPANS, not the h1. .nav-title is itself a flex
|
|
180
216
|
container (a column below 768px, a baseline row above), so the
|
|
181
217
|
overflow that has to be clipped belongs to each word's own box —
|
|
182
218
|
`truncate` on the h1 would clip the flex CONTAINER and leave the
|
|
183
219
|
spans drawing outside it, which is the bug rather than the fix. %>
|
|
184
|
-
|
|
220
|
+
<%# Studio.navbar_brand's words, when set, replace the app_name split
|
|
221
|
+
inside the same h1: one span per word, the host's class plus truncate. %>
|
|
222
|
+
<<%= brand_tag %> class="nav-title font-extrabold text-heading tracking-tight min-w-0"><% if brand_words %><% brand_words.each do |word| %><span class="<%= [word[:class], "truncate"].compact.join(" ") %>"><%= word[:text] %></span><% end %><% else %><span class="truncate"><%= prefix %></span><span class="text-primary truncate"><%= last_word %></span><% end %></<%= brand_tag %>>
|
|
185
223
|
<% end %>
|
|
186
224
|
<% end %>
|
|
187
225
|
<%# Desktop nav — Studio.navbar_links, then Studio.navbar_items. data-nav-links, only where the
|
|
@@ -201,16 +239,21 @@
|
|
|
201
239
|
<%# Right side: user nav. The fixed column (matches sidebar width) exists
|
|
202
240
|
to seat a balance in the top row; with no balance content it shrinks
|
|
203
241
|
to fit. %>
|
|
204
|
-
<div class="<%= balance_html.present? ? 'user-nav-col' : 'user-nav-fit' %> flex-shrink-0 pl-0 pr-4 md:px-4">
|
|
242
|
+
<div class="<%= balance_html.present? || user_nav_slots[:balance_slot].present? ? 'user-nav-col' : 'user-nav-fit' %> flex-shrink-0 pl-0 pr-4 md:px-4">
|
|
205
243
|
<% if show_user %>
|
|
206
|
-
<%= render "components/user_nav", balance_html: balance_html, extra_icons_html: extra_icons_html %>
|
|
244
|
+
<%= render "components/user_nav", balance_html: balance_html, extra_icons_html: extra_icons_html, **user_nav_slots %>
|
|
207
245
|
<% else %>
|
|
208
246
|
<div class="flex items-center justify-end gap-3">
|
|
209
247
|
<%= sidebar_trigger_desktop if sidebar_active %>
|
|
210
248
|
<span class="hidden md:flex"><%= render "components/theme_toggle_morph" %></span>
|
|
211
249
|
<% if signed_out_cta %>
|
|
212
|
-
|
|
213
|
-
|
|
250
|
+
<%# sign_in_link: false drops the quiet link (the CTA is the sign-in);
|
|
251
|
+
sign_in_path points it elsewhere; modal: opens a modal from the
|
|
252
|
+
button, its path the fallback (lib/studio/navbar_signed_out_cta.rb). %>
|
|
253
|
+
<% unless signed_out_cta[:sign_in_link] == false %>
|
|
254
|
+
<%= link_to Studio.sign_in_label, signed_out_cta[:sign_in_path] || login_path, class: "hidden md:inline text-sm font-semibold text-secondary hover:text-primary whitespace-nowrap transition", data: { nav_sign_in: "" } %>
|
|
255
|
+
<% end %>
|
|
256
|
+
<%= link_to signed_out_cta[:label], signed_out_cta[:path], class: "btn btn-primary whitespace-nowrap", data: { nav_signed_out_cta: "" }, **studio_navbar_cta_modal_options(signed_out_cta) %>
|
|
214
257
|
<% else %>
|
|
215
258
|
<%= link_to Studio.sign_in_label, login_path, class: "btn btn-primary" %>
|
|
216
259
|
<% end %>
|
|
@@ -231,11 +274,18 @@
|
|
|
231
274
|
<%= studio_navbar_slot_tag(item) %>
|
|
232
275
|
<% end %>
|
|
233
276
|
<span class="ml-auto flex items-center gap-3">
|
|
234
|
-
<% if signed_out_cta %>
|
|
235
|
-
<%= link_to Studio.sign_in_label, login_path, class: "text-xs font-semibold text-secondary hover:text-primary whitespace-nowrap transition", data: { nav_sign_in: "" } %>
|
|
277
|
+
<% if signed_out_cta && signed_out_cta[:sign_in_link] != false %>
|
|
278
|
+
<%= link_to Studio.sign_in_label, signed_out_cta[:sign_in_path] || login_path, class: "text-xs font-semibold text-secondary hover:text-primary whitespace-nowrap transition", data: { nav_sign_in: "" } %>
|
|
236
279
|
<% end %>
|
|
237
280
|
<%= render "components/link_sidebar_trigger" if sidebar_active %>
|
|
281
|
+
<%# Studio.navbar_menu_trigger takes the admin dropdown's seat here too: its
|
|
282
|
+
second render (the rail is the first), so the partial carries no ids. %>
|
|
283
|
+
<% menu_trigger = respond_to?(:studio_navbar_menu_trigger) ? studio_navbar_menu_trigger : nil %>
|
|
284
|
+
<% if menu_trigger %>
|
|
285
|
+
<%= studio_navbar_slot_tag(menu_trigger) %>
|
|
286
|
+
<% else %>
|
|
238
287
|
<%= render "components/admin_dropdown" unless sidebar_active && studio_sidebar_replaces_admin_menu? %>
|
|
288
|
+
<% end %>
|
|
239
289
|
<%= render "components/theme_toggle_morph" %>
|
|
240
290
|
</span>
|
|
241
291
|
</div>
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
<%#
|
|
2
2
|
THE X-DATA FACTORIES' GUARD: says so when a page's factories did not load.
|
|
3
3
|
|
|
4
|
-
The photo crop, the upload hosts, the birthday card and row
|
|
5
|
-
form bind an x-data
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
The photo crop, the upload hosts, the birthday card and row, the profile
|
|
5
|
+
form, the error card's countdown and the newsletter cards bind an x-data
|
|
6
|
+
factory by name (cropPhotoModal, imageUploadHost, avatarCropperHost,
|
|
7
|
+
birthdayModal, studioBirthdayFields, studioProfileForm, studioModalCountdown,
|
|
8
|
+
studioNewsletterModal), and all eight are published by studio/alpine_scopes
|
|
9
|
+
from six modules (studio/cropper, studio/image_upload, studio/birthday,
|
|
10
|
+
studio/profile_form, studio/modal_countdown, studio/newsletter_modal).
|
|
9
11
|
A browser keeps a failed module request for the life of the document, so one
|
|
10
|
-
failure among the
|
|
12
|
+
failure among the seven at page load leaves every name undefined until a
|
|
11
13
|
reload: the profile page with no Save, the birthday card with nothing to
|
|
12
14
|
press. This script is what speaks then, so it is INLINE and imports nothing:
|
|
13
15
|
it shares no request with the modules whose failure it reports.
|
|
@@ -46,7 +48,7 @@
|
|
|
46
48
|
(function () {
|
|
47
49
|
if (window.studioScopesGuard) return;
|
|
48
50
|
|
|
49
|
-
var NAMES = ["cropPhotoModal", "imageUploadHost", "avatarCropperHost", "birthdayModal", "studioBirthdayFields", "studioProfileForm"];
|
|
51
|
+
var NAMES = ["cropPhotoModal", "imageUploadHost", "avatarCropperHost", "birthdayModal", "studioBirthdayFields", "studioProfileForm", "studioModalCountdown", "studioNewsletterModal"];
|
|
50
52
|
var BOUND = "[x-data]";
|
|
51
53
|
var FAILED = "data-studio-scope-failed";
|
|
52
54
|
var FALLBACK = "data-studio-scope-fallback";
|
|
@@ -7,6 +7,18 @@
|
|
|
7
7
|
page through studio/alpine_scopes; this partial loads the library alone.
|
|
8
8
|
|
|
9
9
|
Pair with: the crop-photo and saving modal registrations in your modal-host
|
|
10
|
-
block (see the README and CHANGELOG), and x-data="imageUploadHost({...})".
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
block (see the README and CHANGELOG), and x-data="imageUploadHost({...})".
|
|
11
|
+
|
|
12
|
+
Both tags are pinned with Subresource Integrity, so a changed file on the CDN
|
|
13
|
+
fails to load rather than running. The hashes are sha384 over the exact
|
|
14
|
+
1.6.2 files; a version bump recomputes both (openssl dgst -sha384 -binary
|
|
15
|
+
FILE | openssl base64 -A), replaces the byte-exact copies in
|
|
16
|
+
test/fixtures/files/cropperjs-<version> that the browser lane serves, and
|
|
17
|
+
runs bin/cropper-sri-live. Keep the URLs exact: consumer CSPs name the
|
|
18
|
+
script by file. %>
|
|
19
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css"
|
|
20
|
+
integrity="sha384-6LFfkTKLRlzFtgx8xsWyBdKGpcMMQTkv+dB7rAbugeJAu1Ym2q1Aji1cjHBG12Xh"
|
|
21
|
+
crossorigin="anonymous">
|
|
22
|
+
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"
|
|
23
|
+
integrity="sha384-jrOgQzBlDeUNdmQn3rUt/PZD+pdcRBdWd/HWRqRo+n2OR2QtGyjSaJC0GiCeH+ir"
|
|
24
|
+
crossorigin="anonymous"></script>
|
|
@@ -58,6 +58,12 @@
|
|
|
58
58
|
demo_underage (Boolean, optional) — style-guide preview ONLY: make the local
|
|
59
59
|
resolve take the REFUSED branch, so the guide can show the
|
|
60
60
|
handoff without a backend. Ignored unless demo.
|
|
61
|
+
progress_pill (Hash, optional) — { current: Integer, total: Integer } for a
|
|
62
|
+
card that is one step of a chain (turf-monster's onboarding runs
|
|
63
|
+
it at 2 of 3). Renders blocks/_progress_pill FIRST inside the
|
|
64
|
+
card's root, above the title row, where the chain's other steps
|
|
65
|
+
put theirs. Absent (the default), the card renders exactly as it
|
|
66
|
+
did before the local existed. Any other key raises.
|
|
61
67
|
|
|
62
68
|
The scope, birthdayModal(), is studio/birthday, published on every page by
|
|
63
69
|
studio/alpine_scopes. Single root element (modal-host template mount rule).
|
|
@@ -90,8 +96,20 @@
|
|
|
90
96
|
demo = local_assigns.fetch(:demo, false)
|
|
91
97
|
demo_underage = local_assigns.fetch(:demo_underage, false)
|
|
92
98
|
age_gate_modal_id = local_assigns.fetch(:age_gate_modal_id, "age-gate")
|
|
99
|
+
# A Hash or nothing, and only the two keys blocks/_progress_pill reads: a typo
|
|
100
|
+
# (`totl:`) would otherwise fall back to the pill's default total and draw a
|
|
101
|
+
# chain of the wrong length with no error anywhere.
|
|
102
|
+
progress_pill = local_assigns[:progress_pill]
|
|
103
|
+
unless progress_pill.nil?
|
|
104
|
+
unless progress_pill.is_a?(Hash) && progress_pill.key?(:current) && (progress_pill.keys - %i[current total]).empty?
|
|
105
|
+
raise ArgumentError, "blocks/_birthday progress_pill: takes { current:, total: }, got #{progress_pill.inspect}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
93
108
|
%>
|
|
94
109
|
<div x-data="birthdayModal({ minAge: <%= min_age.to_i %>, state: '<%= j state %>', url: '<%= j submit_url %>', store: '<%= j modal_store %>', demo: <%= demo ? 'true' : 'false' %>, demoUnderage: <%= demo_underage ? 'true' : 'false' %>, gateId: '<%= j age_gate_modal_id.to_s %>' })">
|
|
110
|
+
<% if progress_pill %>
|
|
111
|
+
<%= render "studio/modals/blocks/progress_pill", **progress_pill %>
|
|
112
|
+
<% end %>
|
|
95
113
|
<%= render layout: "studio/modals/blocks/shell", locals: { title: title, modal_store: modal_store } do %>
|
|
96
114
|
<%# The age line only exists when the app HAS a bar. A card that says
|
|
97
115
|
"You must be 0+" is what an unconditional line produces for a host that
|