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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# locals: (entry:, uploads_available:, max_width:)
|
|
1
|
+
<%# locals: (entry:, uploads_available:, max_width:, preview_name: nil)
|
|
2
2
|
One registered email: its live banner, its name, and where that banner comes
|
|
3
3
|
from. The whole row is the imageUploadHost x-data so the thumbnail, the Edit
|
|
4
4
|
button, and the hidden multipart form the cropper submits all sit together —
|
|
@@ -8,6 +8,40 @@
|
|
|
8
8
|
source = Studio::EmailCatalog.source(entry.key)
|
|
9
9
|
banner_url = Studio::EmailCatalog.preview_url(entry.key)
|
|
10
10
|
form_id = "email-banner-form-#{entry.key.dasherize}"
|
|
11
|
+
card_width = Studio::Banner::DEFAULT_WIDTH
|
|
12
|
+
|
|
13
|
+
# The banner AS IT ARRIVES, not the bare artwork. Same partial the mailer
|
|
14
|
+
# renders, in preview mode so the text nodes carry handles — the row repaints
|
|
15
|
+
# when the example recipient changes.
|
|
16
|
+
# Defaulted rather than required: the row is rendered by tests and by any host
|
|
17
|
+
# that has not passed a recipient, and "nobody selected" is a real state — it
|
|
18
|
+
# renders the name-free header, which is what a stranger receives.
|
|
19
|
+
name = local_assigns.fetch(:preview_name, nil)
|
|
20
|
+
# THE BANNER AS IT ARRIVES — for a layered email, artwork with live text on it.
|
|
21
|
+
#
|
|
22
|
+
# Two conditions, and both were learned the hard way.
|
|
23
|
+
#
|
|
24
|
+
# It renders in an IFRAME. The banner is the email's own <table>, and putting
|
|
25
|
+
# one directly in a list row nests rows inside rows: every host asserting "one
|
|
26
|
+
# row per email" counted three per layered email, and two consumer suites went
|
|
27
|
+
# red. An iframe is a separate document, so the list's markup contract is
|
|
28
|
+
# untouched — the same reason the email preview lower down the detail page is
|
|
29
|
+
# one.
|
|
30
|
+
#
|
|
31
|
+
# And it only layers when THIS APP's artwork is layered. Whether an email
|
|
32
|
+
# layers belongs to its mailer, which the catalogue cannot see; the closest
|
|
33
|
+
# honest proxy is whose artwork is registered. turf-monster re-registers
|
|
34
|
+
# magic_link with its own flat banner and inherits the engine's background, so
|
|
35
|
+
# a naive check drew a layered picture turf-monster never sends.
|
|
36
|
+
layered = Studio::EmailCatalog.background_url(entry.key).present? && entry.engine_artwork?
|
|
37
|
+
banner = layered ? Studio::Banner.for(entry.key, name: name) : nil
|
|
38
|
+
subject = Studio::EmailCatalog.subject_for(entry.key, name: name)
|
|
39
|
+
|
|
40
|
+
# The TEMPLATES ride on the row so the repaint script can re-resolve them for
|
|
41
|
+
# whoever is selected, without a second payload to keep in step.
|
|
42
|
+
header_template = Studio::EmailCatalog.header_template(entry.key)
|
|
43
|
+
header_fallback = Studio::EmailCatalog.header_fallback(entry.key)
|
|
44
|
+
subject_template = Studio::EmailSetting.copy_for(entry.key, :subject) || entry.subject
|
|
11
45
|
|
|
12
46
|
# `badge` is a shape-only utility in engine.css — the state color comes from
|
|
13
47
|
# theme-token utilities so it follows each app's palette in light and dark.
|
|
@@ -35,13 +69,25 @@
|
|
|
35
69
|
override. On a read-only app the cropper assets are not loaded either, so an
|
|
36
70
|
unconditional x-data would reference an undefined factory and Alpine would
|
|
37
71
|
throw on every row. %>
|
|
38
|
-
|
|
72
|
+
<%# THE WHOLE ROW OPENS THE EMAIL. Kept as a click handler on the row with the
|
|
73
|
+
name still a real <a>: wrapping a table row in a link is invalid HTML, and
|
|
74
|
+
making every cell a link would break the Upload button inside one of them.
|
|
75
|
+
The handler ignores clicks that landed on a control, so Upload, Revert and
|
|
76
|
+
the name link keep their own behaviour. %>
|
|
77
|
+
<tr class="border-b border-subtle last:border-0 cursor-pointer hover:bg-inset/60" data-email-row
|
|
78
|
+
data-email-path="<%= admin_email_path(entry.key) %>"
|
|
79
|
+
data-header="<%= header_template %>"
|
|
80
|
+
data-header-fallback="<%= header_fallback %>"
|
|
81
|
+
data-subject="<%= subject_template %>"
|
|
39
82
|
<% if uploads_available %>
|
|
40
83
|
x-data="imageUploadHost({
|
|
41
84
|
store: 'emailModals',
|
|
42
85
|
aspectRatio: <%= entry.ratio %>,
|
|
43
86
|
maxWidth: <%= max_width %>,
|
|
44
87
|
transparent: false,
|
|
88
|
+
<%# Same answer as the email's own page. Without this the SAME button
|
|
89
|
+
refused an animated GIF here and accepted it there. %>
|
|
90
|
+
allowGifs: true,
|
|
45
91
|
filename: '<%= entry.key %>.png',
|
|
46
92
|
saving: 'Saving banner…',
|
|
47
93
|
success: 'Banner updated',
|
|
@@ -52,9 +98,23 @@
|
|
|
52
98
|
<% end %>>
|
|
53
99
|
|
|
54
100
|
<td class="px-4 py-4">
|
|
55
|
-
<div class="rounded-lg overflow-hidden border border-subtle w-
|
|
101
|
+
<div class="rounded-lg overflow-hidden border border-subtle w-56" data-email-banner-frame
|
|
56
102
|
style="aspect-ratio: <%= entry.ratio %>; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
|
|
57
|
-
<% if
|
|
103
|
+
<% if banner %>
|
|
104
|
+
<%# srcdoc, so the email's table lives in its own document. Root-relative
|
|
105
|
+
asset paths inside it resolve against this page's origin, which is
|
|
106
|
+
what the admin preview wants. %>
|
|
107
|
+
<iframe class="pointer-events-none" scrolling="no" tabindex="-1" loading="lazy"
|
|
108
|
+
data-email-banner-preview data-banner-width="<%= card_width %>"
|
|
109
|
+
title="<%= entry.label %> banner"
|
|
110
|
+
style="width:<%= card_width %>px;height:<%= (card_width / entry.ratio).round %>px;border:0;transform-origin:top left;display:block;"
|
|
111
|
+
<%# ESCAPED EXPLICITLY. render returns an html_safe buffer, so a bare
|
|
112
|
+
output tag injects it RAW into the attribute and closes the iframe
|
|
113
|
+
at the banner's first quote — the row's markup collapses and the
|
|
114
|
+
email's name disappears from the page. to_str drops the safe flag
|
|
115
|
+
so the escaping actually happens. %>
|
|
116
|
+
srcdoc="<%= ERB::Util.html_escape(render("studio/mailers/layered_banner", banner: banner, preview: true).to_str) %>"></iframe>
|
|
117
|
+
<% elsif banner_url %>
|
|
58
118
|
<%= image_tag banner_url, class: "w-full h-full object-cover",
|
|
59
119
|
alt: "#{entry.label} email banner", loading: "lazy" %>
|
|
60
120
|
<% else %>
|
|
@@ -78,6 +138,10 @@
|
|
|
78
138
|
<p class="font-mono text-2xs text-muted mt-1"><%= entry.key %></p>
|
|
79
139
|
</td>
|
|
80
140
|
|
|
141
|
+
<td class="px-4 py-4">
|
|
142
|
+
<p class="text-sm text-heading" data-row-subject><%= subject %></p>
|
|
143
|
+
</td>
|
|
144
|
+
|
|
81
145
|
<%# whitespace-nowrap keeps the pill a pill — "<App>'s own" is long enough to
|
|
82
146
|
wrap inside a bordered badge, which reads as a broken box next to the
|
|
83
147
|
short "Inherited default". %>
|
|
@@ -24,10 +24,22 @@
|
|
|
24
24
|
# Counts the app's OWN artwork either way — uploaded here or committed in this
|
|
25
25
|
# app's assets. Counting only uploads made turf-monster, whose eight banners
|
|
26
26
|
# all ship from its own repo, report "all inheriting the default artwork".
|
|
27
|
+
# The picker renders entirely from this payload — a field omitted here is a
|
|
28
|
+
# grey circle with no letter in it, which is exactly what shipped once.
|
|
29
|
+
recipients_config = {
|
|
30
|
+
targets: (@targets || []).map do |t|
|
|
31
|
+
{ id: t.id, label: t.label, name: t.name, email: t.email, admin: t.admin?,
|
|
32
|
+
avatar_url: t.avatar_url, avatar_color: t.avatar_color, initials: t.initials }
|
|
33
|
+
end,
|
|
34
|
+
targetId: @target&.id,
|
|
35
|
+
appName: Studio.app_name.to_s
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
app_artwork = @entries.count { |entry| Studio::EmailCatalog.app_artwork?(entry.key) }
|
|
28
39
|
bannerless = @entries.count { |entry| Studio::EmailCatalog.source(entry.key) == :none }
|
|
29
40
|
%>
|
|
30
|
-
<div class="max-w-5xl mx-auto px-4 pb-16"
|
|
41
|
+
<div class="max-w-5xl mx-auto px-4 pb-16"
|
|
42
|
+
x-data="emailRecipients(<%= recipients_config.to_json %>)">
|
|
31
43
|
<header class="space-y-2 pt-8 pb-6">
|
|
32
44
|
<p class="label-upper">studio-engine · v<%= Studio::VERSION %></p>
|
|
33
45
|
<h1 class="text-3xl font-bold text-heading">Emails</h1>
|
|
@@ -74,30 +86,44 @@
|
|
|
74
86
|
</div>
|
|
75
87
|
<% end %>
|
|
76
88
|
|
|
89
|
+
<%# WHO the list is rendered for. Every banner greets by name and a subject may
|
|
90
|
+
too, so a list rendered for one person is a list of half-truths about
|
|
91
|
+
everyone else — the control belongs above the table, not on each row. %>
|
|
92
|
+
<% if (@targets || []).any? %>
|
|
93
|
+
<div class="mb-4 max-w-sm">
|
|
94
|
+
<p class="label-upper mb-2">Example</p>
|
|
95
|
+
<%= render "studio/emails/recipient_picker" %>
|
|
96
|
+
</div>
|
|
97
|
+
<% end %>
|
|
98
|
+
|
|
77
99
|
<div class="card overflow-hidden">
|
|
78
100
|
<div class="overflow-x-auto">
|
|
79
101
|
<table class="w-full text-left align-middle">
|
|
80
102
|
<thead>
|
|
81
103
|
<tr class="border-b border-subtle">
|
|
82
|
-
<th class="label-upper px-4 py-3 w-
|
|
104
|
+
<th class="label-upper px-4 py-3 w-64">Banner</th>
|
|
83
105
|
<th class="label-upper px-4 py-3">Email</th>
|
|
106
|
+
<th class="label-upper px-4 py-3 w-56">Subject</th>
|
|
84
107
|
<th class="label-upper px-4 py-3 w-52">Image</th>
|
|
85
108
|
<% if @uploads_available %>
|
|
86
109
|
<th class="label-upper px-4 py-3 w-40 text-right">Actions</th>
|
|
87
110
|
<% end %>
|
|
88
111
|
</tr>
|
|
89
112
|
</thead>
|
|
90
|
-
<tbody>
|
|
113
|
+
<tbody @click="openRow($event, $event.target.closest('[data-email-row]'))">
|
|
91
114
|
<% @entries.each do |entry| %>
|
|
92
115
|
<%= render "studio/emails/row", entry: entry,
|
|
93
116
|
uploads_available: @uploads_available,
|
|
94
|
-
max_width: max_width %>
|
|
117
|
+
max_width: max_width, preview_name: @preview_name %>
|
|
95
118
|
<% end %>
|
|
96
119
|
</tbody>
|
|
97
120
|
</table>
|
|
98
121
|
</div>
|
|
99
122
|
</div>
|
|
100
123
|
|
|
124
|
+
<%= render "studio/emails/banner_scale" %>
|
|
125
|
+
<%= render "studio/emails/recipient_repaint" %>
|
|
126
|
+
|
|
101
127
|
<p class="text-xs text-muted mt-4">
|
|
102
128
|
Banners render full-bleed at 600px wide inside the email card. The crop is
|
|
103
129
|
saved at up to <%= max_width %>px, which is retina-sharp in an inbox. Each
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<%# admin/emails/:key for an ORPHAN — an email this app uploaded artwork for,
|
|
2
|
+
which has since left the registry.
|
|
3
|
+
|
|
4
|
+
It exists so the operator has a way out. Without the page, that upload is a
|
|
5
|
+
live ImageCache row and a paid-for S3 object with no revert button anywhere,
|
|
6
|
+
because the only control that could remove it lived on a page that 404s the
|
|
7
|
+
moment the key is unregistered.
|
|
8
|
+
|
|
9
|
+
Deliberately minimal: there is no banner to preview, no copy to edit and no
|
|
10
|
+
recipient to render as. The one honest affordance is "drop it". %>
|
|
11
|
+
<% content_for(:title) { "Unregistered email" } %>
|
|
12
|
+
<div class="max-w-5xl mx-auto px-4 pb-16">
|
|
13
|
+
<header class="pt-8 pb-5">
|
|
14
|
+
<%= link_to "← All emails", admin_emails_path,
|
|
15
|
+
class: "text-sm text-muted hover:text-heading underline underline-offset-2" %>
|
|
16
|
+
<h1 class="text-3xl font-bold text-heading mt-2">Unregistered email</h1>
|
|
17
|
+
<div class="flex flex-wrap items-center gap-2 mt-3">
|
|
18
|
+
<span class="badge bg-warning/10 text-warning border-warning/30 whitespace-nowrap">Not registered</span>
|
|
19
|
+
<span class="font-mono text-2xs text-muted"><%= @key %></span>
|
|
20
|
+
</div>
|
|
21
|
+
</header>
|
|
22
|
+
|
|
23
|
+
<section class="card p-4 mb-6">
|
|
24
|
+
<p class="text-body max-w-2xl">
|
|
25
|
+
<%= Studio.app_name %> uploaded artwork for <code class="font-mono text-2xs bg-inset px-1.5 py-0.5 rounded"><%= @key %></code>,
|
|
26
|
+
but no email is registered under that key any more — so nothing sends it. The
|
|
27
|
+
image is still stored, and this page is here so you can drop it.
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
<% if (record = Studio::EmailCatalog.record(@key)) %>
|
|
31
|
+
<div class="rounded-lg overflow-hidden border border-subtle w-56 mt-4">
|
|
32
|
+
<%= image_tag record.url, class: "w-full h-auto", alt: "Stored artwork for #{@key}" %>
|
|
33
|
+
</div>
|
|
34
|
+
<p class="font-mono text-2xs text-muted mt-2 break-all"><%= record.s3_key %></p>
|
|
35
|
+
<% end %>
|
|
36
|
+
|
|
37
|
+
<% if @uploads_available %>
|
|
38
|
+
<div class="mt-4">
|
|
39
|
+
<%= button_to "Drop this image", admin_email_path(@key), method: :delete,
|
|
40
|
+
class: "btn btn-outline btn-sm",
|
|
41
|
+
form: { data: { turbo_confirm: "Delete the stored image for #{@key}? Nothing sends it." } } %>
|
|
42
|
+
</div>
|
|
43
|
+
<% end %>
|
|
44
|
+
</section>
|
|
45
|
+
</div>
|