studio-engine 0.40.0 → 0.42.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 +116 -0
- data/Gemfile +6 -0
- data/README.md +55 -1
- data/app/assets/images/emails/logo-horizontal.png +0 -0
- data/app/assets/images/emails/magic-link-background.gif +0 -0
- data/app/assets/images/emails/newsletter-subscribed-background.gif +0 -0
- data/app/controllers/studio/emails_controller.rb +136 -6
- data/app/mailers/studio/newsletter_mailer.rb +43 -0
- data/app/mailers/user_mailer.rb +48 -1
- data/app/models/studio/email_setting.rb +131 -0
- data/app/services/studio/banner.rb +186 -0
- data/app/services/studio/email_catalog.rb +248 -19
- data/app/services/studio/email_preview_target.rb +195 -0
- data/app/views/layouts/branded_mailer.html.erb +23 -6
- data/app/views/studio/emails/_banner_editor.html.erb +192 -0
- data/app/views/studio/emails/_banner_scale.html.erb +56 -0
- data/app/views/studio/emails/_recipient_picker.html.erb +63 -0
- data/app/views/studio/emails/_recipient_repaint.html.erb +122 -0
- data/app/views/studio/emails/_row.html.erb +68 -4
- data/app/views/studio/emails/index.html.erb +30 -4
- data/app/views/studio/emails/orphan.html.erb +45 -0
- data/app/views/studio/emails/show.html.erb +364 -31
- data/app/views/studio/mailers/_layered_banner.html.erb +136 -0
- data/app/views/studio/modals/_image_upload.html.erb +55 -3
- data/app/views/studio/newsletter_mailer/subscribed.html.erb +33 -0
- data/app/views/studio/newsletter_mailer/subscribed.text.erb +13 -0
- data/app/views/user_mailer/magic_link.html.erb +13 -4
- data/db/migrate/20260812000000_create_studio_email_settings.rb +26 -0
- data/db/migrate/20260812210000_add_copy_to_studio_email_settings.rb +32 -0
- data/db/migrate/20260812220000_add_subject_to_studio_email_settings.rb +15 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +12 -0
- data/studio-engine.gemspec +5 -1
- metadata +20 -2
|
@@ -2,14 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
A bare content wrapper like the index, so it renders inside each host's
|
|
4
4
|
layout. Lifted from turf-monster's admin/emails/show (the prior art this
|
|
5
|
-
work folds into the engine) and given the banner half
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
work folds into the engine) and given the banner half.
|
|
6
|
+
|
|
7
|
+
LAYOUT, AND WHY IT IS PAIRED
|
|
8
|
+
Two rows of two. Row one is WHO and WHAT SHOWS IN AN INBOX LIST: the subject
|
|
9
|
+
beside the person receiving it. Row two is WHAT THEY SEE: the two pictures
|
|
10
|
+
stacked on the left, the words that ride on them on the right. Every control
|
|
11
|
+
sits beside the thing it changes, and typing in one repaints the other with
|
|
12
|
+
no round trip.
|
|
13
|
+
|
|
14
|
+
NOTE: never write an ERB delimiter inside a comment like this one, in any
|
|
15
|
+
form. A comment ends at the first closing delimiter it contains, escaped or
|
|
16
|
+
not, and the remainder lands on the page as visible text. That shipped here
|
|
17
|
+
once. Name tags in prose instead of typing them. %>
|
|
8
18
|
<% content_for(:title) { @entry.label } %>
|
|
9
19
|
<%
|
|
10
20
|
source = Studio::EmailCatalog.source(@entry.key)
|
|
11
21
|
banner_url = Studio::EmailCatalog.preview_url(@entry.key)
|
|
12
22
|
aspect = @entry.ratio
|
|
23
|
+
max_width = Studio::EmailCatalog::MAX_WIDTH
|
|
24
|
+
card_width = Studio::Banner::DEFAULT_WIDTH
|
|
25
|
+
|
|
26
|
+
saved = {
|
|
27
|
+
header: Studio::EmailCatalog.header_template(@entry.key).to_s,
|
|
28
|
+
header_fallback: Studio::EmailCatalog.header_fallback(@entry.key).to_s,
|
|
29
|
+
subtext: Studio::EmailCatalog.subtext(@entry.key).to_s,
|
|
30
|
+
subject: (Studio::EmailSetting.copy_for(@entry.key, :subject) || @entry.subject).to_s,
|
|
31
|
+
logo_url: Studio::EmailSetting.copy_for(@entry.key, :logo_url).to_s,
|
|
32
|
+
hide_logo: Studio::EmailSetting.hide_logo?(@entry.key),
|
|
33
|
+
scrim_percent: Studio::EmailCatalog.scrim_percent(@entry.key).to_s
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
editor_config = {
|
|
37
|
+
values: saved,
|
|
38
|
+
targetId: @target&.id,
|
|
39
|
+
inheritedLogo: Studio::EmailCatalog.logo_url(@entry.key).to_s,
|
|
40
|
+
uploadedLogo: Studio::EmailCatalog.uploaded_logo_url(@entry.key).to_s,
|
|
41
|
+
defaultScrim: (Studio::Banner::DEFAULT_SCRIM * 100).round,
|
|
42
|
+
appName: Studio.app_name.to_s,
|
|
43
|
+
# The avatar fields are part of this payload, not an afterthought: the picker
|
|
44
|
+
# renders entirely from it, so a field omitted here is a grey circle with no
|
|
45
|
+
# letter in it — which is exactly what shipped.
|
|
46
|
+
targets: (@targets || []).map do |t|
|
|
47
|
+
{ id: t.id, label: t.label, name: t.name, email: t.email, admin: t.admin?,
|
|
48
|
+
avatar_url: t.avatar_url, avatar_color: t.avatar_color, initials: t.initials }
|
|
49
|
+
end
|
|
50
|
+
}
|
|
13
51
|
|
|
14
52
|
badge_class, badge_label =
|
|
15
53
|
case source
|
|
@@ -19,11 +57,28 @@
|
|
|
19
57
|
else ["badge bg-warning/10 text-warning border-warning/30", "No image"]
|
|
20
58
|
end
|
|
21
59
|
%>
|
|
22
|
-
<div class="max-w-5xl mx-auto px-4 pb-16"
|
|
60
|
+
<div class="max-w-5xl mx-auto px-4 pb-16"
|
|
61
|
+
x-data="emailBannerEditor(<%= editor_config.to_json %>)">
|
|
23
62
|
<header class="pt-8 pb-5">
|
|
24
63
|
<%= link_to "← All emails", admin_emails_path,
|
|
25
64
|
class: "text-sm text-muted hover:text-heading underline underline-offset-2" %>
|
|
26
|
-
|
|
65
|
+
|
|
66
|
+
<%# ONE save for the whole page, on the title's own row so it reads as
|
|
67
|
+
acting on this email rather than on the back-link above it. Shown only
|
|
68
|
+
once something has been typed, and disabled whenever the form matches
|
|
69
|
+
what is saved — so typing "A" and deleting it leaves a visible, disabled
|
|
70
|
+
button rather than an enabled one that would write nothing. %>
|
|
71
|
+
<div class="flex items-center justify-between gap-4 mt-2">
|
|
72
|
+
<h1 class="text-3xl font-bold text-heading"><%= @entry.label %></h1>
|
|
73
|
+
<div class="flex items-center gap-3 shrink-0">
|
|
74
|
+
<span class="text-2xs text-muted" x-show="touched && !dirty()" x-cloak>No changes</span>
|
|
75
|
+
<button type="button" @click="save()" x-show="touched" x-cloak
|
|
76
|
+
:disabled="!dirty()" data-save-all
|
|
77
|
+
:class="dirty() ? 'btn btn-primary btn-sm' : 'btn btn-primary btn-sm opacity-50 cursor-not-allowed'">
|
|
78
|
+
Save changes
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
27
82
|
|
|
28
83
|
<div class="flex flex-wrap items-center gap-2 mt-3">
|
|
29
84
|
<span class="badge <%= @entry.marketing? ? "bg-primary/10 text-primary border-primary/30" : "bg-inset text-muted border-subtle" %> whitespace-nowrap">
|
|
@@ -36,41 +91,256 @@
|
|
|
36
91
|
<% if @entry.description.present? %>
|
|
37
92
|
<p class="text-body max-w-2xl mt-3"><%= @entry.description %></p>
|
|
38
93
|
<% end %>
|
|
94
|
+
</header>
|
|
39
95
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
96
|
+
<%# ROW ONE — the subject line, and who is receiving it. %>
|
|
97
|
+
<div class="grid gap-6 md:grid-cols-2 mb-6">
|
|
98
|
+
<section class="card p-4">
|
|
99
|
+
<p class="label-upper mb-3">Example</p>
|
|
100
|
+
<%# The rendered subject sits with the person it is rendered FOR. It moved
|
|
101
|
+
out of the Subject card because what it shows depends on who is
|
|
102
|
+
selected below, not on the template above. %>
|
|
103
|
+
<p class="text-sm text-muted mb-3">
|
|
104
|
+
Sends as: <strong class="text-heading" x-text="subjectText()"></strong>
|
|
43
105
|
</p>
|
|
44
|
-
|
|
45
|
-
|
|
106
|
+
<% if editor_config[:targets].any? %>
|
|
107
|
+
<%# A custom listbox rather than a <select>: a native option cannot carry
|
|
108
|
+
an image, and the avatar is the whole reason this exists — it is how
|
|
109
|
+
you tell at a glance which account you are looking at. Keyboard and
|
|
110
|
+
click-away are handled; the value lives in Alpine state, and the
|
|
111
|
+
hidden form carries it. %>
|
|
112
|
+
<div class="relative" @keydown.escape="pickerOpen = false" @click.outside="pickerOpen = false">
|
|
113
|
+
<button type="button" @click="pickerOpen = !pickerOpen" data-preview-target
|
|
114
|
+
class="input-field w-full py-2 flex items-center gap-3 text-left"
|
|
115
|
+
:aria-expanded="pickerOpen" aria-haspopup="listbox">
|
|
116
|
+
<template x-if="target().avatar_url">
|
|
117
|
+
<img :src="target().avatar_url" alt="" class="w-8 h-8 rounded-full object-cover shrink-0">
|
|
118
|
+
</template>
|
|
119
|
+
<template x-if="!target().avatar_url">
|
|
120
|
+
<span class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
|
|
121
|
+
:style="'background-color: ' + (target().avatar_color || '#6b7280')"
|
|
122
|
+
x-text="target().initials"></span>
|
|
123
|
+
</template>
|
|
124
|
+
<span class="min-w-0">
|
|
125
|
+
<span class="block text-heading truncate" x-text="target().name || target().label"></span>
|
|
126
|
+
<span class="block text-2xs text-muted truncate" x-text="target().email"></span>
|
|
127
|
+
</span>
|
|
128
|
+
<svg class="w-4 h-4 text-muted ml-auto shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
129
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
130
|
+
</svg>
|
|
131
|
+
</button>
|
|
46
132
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
133
|
+
<ul x-show="pickerOpen" x-cloak role="listbox"
|
|
134
|
+
class="absolute z-20 mt-1 w-full rounded-lg border border-subtle bg-surface shadow-lg overflow-hidden">
|
|
135
|
+
<template x-for="option in targets" :key="option.id">
|
|
136
|
+
<li>
|
|
137
|
+
<button type="button" role="option" data-preview-option
|
|
138
|
+
@click="targetId = option.id; pickerOpen = false"
|
|
139
|
+
:aria-selected="option.id === targetId"
|
|
140
|
+
class="w-full px-3 py-2 flex items-center gap-3 text-left hover:bg-inset"
|
|
141
|
+
:class="option.id === targetId ? 'bg-inset' : ''">
|
|
142
|
+
<template x-if="option.avatar_url">
|
|
143
|
+
<img :src="option.avatar_url" alt="" class="w-8 h-8 rounded-full object-cover shrink-0">
|
|
144
|
+
</template>
|
|
145
|
+
<template x-if="!option.avatar_url">
|
|
146
|
+
<span class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
|
|
147
|
+
:style="'background-color: ' + (option.avatar_color || '#6b7280')"
|
|
148
|
+
x-text="option.initials"></span>
|
|
149
|
+
</template>
|
|
150
|
+
<span class="min-w-0">
|
|
151
|
+
<span class="block text-heading truncate" x-text="option.name || option.label"></span>
|
|
152
|
+
<span class="block text-2xs text-muted truncate" x-text="option.email"></span>
|
|
153
|
+
</span>
|
|
154
|
+
</button>
|
|
155
|
+
</li>
|
|
156
|
+
</template>
|
|
157
|
+
</ul>
|
|
58
158
|
</div>
|
|
159
|
+
<% else %>
|
|
160
|
+
<p class="text-sm text-muted">This app has no users to preview as yet.</p>
|
|
59
161
|
<% end %>
|
|
60
|
-
</
|
|
61
|
-
|
|
162
|
+
</section>
|
|
163
|
+
|
|
164
|
+
<section class="card p-4">
|
|
165
|
+
<p class="label-upper mb-2">Subject</p>
|
|
166
|
+
<p class="text-sm text-body mb-3">
|
|
167
|
+
Variables:
|
|
168
|
+
<code class="font-mono text-2xs px-1 py-0.5 rounded bg-inset">{name}</code>
|
|
169
|
+
<code class="font-mono text-2xs px-1 py-0.5 rounded bg-inset">{app}</code>
|
|
170
|
+
</p>
|
|
171
|
+
<%= text_field_tag :subject, saved[:subject], id: "subject", name: nil,
|
|
172
|
+
"x-model": "form.subject", class: "input-field w-full py-2" %>
|
|
173
|
+
</section>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<%# ROW TWO — the pictures, and the words that ride on them. %>
|
|
177
|
+
<div class="grid gap-6 md:grid-cols-2 mb-6">
|
|
178
|
+
<section class="card p-4"
|
|
62
179
|
<% if @uploads_available %>
|
|
63
|
-
|
|
180
|
+
x-data="imageUploadHost({
|
|
181
|
+
store: 'emailModals',
|
|
182
|
+
aspectRatio: <%= aspect %>,
|
|
183
|
+
maxWidth: <%= max_width %>,
|
|
184
|
+
transparent: false,
|
|
185
|
+
allowGifs: true,
|
|
186
|
+
filename: '<%= @entry.key %>.png',
|
|
187
|
+
saving: 'Saving banner…',
|
|
188
|
+
success: 'Banner updated',
|
|
189
|
+
successMessage: '<%= j @entry.label %> now uses this app\'s own image.',
|
|
190
|
+
failure: 'Couldn\'t save the banner'
|
|
191
|
+
})"
|
|
192
|
+
@crop-photo-confirmed.window="onCropConfirmed($event.detail)"
|
|
193
|
+
<% end %>>
|
|
194
|
+
<p class="label-upper mb-3">Banner</p>
|
|
195
|
+
<% if @banner %>
|
|
196
|
+
<div class="rounded-lg overflow-hidden border border-subtle"
|
|
197
|
+
style="aspect-ratio: <%= aspect %>; max-width: <%= card_width %>px;" data-email-banner-frame>
|
|
198
|
+
<div data-email-banner-preview data-banner-width="<%= card_width %>"
|
|
199
|
+
style="width:<%= card_width %>px;transform-origin:top left;">
|
|
200
|
+
<%= render "studio/mailers/layered_banner", banner: @banner, preview: true %>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
64
203
|
<% else %>
|
|
65
|
-
|
|
66
|
-
|
|
204
|
+
<div class="rounded-lg overflow-hidden border border-subtle flex items-center justify-center text-xs text-muted text-center px-4"
|
|
205
|
+
style="aspect-ratio: <%= aspect %>; max-width: <%= card_width %>px;">
|
|
206
|
+
This email has no layered banner, so it sends the artwork as a plain image.
|
|
207
|
+
</div>
|
|
67
208
|
<% end %>
|
|
68
|
-
</p>
|
|
69
|
-
</section>
|
|
70
209
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
210
|
+
<p class="label-upper mb-3 mt-6">Artwork</p>
|
|
211
|
+
<%# The picture ALONE — the file an upload replaces. Hovering reveals the way
|
|
212
|
+
to change it, so the control sits on the thing it changes instead of in a
|
|
213
|
+
sentence pointing at another page. %>
|
|
214
|
+
<div class="relative group rounded-lg overflow-hidden border border-subtle" data-email-artwork-frame
|
|
215
|
+
style="aspect-ratio: <%= aspect %>; max-width: <%= card_width %>px; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
|
|
216
|
+
<% if banner_url %>
|
|
217
|
+
<%= image_tag banner_url, class: "w-full h-full object-cover",
|
|
218
|
+
alt: "#{@entry.label} email artwork" %>
|
|
219
|
+
<% else %>
|
|
220
|
+
<div class="w-full h-full flex items-center justify-center text-xs text-white/80 text-center px-4">
|
|
221
|
+
This email sends without a banner.
|
|
222
|
+
</div>
|
|
223
|
+
<% end %>
|
|
224
|
+
<% if @uploads_available %>
|
|
225
|
+
<button type="button" @click="open()" data-modify-artwork
|
|
226
|
+
class="absolute inset-0 w-full h-full flex items-center justify-center gap-2
|
|
227
|
+
bg-black/55 text-white text-sm font-semibold opacity-0
|
|
228
|
+
group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150">
|
|
229
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
230
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
231
|
+
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
|
232
|
+
</svg>
|
|
233
|
+
Modify image
|
|
234
|
+
</button>
|
|
235
|
+
<% end %>
|
|
236
|
+
</div>
|
|
237
|
+
<% if @uploads_available %>
|
|
238
|
+
<%# The upload host writes the chosen file into THIS input and submits it.
|
|
239
|
+
Without the pair, imageUploadHost has no $refs.fileInput and the whole
|
|
240
|
+
flow dies at "Cannot set properties of undefined" — after the modal has
|
|
241
|
+
already closed, so it reads as a silent no-op. %>
|
|
242
|
+
<%= form_with url: admin_email_path(@entry.key), method: :patch,
|
|
243
|
+
html: { multipart: true, id: "email-banner-form-#{@entry.key.tr("_", "-")}", "x-ref": "form" } do %>
|
|
244
|
+
<%= file_field_tag :image, class: "hidden", "x-ref": "fileInput" %>
|
|
245
|
+
<% end %>
|
|
246
|
+
<% end %>
|
|
247
|
+
<p class="text-2xs text-muted mt-2">
|
|
248
|
+
<% if @uploads_available %>
|
|
249
|
+
GIFs keep their animation. Displayed at <%= card_width %>x<%= (card_width / aspect).round %>.
|
|
250
|
+
<% else %>
|
|
251
|
+
<%= Studio.app_name %> has no object storage configured, so this image can be
|
|
252
|
+
viewed but not replaced yet.
|
|
253
|
+
<% end %>
|
|
254
|
+
</p>
|
|
255
|
+
|
|
256
|
+
<%# The same artwork with the tint, the words and the logo on it — rendered
|
|
257
|
+
by the mailer's own partial, so it cannot drift from the email. The email
|
|
258
|
+
table is a fixed 600px because an inbox has no responsive layout; it is
|
|
259
|
+
scaled to this frame, and both frames are capped at 600 so they match at
|
|
260
|
+
every width. %>
|
|
261
|
+
</section>
|
|
262
|
+
<section class="card p-4">
|
|
263
|
+
<%# No name attributes and no form here. Every field is Alpine state, and
|
|
264
|
+
the hidden form at the bottom of the page mirrors all of it — one Save
|
|
265
|
+
writes the subject, the words, the logo and the tint together. %>
|
|
266
|
+
<div class="space-y-4">
|
|
267
|
+
<div>
|
|
268
|
+
<label class="text-sm text-muted block mb-1" for="header">Header</label>
|
|
269
|
+
<%= text_field_tag :header, saved[:header], id: "header", name: nil,
|
|
270
|
+
"x-model": "form.header", class: "input-field w-full py-2" %>
|
|
271
|
+
</div>
|
|
272
|
+
<div>
|
|
273
|
+
<label class="text-sm text-muted block mb-1" for="header_fallback">
|
|
274
|
+
Header for someone with no name on file
|
|
275
|
+
</label>
|
|
276
|
+
<%= text_field_tag :header_fallback, saved[:header_fallback], id: "header_fallback", name: nil,
|
|
277
|
+
"x-model": "form.header_fallback", class: "input-field w-full py-2" %>
|
|
278
|
+
<p class="text-2xs text-muted mt-1">
|
|
279
|
+
Used instead of the header above when we hold no name — a magic link is
|
|
280
|
+
often the first thing a stranger receives, and "Welcome !" is the
|
|
281
|
+
alternative.
|
|
282
|
+
</p>
|
|
283
|
+
</div>
|
|
284
|
+
<div>
|
|
285
|
+
<label class="text-sm text-muted block mb-1" for="subtext">Sub-text</label>
|
|
286
|
+
<%= text_field_tag :subtext, saved[:subtext], id: "subtext", name: nil,
|
|
287
|
+
"x-model": "form.subtext", class: "input-field w-full py-2" %>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
<%# THE LOGO — three states, because a URL box can only express two.
|
|
291
|
+
Standard inherits the shared mark, Custom is what this app uploaded,
|
|
292
|
+
Hidden is the deliberate "no logo" that blank cannot say. %>
|
|
293
|
+
<div>
|
|
294
|
+
<span class="text-sm text-muted block mb-1">Logo</span>
|
|
295
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
296
|
+
<button type="button" @click="setLogoMode('standard')" data-logo-standard
|
|
297
|
+
:class="logoMode() === 'standard' ? 'btn btn-primary btn-sm' : 'btn btn-outline btn-sm'">
|
|
298
|
+
Standard
|
|
299
|
+
</button>
|
|
300
|
+
<% if @uploads_available %>
|
|
301
|
+
<button type="button" @click="$dispatch('open-logo-upload')" data-logo-upload
|
|
302
|
+
:class="logoMode() === 'custom' ? 'btn btn-primary btn-sm' : 'btn btn-outline btn-sm'">
|
|
303
|
+
Upload new
|
|
304
|
+
</button>
|
|
305
|
+
<% end %>
|
|
306
|
+
<button type="button" @click="setLogoMode('hidden')" data-logo-hidden
|
|
307
|
+
:class="logoMode() === 'hidden' ? 'btn btn-primary btn-sm' : 'btn btn-outline btn-sm'">
|
|
308
|
+
None
|
|
309
|
+
</button>
|
|
310
|
+
</div>
|
|
311
|
+
<% if Studio::EmailCatalog.uploaded_logo_url(@entry.key).present? %>
|
|
312
|
+
<%= button_to "Back to the standard logo", admin_email_logo_path(@entry.key),
|
|
313
|
+
method: :patch, params: { remove: "1" },
|
|
314
|
+
class: "text-2xs text-muted underline underline-offset-2 mt-2" %>
|
|
315
|
+
<% end %>
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
<%# THE TINT lives here, beside the words it makes legible, and repaints
|
|
319
|
+
the banner as it changes. It was its own card with its own Save
|
|
320
|
+
button; that put the one control that trades legibility against
|
|
321
|
+
picture in a different place from the text it is protecting. %>
|
|
322
|
+
<div>
|
|
323
|
+
<label class="text-sm text-muted block mb-1" for="scrim_percent">
|
|
324
|
+
Tint — how far the artwork is darkened behind the text
|
|
325
|
+
</label>
|
|
326
|
+
<div class="flex items-center gap-3">
|
|
327
|
+
<input type="range" min="0" max="100" step="1" id="scrim_percent" data-scrim-range
|
|
328
|
+
x-model="form.scrim_percent" class="studio-range flex-1"
|
|
329
|
+
:style="'--range-fill: ' + scrimFill()">
|
|
330
|
+
<span class="font-mono text-2xs text-heading w-12 text-right"
|
|
331
|
+
x-text="(form.scrim_percent === '' ? <%= (Studio::Banner::DEFAULT_SCRIM * 100).round %> : form.scrim_percent) + '%'"></span>
|
|
332
|
+
</div>
|
|
333
|
+
<p class="text-2xs text-muted mt-1">
|
|
334
|
+
More tint means a more legible greeting; less means a brighter picture.
|
|
335
|
+
</p>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
</section>
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<%# The email itself. An iframe because the response IS an email document — its
|
|
342
|
+
own document shell, its own table layout, and no business inheriting the
|
|
343
|
+
admin page's stylesheet. %>
|
|
74
344
|
<section class="card p-3 bg-inset">
|
|
75
345
|
<p class="label-upper mb-3 px-1">Preview</p>
|
|
76
346
|
<% if @entry.previewable? %>
|
|
@@ -89,4 +359,67 @@
|
|
|
89
359
|
<p class="text-2xs text-danger mt-2 px-1 font-mono break-all"><%= @preview_error %></p>
|
|
90
360
|
<% end %>
|
|
91
361
|
</section>
|
|
362
|
+
|
|
363
|
+
<%# THE ONE WRITE. Every visible control above is Alpine state with no name
|
|
364
|
+
attribute; these hidden inputs mirror that state and this is the form the
|
|
365
|
+
header's Save submits. Done this way because a single <form> cannot wrap
|
|
366
|
+
the page — the artwork uploader is itself a form, and nesting forms is
|
|
367
|
+
invalid HTML that browsers silently repair by dropping one. %>
|
|
368
|
+
<%= form_with url: admin_email_copy_path(@entry.key), method: :patch,
|
|
369
|
+
html: { "x-ref": "saveForm", class: "hidden" } do %>
|
|
370
|
+
<input type="hidden" name="subject" :value="form.subject">
|
|
371
|
+
<input type="hidden" name="header" :value="form.header">
|
|
372
|
+
<input type="hidden" name="header_fallback" :value="form.header_fallback">
|
|
373
|
+
<input type="hidden" name="subtext" :value="form.subtext">
|
|
374
|
+
<input type="hidden" name="logo_url" :value="form.logo_url">
|
|
375
|
+
<input type="hidden" name="hide_logo" :value="form.hide_logo ? '1' : ''">
|
|
376
|
+
<input type="hidden" name="scrim_percent" :value="form.scrim_percent">
|
|
377
|
+
<% end %>
|
|
378
|
+
|
|
379
|
+
<%= render "studio/emails/banner_scale" %>
|
|
380
|
+
<%= render "studio/emails/banner_editor" %>
|
|
381
|
+
|
|
382
|
+
<% if @uploads_available %>
|
|
383
|
+
<%# Cropper + the page-scoped modal host, exactly as the index mounts them:
|
|
384
|
+
scoped_host rather than modals/host, because mcritchie-studio and
|
|
385
|
+
turf-monster both ship app copies of modals/host that shadow the engine's
|
|
386
|
+
in this non-isolated engine.
|
|
387
|
+
|
|
388
|
+
Optional chaining on current() is load-bearing: the outer template
|
|
389
|
+
unmounts one tick AFTER the stack empties, so a bare .id throws on every
|
|
390
|
+
modal close. %>
|
|
391
|
+
<%# The LOGO uploader — a second host, listening for its own event. Separate
|
|
392
|
+
from the artwork host because the two want different crops: artwork is
|
|
393
|
+
3:1 and may be an animated GIF, a logo is a small transparent mark that
|
|
394
|
+
should keep its alpha and be cropped freely. %>
|
|
395
|
+
<div x-data="imageUploadHost({
|
|
396
|
+
store: 'emailModals',
|
|
397
|
+
aspectRatio: 0,
|
|
398
|
+
maxWidth: 600,
|
|
399
|
+
transparent: true,
|
|
400
|
+
allowGifs: false,
|
|
401
|
+
filename: '<%= @entry.key %>-logo.png',
|
|
402
|
+
saving: 'Saving logo…',
|
|
403
|
+
success: 'Logo updated',
|
|
404
|
+
successMessage: 'This email now uses its own logo.',
|
|
405
|
+
failure: 'Couldn\'t save the logo'
|
|
406
|
+
})"
|
|
407
|
+
@open-logo-upload.window="open()"
|
|
408
|
+
@crop-photo-confirmed.window="onCropConfirmed($event.detail)">
|
|
409
|
+
<%= form_with url: admin_email_logo_path(@entry.key), method: :patch,
|
|
410
|
+
html: { multipart: true, "x-ref": "form", class: "hidden" } do %>
|
|
411
|
+
<%= file_field_tag :image, class: "hidden", "x-ref": "fileInput" %>
|
|
412
|
+
<% end %>
|
|
413
|
+
</div>
|
|
414
|
+
|
|
415
|
+
<%= render "studio/cropper_assets" %>
|
|
416
|
+
<%= render "studio/modals/scoped_host", store: "emailModals" do %>
|
|
417
|
+
<template x-if="$store.emailModals.current()?.id === 'crop-photo'">
|
|
418
|
+
<div><%= render "studio/modals/crop_photo", store: "emailModals" %></div>
|
|
419
|
+
</template>
|
|
420
|
+
<template x-if="$store.emailModals.current()?.id === 'saving'">
|
|
421
|
+
<div><%= render "studio/modals/saving", store: "emailModals" %></div>
|
|
422
|
+
</template>
|
|
423
|
+
<% end %>
|
|
424
|
+
<% end %>
|
|
92
425
|
</div>
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
The layered email banner: background image, live text on top.
|
|
3
|
+
|
|
4
|
+
locals: (banner:) — a Studio::Banner
|
|
5
|
+
|
|
6
|
+
The markup is deliberately old-fashioned. Outlook on Windows renders through
|
|
7
|
+
Word and ignores `background-image` on nearly everything, so the picture is
|
|
8
|
+
carried three ways at once:
|
|
9
|
+
|
|
10
|
+
* the `background` ATTRIBUTE on the td, which the widest set of clients honour
|
|
11
|
+
* the CSS `background-image`, for the ones that prefer it
|
|
12
|
+
* a VML `v:rect` / `v:fill` inside a `gte mso 9` conditional, which is the
|
|
13
|
+
only thing that reaches Outlook. Every other client never sees it.
|
|
14
|
+
|
|
15
|
+
`bgcolor` is the floor: if the image is blocked or still loading, the cell is a
|
|
16
|
+
brand colour with legible text on it rather than white-on-white.
|
|
17
|
+
|
|
18
|
+
The scrim is a coloured overlay cell between artwork and type. Background art
|
|
19
|
+
is chosen to look good, not to guarantee contrast, and white text on a pale
|
|
20
|
+
sky cannot be read. Set scrim: 0 on artwork dark enough to carry the type.
|
|
21
|
+
%>
|
|
22
|
+
<%
|
|
23
|
+
banner = local_assigns.fetch(:banner)
|
|
24
|
+
|
|
25
|
+
# PREVIEW HOOKS, off by default.
|
|
26
|
+
#
|
|
27
|
+
# The admin page edits this banner's words and repaints it as you type, which
|
|
28
|
+
# needs handles on the three text nodes. They are opt-in because this partial
|
|
29
|
+
# renders REAL EMAIL: shipping data- attributes into every inbox to serve an
|
|
30
|
+
# admin screen is the kind of thing that is invisible until a mail client
|
|
31
|
+
# chokes on it. With preview off the markup is byte-for-byte what it was, and
|
|
32
|
+
# a test asserts exactly that.
|
|
33
|
+
preview = local_assigns.fetch(:preview, false)
|
|
34
|
+
hook = ->(name) { preview ? %( data-banner-#{name}).html_safe : "" }
|
|
35
|
+
|
|
36
|
+
# TYPE AND RHYTHM SCALE WITH THE BANNER.
|
|
37
|
+
#
|
|
38
|
+
# Everything below is a proportion of the height, not a fixed pixel size.
|
|
39
|
+
# Hardcoding 42px type looked right at 300px tall and OVERFLOWED at 200 —
|
|
40
|
+
# the box shrank, the words did not, and the banner rendered 238px with the
|
|
41
|
+
# header clipped at both edges. Proportions mean the next height change is a
|
|
42
|
+
# one-line edit rather than a re-tune.
|
|
43
|
+
h = banner.height
|
|
44
|
+
|
|
45
|
+
header_size = (h * 0.15).round # 30px at 200, 45 at 300
|
|
46
|
+
header_line = (h * 0.17).round
|
|
47
|
+
subtext_size = (h * 0.075).round # 15px at 200
|
|
48
|
+
subtext_line = (h * 0.10).round
|
|
49
|
+
logo_width = (h * 0.60).round # 120px at 200
|
|
50
|
+
side_padding = (banner.width * 0.05).round
|
|
51
|
+
|
|
52
|
+
# The block is vertically centred, so the gap between sub-text and logo is
|
|
53
|
+
# what pushes the header toward the top edge and the logo toward the bottom.
|
|
54
|
+
#
|
|
55
|
+
# It has to ADAPT to the greeting. A gap that fills the banner for "Welcome
|
|
56
|
+
# Alex!" leaves "Welcome Bartholomew Fitzgerald-Montgomery!" clipped, because
|
|
57
|
+
# a wrapped header is a whole line taller while the box is not. The line
|
|
58
|
+
# estimate is deliberately crude — it only has to answer "one line or more",
|
|
59
|
+
# client fonts differ anyway, and being wrong costs breathing room rather
|
|
60
|
+
# than a broken banner.
|
|
61
|
+
gap_after_header = (h * 0.02).round
|
|
62
|
+
chars_per_line = 21
|
|
63
|
+
header_lines = [(banner.header.to_s.length / chars_per_line.to_f).ceil, 1].max
|
|
64
|
+
gap_before_logo = header_lines > 1 ? (h * 0.18).round : (h * 0.35).round
|
|
65
|
+
%>
|
|
66
|
+
<table role="presentation" width="<%= banner.width %>" cellpadding="0" cellspacing="0" border="0"
|
|
67
|
+
style="width:<%= banner.width %>px;border-collapse:collapse;">
|
|
68
|
+
<tr>
|
|
69
|
+
<td <%= %(background="#{banner.background_url}").html_safe if banner.background_url.present? %>
|
|
70
|
+
bgcolor="<%= Studio.theme_primary %>"
|
|
71
|
+
width="<%= banner.width %>" height="<%= banner.height %>" valign="middle"
|
|
72
|
+
style="width:<%= banner.width %>px;height:<%= banner.height %>px;<%= "background-image:url(#{banner.background_url});background-size:cover;background-position:center;background-repeat:no-repeat;" if banner.background_url.present? %>">
|
|
73
|
+
<% if banner.background_url.present? %>
|
|
74
|
+
<!--[if gte mso 9]>
|
|
75
|
+
<v:rect xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false"
|
|
76
|
+
style="width:<%= banner.width %>px;height:<%= banner.height %>px;">
|
|
77
|
+
<v:fill type="frame" src="<%= banner.background_url %>" color="<%= Studio.theme_primary %>" />
|
|
78
|
+
<v:textbox inset="0,0,0,0">
|
|
79
|
+
<![endif]-->
|
|
80
|
+
<% end %>
|
|
81
|
+
<div>
|
|
82
|
+
<table role="presentation" width="<%= banner.width %>" cellpadding="0" cellspacing="0" border="0"
|
|
83
|
+
style="width:<%= banner.width %>px;">
|
|
84
|
+
<tr>
|
|
85
|
+
<%# VERTICAL PADDING IS ZERO ON PURPOSE.
|
|
86
|
+
A table cell's padding ADDS to its declared height, so
|
|
87
|
+
height:300 plus 26px top and bottom rendered a 352px banner.
|
|
88
|
+
Dropping the height instead shrank the scrim to a band across
|
|
89
|
+
the middle, because the tinted cell then only covered its own
|
|
90
|
+
content. Full height with horizontal-only padding gives both:
|
|
91
|
+
exactly 300px, and a wash over the whole picture. valign does
|
|
92
|
+
the vertical centring that the padding was doing. %>
|
|
93
|
+
<td align="center" valign="middle" height="<%= banner.height %>"<%= hook.call("scrim") %>
|
|
94
|
+
<%# bgcolor is the OUTLOOK path and the CSS below is everywhere
|
|
95
|
+
else. Word ignores rgba(), so without the attribute the wash
|
|
96
|
+
vanishes there and white text sits on bare artwork — the exact
|
|
97
|
+
contrast case the scrim exists for, in the one client that
|
|
98
|
+
cannot be spot-checked. Every other client honours the rgba
|
|
99
|
+
declaration and overrides the flat attribute. %>
|
|
100
|
+
<%= %(bgcolor="#{banner.scrim_solid_hex}").html_safe if banner.scrim_opacity.positive? %>
|
|
101
|
+
style="height:<%= banner.height %>px;padding:0 <%= side_padding %>px;<%= "background-color:rgba(24,16,64,#{banner.scrim_opacity});" if banner.scrim_opacity.positive? %>">
|
|
102
|
+
<% if preview || banner.header.present? %>
|
|
103
|
+
<p<%= hook.call("header") %> style="margin:0 0 <%= gap_after_header %>px;font-family:Montserrat,'Segoe UI',Helvetica,Arial,sans-serif;font-size:<%= header_size %>px;line-height:<%= header_line %>px;font-weight:700;color:#ffffff;">
|
|
104
|
+
<%= banner.header %>
|
|
105
|
+
</p>
|
|
106
|
+
<% end %>
|
|
107
|
+
<% if preview || banner.subtext.present? %>
|
|
108
|
+
<p<%= hook.call("subtext") %> style="margin:0 0 <%= gap_before_logo %>px;font-family:Montserrat,'Segoe UI',Helvetica,Arial,sans-serif;font-size:<%= subtext_size %>px;line-height:<%= subtext_line %>px;color:#efeaff;">
|
|
109
|
+
<%= banner.subtext %>
|
|
110
|
+
</p>
|
|
111
|
+
<% end %>
|
|
112
|
+
<% if banner.logo_url.present? %>
|
|
113
|
+
<%= image_tag banner.logo_url, width: logo_width, alt: banner.logo_alt.to_s,
|
|
114
|
+
data: (preview ? { banner_logo: true } : {}),
|
|
115
|
+
style: "display:block;margin:0 auto;width:#{logo_width}px;height:auto;border:0;" %>
|
|
116
|
+
<% elsif preview %>
|
|
117
|
+
<%# A hidden placeholder so ticking "hide the logo" and unticking it
|
|
118
|
+
again repaints without a round trip. Preview only — an email
|
|
119
|
+
with no logo ships no img tag at all. %>
|
|
120
|
+
<img data-banner-logo width="<%= logo_width %>" alt=""
|
|
121
|
+
src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
|
|
122
|
+
style="display:none;margin:0 auto;width:<%= logo_width %>px;height:auto;border:0;">
|
|
123
|
+
<% end %>
|
|
124
|
+
</td>
|
|
125
|
+
</tr>
|
|
126
|
+
</table>
|
|
127
|
+
</div>
|
|
128
|
+
<% if banner.background_url.present? %>
|
|
129
|
+
<!--[if gte mso 9]>
|
|
130
|
+
</v:textbox>
|
|
131
|
+
</v:rect>
|
|
132
|
+
<![endif]-->
|
|
133
|
+
<% end %>
|
|
134
|
+
</td>
|
|
135
|
+
</tr>
|
|
136
|
+
</table>
|