studio-engine 0.97.0 → 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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/app/javascript/studio/alpine_scopes.js +66 -0
  4. data/app/javascript/studio/alpine_shims.js +4 -0
  5. data/app/javascript/studio/application.js +4 -1
  6. data/app/javascript/studio/birthday.js +351 -0
  7. data/app/javascript/studio/confirm_interstitial.js +44 -0
  8. data/app/javascript/studio/controllers/identity_mini_controller.js +32 -0
  9. data/app/javascript/studio/controllers/style_modals_controller.js +41 -0
  10. data/app/javascript/studio/cropper.js +281 -0
  11. data/app/javascript/studio/identity_mini.js +74 -0
  12. data/app/javascript/studio/image_upload.js +189 -0
  13. data/app/javascript/studio/profile_form.js +88 -0
  14. data/app/javascript/studio/stimulus.js +4 -0
  15. data/app/javascript/studio/style_guide.js +13 -0
  16. data/app/javascript/studio/style_modals.js +214 -0
  17. data/app/views/components/_avatar_cropper.html.erb +3 -38
  18. data/app/views/layouts/studio/_head.html.erb +12 -2
  19. data/app/views/studio/_alpine_scopes_guard.html.erb +178 -0
  20. data/app/views/studio/_birthday_assets.html.erb +6 -240
  21. data/app/views/studio/_confirm_interstitial.html.erb +31 -18
  22. data/app/views/studio/_cropper_assets.html.erb +9 -8
  23. data/app/views/studio/modals/_crop_photo.html.erb +1 -1
  24. data/app/views/studio/modals/_image_upload.html.erb +8 -321
  25. data/app/views/studio/modals/blocks/_birthday.html.erb +2 -3
  26. data/app/views/studio/profiles/_birthday_fields.html.erb +0 -1
  27. data/app/views/studio/profiles/_identity_mini.html.erb +5 -62
  28. data/app/views/studio/profiles/_plain_save.html.erb +11 -0
  29. data/app/views/studio/profiles/edit.html.erb +1 -6
  30. data/app/views/style/_modals.html.erb +36 -420
  31. data/lib/studio/version.rb +1 -1
  32. metadata +14 -3
  33. data/app/views/studio/profiles/_birthday_picker_script.html.erb +0 -146
  34. data/app/views/studio/profiles/_form_script.html.erb +0 -65
@@ -1,326 +1,13 @@
1
1
  <%#
2
- Image-upload client behavior for the crop-photo + saving modals.
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
- Ships three window factories inline (engine convention — see _head /
5
- _flash) so they're defined before Alpine processes any x-data that
6
- references them:
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
- cropPhotoModal() x-data for studio/modals/_crop_photo
9
- submitFormWithProgress() loading card + toast around a Turbo submit
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
- Requires window.birthdayModal — render studio/_birthday_assets once at
63
- layout level (the factory can’t ship inside this template; a cloned script
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
@@ -87,4 +87,3 @@
87
87
 
88
88
  <p class="text-muted text-xs mt-2">Stored as day, month and year — we use it for birthdays and age checks.</p>
89
89
 
90
- <%= render "studio/profiles/birthday_picker_script" %>
@@ -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
- <%# The JS-less path. Hidden once Alpine boots, because with the bar up
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