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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/Gemfile +6 -0
  4. data/README.md +55 -1
  5. data/app/assets/images/emails/logo-horizontal.png +0 -0
  6. data/app/assets/images/emails/magic-link-background.gif +0 -0
  7. data/app/assets/images/emails/newsletter-subscribed-background.gif +0 -0
  8. data/app/controllers/studio/emails_controller.rb +136 -6
  9. data/app/mailers/studio/newsletter_mailer.rb +43 -0
  10. data/app/mailers/user_mailer.rb +48 -1
  11. data/app/models/studio/email_setting.rb +131 -0
  12. data/app/services/studio/banner.rb +186 -0
  13. data/app/services/studio/email_catalog.rb +248 -19
  14. data/app/services/studio/email_preview_target.rb +195 -0
  15. data/app/views/layouts/branded_mailer.html.erb +23 -6
  16. data/app/views/studio/emails/_banner_editor.html.erb +192 -0
  17. data/app/views/studio/emails/_banner_scale.html.erb +56 -0
  18. data/app/views/studio/emails/_recipient_picker.html.erb +63 -0
  19. data/app/views/studio/emails/_recipient_repaint.html.erb +122 -0
  20. data/app/views/studio/emails/_row.html.erb +68 -4
  21. data/app/views/studio/emails/index.html.erb +30 -4
  22. data/app/views/studio/emails/orphan.html.erb +45 -0
  23. data/app/views/studio/emails/show.html.erb +364 -31
  24. data/app/views/studio/mailers/_layered_banner.html.erb +136 -0
  25. data/app/views/studio/modals/_image_upload.html.erb +55 -3
  26. data/app/views/studio/newsletter_mailer/subscribed.html.erb +33 -0
  27. data/app/views/studio/newsletter_mailer/subscribed.text.erb +13 -0
  28. data/app/views/user_mailer/magic_link.html.erb +13 -4
  29. data/db/migrate/20260812000000_create_studio_email_settings.rb +26 -0
  30. data/db/migrate/20260812210000_add_copy_to_studio_email_settings.rb +32 -0
  31. data/db/migrate/20260812220000_add_subject_to_studio_email_settings.rb +15 -0
  32. data/lib/studio/version.rb +1 -1
  33. data/lib/studio.rb +12 -0
  34. data/studio-engine.gemspec +5 -1
  35. 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
- <tr class="border-b border-subtle last:border-0"
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-40"
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 banner_url %>
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 &middot; 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-48">Banner</th>
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>