studio-engine 0.4.11 → 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: b843f965201c31cd83f0c1fd3f39830730fbc9e139a31c3a856c3e99853bf053
4
- data.tar.gz: c7383f3cf9943f93a2f06150909ee23461d194e93b44c33848bb39106a6f10f9
3
+ metadata.gz: 38d351424eeef931252e2c219759db0fdd55605176d2daf7ca10ba283332144f
4
+ data.tar.gz: d23cacb846cc912b926bca4093bf6d9a8d0de3c1332147244cb0005d82ace99b
5
5
  SHA512:
6
- metadata.gz: 2b65ebd2d798a2e06aa2572160d241e2c4557033da6aa3fb2ed4c2bb214e9f2c2119b11cd53e778401f100f318fce49a33531f260e0e8fe05d26449df3281fca
7
- data.tar.gz: 8eae952bd9cc78f3705cea486a63fdfb6b667e112c1c207992813b2d3c942bda0fa513f1df4120069b3916703db4bab1d7aabdac3dd6553cacc0a8e239a77353
6
+ metadata.gz: 6e3af533680b72fc4a010c6c07143d75f332d7f6b82c8f5ef8310bfdd979258f2050d7bb581c39aad61c8187d7510e30abb5d621a7d4626769122f20bafe8957
7
+ data.tar.gz: 9434c0b55cd9275491cddaad6eae404ca78b30dd7d3e0fe42e29ae0b5ae8f5ee78b7a346b8e00c89ad8c4f881097e512092d9c607d023670bb017416a1bfb2b8
data/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
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
+
5
37
  ## v0.4.11 (2026-05-24)
6
38
 
7
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.
@@ -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>
@@ -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.11"
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.11
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