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 +4 -4
- data/CHANGELOG.md +32 -0
- data/app/views/studio/_cropper_assets.html.erb +11 -0
- data/app/views/studio/modals/_crop_photo.html.erb +69 -0
- data/app/views/studio/modals/_image_upload.html.erb +232 -0
- data/app/views/studio/modals/_saving.html.erb +7 -0
- data/lib/studio/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 38d351424eeef931252e2c219759db0fdd55605176d2daf7ca10ba283332144f
|
|
4
|
+
data.tar.gz: d23cacb846cc912b926bca4093bf6d9a8d0de3c1332147244cb0005d82ace99b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 & 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>
|
data/lib/studio/version.rb
CHANGED
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.
|
|
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-
|
|
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
|