studio-engine 0.36.0 → 0.38.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 +210 -0
- data/README.md +199 -12
- 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 +42 -7
- data/app/controllers/studio/emails_controller.rb +129 -0
- data/app/mailers/user_mailer.rb +4 -1
- data/app/models/image_cache.rb +1 -1
- data/app/services/studio/email_catalog.rb +450 -0
- data/app/services/studio/email_image.rb +57 -74
- data/app/views/layouts/branded_mailer.html.erb +1 -1
- data/app/views/studio/email_images/index.html.erb +30 -1
- data/app/views/studio/emails/_row.html.erb +107 -0
- data/app/views/studio/emails/index.html.erb +116 -0
- data/app/views/studio/emails/show.html.erb +91 -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 +75 -4
- metadata +10 -2
|
@@ -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.
|
|
@@ -206,6 +218,19 @@ module Studio
|
|
|
206
218
|
mattr_accessor :s3_bucket_prefix, default: nil
|
|
207
219
|
mattr_accessor :s3_region, default: "us-east-2"
|
|
208
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
|
+
|
|
209
234
|
class S3ConfigError < StandardError; end
|
|
210
235
|
|
|
211
236
|
# Whether to validate the host app's User model at boot. See docs/USER_CONTRACT.md.
|
|
@@ -505,11 +530,57 @@ module Studio
|
|
|
505
530
|
get "admin/style", to: "style#index", as: :admin_style
|
|
506
531
|
get "admin/design_system", to: redirect("/admin/style"), as: :admin_design_system
|
|
507
532
|
|
|
508
|
-
#
|
|
509
|
-
# index lists
|
|
510
|
-
#
|
|
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
|
+
# /raw is drawn BEFORE /:key so "raw" is never captured as a key.
|
|
563
|
+
get "admin/emails/:key/raw", to: "studio/emails#raw", as: :admin_email_raw,
|
|
564
|
+
constraints: { key: /[a-z0-9_]+/ }
|
|
565
|
+
get "admin/emails/:key", to: "studio/emails#show", as: :admin_email,
|
|
566
|
+
constraints: { key: /[a-z0-9_]+/ }
|
|
567
|
+
# Same path, same helper (admin_email_path) — a named route only needs
|
|
568
|
+
# to be declared once per name, and these share the show route's URL.
|
|
569
|
+
patch "admin/emails/:key", to: "studio/emails#update",
|
|
570
|
+
constraints: { key: /[a-z0-9_]+/ }
|
|
571
|
+
delete "admin/emails/:key", to: "studio/emails#destroy",
|
|
572
|
+
constraints: { key: /[a-z0-9_]+/ }
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# DEPRECATED, kept for ONE release. Not a redirect: consumer-ci.yml runs
|
|
576
|
+
# each consumer's DEFAULT-BRANCH suite against this engine, and both
|
|
577
|
+
# mcritchie-studio and turf-monster have tests on `main` that GET this page
|
|
578
|
+
# and PATCH through admin_email_image_path. Redirecting (or deleting) here
|
|
579
|
+
# reddens their lanes the moment the PR opens, and no change inside the
|
|
580
|
+
# engine PR can fix it. Each app's adoption task moves its link + tests; a
|
|
581
|
+
# later engine minor deletes these two routes with the controller and view.
|
|
511
582
|
get "admin/email_images", to: "studio/email_images#index", as: :admin_email_images
|
|
512
|
-
patch "admin/email_images/:variant", to: "studio/email_images#update",
|
|
583
|
+
patch "admin/email_images/:variant", to: "studio/email_images#update", as: :admin_email_image,
|
|
513
584
|
constraints: { variant: /[a-z_]+/ }
|
|
514
585
|
|
|
515
586
|
# 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.38.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
|
|
@@ -220,6 +223,7 @@ files:
|
|
|
220
223
|
- app/models/studio/model_page.rb
|
|
221
224
|
- app/models/theme_setting.rb
|
|
222
225
|
- app/services/google_oauth_validator.rb
|
|
226
|
+
- app/services/studio/email_catalog.rb
|
|
223
227
|
- app/services/studio/email_image.rb
|
|
224
228
|
- app/views/components/_admin_dropdown.html.erb
|
|
225
229
|
- app/views/components/_avatar.html.erb
|
|
@@ -271,6 +275,9 @@ files:
|
|
|
271
275
|
- app/views/studio/board/_card_shell.html.erb
|
|
272
276
|
- app/views/studio/board/_column.html.erb
|
|
273
277
|
- app/views/studio/email_images/index.html.erb
|
|
278
|
+
- app/views/studio/emails/_row.html.erb
|
|
279
|
+
- app/views/studio/emails/index.html.erb
|
|
280
|
+
- app/views/studio/emails/show.html.erb
|
|
274
281
|
- app/views/studio/links/confirm.html.erb
|
|
275
282
|
- app/views/studio/local_emails/index.html.erb
|
|
276
283
|
- app/views/studio/modals/_crop_photo.html.erb
|
|
@@ -278,6 +285,7 @@ files:
|
|
|
278
285
|
- app/views/studio/modals/_image_upload.html.erb
|
|
279
286
|
- app/views/studio/modals/_load_convention.html.erb
|
|
280
287
|
- app/views/studio/modals/_saving.html.erb
|
|
288
|
+
- app/views/studio/modals/_scoped_host.html.erb
|
|
281
289
|
- app/views/studio/modals/auth/_resend_footer.html.erb
|
|
282
290
|
- app/views/studio/modals/blocks/_age_verify.html.erb
|
|
283
291
|
- app/views/studio/modals/blocks/_card_header.html.erb
|