studio-engine 0.41.0 → 0.43.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 +97 -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 +152 -6
- data/app/mailers/studio/newsletter_mailer.rb +47 -0
- data/app/mailers/user_mailer.rb +55 -1
- data/app/models/studio/email_setting.rb +206 -0
- data/app/services/studio/banner.rb +191 -0
- data/app/services/studio/email_catalog.rb +387 -19
- data/app/services/studio/email_preview_target.rb +195 -0
- data/app/views/layouts/branded_mailer.html.erb +55 -6
- data/app/views/studio/emails/_banner_editor.html.erb +199 -0
- data/app/views/studio/emails/_banner_preview.html.erb +62 -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 +64 -82
- data/app/views/studio/emails/index.html.erb +34 -8
- data/app/views/studio/emails/orphan.html.erb +45 -0
- data/app/views/studio/emails/show.html.erb +468 -34
- 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 +40 -0
- data/app/views/studio/newsletter_mailer/subscribed.text.erb +13 -0
- data/app/views/user_mailer/magic_link.html.erb +49 -12
- 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/db/migrate/20260813010000_add_body_cta_footer_to_studio_email_settings.rb +32 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +12 -0
- metadata +22 -2
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<%# Repaints EVERY row on the emails list when the example recipient changes.
|
|
2
|
+
|
|
3
|
+
The list shows each email's banner and subject as they would arrive. Both are
|
|
4
|
+
per-recipient — the banner greets by first name and the subject may too — so
|
|
5
|
+
a list rendered for one person is a list of half-truths about everyone else.
|
|
6
|
+
Changing the selection has to repaint all of them.
|
|
7
|
+
|
|
8
|
+
WHY THE TEMPLATES RIDE ON THE ROW
|
|
9
|
+
Each row carries its own header / fallback / subject templates as data
|
|
10
|
+
attributes, and this reads them from the DOM. The alternative was a second
|
|
11
|
+
JSON payload listing every email, which would have to be kept in step with
|
|
12
|
+
the rows themselves — and a row whose payload entry went missing would
|
|
13
|
+
silently stop repainting while still looking correct for whoever was selected
|
|
14
|
+
at page load.
|
|
15
|
+
|
|
16
|
+
The placeholder rule is the same one Studio::Banner applies server-side, and
|
|
17
|
+
the same one the detail page's editor applies. It is small, and it is pinned
|
|
18
|
+
on both sides. If they disagree, the server is right. %>
|
|
19
|
+
<script>
|
|
20
|
+
(function () {
|
|
21
|
+
function recipients(config) {
|
|
22
|
+
return {
|
|
23
|
+
targets: config.targets || [],
|
|
24
|
+
targetId: config.targetId,
|
|
25
|
+
pickerOpen: false,
|
|
26
|
+
|
|
27
|
+
init() {
|
|
28
|
+
this.paint();
|
|
29
|
+
this.$watch("targetId", () => this.paint());
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
target() {
|
|
33
|
+
return this.targets.find((t) => t.id === this.targetId) || this.targets[0] || {};
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
firstName() {
|
|
37
|
+
return (this.target().name || "").trim().split(/\s+/)[0] || "";
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
resolve(template, fallback) {
|
|
41
|
+
var first = this.firstName();
|
|
42
|
+
var app = config.appName || "";
|
|
43
|
+
var text = (template || "").replace(/\{app\}/g, app);
|
|
44
|
+
if (first) return text.replace(/\{name\}/g, first);
|
|
45
|
+
if (text.indexOf("{name}") !== -1) return (fallback || "").replace(/\{app\}/g, app);
|
|
46
|
+
return text;
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
// A subject has no second field for the nameless case, so an unresolved
|
|
50
|
+
// placeholder is removed along with the punctuation holding it — the
|
|
51
|
+
// same two passes Studio::Banner.interpolate makes, so "Sign in, {name}"
|
|
52
|
+
// reads "Sign in" here exactly as it would in an inbox.
|
|
53
|
+
resolveSubject(template) {
|
|
54
|
+
var app = config.appName || "";
|
|
55
|
+
var text = (template || "").replace(/\{app\}/g, app);
|
|
56
|
+
var first = this.firstName();
|
|
57
|
+
if (first) return text.replace(/\{name\}/g, first);
|
|
58
|
+
return text.replace(/[,;:—-]?\s*\{name\}/g, "")
|
|
59
|
+
.replace(/^\s*[,;:—-]\s*/, "")
|
|
60
|
+
.replace(/\s+/g, " ")
|
|
61
|
+
.trim();
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
// A row's banner lives in an IFRAME — the email's table is its own
|
|
65
|
+
// document so it cannot nest rows inside the list. Same-origin srcdoc,
|
|
66
|
+
// so its nodes are reachable; contentDocument is null until the frame
|
|
67
|
+
// has parsed, which is why the load listener is wired as well as the
|
|
68
|
+
// immediate attempt.
|
|
69
|
+
bannerNode(row, selector) {
|
|
70
|
+
var frame = row.querySelector("iframe[data-email-banner-preview]");
|
|
71
|
+
if (frame) {
|
|
72
|
+
try { return frame.contentDocument && frame.contentDocument.querySelector(selector); }
|
|
73
|
+
catch (_) { return null; }
|
|
74
|
+
}
|
|
75
|
+
return row.querySelector(selector);
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
paintRow(row) {
|
|
79
|
+
var header = this.bannerNode(row, "[data-banner-header]");
|
|
80
|
+
var subject = row.querySelector("[data-row-subject]");
|
|
81
|
+
if (header) {
|
|
82
|
+
header.textContent = this.resolve(row.dataset.header, row.dataset.headerFallback);
|
|
83
|
+
}
|
|
84
|
+
if (subject) subject.textContent = this.resolveSubject(row.dataset.subject);
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
paint() {
|
|
88
|
+
var self = this;
|
|
89
|
+
this.$root.querySelectorAll("[data-email-row]").forEach(function (row) {
|
|
90
|
+
self.paintRow(row);
|
|
91
|
+
var frame = row.querySelector("iframe[data-email-banner-preview]");
|
|
92
|
+
if (frame && !frame.dataset.repaintBound) {
|
|
93
|
+
frame.dataset.repaintBound = "1";
|
|
94
|
+
frame.addEventListener("load", function () { self.paintRow(row); });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// Anything interactive keeps its own behaviour; everything else opens
|
|
100
|
+
// the email. Without the closest() guard, clicking Upload would both
|
|
101
|
+
// open the cropper and navigate away from it.
|
|
102
|
+
openRow(event, row) {
|
|
103
|
+
// The listener sits on the tbody, so a click can land between rows and
|
|
104
|
+
// arrive with no row at all.
|
|
105
|
+
if (!row) return;
|
|
106
|
+
if (event.target.closest("a, button, input, label, form")) return;
|
|
107
|
+
var path = row.dataset.emailPath;
|
|
108
|
+
if (path) window.location = path;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
window.emailRecipients = recipients;
|
|
114
|
+
if (typeof Alpine !== "undefined") {
|
|
115
|
+
Alpine.data("emailRecipients", recipients);
|
|
116
|
+
} else {
|
|
117
|
+
document.addEventListener("alpine:init", function () {
|
|
118
|
+
Alpine.data("emailRecipients", recipients);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
})();
|
|
122
|
+
</script>
|
|
@@ -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,61 +8,76 @@
|
|
|
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
|
|
11
12
|
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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.
|
|
14
21
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 — a question
|
|
32
|
+
# background_url now answers by itself (it returns nil unless entry.layered?).
|
|
33
|
+
# The row used to carry a SECOND copy of that guard; when the guard moved down
|
|
34
|
+
# and grew the host-registered-background case, the stale copy started
|
|
35
|
+
# disagreeing with the mailer. One guard, one place, so they cannot drift.
|
|
36
|
+
layered = Studio::EmailCatalog.background_url(entry.key).present?
|
|
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
|
|
45
|
+
|
|
33
46
|
%>
|
|
34
|
-
<%#
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
successMessage: '<%= j entry.label %> now uses this app\'s own image.',
|
|
49
|
-
failure: 'Couldn\'t save the banner'
|
|
50
|
-
})"
|
|
51
|
-
@crop-photo-confirmed.window="onCropConfirmed($event.detail)"
|
|
52
|
-
<% end %>>
|
|
47
|
+
<%# THE WHOLE ROW OPENS THE EMAIL, and now that is the only thing it does. The
|
|
48
|
+
provenance badge and the upload button lived here and both moved to the
|
|
49
|
+
email's own page — one place where changes are made, rather than a row that
|
|
50
|
+
is half a control panel.
|
|
51
|
+
|
|
52
|
+
A click handler rather than a wrapping link: an anchor around a <tr> is
|
|
53
|
+
invalid HTML. The name stays a real <a> so it keeps middle-click and
|
|
54
|
+
open-in-new-tab. %>
|
|
55
|
+
<tr class="border-b border-subtle last:border-0 cursor-pointer hover:bg-inset/60" data-email-row
|
|
56
|
+
data-email-path="<%= admin_email_path(entry.key) %>"
|
|
57
|
+
data-header="<%= header_template %>"
|
|
58
|
+
data-header-fallback="<%= header_fallback %>"
|
|
59
|
+
data-subject="<%= subject_template %>"
|
|
60
|
+
>
|
|
53
61
|
|
|
54
62
|
<td class="px-4 py-4">
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
<% if banner %>
|
|
64
|
+
<%= render "studio/emails/banner_preview", banner: banner, ratio: entry.ratio, max_width: 224 %>
|
|
65
|
+
<% elsif banner_url %>
|
|
66
|
+
<div class="rounded-lg overflow-hidden border border-subtle"
|
|
67
|
+
style="aspect-ratio: <%= entry.ratio %>; max-width: 224px;">
|
|
58
68
|
<%= image_tag banner_url, class: "w-full h-full object-cover",
|
|
59
69
|
alt: "#{entry.label} email banner", loading: "lazy" %>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
</div>
|
|
71
|
+
<% else %>
|
|
72
|
+
<div class="rounded-lg overflow-hidden border border-subtle flex items-center justify-center text-2xs text-white/80 text-center px-3"
|
|
73
|
+
style="aspect-ratio: <%= entry.ratio %>; max-width: 224px; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
|
|
74
|
+
No banner
|
|
75
|
+
</div>
|
|
76
|
+
<% end %>
|
|
77
|
+
</td>
|
|
78
|
+
|
|
79
|
+
<td class="px-4 py-4">
|
|
80
|
+
<p class="text-sm text-heading" data-row-subject><%= subject %></p>
|
|
66
81
|
</td>
|
|
67
82
|
|
|
68
83
|
<td class="px-4 py-4">
|
|
@@ -78,37 +93,4 @@
|
|
|
78
93
|
<p class="font-mono text-2xs text-muted mt-1"><%= entry.key %></p>
|
|
79
94
|
</td>
|
|
80
95
|
|
|
81
|
-
<%# whitespace-nowrap keeps the pill a pill — "<App>'s own" is long enough to
|
|
82
|
-
wrap inside a bordered badge, which reads as a broken box next to the
|
|
83
|
-
short "Inherited default". %>
|
|
84
|
-
<td class="px-4 py-4">
|
|
85
|
-
<span class="<%= badge_class %> whitespace-nowrap"><%= badge_label %></span>
|
|
86
|
-
<p class="text-2xs text-muted mt-1.5 max-w-[12rem]"><%= badge_note %></p>
|
|
87
|
-
</td>
|
|
88
|
-
|
|
89
|
-
<% if uploads_available %>
|
|
90
|
-
<td class="px-4 py-4 text-right whitespace-nowrap">
|
|
91
|
-
<button type="button" @click="open()" class="btn btn-outline btn-sm inline-flex items-center gap-1.5">
|
|
92
|
-
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
93
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
94
|
-
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"/>
|
|
95
|
-
</svg>
|
|
96
|
-
<%= source == :app ? "Replace" : "Upload" %>
|
|
97
|
-
</button>
|
|
98
|
-
|
|
99
|
-
<%# Only an APP-OWNED image can be reverted — there is nothing to drop when
|
|
100
|
-
the row is already showing the inherited default. %>
|
|
101
|
-
<% if source == :app %>
|
|
102
|
-
<%= button_to "Revert", admin_email_path(entry.key), method: :delete,
|
|
103
|
-
class: "btn btn-neutral btn-sm mt-1.5 w-full",
|
|
104
|
-
form: { data: { turbo_confirm: "Drop #{Studio.app_name}'s own #{entry.label} banner and go back to the shared default?" } } %>
|
|
105
|
-
<% end %>
|
|
106
|
-
|
|
107
|
-
<%# The cropper drops its Blob into this hidden input and submits. %>
|
|
108
|
-
<%= form_with url: admin_email_path(entry.key), method: :patch,
|
|
109
|
-
html: { multipart: true, id: form_id, "x-ref": "form" } do %>
|
|
110
|
-
<%= file_field_tag :image, class: "hidden", "x-ref": "fileInput" %>
|
|
111
|
-
<% end %>
|
|
112
|
-
</td>
|
|
113
|
-
<% end %>
|
|
114
96
|
</tr>
|
|
@@ -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
|
-
|
|
104
|
+
<%# Banner, then what an inbox shows: the subject line, then the
|
|
105
|
+
email itself. Provenance and the upload button used to sit here;
|
|
106
|
+
both belong on the email's own page, which is one click away and
|
|
107
|
+
is where every other change is made. %>
|
|
108
|
+
<th class="label-upper px-4 py-3 w-64">Banner</th>
|
|
109
|
+
<th class="label-upper px-4 py-3 w-64">Subject</th>
|
|
83
110
|
<th class="label-upper px-4 py-3">Email</th>
|
|
84
|
-
<th class="label-upper px-4 py-3 w-52">Image</th>
|
|
85
|
-
<% if @uploads_available %>
|
|
86
|
-
<th class="label-upper px-4 py-3 w-40 text-right">Actions</th>
|
|
87
|
-
<% 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>
|