studio-engine 0.4.10 → 0.4.12

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69ebed5ecf2c4d41815556f20f2c783e71b14c0b381cd07b5f5731fb89d981ba
4
- data.tar.gz: 264b1eb9ba0a3bf3bb8abb5fe2ee0d60a2b5abd30c792d307885d7b1dd623475
3
+ metadata.gz: 38d351424eeef931252e2c219759db0fdd55605176d2daf7ca10ba283332144f
4
+ data.tar.gz: d23cacb846cc912b926bca4093bf6d9a8d0de3c1332147244cb0005d82ace99b
5
5
  SHA512:
6
- metadata.gz: ce8cb07a7eeba5953bb5704836721b089ab591bc3d26c2af0ee8c7331f2f0b3734cbea4d506bed5f19b1d187adda3c40a585986413d6617ed80206c9158566cd
7
- data.tar.gz: 56fc96ba668bf57fb9cab85cdc7dbc8b02bc9fafb47a6829745b73689dcf5efb3ef0ef9be528ad9764af06f4cf5c7aa1b979c1c3c01776eade15f56a48f2a85a
6
+ metadata.gz: 6e3af533680b72fc4a010c6c07143d75f332d7f6b82c8f5ef8310bfdd979258f2050d7bb581c39aad61c8187d7510e30abb5d621a7d4626769122f20bafe8957
7
+ data.tar.gz: 9434c0b55cd9275491cddaad6eae404ca78b30dd7d3e0fe42e29ae0b5ae8f5ee78b7a346b8e00c89ad8c4f881097e512092d9c607d023670bb017416a1bfb2b8
data/CHANGELOG.md CHANGED
@@ -2,6 +2,51 @@
2
2
 
3
3
  The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Both consumer Rails apps pin to a tag in their `Gemfile`; bumping the tag is a release.
4
4
 
5
+ ## v0.4.12 (2026-06-02)
6
+
7
+ Promotes the image crop-and-upload UI out of Turf Monster into the engine: a shared cropper modal, the immediate-save upload host, the loading-card-around-a-Turbo-submit helper, and the generic "saving" card. Any consumer can now add a cropped image upload (avatar, banner, logo, OG image) with one `imageUploadHost(...)` x-data plus the cropper assets partial — no bespoke JS.
8
+
9
+ ### Added
10
+ - **`studio/_cropper_assets`** — cropper.js (1.6.2) CSS + JS **and** the three inline factories below. Render it on pages that can open the cropper (avatar field, banner editor); both the library and the behavior load only where an upload trigger exists. The JS rides with the page, **not** the modal host, so it works whether or not an app overrides `studio/modals/_host`.
11
+ - `window.imageUploadHost(opts)` — x-data host for crop-then-immediate-save uploaders. `open()` (modal-as-picker) / `onFileSelected()` (native picker) → `applyCrop()` drops the Blob into a hidden form input and submits with a loading card + toast.
12
+ - `window.submitFormWithProgress(form, opts)` — opens the `saving` card, holds ≥450ms, submits the Turbo form, then closes + toasts on `turbo:submit-end`.
13
+ - `window.cropPhotoModal(opts)` — the crop modal's x-data factory.
14
+ - **`studio/modals/_crop_photo`** — the shared image cropper modal. Opens via `Alpine.store('modals').open('crop-photo', { imageUrl?, aspectRatio?, maxWidth?, maxHeight?, transparent?, autoCropArea?, dispatch? })`; hands the cropped Blob back via the `crop-photo-confirmed` window event.
15
+ - **`studio/modals/_saving`** — generic loading card (title from `props.title`), opened by `submitFormWithProgress`.
16
+
17
+ ### Integration
18
+ Register the two modals in your `studio/modals/host` block + render the assets on each upload page:
19
+ ```erb
20
+ <%# inside the studio/modals/host block %>
21
+ <template x-if="$store.modals.current().id === 'crop-photo'"><%= render "studio/modals/crop_photo" %></template>
22
+ <template x-if="$store.modals.current().id === 'saving'"><%= render "studio/modals/saving" %></template>
23
+
24
+ <%# on each page with an image upload %>
25
+ <%= render "studio/cropper_assets" %>
26
+ <div x-data="imageUploadHost({ aspectRatio: 1, filename: 'avatar.png', saving: 'Saving photo…', dismissible: true, toast: false })"
27
+ @crop-photo-confirmed.window="applyCrop($event.detail.blob)"> … hidden form (x-ref form + fileInput) + trigger … </div>
28
+ ```
29
+ Registrations live in the host **block** (the consumer's `yield` content), so apps that override `studio/modals/_host` — like Turf Monster, which has a substantially diverged host — integrate the same way.
30
+
31
+ ### Migration
32
+ For an app that had its own copies (Turf Monster):
33
+ 1. `bundle update studio-engine`.
34
+ 2. Delete the local `crop_photo_modal.js` (+ its importmap pin / `application.js` import), the `imageUploadHost` + `submitFormWithProgress` definitions, and `modals/_crop_photo` / `modals/_saving` / `shared/_cropper_assets`.
35
+ 3. Point the host block's crop-photo / saving registrations at `studio/modals/crop_photo` / `studio/modals/saving`, and the cropper-asset renders at `studio/cropper_assets`.
36
+
37
+ ## v0.4.11 (2026-05-24)
38
+
39
+ Preserves non-dismissible modals (e.g. pending on-chain TX) across bfcache restore and Turbo snapshot caching. Previously, the modal host's cleanup hooks called `closeAll()` on both `pageshow.persisted` and `turbo:before-cache`, which silently dropped any `dismissible: false` modal — including the processing card a still-in-flight JS promise was expecting to resolve against. The promise's `solanaModal.success()` then no-op'd against an empty stack and the user saw nothing despite their TX landing on-chain.
40
+
41
+ ### Added
42
+ - **`Alpine.store('modals').closeAllDismissible()`** — drops every modal in the stack whose `props.dismissible !== false`, leaves locked modals in place.
43
+
44
+ ### Changed
45
+ - **bfcache + Turbo snapshot cleanup** now calls `closeAllDismissible()` instead of `closeAll()`. Celebratory modals still clear on return; pending-TX modals survive.
46
+
47
+ ### Migration
48
+ None required — celebratory modal behavior is unchanged. Consumers relying on `dismissible: false` (turf-monster's `onchain-tx` modal) gain crash-recovery for free.
49
+
5
50
  ## v0.4.10 (2026-05-23)
6
51
 
7
52
  Lets consumer apps override toast z-indexes without `!important`. Previously, `#toast-container` set `z-index: 60` via an inline `style=` attribute, which forced any consumer override to use `!important`. Same source-order problem for `.toast-page-blur` (z-index in the inline `<style>` block here loaded after the consumer's `application.tailwind.css`). Both now read from CSS custom properties with the previous values as fallback defaults.
data/README.md CHANGED
@@ -7,13 +7,13 @@ Shared Rails engine for McRitchie apps. Provides authentication, error handling,
7
7
  ## Installation
8
8
 
9
9
  ```ruby
10
- # Gemfile — pin to a tag (recommended; see Releases section)
11
- gem "studio-engine", git: "https://github.com/amcritchie/studio-engine.git", tag: "v0.3.0"
10
+ # Gemfile — install from RubyGems (recommended)
11
+ gem "studio-engine", "~> 0.4.0"
12
12
  ```
13
13
 
14
- Then `bundle install`. The current release is **v0.3.0**; see [`CHANGELOG.md`](./CHANGELOG.md) for the history.
14
+ Then `bundle install`. The current release is **v0.4.10**; see [`CHANGELOG.md`](./CHANGELOG.md) for the history.
15
15
 
16
- > Pinning to a tag (not `main`) is now the recommended pattern. Consumer apps that track `main` will silently inherit any engine merge bad when one merge breaks several apps at once.
16
+ > Published to RubyGems as of v0.4.0 (2026-05-17). Earlier consumers used a `git:` ref pinned to a tag; that pattern is preserved here for reference but new installs should use the RubyGems form, which the consumer Rails apps (`mcritchie-studio`, `turf-monster`, `tax-studio`) already use.
17
17
 
18
18
  ## What It Provides
19
19
 
@@ -0,0 +1,11 @@
1
+ <%# Cropper.js + the image-upload factories for the shared crop-photo modal.
2
+ Render on pages that can open the cropper (an avatar field, a banner editor,
3
+ etc.) — this loads cropper.js (~40 KB) AND defines imageUploadHost /
4
+ cropPhotoModal / submitFormWithProgress inline, so both the library and the
5
+ behavior only load where an upload trigger exists rather than globally.
6
+
7
+ Pair with: the crop-photo + saving modal registrations in your modal-host
8
+ block (see the README / CHANGELOG), and x-data="imageUploadHost({...})". %>
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css">
10
+ <script defer src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"></script>
11
+ <%= render "studio/modals/image_upload" %>
@@ -0,0 +1,69 @@
1
+ <%#
2
+ Crop Photo modal — content partial for the modal host. Register it in your
3
+ studio/modals/host block alongside the `saving` modal — a template x-if on
4
+ $store.modals.current().id === 'crop-photo' that renders this partial (see
5
+ the CHANGELOG Integration section). Opened via
6
+ Alpine.store('modals').open('crop-photo', { imageUrl? }).
7
+
8
+ The modal always hands the cropped Blob back to its opener via the
9
+ 'crop-photo-confirmed' window event — the opener's host owns the upload.
10
+ How the image gets IN depends on the props:
11
+
12
+ * imageUrl passed — the opener already let the user pick a file; the
13
+ modal jumps straight to the cropper (e.g. an avatar field).
14
+
15
+ * no imageUrl — the modal IS the picker: a click/drag-drop empty state
16
+ lets the user choose a file inside the modal, then crops (e.g. a
17
+ banner editor's open()).
18
+
19
+ The cropPhotoModal() factory ships in studio/modals/_image_upload; cropper.js
20
+ loads via studio/_cropper_assets on the pages that can open this modal.
21
+
22
+ Single root: lives inside the host's <template x-if>, so only the outer
23
+ div is the top-level element here.
24
+ %>
25
+ <div x-data="cropPhotoModal()">
26
+ <h3 class="text-heading font-bold text-lg text-center mb-4">Crop Photo</h3>
27
+
28
+ <template x-if="error">
29
+ <p class="text-red-400 text-sm mb-3 text-center" x-text="error"></p>
30
+ </template>
31
+
32
+ <input type="file" x-ref="filePicker" @change="onFilePicked($event)" accept="image/*" class="hidden">
33
+
34
+ <%# Empty state: click / drag-drop landing zone. %>
35
+ <template x-if="!imageUrl">
36
+ <div @click="$refs.filePicker.click()"
37
+ @dragover.prevent="dragging = true"
38
+ @dragleave.prevent="dragging = false"
39
+ @drop.prevent="onDrop($event)"
40
+ :class="dragging ? 'border-primary bg-primary/10' : 'border-subtle hover:border-primary'"
41
+ class="cursor-pointer border-2 border-dashed rounded-lg flex flex-col items-center justify-center text-center p-8 mb-4 transition"
42
+ style="min-height: 240px;">
43
+ <svg class="w-12 h-12 text-muted mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
44
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.9 5 5 0 019.9-1A5.5 5.5 0 0118 16h-1m-6-4l3-3m0 0l3 3m-3-3v12"/>
45
+ </svg>
46
+ <p class="text-sm font-medium text-body">Drop an image or click to upload</p>
47
+ <p class="text-xs text-muted mt-1">PNG or JPG</p>
48
+ </div>
49
+ </template>
50
+
51
+ <%# Loaded state: cropper canvas. %>
52
+ <template x-if="imageUrl">
53
+ <div class="w-full mb-4" style="max-height: 360px;">
54
+ <img x-ref="cropImage" :src="imageUrl" class="max-w-full" style="display: block;">
55
+ </div>
56
+ </template>
57
+
58
+ <button type="button" @click="confirm()"
59
+ :disabled="!imageUrl"
60
+ :class="!imageUrl ? 'opacity-60 cursor-not-allowed' : ''"
61
+ class="btn btn-primary btn-lg w-full"
62
+ x-text="imageUrl ? 'Crop &amp; Save' : 'Upload an Image to Continue'">
63
+ </button>
64
+
65
+ <button type="button" @click="cancel()"
66
+ class="block mx-auto mt-4 text-xs text-secondary hover:text-heading underline underline-offset-2">
67
+ Cancel
68
+ </button>
69
+ </div>
@@ -70,6 +70,19 @@
70
70
  this.stack = [];
71
71
  this._sync();
72
72
  },
73
+ // Drops every modal whose props don't explicitly opt out of dismissal
74
+ // (i.e. dismissible: false). Used by the bfcache + Turbo snapshot
75
+ // cleanup below so an in-flight on-chain TX modal — which sets
76
+ // dismissible: false precisely to forbid losing its UI mid-flight —
77
+ // survives a back/forward navigation. Celebratory modals (default
78
+ // dismissible: true) are still cleared so users don't return to a
79
+ // stale "Success!" card.
80
+ closeAllDismissible: function() {
81
+ this.stack = this.stack.filter(function(modal) {
82
+ return modal.props && modal.props.dismissible === false;
83
+ });
84
+ this._sync();
85
+ },
73
86
  isOpen: function(id) {
74
87
  for (var i = 0; i < this.stack.length; i++) {
75
88
  if (this.stack[i].id === id) return true;
@@ -93,15 +106,23 @@
93
106
  //
94
107
  // Without this, a user who lands on a celebratory modal and then
95
108
  // navigates away (or hits the browser's bfcache on back) sees the
96
- // modal again on their next visit. Clear the stack on both signals.
97
- function closeAllModals() {
109
+ // modal again on their next visit. closeAllDismissible() keeps
110
+ // dismissible: false modals (e.g. pending on-chain TX) so the
111
+ // user returning via bfcache doesn't silently lose the modal that
112
+ // a still-in-flight JS promise will resolve against.
113
+ function clearStaleModals() {
98
114
  if (window.Alpine && Alpine.store) {
99
115
  var m = Alpine.store('modals');
100
- if (m && typeof m.closeAll === 'function') m.closeAll();
116
+ if (m && typeof m.closeAllDismissible === 'function') {
117
+ m.closeAllDismissible();
118
+ } else if (m && typeof m.closeAll === 'function') {
119
+ // Fallback for older engine versions that registered the store first.
120
+ m.closeAll();
121
+ }
101
122
  }
102
123
  }
103
- window.addEventListener('pageshow', function(e) { if (e.persisted) closeAllModals(); });
104
- document.addEventListener('turbo:before-cache', closeAllModals);
124
+ window.addEventListener('pageshow', function(e) { if (e.persisted) clearStaleModals(); });
125
+ document.addEventListener('turbo:before-cache', clearStaleModals);
105
126
  })();
106
127
  </script>
107
128
 
@@ -0,0 +1,232 @@
1
+ <%#
2
+ Image-upload client behavior for the crop-photo + saving modals.
3
+
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:
7
+
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
14
+ studio/_cropper_assets on the pages that can open the cropper.
15
+ %>
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
+ cropper: null,
30
+ imageUrl: null,
31
+ fromParent: false,
32
+ dragging: false,
33
+ error: null,
34
+ // Configurable crop output. Defaults = avatar (square 256px, transparent).
35
+ // Callers override per-use via the modal props, e.g.
36
+ // Alpine.store('modals').open('crop-photo',
37
+ // { imageUrl, aspectRatio: 3, maxWidth: 900, maxHeight: 300 })
38
+ aspectRatio: 1,
39
+ maxWidth: 256,
40
+ maxHeight: 256,
41
+ transparent: true,
42
+ autoCropArea: 0.9,
43
+ // dispatch: keep the modal open after confirm so the opener's host can run
44
+ // its own processing -> success flow (it replaces the modal). When false
45
+ // (fromParent, e.g. avatar_cropper) the modal closes itself after confirm.
46
+ dispatch: false,
47
+
48
+ init() {
49
+ var current = this.$store.modals.current();
50
+ var props = (current && current.props) || {};
51
+ if (props.aspectRatio) this.aspectRatio = props.aspectRatio;
52
+ if (props.maxWidth) this.maxWidth = props.maxWidth;
53
+ if (props.maxHeight) this.maxHeight = props.maxHeight;
54
+ if (typeof props.transparent === "boolean") this.transparent = props.transparent;
55
+ if (props.dispatch) this.dispatch = true;
56
+ if (props.autoCropArea) this.autoCropArea = props.autoCropArea;
57
+ if (props.imageUrl) {
58
+ this.fromParent = true;
59
+ this.imageUrl = props.imageUrl;
60
+ this.mountCropper();
61
+ }
62
+ },
63
+
64
+ destroy() {
65
+ if (this.cropper) { this.cropper.destroy(); this.cropper = null; }
66
+ },
67
+
68
+ mountCropper() {
69
+ var self = this;
70
+ this.$nextTick(function () {
71
+ if (typeof Cropper === "undefined") return;
72
+ if (self.cropper) { self.cropper.destroy(); self.cropper = null; }
73
+ self.cropper = new Cropper(self.$refs.cropImage, {
74
+ aspectRatio: self.aspectRatio, viewMode: 1, dragMode: "move",
75
+ autoCropArea: self.autoCropArea, cropBoxResizable: true,
76
+ cropBoxMovable: true, background: false, guides: true
77
+ });
78
+ // Cropping in progress — lock the modal (no click-outside / escape) so
79
+ // an accidental click doesn't discard the crop. Cancel still works.
80
+ var cur = self.$store.modals.current();
81
+ if (cur && cur.props) cur.props.dismissible = false;
82
+ });
83
+ },
84
+
85
+ readFile(file) {
86
+ if (!file) return;
87
+ if (!file.type || file.type.indexOf("image/") !== 0) {
88
+ this.error = "Please choose an image file.";
89
+ return;
90
+ }
91
+ var self = this;
92
+ var reader = new FileReader();
93
+ reader.onload = function (e) {
94
+ self.error = null;
95
+ self.imageUrl = e.target.result;
96
+ self.mountCropper();
97
+ };
98
+ reader.readAsDataURL(file);
99
+ },
100
+
101
+ onFilePicked(event) {
102
+ var file = event.target.files[0];
103
+ event.target.value = "";
104
+ this.readFile(file);
105
+ },
106
+
107
+ onDrop(event) {
108
+ this.dragging = false;
109
+ var file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0];
110
+ this.readFile(file);
111
+ },
112
+
113
+ cancel() {
114
+ if (this.cropper) { this.cropper.destroy(); this.cropper = null; }
115
+ this.$store.modals.close();
116
+ },
117
+
118
+ confirm() {
119
+ if (!this.cropper) return;
120
+ var self = this;
121
+ // Cap WIDTH only. For a fixed-aspect crop the aspect already bounds the
122
+ // height (= width / aspectRatio); also passing maxHeight makes cropper.js
123
+ // downscale the whole SOURCE to fit maxHeight when the source is taller
124
+ // (a 1983x793 upload -> 500/793 ~= 0.63x), tanking fidelity. maxWidth
125
+ // alone keeps the crop at source resolution up to the cap.
126
+ var canvasOpts = { maxWidth: this.maxWidth, imageSmoothingQuality: "high" };
127
+ if (!this.transparent) canvasOpts.fillColor = "#ffffff";
128
+ var canvas = this.cropper.getCroppedCanvas(canvasOpts);
129
+ canvas.toBlob(function (blob) {
130
+ try {
131
+ window.dispatchEvent(new CustomEvent("crop-photo-confirmed", { detail: { blob: blob } }));
132
+ } catch (_) {}
133
+ if (self.cropper) { self.cropper.destroy(); self.cropper = null; }
134
+ // dispatch mode: the opener's host owns the post-confirm flow
135
+ // (processing modal -> success toast), so don't pop the stack here.
136
+ // fromParent (no dispatch): close the modal now.
137
+ if (!self.dispatch) self.$store.modals.close();
138
+ }, "image/png");
139
+ }
140
+ };
141
+ }
142
+ window.cropPhotoModal = cropPhotoModal;
143
+ if (typeof Alpine !== "undefined") {
144
+ Alpine.data("cropPhotoModal", cropPhotoModal);
145
+ } else {
146
+ document.addEventListener("alpine:init", function () { Alpine.data("cropPhotoModal", cropPhotoModal); });
147
+ }
148
+
149
+ // === window.submitFormWithProgress — loading card + toast for a Turbo submit
150
+ //
151
+ // Swaps in the 'saving' card while the form uploads, then closes it (held
152
+ // >= ~450ms so it doesn't flash) and toasts on completion. opts: { saving,
153
+ // success, successMessage, failure, failureMessage, dismissible (default
154
+ // false), toast (default true) }.
155
+ window.submitFormWithProgress = function (form, opts) {
156
+ opts = opts || {};
157
+ var store = window.Alpine && Alpine.store("modals");
158
+ var hold = (window.StudioModals && window.StudioModals.holdAtLeast)
159
+ ? window.StudioModals.holdAtLeast(450)
160
+ : { then: function (cb) { cb(); } };
161
+ if (store) {
162
+ store.open("saving", { dismissible: opts.dismissible === true, title: opts.saving || "Saving…" }, { replace: true });
163
+ }
164
+ var onEnd = function (e) {
165
+ form.removeEventListener("turbo:submit-end", onEnd);
166
+ var ok = e.detail && e.detail.success;
167
+ hold.then(function () {
168
+ if (store) store.close();
169
+ if (opts.toast !== false) {
170
+ window.dispatchEvent(new CustomEvent("toast", { detail: {
171
+ type: ok ? "notice" : "alert",
172
+ title: ok ? (opts.success || "Saved") : (opts.failure || "Couldn't save"),
173
+ message: ok ? (opts.successMessage || "") : (opts.failureMessage || "Please try again.")
174
+ } }));
175
+ }
176
+ });
177
+ };
178
+ form.addEventListener("turbo:submit-end", onEnd);
179
+ form.requestSubmit();
180
+ };
181
+
182
+ // === window.imageUploadHost — x-data host for crop-then-immediate-save ======
183
+ //
184
+ // Two entry points feed the SHARED crop-photo modal in dispatch mode:
185
+ // open() — the modal itself is the click/drag-drop target (banner)
186
+ // onFileSelected() — a native file picker hands the image in (avatar)
187
+ // Both dispatch the cropped Blob via 'crop-photo-confirmed'; applyCrop() drops
188
+ // it into the host's own hidden form input and submits immediately with a
189
+ // loading card + toast (submitFormWithProgress). opts = crop config
190
+ // (aspectRatio, maxWidth, maxHeight, transparent, autoCropArea) + save copy
191
+ // (saving, success, successMessage, failure, dismissible, toast, filename).
192
+ window.imageUploadHost = function (opts) {
193
+ opts = opts || {};
194
+ function cropProps(extra) {
195
+ var p = {
196
+ aspectRatio: opts.aspectRatio || 1,
197
+ transparent: opts.transparent !== false,
198
+ dispatch: true
199
+ };
200
+ if (opts.maxWidth) p.maxWidth = opts.maxWidth;
201
+ if (opts.maxHeight) p.maxHeight = opts.maxHeight;
202
+ if (opts.autoCropArea) p.autoCropArea = opts.autoCropArea;
203
+ if (extra) { for (var k in extra) { p[k] = extra[k]; } }
204
+ return p;
205
+ }
206
+ return {
207
+ // Modal-as-picker: the crop modal itself is the file picker / drop target.
208
+ open() {
209
+ if (!window.Alpine || !Alpine.store("modals")) return;
210
+ Alpine.store("modals").open("crop-photo", cropProps());
211
+ },
212
+ // Native picker: read the chosen image, then hand it to the modal.
213
+ onFileSelected(event) {
214
+ var file = event.target.files[0];
215
+ if (!file) return;
216
+ var reader = new FileReader();
217
+ reader.onload = function (e) {
218
+ Alpine.store("modals").open("crop-photo", cropProps({ imageUrl: e.target.result }));
219
+ };
220
+ reader.readAsDataURL(file);
221
+ event.target.value = "";
222
+ },
223
+ applyCrop(blob) {
224
+ if (!blob) return;
225
+ var dt = new DataTransfer();
226
+ dt.items.add(new File([blob], opts.filename || "image.png", { type: "image/png" }));
227
+ this.$refs.fileInput.files = dt.files;
228
+ window.submitFormWithProgress(this.$refs.form, opts);
229
+ }
230
+ };
231
+ };
232
+ </script>
@@ -0,0 +1,7 @@
1
+ <%# Generic "saving" loading card, opened by window.submitFormWithProgress with
2
+ a { title } prop while a Turbo form uploads. Register it in your modal-host
3
+ block alongside crop-photo. Single root for the host's <template x-if>. %>
4
+ <div>
5
+ <%= render "studio/modals/blocks/processing_card",
6
+ title_key: "$store.modals.current()?.props?.title || 'Saving…'" %>
7
+ </div>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.4.10"
2
+ VERSION = "0.4.12"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.10
4
+ version: 0.4.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-24 00:00:00.000000000 Z
11
+ date: 2026-06-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -146,7 +146,11 @@ files:
146
146
  - app/views/schema/index.html.erb
147
147
  - app/views/sessions/_sso_continue.html.erb
148
148
  - app/views/sessions/new.html.erb
149
+ - app/views/studio/_cropper_assets.html.erb
150
+ - app/views/studio/modals/_crop_photo.html.erb
149
151
  - app/views/studio/modals/_host.html.erb
152
+ - app/views/studio/modals/_image_upload.html.erb
153
+ - app/views/studio/modals/_saving.html.erb
150
154
  - app/views/studio/modals/blocks/_error_card.html.erb
151
155
  - app/views/studio/modals/blocks/_processing_card.html.erb
152
156
  - app/views/studio/modals/blocks/_progress_countdown.html.erb