studio-engine 0.97.0.rc1 → 0.98.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 +89 -0
- data/app/javascript/studio/alpine_scopes.js +66 -0
- data/app/javascript/studio/alpine_shims.js +4 -0
- data/app/javascript/studio/application.js +4 -1
- data/app/javascript/studio/birthday.js +351 -0
- data/app/javascript/studio/confirm_interstitial.js +44 -0
- data/app/javascript/studio/controllers/identity_mini_controller.js +32 -0
- data/app/javascript/studio/controllers/style_modals_controller.js +41 -0
- data/app/javascript/studio/cropper.js +281 -0
- data/app/javascript/studio/identity_mini.js +74 -0
- data/app/javascript/studio/image_upload.js +189 -0
- data/app/javascript/studio/profile_form.js +88 -0
- data/app/javascript/studio/stimulus.js +4 -0
- data/app/javascript/studio/style_guide.js +13 -0
- data/app/javascript/studio/style_modals.js +214 -0
- data/app/views/components/_avatar_cropper.html.erb +3 -38
- data/app/views/layouts/studio/_head.html.erb +12 -2
- data/app/views/studio/_alpine_scopes_guard.html.erb +178 -0
- data/app/views/studio/_birthday_assets.html.erb +6 -240
- data/app/views/studio/_confirm_interstitial.html.erb +31 -18
- data/app/views/studio/_cropper_assets.html.erb +9 -8
- data/app/views/studio/modals/_crop_photo.html.erb +1 -1
- data/app/views/studio/modals/_image_upload.html.erb +8 -321
- data/app/views/studio/modals/blocks/_birthday.html.erb +2 -3
- data/app/views/studio/profiles/_birthday_fields.html.erb +0 -1
- data/app/views/studio/profiles/_identity_mini.html.erb +5 -62
- data/app/views/studio/profiles/_plain_save.html.erb +11 -0
- data/app/views/studio/profiles/edit.html.erb +1 -6
- data/app/views/style/_modals.html.erb +36 -420
- data/lib/studio/version.rb +1 -1
- metadata +14 -3
- data/app/views/studio/profiles/_birthday_picker_script.html.erb +0 -146
- data/app/views/studio/profiles/_form_script.html.erb +0 -65
|
@@ -1,326 +1,13 @@
|
|
|
1
1
|
<%#
|
|
2
|
-
|
|
2
|
+
The image-upload behaviour for the crop-photo and saving modals is two
|
|
3
|
+
modules, published on every page by studio/alpine_scopes:
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
cropPhotoModal() studio/cropper: x-data for studio/modals/_crop_photo
|
|
6
|
+
imageUploadHost() studio/image_upload: a crop-then-save uploader
|
|
7
|
+
submitFormWithProgress() studio/image_upload: a Turbo submit behind the
|
|
8
|
+
saving card, with a toast at the end
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
imageUploadHost() host for crop-then-immediate-save uploaders
|
|
11
|
-
(x-data in the consumer's upload views)
|
|
12
|
-
|
|
13
|
-
Rendered once by the modal host. cropper.js itself loads via
|
|
10
|
+
THIS PARTIAL RENDERS NOTHING. It stays so an app that renders it by name gets
|
|
11
|
+
no missing-template error. Cropper.js itself loads through
|
|
14
12
|
studio/_cropper_assets on the pages that can open the cropper.
|
|
15
13
|
%>
|
|
16
|
-
<script>
|
|
17
|
-
// === window.cropPhotoModal — x-data for studio/modals/_crop_photo =========
|
|
18
|
-
//
|
|
19
|
-
// The modal hands the cropped Blob back to its opener via the
|
|
20
|
-
// 'crop-photo-confirmed' window event — the opener's host owns the upload.
|
|
21
|
-
// The image gets IN two ways:
|
|
22
|
-
// * imageUrl prop — the opener already picked a file and passes the data
|
|
23
|
-
// URL (avatar uploader / avatar_cropper); the modal jumps to the cropper.
|
|
24
|
-
// * no imageUrl — the modal itself is the picker (click/drag-drop empty
|
|
25
|
-
// state), then crops (e.g. a banner editor's open()).
|
|
26
|
-
function cropPhotoModal(opts) {
|
|
27
|
-
opts = opts || {};
|
|
28
|
-
return {
|
|
29
|
-
// Which Alpine store this modal is mounted in. Defaults to 'modals' (the
|
|
30
|
-
// shared host); the living style guide passes { store: 'dsModals' } so the
|
|
31
|
-
// crop-photo modal opens on its page-scoped host without colliding with
|
|
32
|
-
// the app-level shared host.
|
|
33
|
-
_storeName: opts.store || 'modals',
|
|
34
|
-
cropper: null,
|
|
35
|
-
imageUrl: null,
|
|
36
|
-
fromParent: false,
|
|
37
|
-
dragging: false,
|
|
38
|
-
error: null,
|
|
39
|
-
// Configurable crop output. Defaults = avatar (square 256px, transparent).
|
|
40
|
-
// Callers override per-use via the modal props, e.g.
|
|
41
|
-
// Alpine.store('modals').open('crop-photo',
|
|
42
|
-
// { imageUrl, aspectRatio: 3, maxWidth: 900, maxHeight: 300 })
|
|
43
|
-
aspectRatio: 1,
|
|
44
|
-
maxWidth: 256,
|
|
45
|
-
maxHeight: 256,
|
|
46
|
-
transparent: true,
|
|
47
|
-
// GIFS BYPASS THE CROPPER ENTIRELY when allowed. They cannot go through
|
|
48
|
-
// it: confirm() paints the crop onto a canvas and calls toBlob(...,
|
|
49
|
-
// "image/png"), which keeps frame one and throws the animation away —
|
|
50
|
-
// silently, with a perfectly good-looking still as the result. So an
|
|
51
|
-
// allowed GIF is passed through as its ORIGINAL BYTES, uncropped, and the
|
|
52
|
-
// aspect it was authored at is the aspect that ships.
|
|
53
|
-
//
|
|
54
|
-
// Off by default because most upload sites (avatars, square thumbnails)
|
|
55
|
-
// genuinely want the crop, and an un-cropped image there is a bug.
|
|
56
|
-
allowGifs: false,
|
|
57
|
-
autoCropArea: 0.9,
|
|
58
|
-
// dispatch: keep the modal open after confirm so the opener's host can run
|
|
59
|
-
// its own processing -> success flow (it replaces the modal). When false
|
|
60
|
-
// (fromParent, e.g. avatar_cropper) the modal closes itself after confirm.
|
|
61
|
-
dispatch: false,
|
|
62
|
-
|
|
63
|
-
init() {
|
|
64
|
-
var current = this.$store[this._storeName].current();
|
|
65
|
-
var props = (current && current.props) || {};
|
|
66
|
-
if (props.aspectRatio) this.aspectRatio = props.aspectRatio;
|
|
67
|
-
if (props.maxWidth) this.maxWidth = props.maxWidth;
|
|
68
|
-
if (props.maxHeight) this.maxHeight = props.maxHeight;
|
|
69
|
-
if (typeof props.transparent === "boolean") this.transparent = props.transparent;
|
|
70
|
-
if (typeof props.allowGifs === "boolean") this.allowGifs = props.allowGifs;
|
|
71
|
-
if (props.dispatch) this.dispatch = true;
|
|
72
|
-
// Carried through untouched and echoed back on confirm, so the host that
|
|
73
|
-
// OPENED the cropper is the only one that acts on the result.
|
|
74
|
-
this.owner = props.owner || null;
|
|
75
|
-
if (props.autoCropArea) this.autoCropArea = props.autoCropArea;
|
|
76
|
-
if (props.imageUrl) {
|
|
77
|
-
this.fromParent = true;
|
|
78
|
-
this.imageUrl = props.imageUrl;
|
|
79
|
-
this.mountCropper();
|
|
80
|
-
}
|
|
81
|
-
},
|
|
82
|
-
|
|
83
|
-
destroy() {
|
|
84
|
-
if (this.cropper) { this.cropper.destroy(); this.cropper = null; }
|
|
85
|
-
},
|
|
86
|
-
|
|
87
|
-
mountCropper() {
|
|
88
|
-
var self = this;
|
|
89
|
-
this.$nextTick(function () {
|
|
90
|
-
if (typeof Cropper === "undefined") return;
|
|
91
|
-
if (self.cropper) { self.cropper.destroy(); self.cropper = null; }
|
|
92
|
-
self.cropper = new Cropper(self.$refs.cropImage, {
|
|
93
|
-
aspectRatio: self.aspectRatio, viewMode: 1, dragMode: "move",
|
|
94
|
-
autoCropArea: self.autoCropArea, cropBoxResizable: true,
|
|
95
|
-
cropBoxMovable: true, background: false, guides: true
|
|
96
|
-
});
|
|
97
|
-
// Cropping in progress — lock the modal (no click-outside / escape) so
|
|
98
|
-
// an accidental click doesn't discard the crop. Cancel still works.
|
|
99
|
-
// Also reflect the crop sub-state onto the store entry (cropReady) so
|
|
100
|
-
// an external observer can tell the picker state apart from the crop
|
|
101
|
-
// state — the modal's own `imageUrl` is LOCAL x-data and never lands on
|
|
102
|
-
// props, so props alone can't distinguish them. The living style guide
|
|
103
|
-
// reads this to move its active-card glow from the Image Upload card to
|
|
104
|
-
// the Crop Photo card the moment an image is loaded (picker -> cropper).
|
|
105
|
-
var cur = self.$store[self._storeName].current();
|
|
106
|
-
if (cur && cur.props) { cur.props.dismissible = false; cur.props.cropReady = true; }
|
|
107
|
-
});
|
|
108
|
-
},
|
|
109
|
-
|
|
110
|
-
readFile(file) {
|
|
111
|
-
if (!file) return;
|
|
112
|
-
if (!file.type || file.type.indexOf("image/") !== 0) {
|
|
113
|
-
this.error = "Please choose an image file.";
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
var self = this;
|
|
117
|
-
|
|
118
|
-
if (this.isAnimatedCandidate(file)) {
|
|
119
|
-
if (!this.allowGifs) {
|
|
120
|
-
this.error = "GIFs aren't accepted here. Use a PNG, JPG or WebP.";
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
this.error = null;
|
|
124
|
-
this.passThrough(file);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
var reader = new FileReader();
|
|
129
|
-
reader.onload = function (e) {
|
|
130
|
-
self.error = null;
|
|
131
|
-
self.imageUrl = e.target.result;
|
|
132
|
-
self.mountCropper();
|
|
133
|
-
};
|
|
134
|
-
reader.readAsDataURL(file);
|
|
135
|
-
},
|
|
136
|
-
|
|
137
|
-
// Type, not extension: a file renamed to .gif is still a PNG and belongs in
|
|
138
|
-
// the cropper, and a .GIF from a Windows machine is still a GIF.
|
|
139
|
-
isAnimatedCandidate(file) {
|
|
140
|
-
return (file.type || "").toLowerCase() === "image/gif";
|
|
141
|
-
},
|
|
142
|
-
|
|
143
|
-
// Hand the ORIGINAL file straight to the opener, no canvas in the path.
|
|
144
|
-
// Same event and the same owner token as a cropped confirm, so every host
|
|
145
|
-
// downstream is unchanged — the only difference is what is in the blob.
|
|
146
|
-
passThrough(file) {
|
|
147
|
-
try {
|
|
148
|
-
window.dispatchEvent(new CustomEvent("crop-photo-confirmed",
|
|
149
|
-
{ detail: { blob: file, owner: this.owner || null,
|
|
150
|
-
filename: file.name, uncropped: true } }));
|
|
151
|
-
} catch (_) {}
|
|
152
|
-
if (this.cropper) { this.cropper.destroy(); this.cropper = null; }
|
|
153
|
-
if (!this.dispatch) this.$store[this._storeName].close();
|
|
154
|
-
},
|
|
155
|
-
|
|
156
|
-
onFilePicked(event) {
|
|
157
|
-
var file = event.target.files[0];
|
|
158
|
-
event.target.value = "";
|
|
159
|
-
this.readFile(file);
|
|
160
|
-
},
|
|
161
|
-
|
|
162
|
-
onDrop(event) {
|
|
163
|
-
this.dragging = false;
|
|
164
|
-
var file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0];
|
|
165
|
-
this.readFile(file);
|
|
166
|
-
},
|
|
167
|
-
|
|
168
|
-
cancel() {
|
|
169
|
-
if (this.cropper) { this.cropper.destroy(); this.cropper = null; }
|
|
170
|
-
this.$store[this._storeName].close();
|
|
171
|
-
},
|
|
172
|
-
|
|
173
|
-
confirm() {
|
|
174
|
-
if (!this.cropper) return;
|
|
175
|
-
var self = this;
|
|
176
|
-
// Cap WIDTH only. For a fixed-aspect crop the aspect already bounds the
|
|
177
|
-
// height (= width / aspectRatio); also passing maxHeight makes cropper.js
|
|
178
|
-
// downscale the whole SOURCE to fit maxHeight when the source is taller
|
|
179
|
-
// (a 1983x793 upload -> 500/793 ~= 0.63x), tanking fidelity. maxWidth
|
|
180
|
-
// alone keeps the crop at source resolution up to the cap.
|
|
181
|
-
var canvasOpts = { maxWidth: this.maxWidth, imageSmoothingQuality: "high" };
|
|
182
|
-
if (!this.transparent) canvasOpts.fillColor = "#ffffff";
|
|
183
|
-
var canvas = this.cropper.getCroppedCanvas(canvasOpts);
|
|
184
|
-
canvas.toBlob(function (blob) {
|
|
185
|
-
try {
|
|
186
|
-
// OWNER RIDES WITH THE BLOB. `crop-photo-confirmed` is a WINDOW event, so
|
|
187
|
-
// every imageUploadHost on the page hears it — and /admin/emails mounts
|
|
188
|
-
// one host PER ROW. Without an owner, one confirmed crop PATCHed every
|
|
189
|
-
// row's banner with the same image, destroying any app-owned banner
|
|
190
|
-
// already there; the FIRST upload an admin ever performed hit it, because
|
|
191
|
-
// the engine pre-registers two emails. `owner` is the token the opening
|
|
192
|
-
// host stamped on the crop props, echoed back untouched.
|
|
193
|
-
window.dispatchEvent(new CustomEvent("crop-photo-confirmed",
|
|
194
|
-
{ detail: { blob: blob, owner: self.owner || null } }));
|
|
195
|
-
} catch (_) {}
|
|
196
|
-
if (self.cropper) { self.cropper.destroy(); self.cropper = null; }
|
|
197
|
-
// dispatch mode: the opener's host owns the post-confirm flow
|
|
198
|
-
// (processing modal -> success toast), so don't pop the stack here.
|
|
199
|
-
// fromParent (no dispatch): close the modal now.
|
|
200
|
-
if (!self.dispatch) self.$store[self._storeName].close();
|
|
201
|
-
}, "image/png");
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
window.cropPhotoModal = cropPhotoModal;
|
|
206
|
-
if (typeof Alpine !== "undefined") {
|
|
207
|
-
Alpine.data("cropPhotoModal", cropPhotoModal);
|
|
208
|
-
} else {
|
|
209
|
-
document.addEventListener("alpine:init", function () { Alpine.data("cropPhotoModal", cropPhotoModal); });
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// === window.submitFormWithProgress — loading card + toast for a Turbo submit
|
|
213
|
-
//
|
|
214
|
-
// Swaps in the 'saving' card while the form uploads, then closes it (held
|
|
215
|
-
// >= ~450ms so it doesn't flash) and toasts on completion. opts: { saving,
|
|
216
|
-
// success, successMessage, failure, failureMessage, dismissible (default
|
|
217
|
-
// false), toast (default true), store (Alpine store name, default "modals" —
|
|
218
|
-
// pass a page-scoped host's name, e.g. "emailModals") }.
|
|
219
|
-
window.submitFormWithProgress = function (form, opts) {
|
|
220
|
-
opts = opts || {};
|
|
221
|
-
var store = window.Alpine && Alpine.store(opts.store || "modals");
|
|
222
|
-
var hold = (window.StudioModals && window.StudioModals.holdAtLeast)
|
|
223
|
-
? window.StudioModals.holdAtLeast(450)
|
|
224
|
-
: { then: function (cb) { cb(); } };
|
|
225
|
-
if (store) {
|
|
226
|
-
store.open("saving", { dismissible: opts.dismissible === true, title: opts.saving || "Saving…" }, { replace: true });
|
|
227
|
-
}
|
|
228
|
-
var onEnd = function (e) {
|
|
229
|
-
form.removeEventListener("turbo:submit-end", onEnd);
|
|
230
|
-
var ok = e.detail && e.detail.success;
|
|
231
|
-
hold.then(function () {
|
|
232
|
-
if (store) store.close();
|
|
233
|
-
if (opts.toast !== false) {
|
|
234
|
-
window.dispatchEvent(new CustomEvent("toast", { detail: {
|
|
235
|
-
type: ok ? "notice" : "alert",
|
|
236
|
-
title: ok ? (opts.success || "Saved") : (opts.failure || "Couldn't save"),
|
|
237
|
-
message: ok ? (opts.successMessage || "") : (opts.failureMessage || "Please try again.")
|
|
238
|
-
} }));
|
|
239
|
-
}
|
|
240
|
-
});
|
|
241
|
-
};
|
|
242
|
-
form.addEventListener("turbo:submit-end", onEnd);
|
|
243
|
-
form.requestSubmit();
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
// === window.imageUploadHost — x-data host for crop-then-immediate-save ======
|
|
247
|
-
//
|
|
248
|
-
// Two entry points feed the SHARED crop-photo modal in dispatch mode:
|
|
249
|
-
// open() — the modal itself is the click/drag-drop target (banner)
|
|
250
|
-
// onFileSelected() — a native file picker hands the image in (avatar)
|
|
251
|
-
// Both dispatch the cropped Blob via 'crop-photo-confirmed'; applyCrop() drops
|
|
252
|
-
// it into the host's own hidden form input and submits immediately with a
|
|
253
|
-
// loading card + toast (submitFormWithProgress). opts = crop config
|
|
254
|
-
// (aspectRatio, maxWidth, maxHeight, transparent, autoCropArea) + save copy
|
|
255
|
-
// (saving, success, successMessage, failure, dismissible, toast, filename) +
|
|
256
|
-
// store (Alpine store name, default "modals"). Pass `store` when the crop and
|
|
257
|
-
// saving modals are mounted on a PAGE-SCOPED host rather than the app's shared
|
|
258
|
-
// one — /admin/emails does, so the uploader works even in a host app that
|
|
259
|
-
// renders no shared modal host at all.
|
|
260
|
-
window.imageUploadHost = function (opts) {
|
|
261
|
-
opts = opts || {};
|
|
262
|
-
var storeName = opts.store || "modals";
|
|
263
|
-
function cropProps(extra) {
|
|
264
|
-
var p = {
|
|
265
|
-
aspectRatio: opts.aspectRatio || 1,
|
|
266
|
-
transparent: opts.transparent !== false,
|
|
267
|
-
dispatch: true
|
|
268
|
-
};
|
|
269
|
-
if (opts.maxWidth) p.maxWidth = opts.maxWidth;
|
|
270
|
-
if (opts.maxHeight) p.maxHeight = opts.maxHeight;
|
|
271
|
-
if (opts.autoCropArea) p.autoCropArea = opts.autoCropArea;
|
|
272
|
-
if (typeof opts.allowGifs === "boolean") p.allowGifs = opts.allowGifs;
|
|
273
|
-
if (extra) { for (var k in extra) { p[k] = extra[k]; } }
|
|
274
|
-
return p;
|
|
275
|
-
}
|
|
276
|
-
// A token unique to THIS host instance. Many hosts can be mounted on one page
|
|
277
|
-
// (one per row on /admin/emails) and they all hear the same window event, so a
|
|
278
|
-
// confirm has to say WHICH host opened the cropper.
|
|
279
|
-
var ownerId = "iuh-" + (window.__studioImageUploadHostSeq = (window.__studioImageUploadHostSeq || 0) + 1);
|
|
280
|
-
|
|
281
|
-
return {
|
|
282
|
-
ownerId: ownerId,
|
|
283
|
-
// Modal-as-picker: the crop modal itself is the file picker / drop target.
|
|
284
|
-
open() {
|
|
285
|
-
if (!window.Alpine || !Alpine.store(storeName)) return;
|
|
286
|
-
Alpine.store(storeName).open("crop-photo", cropProps({ owner: ownerId }));
|
|
287
|
-
},
|
|
288
|
-
// The window-event guard: a confirm addressed to a DIFFERENT host is not ours
|
|
289
|
-
// to act on. An owner-LESS confirm still applies, so every existing
|
|
290
|
-
// single-host page keeps working unchanged.
|
|
291
|
-
onCropConfirmed(detail) {
|
|
292
|
-
if (!detail) return;
|
|
293
|
-
if (detail.owner && detail.owner !== ownerId) return;
|
|
294
|
-
// An uncropped pass-through (a GIF) carries its own name and type. The
|
|
295
|
-
// cropped path does not — it produced a PNG — so it keeps the caller's
|
|
296
|
-
// filename. Sending "banner.png" for GIF bytes would have the server
|
|
297
|
-
// store an animated file under a name and content type that say
|
|
298
|
-
// otherwise, and the inbox would be told it is a PNG.
|
|
299
|
-
this.applyCrop(detail.blob, detail.filename);
|
|
300
|
-
},
|
|
301
|
-
// Native picker: read the chosen image, then hand it to the modal.
|
|
302
|
-
onFileSelected(event) {
|
|
303
|
-
var file = event.target.files[0];
|
|
304
|
-
if (!file) return;
|
|
305
|
-
var reader = new FileReader();
|
|
306
|
-
reader.onload = function (e) {
|
|
307
|
-
Alpine.store(storeName).open("crop-photo", cropProps({ imageUrl: e.target.result, owner: ownerId }));
|
|
308
|
-
};
|
|
309
|
-
reader.readAsDataURL(file);
|
|
310
|
-
event.target.value = "";
|
|
311
|
-
},
|
|
312
|
-
applyCrop(blob, filename) {
|
|
313
|
-
if (!blob) return;
|
|
314
|
-
// The blob's OWN type when it has one — a pass-through GIF arrives as the
|
|
315
|
-
// original File and already knows it is image/gif. Only the cropped path
|
|
316
|
-
// needs the png default, because that is what toBlob produced.
|
|
317
|
-
var type = blob.type || "image/png";
|
|
318
|
-
var name = filename || opts.filename || "image.png";
|
|
319
|
-
var dt = new DataTransfer();
|
|
320
|
-
dt.items.add(new File([blob], name, { type: type }));
|
|
321
|
-
this.$refs.fileInput.files = dt.files;
|
|
322
|
-
window.submitFormWithProgress(this.$refs.form, opts);
|
|
323
|
-
}
|
|
324
|
-
};
|
|
325
|
-
};
|
|
326
|
-
</script>
|
|
@@ -59,9 +59,8 @@
|
|
|
59
59
|
resolve take the REFUSED branch, so the guide can show the
|
|
60
60
|
handoff without a backend. Ignored unless demo.
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
never runs). Single root element (modal-host template mount rule).
|
|
62
|
+
The scope, birthdayModal(), is studio/birthday, published on every page by
|
|
63
|
+
studio/alpine_scopes. Single root element (modal-host template mount rule).
|
|
65
64
|
%>
|
|
66
65
|
<%
|
|
67
66
|
# min_age is OPTIONAL now. Absent (or 0) means this app asks for a birthday and
|
|
@@ -52,8 +52,12 @@
|
|
|
52
52
|
|
|
53
53
|
ON BY DEFAULT rather than set only in the observer's callback, so the
|
|
54
54
|
early return for a browser without IntersectionObserver — which leaves the
|
|
55
|
-
bar hidden forever — leaves it inert forever too.
|
|
55
|
+
bar hidden forever — leaves it inert forever too.
|
|
56
|
+
|
|
57
|
+
WHEN THE BAR SHOWS is studio/identity_mini, bound by the identity-mini
|
|
58
|
+
controller on this element. %>
|
|
56
59
|
<div data-studio-identity-mini
|
|
60
|
+
data-studio-controller="identity-mini"
|
|
57
61
|
class="studio-identity-mini"
|
|
58
62
|
inert>
|
|
59
63
|
<div class="px-4">
|
|
@@ -94,64 +98,3 @@
|
|
|
94
98
|
</div>
|
|
95
99
|
</div>
|
|
96
100
|
|
|
97
|
-
<script>
|
|
98
|
-
(function () {
|
|
99
|
-
var CLASS = "is-visible";
|
|
100
|
-
|
|
101
|
-
function wire() {
|
|
102
|
-
var full = document.querySelector("[data-studio-identity-full]");
|
|
103
|
-
var mini = document.querySelector("[data-studio-identity-mini]");
|
|
104
|
-
if (!full || !mini) return;
|
|
105
|
-
|
|
106
|
-
// No IntersectionObserver: leave the bar hidden rather than showing a
|
|
107
|
-
// duplicate header permanently. The page is fully usable without it.
|
|
108
|
-
if (!("IntersectionObserver" in window)) return;
|
|
109
|
-
|
|
110
|
-
if (mini._studioObserver) mini._studioObserver.disconnect();
|
|
111
|
-
|
|
112
|
-
// HAND OVER PART-WAY, not once the card has gone. Waiting for the last
|
|
113
|
-
// pixel left a gap where neither header was on screen, which is the moment
|
|
114
|
-
// the bar exists to cover. It appears once most of the card has scrolled
|
|
115
|
-
// past, so the two overlap briefly instead.
|
|
116
|
-
//
|
|
117
|
-
// Ratio, not a pixel offset: the card's height changes with the name's
|
|
118
|
-
// wrap and with whether there is an email under it, so a fixed offset
|
|
119
|
-
// would mean something different on every account.
|
|
120
|
-
var SHOW_BELOW_RATIO = 0.6;
|
|
121
|
-
|
|
122
|
-
var observer = new IntersectionObserver(function (entries) {
|
|
123
|
-
var entry = entries[0];
|
|
124
|
-
// A card taller than the viewport can never reach ratio 1, so "not
|
|
125
|
-
// intersecting at all" has to count as gone in its own right.
|
|
126
|
-
var gone = !entry.isIntersecting || entry.intersectionRatio < SHOW_BELOW_RATIO;
|
|
127
|
-
mini.classList.toggle(CLASS, gone);
|
|
128
|
-
// The tab order and the accessibility tree follow the same decision the
|
|
129
|
-
// class does. opacity and pointer-events cannot express "not here".
|
|
130
|
-
mini.inert = !gone;
|
|
131
|
-
}, {
|
|
132
|
-
// The navbar covers the top of the viewport, so pixels behind it are not
|
|
133
|
-
// visible pixels — without this the card reads as on-screen while it is
|
|
134
|
-
// hidden under the nav.
|
|
135
|
-
rootMargin: "-72px 0px 0px 0px",
|
|
136
|
-
// A spread of thresholds, or the callback only fires at fully-in and
|
|
137
|
-
// fully-out and the ratio test above never gets a chance to run.
|
|
138
|
-
threshold: [0, 0.2, 0.4, 0.5, 0.6, 0.8, 1]
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
observer.observe(full);
|
|
142
|
-
mini._studioObserver = observer;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
document.addEventListener("turbo:load", wire);
|
|
146
|
-
document.addEventListener("turbo:before-cache", function () {
|
|
147
|
-
var mini = document.querySelector("[data-studio-identity-mini]");
|
|
148
|
-
if (mini) {
|
|
149
|
-
mini.classList.remove(CLASS);
|
|
150
|
-
mini.inert = true;
|
|
151
|
-
if (mini._studioObserver) mini._studioObserver.disconnect();
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
if (document.readyState !== "loading") wire();
|
|
155
|
-
else document.addEventListener("DOMContentLoaded", wire);
|
|
156
|
-
})();
|
|
157
|
-
</script>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<%# The profile form's plain Save: a submit button inside the form, with no
|
|
2
|
+
script behind it.
|
|
3
|
+
|
|
4
|
+
It is the JS-less path, hidden once Alpine boots the form's scope, because
|
|
5
|
+
with the save controls up there would be two Saves. It is also what a person
|
|
6
|
+
saves with when the form's factory failed to load: the guard in the head
|
|
7
|
+
(studio/_alpine_scopes_guard) shows every data-studio-scope-fallback inside
|
|
8
|
+
a scope that did not load. %>
|
|
9
|
+
<div class="mt-6" x-show="!alpine" x-cloak data-studio-scope-fallback>
|
|
10
|
+
<button type="submit" class="btn btn-primary">Save changes</button>
|
|
11
|
+
</div>
|
|
@@ -114,16 +114,11 @@
|
|
|
114
114
|
</section>
|
|
115
115
|
<% end %>
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
there would be two Saves. %>
|
|
119
|
-
<div class="mt-6" x-show="!alpine" x-cloak>
|
|
120
|
-
<button type="submit" class="btn btn-primary">Save changes</button>
|
|
121
|
-
</div>
|
|
117
|
+
<%= render "studio/profiles/plain_save" %>
|
|
122
118
|
<% end %>
|
|
123
119
|
</div>
|
|
124
120
|
</div>
|
|
125
121
|
|
|
126
|
-
<%= render "studio/profiles/form_script" %>
|
|
127
122
|
|
|
128
123
|
<% if avatar_editable %>
|
|
129
124
|
<%# THE `x-data` IS LOAD-BEARING, and this page was missing it. Alpine 3 only
|