studio-engine 0.33.0 → 0.37.0
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 +222 -0
- data/README.md +157 -0
- data/app/assets/images/emails/email-change-confirmation.png +0 -0
- data/app/assets/images/emails/magic-link.png +0 -0
- data/app/controllers/studio/email_images_controller.rb +38 -3
- data/app/controllers/studio/emails_controller.rb +85 -0
- data/app/controllers/studio/local_reviews_controller.rb +85 -1
- data/app/mailers/user_mailer.rb +4 -1
- data/app/services/studio/email_image.rb +277 -33
- data/app/views/studio/email_images/index.html.erb +30 -1
- data/app/views/studio/emails/_row.html.erb +102 -0
- data/app/views/studio/emails/index.html.erb +116 -0
- data/app/views/studio/modals/_image_upload.html.erb +38 -7
- data/app/views/studio/modals/_scoped_host.html.erb +195 -0
- data/lib/studio/engine.rb +20 -0
- data/lib/studio/s3.rb +44 -8
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +98 -4
- metadata +8 -2
|
@@ -58,6 +58,9 @@
|
|
|
58
58
|
if (props.maxHeight) this.maxHeight = props.maxHeight;
|
|
59
59
|
if (typeof props.transparent === "boolean") this.transparent = props.transparent;
|
|
60
60
|
if (props.dispatch) this.dispatch = true;
|
|
61
|
+
// Carried through untouched and echoed back on confirm, so the host that
|
|
62
|
+
// OPENED the cropper is the only one that acts on the result.
|
|
63
|
+
this.owner = props.owner || null;
|
|
61
64
|
if (props.autoCropArea) this.autoCropArea = props.autoCropArea;
|
|
62
65
|
if (props.imageUrl) {
|
|
63
66
|
this.fromParent = true;
|
|
@@ -139,7 +142,15 @@
|
|
|
139
142
|
var canvas = this.cropper.getCroppedCanvas(canvasOpts);
|
|
140
143
|
canvas.toBlob(function (blob) {
|
|
141
144
|
try {
|
|
142
|
-
|
|
145
|
+
// OWNER RIDES WITH THE BLOB. `crop-photo-confirmed` is a WINDOW event, so
|
|
146
|
+
// every imageUploadHost on the page hears it — and /admin/emails mounts
|
|
147
|
+
// one host PER ROW. Without an owner, one confirmed crop PATCHed every
|
|
148
|
+
// row's banner with the same image, destroying any app-owned banner
|
|
149
|
+
// already there; the FIRST upload an admin ever performed hit it, because
|
|
150
|
+
// the engine pre-registers two emails. `owner` is the token the opening
|
|
151
|
+
// host stamped on the crop props, echoed back untouched.
|
|
152
|
+
window.dispatchEvent(new CustomEvent("crop-photo-confirmed",
|
|
153
|
+
{ detail: { blob: blob, owner: self.owner || null } }));
|
|
143
154
|
} catch (_) {}
|
|
144
155
|
if (self.cropper) { self.cropper.destroy(); self.cropper = null; }
|
|
145
156
|
// dispatch mode: the opener's host owns the post-confirm flow
|
|
@@ -162,10 +173,11 @@
|
|
|
162
173
|
// Swaps in the 'saving' card while the form uploads, then closes it (held
|
|
163
174
|
// >= ~450ms so it doesn't flash) and toasts on completion. opts: { saving,
|
|
164
175
|
// success, successMessage, failure, failureMessage, dismissible (default
|
|
165
|
-
// false), toast (default true)
|
|
176
|
+
// false), toast (default true), store (Alpine store name, default "modals" —
|
|
177
|
+
// pass a page-scoped host's name, e.g. "emailModals") }.
|
|
166
178
|
window.submitFormWithProgress = function (form, opts) {
|
|
167
179
|
opts = opts || {};
|
|
168
|
-
var store = window.Alpine && Alpine.store("modals");
|
|
180
|
+
var store = window.Alpine && Alpine.store(opts.store || "modals");
|
|
169
181
|
var hold = (window.StudioModals && window.StudioModals.holdAtLeast)
|
|
170
182
|
? window.StudioModals.holdAtLeast(450)
|
|
171
183
|
: { then: function (cb) { cb(); } };
|
|
@@ -199,9 +211,14 @@
|
|
|
199
211
|
// it into the host's own hidden form input and submits immediately with a
|
|
200
212
|
// loading card + toast (submitFormWithProgress). opts = crop config
|
|
201
213
|
// (aspectRatio, maxWidth, maxHeight, transparent, autoCropArea) + save copy
|
|
202
|
-
// (saving, success, successMessage, failure, dismissible, toast, filename)
|
|
214
|
+
// (saving, success, successMessage, failure, dismissible, toast, filename) +
|
|
215
|
+
// store (Alpine store name, default "modals"). Pass `store` when the crop and
|
|
216
|
+
// saving modals are mounted on a PAGE-SCOPED host rather than the app's shared
|
|
217
|
+
// one — /admin/emails does, so the uploader works even in a host app that
|
|
218
|
+
// renders no shared modal host at all.
|
|
203
219
|
window.imageUploadHost = function (opts) {
|
|
204
220
|
opts = opts || {};
|
|
221
|
+
var storeName = opts.store || "modals";
|
|
205
222
|
function cropProps(extra) {
|
|
206
223
|
var p = {
|
|
207
224
|
aspectRatio: opts.aspectRatio || 1,
|
|
@@ -214,11 +231,25 @@
|
|
|
214
231
|
if (extra) { for (var k in extra) { p[k] = extra[k]; } }
|
|
215
232
|
return p;
|
|
216
233
|
}
|
|
234
|
+
// A token unique to THIS host instance. Many hosts can be mounted on one page
|
|
235
|
+
// (one per row on /admin/emails) and they all hear the same window event, so a
|
|
236
|
+
// confirm has to say WHICH host opened the cropper.
|
|
237
|
+
var ownerId = "iuh-" + (window.__studioImageUploadHostSeq = (window.__studioImageUploadHostSeq || 0) + 1);
|
|
238
|
+
|
|
217
239
|
return {
|
|
240
|
+
ownerId: ownerId,
|
|
218
241
|
// Modal-as-picker: the crop modal itself is the file picker / drop target.
|
|
219
242
|
open() {
|
|
220
|
-
if (!window.Alpine || !Alpine.store(
|
|
221
|
-
Alpine.store(
|
|
243
|
+
if (!window.Alpine || !Alpine.store(storeName)) return;
|
|
244
|
+
Alpine.store(storeName).open("crop-photo", cropProps({ owner: ownerId }));
|
|
245
|
+
},
|
|
246
|
+
// The window-event guard: a confirm addressed to a DIFFERENT host is not ours
|
|
247
|
+
// to act on. An owner-LESS confirm still applies, so every existing
|
|
248
|
+
// single-host page keeps working unchanged.
|
|
249
|
+
onCropConfirmed(detail) {
|
|
250
|
+
if (!detail) return;
|
|
251
|
+
if (detail.owner && detail.owner !== ownerId) return;
|
|
252
|
+
this.applyCrop(detail.blob);
|
|
222
253
|
},
|
|
223
254
|
// Native picker: read the chosen image, then hand it to the modal.
|
|
224
255
|
onFileSelected(event) {
|
|
@@ -226,7 +257,7 @@
|
|
|
226
257
|
if (!file) return;
|
|
227
258
|
var reader = new FileReader();
|
|
228
259
|
reader.onload = function (e) {
|
|
229
|
-
Alpine.store(
|
|
260
|
+
Alpine.store(storeName).open("crop-photo", cropProps({ imageUrl: e.target.result, owner: ownerId }));
|
|
230
261
|
};
|
|
231
262
|
reader.readAsDataURL(file);
|
|
232
263
|
event.target.value = "";
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
PAGE-SCOPED modal host — a self-contained modal shell mounted on its OWN
|
|
3
|
+
Alpine store, for a page that must bring its own modals rather than rely on
|
|
4
|
+
the app's shared host.
|
|
5
|
+
|
|
6
|
+
Why this exists as a separate partial rather than a local on
|
|
7
|
+
studio/modals/_host: this is a NON-ISOLATED engine, so an app view at the same
|
|
8
|
+
path SHADOWS the engine's. `mcritchie-studio` and `turf-monster` both ship
|
|
9
|
+
their own app/views/studio/modals/_host.html.erb — older, simpler copies that
|
|
10
|
+
know nothing of a store: local. A page rendering "studio/modals/host" in those
|
|
11
|
+
apps silently gets the fork, and its page-scoped store is never registered.
|
|
12
|
+
This partial's path is unforked in every app, so it renders the engine's code
|
|
13
|
+
everywhere. (The living style guide hand-rolled the same thing inline for the
|
|
14
|
+
same reason; it can rebase onto this later.)
|
|
15
|
+
|
|
16
|
+
Two independent problems it solves at once:
|
|
17
|
+
|
|
18
|
+
1. Apps that DO have a shared host (mcritchie-studio, turf-monster) register
|
|
19
|
+
their own modal set. A page needing crop-photo would have to ask every
|
|
20
|
+
app to add it.
|
|
21
|
+
2. Apps that have NO host at all (mcritchie-industries, moms-app) would have
|
|
22
|
+
nowhere to open a modal from.
|
|
23
|
+
|
|
24
|
+
Both go away when the page owns its host.
|
|
25
|
+
|
|
26
|
+
Animations come from the shared motion layer
|
|
27
|
+
(app/assets/tailwind/studio_engine/engine-motion.css) — the .modal-card-* and
|
|
28
|
+
.modal-backdrop-* classes — rather than an inline copy, so this stays small.
|
|
29
|
+
A consumer that does not bundle engine-motion.css still gets a working modal,
|
|
30
|
+
just without the spring.
|
|
31
|
+
|
|
32
|
+
Locals:
|
|
33
|
+
store — REQUIRED. Alpine store name, e.g. "emailModals". Must be unique
|
|
34
|
+
on the page; never "modals" (that is the shared host's).
|
|
35
|
+
card_class — optional card sizing/skin override. Default is the shared
|
|
36
|
+
host's max-w-sm card.
|
|
37
|
+
|
|
38
|
+
Usage: render this partial with a block, exactly like the shared host, and put
|
|
39
|
+
one `template x-if` per modal id inside it — see app/views/studio/emails/
|
|
40
|
+
index.html.erb for the live call site. Guard each registration with optional
|
|
41
|
+
chaining (`current()?.id`): the outer template unmounts one tick AFTER the
|
|
42
|
+
stack empties, so a bare `.id` throws on every close.
|
|
43
|
+
|
|
44
|
+
(No inline ERB example here on purpose. An ERB comment ends at its FIRST "%" +
|
|
45
|
+
">", so an example containing one silently closes the comment and dumps the
|
|
46
|
+
rest of it onto the page as visible text. That shipped once.)
|
|
47
|
+
%>
|
|
48
|
+
<%
|
|
49
|
+
scoped_store = local_assigns.fetch(:store)
|
|
50
|
+
card_class = local_assigns.fetch(:card_class,
|
|
51
|
+
"bg-surface rounded-xl border border-subtle shadow-2xl p-6 max-w-sm w-full")
|
|
52
|
+
%>
|
|
53
|
+
<style>
|
|
54
|
+
/* Scroll lock, applied by the store's _sync() while the stack is non-empty.
|
|
55
|
+
Shipped here too because an app with no shared host never defines it. */
|
|
56
|
+
body.modal-open { overflow: hidden; }
|
|
57
|
+
</style>
|
|
58
|
+
|
|
59
|
+
<script>
|
|
60
|
+
(function() {
|
|
61
|
+
var STORE_NAME = '<%= scoped_store %>';
|
|
62
|
+
// Keep in sync with .modal-card-unmount in engine-motion.css — the entry is
|
|
63
|
+
// spliced off the stack only after the exit animation has played.
|
|
64
|
+
var CLOSE_ANIM_MS = 220;
|
|
65
|
+
|
|
66
|
+
// window.StudioModals.holdAtLeast(ms) — the minimum-visible-duration
|
|
67
|
+
// convention shared with the app-level host, so a spinner can't flash past
|
|
68
|
+
// the user. Defined defensively: this page may be the only host on it.
|
|
69
|
+
window.StudioModals = window.StudioModals || {};
|
|
70
|
+
window.StudioModals.holdAtLeast = window.StudioModals.holdAtLeast || function(minMs) {
|
|
71
|
+
var startedAt = Date.now();
|
|
72
|
+
return {
|
|
73
|
+
then: function(callback) {
|
|
74
|
+
var remaining = Math.max(0, minMs - (Date.now() - startedAt));
|
|
75
|
+
if (remaining === 0) { callback(); return; }
|
|
76
|
+
setTimeout(callback, remaining);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Named, so the dual-guard below can call it directly on a Turbo visit.
|
|
82
|
+
// Alpine's deferred script fires alpine:init exactly ONCE per full document
|
|
83
|
+
// load; this registration lives in a PAGE BODY, so it was absent during that
|
|
84
|
+
// one init. Without the guard, a Turbo Drive visit to this page leaves the
|
|
85
|
+
// store undefined and every x-data on it throws.
|
|
86
|
+
function registerScopedStore() {
|
|
87
|
+
if (Alpine.store(STORE_NAME)) return;
|
|
88
|
+
|
|
89
|
+
Alpine.store(STORE_NAME, {
|
|
90
|
+
stack: [],
|
|
91
|
+
|
|
92
|
+
// open(id, props, opts) — opts.replace swaps the top entry in place
|
|
93
|
+
// (what submitFormWithProgress does to turn crop-photo into saving).
|
|
94
|
+
open: function(id, props, opts) {
|
|
95
|
+
props = props || {};
|
|
96
|
+
opts = opts || {};
|
|
97
|
+
var entry = { id: id, props: props };
|
|
98
|
+
|
|
99
|
+
if (opts.replace && this.stack.length > 0) {
|
|
100
|
+
this.stack.splice(this.stack.length - 1, 1, entry);
|
|
101
|
+
} else {
|
|
102
|
+
this.stack.push(entry);
|
|
103
|
+
}
|
|
104
|
+
this._sync();
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
swap: function(id, props, opts) {
|
|
108
|
+
opts = opts || {};
|
|
109
|
+
opts.replace = true;
|
|
110
|
+
this.open(id, props, opts);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
// Flip _closing so the exit animation plays, then splice.
|
|
114
|
+
close: function() {
|
|
115
|
+
var entry = this.current();
|
|
116
|
+
if (!entry) return;
|
|
117
|
+
|
|
118
|
+
var self = this;
|
|
119
|
+
entry._closing = true;
|
|
120
|
+
setTimeout(function() {
|
|
121
|
+
var index = self.stack.indexOf(entry);
|
|
122
|
+
if (index !== -1) self.stack.splice(index, 1);
|
|
123
|
+
self._sync();
|
|
124
|
+
}, CLOSE_ANIM_MS);
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
closeAll: function() {
|
|
128
|
+
this.stack = [];
|
|
129
|
+
this._sync();
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
// Keeps dismissible: false entries — a still-in-flight upload must not
|
|
133
|
+
// silently lose the card its promise will resolve against.
|
|
134
|
+
closeAllDismissible: function() {
|
|
135
|
+
this.stack = this.stack.filter(function(entry) {
|
|
136
|
+
return entry.props && entry.props.dismissible === false;
|
|
137
|
+
});
|
|
138
|
+
this._sync();
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
isOpen: function(id) {
|
|
142
|
+
return this.stack.some(function(entry) { return entry.id === id; });
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
current: function() {
|
|
146
|
+
return this.stack.length ? this.stack[this.stack.length - 1] : null;
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
// Mount on open, unmount while closing. _settled pins the entry after
|
|
150
|
+
// its first render so a stale Alpine re-evaluation cannot re-fire the
|
|
151
|
+
// bounce-in keyframe mid-life (which reads as a flash).
|
|
152
|
+
cardClasses: function() {
|
|
153
|
+
var entry = this.current();
|
|
154
|
+
if (!entry) return '';
|
|
155
|
+
if (entry._closing) return 'modal-card-unmount';
|
|
156
|
+
if (!entry._settled) { entry._settled = true; }
|
|
157
|
+
return 'modal-card-mount';
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
_sync: function() {
|
|
161
|
+
document.body.classList.toggle('modal-open', this.stack.length > 0);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (window.Alpine) { registerScopedStore(); }
|
|
167
|
+
else { document.addEventListener('alpine:init', registerScopedStore); }
|
|
168
|
+
|
|
169
|
+
// bfcache + Turbo snapshot cleanup, so a modal left open does not reappear
|
|
170
|
+
// on a back navigation.
|
|
171
|
+
function clearStaleScopedModals() {
|
|
172
|
+
if (!window.Alpine || !Alpine.store) return;
|
|
173
|
+
|
|
174
|
+
var store = Alpine.store(STORE_NAME);
|
|
175
|
+
if (store && typeof store.closeAllDismissible === 'function') store.closeAllDismissible();
|
|
176
|
+
}
|
|
177
|
+
window.addEventListener('pageshow', function(e) { if (e.persisted) clearStaleScopedModals(); });
|
|
178
|
+
document.addEventListener('turbo:before-cache', clearStaleScopedModals);
|
|
179
|
+
})();
|
|
180
|
+
</script>
|
|
181
|
+
|
|
182
|
+
<template x-if="$store.<%= scoped_store %>.current()">
|
|
183
|
+
<div class="fixed inset-0 z-[120] flex items-center justify-center p-4 modal-backdrop-mount"
|
|
184
|
+
:class="$store.<%= scoped_store %>.current()?._closing && 'modal-backdrop-unmount'"
|
|
185
|
+
style="background:rgba(0,0,0,0.6)"
|
|
186
|
+
role="dialog"
|
|
187
|
+
aria-modal="true"
|
|
188
|
+
@keydown.escape.window="$store.<%= scoped_store %>.current() && $store.<%= scoped_store %>.current().props.dismissible !== false && $store.<%= scoped_store %>.close()"
|
|
189
|
+
@click.self="$store.<%= scoped_store %>.current() && $store.<%= scoped_store %>.current().props.dismissible !== false && $store.<%= scoped_store %>.close()">
|
|
190
|
+
<div class="<%= card_class %>" :class="$store.<%= scoped_store %>.cardClasses()">
|
|
191
|
+
<%# Consumer-provided registrations — one <template x-if> per modal id. %>
|
|
192
|
+
<%= yield if block_given? %>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
</template>
|
data/lib/studio/engine.rb
CHANGED
|
@@ -47,6 +47,26 @@ module Studio
|
|
|
47
47
|
studio/studio_confetti.js
|
|
48
48
|
studio/sortable.js
|
|
49
49
|
]
|
|
50
|
+
|
|
51
|
+
# The INHERITED default email banners (Studio::EmailImage). They ride the
|
|
52
|
+
# gem so a brand-new app sends branded email on day one with an empty S3
|
|
53
|
+
# bucket. Enumerated from disk rather than listed by hand so adding a
|
|
54
|
+
# default is a one-file change. Sprockets hosts (mcritchie-studio,
|
|
55
|
+
# turf-monster) need the explicit precompile entry; propshaft hosts
|
|
56
|
+
# (mcritchie-industries, moms-app) serve everything on config.assets.paths
|
|
57
|
+
# and ignore this list.
|
|
58
|
+
# Named on the CLASS, not bare: an initializer block is instance_exec'd on
|
|
59
|
+
# an Engine INSTANCE, where a bare call resolves to nothing and boots red.
|
|
60
|
+
app.config.assets.precompile += Studio::Engine.default_email_banner_logical_paths
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Logical asset paths ("emails/magic-link.png") for every default banner the
|
|
64
|
+
# gem ships.
|
|
65
|
+
def self.default_email_banner_logical_paths
|
|
66
|
+
Dir[File.expand_path("../../app/assets/images/emails/*", __dir__)]
|
|
67
|
+
.select { |path| File.file?(path) }
|
|
68
|
+
.map { |path| "emails/#{File.basename(path)}" }
|
|
69
|
+
.sort
|
|
50
70
|
end
|
|
51
71
|
|
|
52
72
|
rake_tasks do
|
data/lib/studio/s3.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Studio
|
|
|
5
5
|
|
|
6
6
|
class << self
|
|
7
7
|
def upload(key:, body:, content_type: nil, cache_control: nil)
|
|
8
|
-
opts = { bucket: bucket, key: key, body: body }
|
|
8
|
+
opts = { bucket: bucket, key: full_key(key), body: body }
|
|
9
9
|
opts[:content_type] = content_type if content_type
|
|
10
10
|
opts[:cache_control] = cache_control if cache_control
|
|
11
11
|
client.put_object(**opts)
|
|
@@ -13,32 +13,34 @@ module Studio
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def download(key:)
|
|
16
|
-
client.get_object(bucket: bucket, key: key).body.read
|
|
16
|
+
client.get_object(bucket: bucket, key: full_key(key)).body.read
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def url(key:)
|
|
20
|
-
"https://#{bucket}.s3.#{region}.amazonaws.com/#{key}"
|
|
20
|
+
"https://#{bucket}.s3.#{region}.amazonaws.com/#{full_key(key)}"
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def signed_url(key:, expires_in: 3600)
|
|
24
24
|
require "aws-sdk-s3"
|
|
25
|
-
Aws::S3::Presigner.new(client: client).presigned_url(:get_object, bucket: bucket, key: key, expires_in: expires_in)
|
|
25
|
+
Aws::S3::Presigner.new(client: client).presigned_url(:get_object, bucket: bucket, key: full_key(key), expires_in: expires_in)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def exists?(key:)
|
|
29
|
-
client.head_object(bucket: bucket, key: key)
|
|
29
|
+
client.head_object(bucket: bucket, key: full_key(key))
|
|
30
30
|
true
|
|
31
31
|
rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
|
|
32
32
|
false
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def delete(key:)
|
|
36
|
-
client.delete_object(bucket: bucket, key: key)
|
|
36
|
+
client.delete_object(bucket: bucket, key: full_key(key))
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# Returns LOGICAL keys (the app's key namespace stripped back off), so a
|
|
40
|
+
# caller can feed any result straight back into download/delete/url.
|
|
39
41
|
def list(prefix: nil, max: 1000)
|
|
40
|
-
resp = client.list_objects_v2(bucket: bucket, prefix: prefix, max_keys: max)
|
|
41
|
-
resp.contents.map(
|
|
42
|
+
resp = client.list_objects_v2(bucket: bucket, prefix: full_key(prefix), max_keys: max)
|
|
43
|
+
resp.contents.map { |object| logical_key(object.key) }
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
def bucket
|
|
@@ -47,6 +49,40 @@ module Studio
|
|
|
47
49
|
"#{prefix}-#{environment}"
|
|
48
50
|
end
|
|
49
51
|
|
|
52
|
+
# Whether this app can touch object storage at all. Callers that must
|
|
53
|
+
# degrade rather than 500 (the /admin/emails uploader on an app whose
|
|
54
|
+
# bucket was never provisioned) ask this instead of rescuing NotConfigured.
|
|
55
|
+
def configured?
|
|
56
|
+
bucket
|
|
57
|
+
true
|
|
58
|
+
rescue NotConfigured
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Studio.s3_key_prefix, normalized to "" or "something/". The namespace a
|
|
63
|
+
# satellite app lives under when it shares another app's bucket.
|
|
64
|
+
def key_prefix
|
|
65
|
+
prefix = Studio.s3_key_prefix.to_s
|
|
66
|
+
return "" if prefix.empty?
|
|
67
|
+
|
|
68
|
+
prefix.end_with?("/") ? prefix : "#{prefix}/"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Logical key -> the real object key in the bucket.
|
|
72
|
+
def full_key(key)
|
|
73
|
+
return key if key.nil?
|
|
74
|
+
|
|
75
|
+
"#{key_prefix}#{key}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The real object key -> logical key (inverse of full_key).
|
|
79
|
+
def logical_key(key)
|
|
80
|
+
prefix = key_prefix
|
|
81
|
+
return key if prefix.empty? || !key.to_s.start_with?(prefix)
|
|
82
|
+
|
|
83
|
+
key.to_s.delete_prefix(prefix)
|
|
84
|
+
end
|
|
85
|
+
|
|
50
86
|
def region
|
|
51
87
|
Studio.s3_region
|
|
52
88
|
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -129,6 +129,18 @@ module Studio
|
|
|
129
129
|
# its own routes (it can still reuse Studio::Link + Studio::LinkConsumption).
|
|
130
130
|
mattr_accessor :draw_link_routes, default: true
|
|
131
131
|
|
|
132
|
+
# Draw the shared transactional-email page at /admin/emails
|
|
133
|
+
# (Studio::EmailsController). OFF by default because the path AND its helper
|
|
134
|
+
# names (admin_emails_path / admin_email_path) are already taken in
|
|
135
|
+
# turf-monster, where drawing them raises at route-load and kills every route
|
|
136
|
+
# in the app. A host opts in from config/initializers/studio.rb:
|
|
137
|
+
#
|
|
138
|
+
# config.draw_admin_emails_routes = true
|
|
139
|
+
#
|
|
140
|
+
# Gates only the PAGE. Studio::EmailImage's registry and its inherited-default
|
|
141
|
+
# resolution are always on, so an app sends branded email either way.
|
|
142
|
+
mattr_accessor :draw_admin_emails_routes, default: false
|
|
143
|
+
|
|
132
144
|
# Optional admin Act As / impersonation session conventions. Consumers that
|
|
133
145
|
# include Studio::Impersonation get current_user layered over true_user with
|
|
134
146
|
# these session keys, but still own authorization, audit logging, and routes.
|
|
@@ -158,6 +170,36 @@ module Studio
|
|
|
158
170
|
# is truthy, otherwise disabled. Production always disables capture.
|
|
159
171
|
mattr_accessor :local_email_capture, default: nil
|
|
160
172
|
|
|
173
|
+
# The role Studio::LocalReviewsController stamps on the account it provisions
|
|
174
|
+
# for a local review (the board's WAITING APPROVAL button).
|
|
175
|
+
#
|
|
176
|
+
# It defaults to "admin" because the pages sent for review are overwhelmingly
|
|
177
|
+
# admin-gated, and the operator's address is his PRODUCTION one — an address a
|
|
178
|
+
# fresh worktree database has never seen, so the sign-in would otherwise CREATE
|
|
179
|
+
# him at the default role and `require_admin` would bounce him to "/". The
|
|
180
|
+
# sign-in succeeds and he never sees the page: the bug this knob exists to end.
|
|
181
|
+
#
|
|
182
|
+
# Set it to nil (or "") to provision the account WITHOUT touching its role —
|
|
183
|
+
# for an app whose review pages are not admin-gated, or whose role column
|
|
184
|
+
# means something else. Only ever reached behind local_tool_enabled?
|
|
185
|
+
# (non-production AND loopback), so it grants nothing a local reader could not
|
|
186
|
+
# already take from the local email inbox beside it.
|
|
187
|
+
mattr_accessor :local_review_role, default: "admin"
|
|
188
|
+
|
|
189
|
+
# WHO the local-review mint signs in when the caller names no `?email=`.
|
|
190
|
+
#
|
|
191
|
+
# The board's WAITING APPROVAL CTA is a public, sign-in-free redirect, so it
|
|
192
|
+
# sends no email — putting one in that URL would publish the operator's
|
|
193
|
+
# address on a public page. The local stack answers the question instead: it
|
|
194
|
+
# is the machine the reviewer is sitting at.
|
|
195
|
+
#
|
|
196
|
+
# nil (the default) means "derive": the first user in this database already
|
|
197
|
+
# holding local_review_role, by id — falling back to "admin" when that setting
|
|
198
|
+
# is itself nil. So a desk that switches local_review_role OFF has no role to
|
|
199
|
+
# derive FROM and should name its operator here explicitly, as should a desk
|
|
200
|
+
# whose operator is not the first seeded account at that role.
|
|
201
|
+
mattr_accessor :local_review_email, default: nil
|
|
202
|
+
|
|
161
203
|
# Theme role colors (7 roles)
|
|
162
204
|
mattr_accessor :theme_primary, default: "#8E82FE"
|
|
163
205
|
mattr_accessor :theme_dark, default: "#1A1535"
|
|
@@ -176,6 +218,19 @@ module Studio
|
|
|
176
218
|
mattr_accessor :s3_bucket_prefix, default: nil
|
|
177
219
|
mattr_accessor :s3_region, default: "us-east-2"
|
|
178
220
|
|
|
221
|
+
# Optional key namespace INSIDE the bucket, so a satellite app can share an
|
|
222
|
+
# existing bucket instead of provisioning its own pair. Set it and every key a
|
|
223
|
+
# caller passes to Studio::S3 is stored/read under that prefix — callers keep
|
|
224
|
+
# passing logical keys ("email_banners/magic_link-ab12.jpg") and never see it:
|
|
225
|
+
#
|
|
226
|
+
# config.s3_bucket_prefix = "mcritchie-studio"
|
|
227
|
+
# config.s3_key_prefix = "mcritchie-industries/"
|
|
228
|
+
# # -> s3://mcritchie-studio-dev/mcritchie-industries/email_banners/...
|
|
229
|
+
#
|
|
230
|
+
# Default nil = no namespace, so every already-shipped app's keys are unchanged.
|
|
231
|
+
# A trailing slash is added if you leave it off.
|
|
232
|
+
mattr_accessor :s3_key_prefix, default: nil
|
|
233
|
+
|
|
179
234
|
class S3ConfigError < StandardError; end
|
|
180
235
|
|
|
181
236
|
# Whether to validate the host app's User model at boot. See docs/USER_CONTRACT.md.
|
|
@@ -475,11 +530,50 @@ module Studio
|
|
|
475
530
|
get "admin/style", to: "style#index", as: :admin_style
|
|
476
531
|
get "admin/design_system", to: redirect("/admin/style"), as: :admin_design_system
|
|
477
532
|
|
|
478
|
-
#
|
|
479
|
-
# index lists
|
|
480
|
-
#
|
|
533
|
+
# The standard transactional-email page. Canonical at /admin/emails
|
|
534
|
+
# (Studio::EmailsController): index lists every registered email with its
|
|
535
|
+
# live banner and whether that banner is inherited or app-owned; update
|
|
536
|
+
# stores this app's own override; destroy drops it back to the inherited
|
|
537
|
+
# default. Surfaced from each app's admin sidebar.
|
|
538
|
+
#
|
|
539
|
+
# /admin/email_images redirects here but KEEPS its admin_email_images_path
|
|
540
|
+
# helper, so a shipped host sidebar link on the old helper still resolves
|
|
541
|
+
# (same treatment as /admin/design_system -> /admin/style).
|
|
542
|
+
# OPT-IN, and it has to be. turf-monster ALREADY owns /admin/emails —
|
|
543
|
+
# `namespace :admin { get "emails", as: :emails }` (its EmailCatalog
|
|
544
|
+
# manager) — which claims the SAME path and the SAME helper names,
|
|
545
|
+
# admin_emails_path and admin_email_path. Drawing these unconditionally
|
|
546
|
+
# raises `ArgumentError: Invalid route name, already in use: 'admin_emails'`
|
|
547
|
+
# while turf-monster's own routes.rb is loading, which takes down its
|
|
548
|
+
# ENTIRE route set (every admin_*_path in the app goes undefined) — not a
|
|
549
|
+
# shadowed page, a dead app. Confirmed on consumer CI, PR #86.
|
|
550
|
+
#
|
|
551
|
+
# A host cannot opt out of something that breaks it before its config is
|
|
552
|
+
# read, and consumer CI runs each consumer's `main` — so default-on cannot
|
|
553
|
+
# be fixed from inside the engine. Default-off, and each app's adoption
|
|
554
|
+
# task turns it on. Flip the default once no consumer's main owns the name.
|
|
555
|
+
#
|
|
556
|
+
# This gates only the PAGE. The registry and the two-layer image resolution
|
|
557
|
+
# are always on, and the engine's own UserMailer already calls
|
|
558
|
+
# Studio::EmailImage.resolved_url — so an app is branded on day one whether
|
|
559
|
+
# or not it draws the page.
|
|
560
|
+
if Studio.draw_admin_emails_routes
|
|
561
|
+
get "admin/emails", to: "studio/emails#index", as: :admin_emails
|
|
562
|
+
patch "admin/emails/:key", to: "studio/emails#update", as: :admin_email,
|
|
563
|
+
constraints: { key: /[a-z0-9_]+/ }
|
|
564
|
+
delete "admin/emails/:key", to: "studio/emails#destroy",
|
|
565
|
+
constraints: { key: /[a-z0-9_]+/ }
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# DEPRECATED, kept for ONE release. Not a redirect: consumer-ci.yml runs
|
|
569
|
+
# each consumer's DEFAULT-BRANCH suite against this engine, and both
|
|
570
|
+
# mcritchie-studio and turf-monster have tests on `main` that GET this page
|
|
571
|
+
# and PATCH through admin_email_image_path. Redirecting (or deleting) here
|
|
572
|
+
# reddens their lanes the moment the PR opens, and no change inside the
|
|
573
|
+
# engine PR can fix it. Each app's adoption task moves its link + tests; a
|
|
574
|
+
# later engine minor deletes these two routes with the controller and view.
|
|
481
575
|
get "admin/email_images", to: "studio/email_images#index", as: :admin_email_images
|
|
482
|
-
patch "admin/email_images/:variant", to: "studio/email_images#update",
|
|
576
|
+
patch "admin/email_images/:variant", to: "studio/email_images#update", as: :admin_email_image,
|
|
483
577
|
constraints: { variant: /[a-z_]+/ }
|
|
484
578
|
|
|
485
579
|
# Model-page protocol (v1) — a reusable per-record inspector. Drawn into
|
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
|
+
version: 0.37.0
|
|
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-08-
|
|
11
|
+
date: 2026-08-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -168,6 +168,8 @@ files:
|
|
|
168
168
|
- Gemfile
|
|
169
169
|
- LICENSE
|
|
170
170
|
- README.md
|
|
171
|
+
- app/assets/images/emails/email-change-confirmation.png
|
|
172
|
+
- app/assets/images/emails/magic-link.png
|
|
171
173
|
- app/assets/images/resend-favicon.png
|
|
172
174
|
- app/assets/images/ses-favicon.png
|
|
173
175
|
- app/assets/javascripts/studio/canvas_confetti.js
|
|
@@ -193,6 +195,7 @@ files:
|
|
|
193
195
|
- app/controllers/sessions_controller.rb
|
|
194
196
|
- app/controllers/solana_sessions_controller.rb
|
|
195
197
|
- app/controllers/studio/email_images_controller.rb
|
|
198
|
+
- app/controllers/studio/emails_controller.rb
|
|
196
199
|
- app/controllers/studio/links_controller.rb
|
|
197
200
|
- app/controllers/studio/local_emails_controller.rb
|
|
198
201
|
- app/controllers/studio/local_reviews_controller.rb
|
|
@@ -271,6 +274,8 @@ files:
|
|
|
271
274
|
- app/views/studio/board/_card_shell.html.erb
|
|
272
275
|
- app/views/studio/board/_column.html.erb
|
|
273
276
|
- app/views/studio/email_images/index.html.erb
|
|
277
|
+
- app/views/studio/emails/_row.html.erb
|
|
278
|
+
- app/views/studio/emails/index.html.erb
|
|
274
279
|
- app/views/studio/links/confirm.html.erb
|
|
275
280
|
- app/views/studio/local_emails/index.html.erb
|
|
276
281
|
- app/views/studio/modals/_crop_photo.html.erb
|
|
@@ -278,6 +283,7 @@ files:
|
|
|
278
283
|
- app/views/studio/modals/_image_upload.html.erb
|
|
279
284
|
- app/views/studio/modals/_load_convention.html.erb
|
|
280
285
|
- app/views/studio/modals/_saving.html.erb
|
|
286
|
+
- app/views/studio/modals/_scoped_host.html.erb
|
|
281
287
|
- app/views/studio/modals/auth/_resend_footer.html.erb
|
|
282
288
|
- app/views/studio/modals/blocks/_age_verify.html.erb
|
|
283
289
|
- app/views/studio/modals/blocks/_card_header.html.erb
|