studio-engine 0.81.1 → 0.82.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.
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # This app's IDENTITY COPY: the title and description that say what the app is,
5
+ # and the picture that goes with them. The operator edits all three together
6
+ # at /admin/link_preview, beside a live preview card.
7
+ #
8
+ # Link previews are the first reader (every page unfurls with these unless it
9
+ # overrides them), but not the only one: the words are the app's reusable
10
+ # identity, so an app reads them through Studio.site_identity (or the view
11
+ # helper studio_site_identity) wherever it needs them — a meta description,
12
+ # share text, an email footer.
13
+ #
14
+ # One row per app (Studio.app_name), exactly like Studio::GeoSetting. Lifted
15
+ # from turf-monster's SiteSetting (default_og_image/title/description and the
16
+ # og_defaults cache), which ran it in production first.
17
+ #
18
+ # THE RESOLUTION, most specific first:
19
+ #
20
+ # operator's saved value (this row) -> the drafted default in code
21
+ # (Studio.site_title / Studio.site_description) -> Studio.app_name / none
22
+ #
23
+ # So an agent setting an app up drafts the words into the initializer, where
24
+ # they are reviewed in a PR, and the operator overrides them here without a
25
+ # deploy. `seed!` carries a drafted default into the row instead.
26
+ #
27
+ # NIL-SAFE THROUGHOUT, because the table is installed by a migration the host
28
+ # runs: an app that has not run it still renders every page.
29
+ class SiteIdentity < ApplicationRecord
30
+ include Sluggable
31
+
32
+ self.table_name = "studio_site_identities"
33
+
34
+ # Read on EVERY page render (the head's tags), edited a few times a year. So
35
+ # the stored values are cached, and busted on any write — see .stored.
36
+ CACHE_KEY_PREFIX = "studio/site_identity/v1"
37
+ CACHE_TTL = 1.hour
38
+
39
+ TITLE_MAX = 200
40
+ DESCRIPTION_MAX = 500
41
+
42
+ # What the image upload accepts. Here rather than on the controller so the
43
+ # page's file input can name it without reaching into a controller.
44
+ IMAGE_TYPES = %w[image/png image/jpeg image/webp image/gif].freeze
45
+ MAX_IMAGE_BYTES = 8 * 1024 * 1024
46
+
47
+ # The image. `service:` is a LITERAL fixed when this class loads, which is why
48
+ # Studio.link_preview_image_service must be set in the initializer. Guarded
49
+ # so a host without Active Storage still loads the class (and simply has no
50
+ # uploadable image).
51
+ if respond_to?(:has_one_attached)
52
+ if Studio.link_preview_image_service
53
+ has_one_attached :image, service: Studio.link_preview_image_service
54
+ else
55
+ has_one_attached :image
56
+ end
57
+ end
58
+
59
+ validates :app_name, presence: true, uniqueness: true
60
+ validates :title, length: { maximum: TITLE_MAX }
61
+ validates :description, length: { maximum: DESCRIPTION_MAX }
62
+
63
+ before_validation { self.app_name = Studio.app_name if app_name.blank? }
64
+
65
+ # A title/description write commits the row; an image attach does NOT touch
66
+ # it, so the controller busts explicitly after one (see bust_cache!).
67
+ after_commit { self.class.bust_cache! }
68
+
69
+ class << self
70
+ # This app's row, or an unsaved one — or nil when the table is not
71
+ # installed, because a model with no table cannot even be instantiated.
72
+ def current
73
+ return nil unless table_ready?
74
+
75
+ find_by(app_name: Studio.app_name) || new(app_name: Studio.app_name)
76
+ end
77
+
78
+ # This app's row, created on first write. The unique index makes a
79
+ # cold-start race raise RecordNotUnique for the loser; it re-reads.
80
+ def current!
81
+ find_or_create_by!(app_name: Studio.app_name)
82
+ rescue ActiveRecord::RecordNotUnique
83
+ find_by!(app_name: Studio.app_name)
84
+ end
85
+
86
+ # THE RESOLVED IDENTITY — what any caller should read:
87
+ #
88
+ # Studio::SiteIdentity.resolved(base_url: request.base_url)
89
+ # # => { title: "Turf Monster", description: "Skill-based pick'em...",
90
+ # # image_url: "https://.../og.png" }
91
+ #
92
+ # `image_url` is the operator's uploaded image, else the static fallback,
93
+ # else nil. It is absolute when it can be: a public service's own URL
94
+ # always is; a proxy path or a root-relative static path is joined to
95
+ # `base_url` when one is given, and left root-relative otherwise.
96
+ # Studio.site_identity is the same call.
97
+ def resolved(base_url: nil)
98
+ stored_values = stored
99
+ image = stored_values[:image_url] || stored_values[:image_path] || static_image
100
+ {
101
+ title: stored_values[:title] || Studio.site_title.presence || Studio.app_name.to_s,
102
+ description: stored_values[:description] || Studio.site_description.presence,
103
+ image_url: base_url ? Studio::LinkPreview.absolute_url(image, base_url: base_url) : image
104
+ }
105
+ end
106
+
107
+ # Only what the OPERATOR saved (nil where they saved nothing), with the
108
+ # image as a PERMANENT URL (a public service's own URL) or a host-relative
109
+ # PROXY path. Neither is a signed, expiring URL: an unfurler caches the
110
+ # og:image URL and re-fetches it days later. Cached; see CACHE_TTL.
111
+ #
112
+ # An app without the table is not cached as "nothing": the first request
113
+ # after the migration must see the table.
114
+ def stored
115
+ return empty_stored unless table_ready?
116
+
117
+ Rails.cache.fetch(cache_key_for_app, expires_in: CACHE_TTL) { compute_stored }
118
+ end
119
+
120
+ # Carry a DRAFTED default into the row, filling only what the operator has
121
+ # not already set — safe to run from db/seeds.rb or a release task on every
122
+ # deploy, because it never overwrites an operator's edit. Returns the row,
123
+ # or nil when the table is not installed yet.
124
+ #
125
+ # Studio::SiteIdentity.seed!(title: "Turf Monster",
126
+ # description: "Skill-based pick'em contests.")
127
+ def seed!(title: nil, description: nil)
128
+ return nil unless table_ready?
129
+
130
+ row = current!
131
+ row.title = title if row.title.blank? && title.present?
132
+ row.description = description if row.description.blank? && description.present?
133
+ row.save! if row.changed?
134
+ row
135
+ end
136
+
137
+ def bust_cache!
138
+ Rails.cache.delete(cache_key_for_app)
139
+ rescue StandardError
140
+ nil
141
+ end
142
+
143
+ def cache_key_for_app
144
+ "#{CACHE_KEY_PREFIX}/#{Studio.app_name.to_s.parameterize}"
145
+ end
146
+
147
+ def table_ready?
148
+ table_exists?
149
+ rescue ActiveRecord::ActiveRecordError, NameError
150
+ false
151
+ end
152
+
153
+ # Whether an attachment's service answers a permanent public URL. A
154
+ # MirrorService never sets public? itself but delegates `url` to its
155
+ # primary, so ask the primary (turf-monster's R2 move depends on this).
156
+ def public_service?(service)
157
+ (service.respond_to?(:primary) ? service.primary : service).public?
158
+ rescue StandardError
159
+ false
160
+ end
161
+
162
+ # A permanent URL, or a host-relative proxy path, for any attachment or
163
+ # blob. Shared by the operator's image and a page override that hands in
164
+ # an attachment (a user's avatar), so both follow one rule.
165
+ def image_location(attachable)
166
+ return nil if attachable.nil?
167
+ return nil if attachable.respond_to?(:attached?) && !attachable.attached?
168
+
169
+ blob = attachable.respond_to?(:blob) ? attachable.blob : attachable
170
+ return nil if blob.nil?
171
+
172
+ if public_service?(blob.service)
173
+ { url: blob.url }
174
+ else
175
+ { path: Rails.application.routes.url_helpers.rails_storage_proxy_path(blob, only_path: true) }
176
+ end
177
+ end
178
+
179
+ # The app's own static card (public/og.png by default), used only when the
180
+ # file is really there — a missing one would be a broken image in every
181
+ # unfurl. Absolute URLs are trusted as given. Memoized per path for the
182
+ # life of the process: public/ is fixed at deploy.
183
+ def static_image
184
+ path = Studio.link_preview_fallback_image.to_s
185
+ return nil if path.empty?
186
+ return path unless path.start_with?("/") && !path.start_with?("//")
187
+
188
+ @static_files ||= Concurrent::Map.new
189
+ present = @static_files.compute_if_absent(path) do
190
+ File.file?(Rails.public_path.join(path.delete_prefix("/")))
191
+ end
192
+ present ? path : nil
193
+ end
194
+
195
+ def reset_static_image!
196
+ @static_files = nil
197
+ end
198
+
199
+ private
200
+
201
+ def empty_stored
202
+ { title: nil, description: nil, image_url: nil, image_path: nil }
203
+ end
204
+
205
+ def compute_stored
206
+ row = find_by(app_name: Studio.app_name)
207
+ return empty_stored if row.nil?
208
+
209
+ location = row.respond_to?(:image) ? image_location(row.image) : nil
210
+ {
211
+ title: row.title.presence,
212
+ description: row.description.presence,
213
+ image_url: location&.dig(:url),
214
+ image_path: location&.dig(:path)
215
+ }
216
+ end
217
+ end
218
+
219
+ def image_attached?
220
+ respond_to?(:image) && image.attached?
221
+ end
222
+
223
+ def name_slug
224
+ "site-identity-#{app_name.to_s.parameterize}"
225
+ end
226
+ end
227
+ end
@@ -1,10 +1,12 @@
1
1
  <%# Avatar partial — shows attached image or colored initials circle
2
- Locals: user (required), size: "sm" | "md" (default) | "lg" %>
2
+ Locals: user (required), size: "sm" | "nav" | "md" (default) | "lg" | "xl",
3
+ alt: the image's alt text (default user.display_name) %>
3
4
  <% size ||= "md" %>
4
- <% size_classes = { "sm" => "w-6 h-6 text-xs", "nav" => "w-8 h-8 text-xs", "md" => "w-10 h-10 text-sm", "lg" => "w-14 h-14 text-lg" }[size] %>
5
+ <% alt = local_assigns[:alt].presence || user.display_name %>
6
+ <% size_classes = { "sm" => "w-6 h-6 text-xs", "nav" => "w-8 h-8 text-xs", "md" => "w-10 h-10 text-sm", "lg" => "w-14 h-14 text-lg", "xl" => "w-28 h-28 text-4xl" }[size] %>
5
7
 
6
8
  <% if user.avatar.attached? %>
7
- <%= image_tag user.avatar, class: "#{size_classes.split.first(2).join(' ')} rounded-full object-cover", alt: user.display_name %>
9
+ <%= image_tag user.avatar, class: "#{size_classes.split.first(2).join(' ')} rounded-full object-cover", alt: alt %>
8
10
  <% else %>
9
11
  <div class="<%= size_classes %> rounded-full flex items-center justify-center font-bold text-white"
10
12
  style="background-color: <%= user.avatar_color %>">
@@ -15,6 +15,12 @@
15
15
  <%= render "studio/session_stamp" %>
16
16
  <%= render "layouts/studio/smooth_load" %>
17
17
  <link rel="icon" type="image/png" href="/favicon.png">
18
+ <%# Link-preview (og:/twitter:) tags: the page's link_preview override, else the
19
+ operator default from /admin/link_preview, else the static fallback. Gated by
20
+ Studio.link_preview_tags? — under the default :auto, only once this app has
21
+ installed the studio_site_identities table, so an app that emits its
22
+ own tags gets no second set. See docs/LINK_PREVIEW.md. %>
23
+ <%= studio_link_preview_tags if respond_to?(:studio_link_preview_tags) && Studio.link_preview_tags? %>
18
24
 
19
25
  <%= yield :head %>
20
26
 
@@ -0,0 +1,26 @@
1
+ <%# locals: (preview:) — the Open Graph + Twitter tags an unfurl reads
2
+ (iMessage, Slack, Discord, X, WhatsApp...). `preview` is
3
+ Studio::LinkPreviewHelper#studio_link_preview: the page override, else the
4
+ operator default from /admin/link_preview, else the static fallback.
5
+ Lifted from turf-monster's layouts/_link_preview_meta. og:image is absolute
6
+ and permanent; the tag is omitted rather than emitted empty. %>
7
+ <meta property="og:site_name" content="<%= preview[:site_name] %>">
8
+ <meta property="og:type" content="website">
9
+ <meta property="og:title" content="<%= preview[:title] %>">
10
+ <% if preview[:description] %>
11
+ <meta property="og:description" content="<%= preview[:description] %>">
12
+ <% end %>
13
+ <% if preview[:url] %>
14
+ <meta property="og:url" content="<%= preview[:url] %>">
15
+ <% end %>
16
+ <% if preview[:image] %>
17
+ <meta property="og:image" content="<%= preview[:image] %>">
18
+ <% end %>
19
+ <meta name="twitter:card" content="<%= preview[:image] ? "summary_large_image" : "summary" %>">
20
+ <meta name="twitter:title" content="<%= preview[:title] %>">
21
+ <% if preview[:description] %>
22
+ <meta name="twitter:description" content="<%= preview[:description] %>">
23
+ <% end %>
24
+ <% if preview[:image] %>
25
+ <meta name="twitter:image" content="<%= preview[:image] %>">
26
+ <% end %>
@@ -0,0 +1,15 @@
1
+ <%# The friendly 404 for /u/:username. Every miss renders this same page
2
+ (unknown, hidden by the host, or an app with no username column), so it
3
+ says nothing about why. Served with status 404. %>
4
+ <% content_for(:title, "User not found") %>
5
+
6
+ <div class="max-w-md mx-auto px-4 py-12" data-public-user-not-found>
7
+ <div class="card p-8 text-center">
8
+ <h1 class="text-xl font-bold text-heading mb-2">No one goes by that name here</h1>
9
+ <p class="text-secondary text-sm mb-6 break-all">
10
+ We couldn't find a user called <span class="font-semibold text-body"><%= @requested_username %></span>.
11
+ Check the spelling, or head back home.
12
+ </p>
13
+ <%= link_to "Go home", "/", class: "btn btn-primary" %>
14
+ </div>
15
+ </div>
@@ -0,0 +1,28 @@
1
+ <%# /u/:username — the public user page (Studio::PublicUsersController).
2
+
3
+ AVATAR AND USERNAME, NOTHING ELSE (operator's call, 2026-09-30). No name,
4
+ email or wallet, and the image alt is the username for the same reason:
5
+ _avatar's default alt is display_name, which a host may build from a name.
6
+
7
+ The preview is the avatar when one is attached; `link_preview image:` treats
8
+ an empty attachment as a blank rung, so an account with no picture unfurls
9
+ with the site identity's image. The avatar URL is the primitive's permanent
10
+ one (public service URL or the storage proxy), never a signed expiring URL.
11
+ The description is left to the site identity. %>
12
+ <% username = @public_user.username.to_s %>
13
+ <% content_for(:title, username) %>
14
+ <% link_preview image: @public_user.avatar, title: username %>
15
+
16
+ <div class="max-w-md mx-auto px-4 py-12" data-public-user="<%= username %>">
17
+ <div class="card p-8 flex flex-col items-center text-center gap-4">
18
+ <% if @public_user.avatar.attached? %>
19
+ <%= render "components/avatar", user: @public_user, size: "xl", alt: username %>
20
+ <% else %>
21
+ <%# Drawn from the username alone: a host's avatar_initials / avatar_color may read the name or email (cyvasse's do). %>
22
+ <% colors = Studio::UserProfile::AVATAR_COLORS %>
23
+ <div class="w-28 h-28 text-4xl rounded-full flex items-center justify-center font-bold text-white"
24
+ style="background-color: <%= colors[Digest::MD5.hexdigest(username).hex % colors.size] %>"><%= username[0].upcase %></div>
25
+ <% end %>
26
+ <h1 class="text-2xl font-bold text-heading break-all" data-public-user-username><%= username %></h1>
27
+ </div>
28
+ </div>
@@ -0,0 +1,233 @@
1
+ <%# /admin/link_preview — the operator edits the SITE IDENTITY
2
+ (Studio::SiteIdentity): the image, title and description an unfurl
3
+ (iMessage, Slack, Discord, X...) shows for any page of this app that does
4
+ not name its own, and the copy Studio.site_identity hands any other reader
5
+ (a meta description, share text, an email footer). Three parts,
6
+ in the order an operator asks: what does a link look like now, what picture
7
+ does it carry, and what words.
8
+
9
+ A bare content wrapper, like /admin/geo and /admin/emails, so it renders in
10
+ the host's layout. The crop and saving modals mount on a PAGE-SCOPED host
11
+ (linkPreviewModals), so the uploader works in an app that renders no shared
12
+ modal host at all — the /admin/emails pattern. %>
13
+ <% content_for(:title) { "Link Preview" } %>
14
+ <%
15
+ setting = @setting
16
+ fallback_title = Studio.site_title.presence || Studio.app_name.to_s
17
+ fallback_desc = Studio.site_description.to_s
18
+ card_title = setting&.title.presence || fallback_title
19
+ card_desc = setting&.description.presence || fallback_desc
20
+ card_image = @default_image_url || @static_image_url
21
+ image_source =
22
+ if @default_image_url then "the uploaded default"
23
+ elsif @static_image_url then "the app's static #{Studio.link_preview_fallback_image}"
24
+ else nil
25
+ end
26
+ tags_on = Studio.link_preview_tags?
27
+ %>
28
+ <style>
29
+ /* The unfurl card. Plain CSS rather than utilities, so no host build has to
30
+ scan this gem for these names. Drawn the way iMessage and Slack draw a
31
+ large-image card: the picture at 1.91:1, then the domain, title and
32
+ description underneath. */
33
+ .lp-card {
34
+ max-width: 440px;
35
+ border-radius: 14px;
36
+ overflow: hidden;
37
+ border: 1px solid rgb(148 163 184 / 0.3);
38
+ background: rgb(148 163 184 / 0.08);
39
+ }
40
+ .lp-card-image {
41
+ aspect-ratio: 1200 / 630;
42
+ background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));
43
+ display: flex;
44
+ align-items: center;
45
+ justify-content: center;
46
+ }
47
+ .lp-card-image img { width: 100%; height: 100%; object-fit: cover; display: block; }
48
+ .lp-card-body { padding: 10px 14px 12px; }
49
+ .lp-card-domain { font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; opacity: 0.7; }
50
+ .lp-card-title { font-weight: 700; line-height: 1.3; margin-top: 2px; }
51
+ .lp-card-desc {
52
+ font-size: 13px; line-height: 1.4; opacity: 0.8; margin-top: 2px;
53
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
54
+ }
55
+ .lp-card-desc:empty { display: none; }
56
+ </style>
57
+
58
+ <%# x-data on the root is load-bearing: studio/modals/scoped_host declares no
59
+ Alpine scope of its own, so without one here the crop modal's store opens
60
+ and its dialog never appears in a host whose body carries no x-data
61
+ (e2e/site_identity.spec.js). %>
62
+ <div class="max-w-3xl mx-auto" x-data data-link-preview-page
63
+ data-fallback-title="<%= fallback_title %>"
64
+ data-fallback-description="<%= fallback_desc %>">
65
+ <h1 class="text-2xl font-extrabold text-heading mb-2">Link Preview</h1>
66
+ <p class="text-sm text-muted mb-6">
67
+ What a link to <%= Studio.app_name %> shows when it is pasted into Messages, Slack, Discord or X.
68
+ Any page can override it with its own picture and words (a contest banner, a profile photo);
69
+ everything else uses this default.
70
+ </p>
71
+
72
+ <% unless @installed %>
73
+ <div class="card p-6 mb-6" data-link-preview-not-installed>
74
+ <h2 class="text-lg font-bold text-heading mb-2">Not installed yet</h2>
75
+ <p class="text-sm text-body mb-3">
76
+ This app has not installed the site identity table, so there is nothing to edit yet and the
77
+ engine's preview tags are <%= tags_on ? "on, using the static fallback" : "off" %>. Install it with:
78
+ </p>
79
+ <pre class="bg-inset rounded-lg p-3 text-xs font-mono overflow-x-auto">bin/rails g studio:site_identity &amp;&amp; bin/rails db:migrate</pre>
80
+ <p class="text-xs text-muted mt-3">
81
+ An app that already writes its own og tags sets <span class="font-mono">config.link_preview_tags = false</span>
82
+ first. See docs/LINK_PREVIEW.md in studio-engine.
83
+ </p>
84
+ </div>
85
+ <% end %>
86
+
87
+ <%# -- The card, as an unfurl draws it ------------------------------------ %>
88
+ <div class="card p-6 mb-6">
89
+ <h2 class="text-lg font-bold text-heading mb-4">Preview</h2>
90
+ <div class="lp-card" data-link-preview-card>
91
+ <div class="lp-card-image">
92
+ <% if card_image %>
93
+ <img src="<%= card_image %>" alt="" data-link-preview-card-image>
94
+ <% else %>
95
+ <span class="text-xs text-white text-center px-4" data-link-preview-card-no-image>No image — links preview without a picture</span>
96
+ <% end %>
97
+ </div>
98
+ <div class="lp-card-body">
99
+ <div class="lp-card-domain" data-link-preview-card-domain><%= @domain %></div>
100
+ <div class="lp-card-title text-heading" data-link-preview-card-title><%= card_title %></div>
101
+ <div class="lp-card-desc text-body" data-link-preview-card-description><%= card_desc %></div>
102
+ </div>
103
+ </div>
104
+ <p class="text-xs text-muted mt-3">
105
+ <% if image_source %>
106
+ The picture is <%= image_source %>.
107
+ <% else %>
108
+ No default picture yet. Upload one below, or add <span class="font-mono">public/og.png</span> (1200 × 630).
109
+ <% end %>
110
+ <% unless tags_on %>
111
+ The engine's preview tags are <strong>off</strong> in this app
112
+ (<span class="font-mono">Studio.link_preview_tags</span>), so pages use the app's own tags<% if (own = Studio.link_preview_own_tags_file) %>, which
113
+ <span class="font-mono"><%= own.to_s.delete_prefix("#{Rails.root}/") %></span> writes<% end %>.
114
+ <% end %>
115
+ </p>
116
+ </div>
117
+
118
+ <% if @installed %>
119
+ <%# -- The picture -------------------------------------------------------- %>
120
+ <div class="card p-6 mb-6"
121
+ <% if @uploads_available %>
122
+ x-data="imageUploadHost({
123
+ store: 'linkPreviewModals',
124
+ aspectRatio: <%= 1200.0 / 630 %>,
125
+ maxWidth: 1200,
126
+ transparent: false,
127
+ filename: 'link-preview.png',
128
+ saving: 'Saving image…',
129
+ success: 'Image updated',
130
+ successMessage: 'Your new link-preview image is live.',
131
+ failure: 'Couldn\'t save the image'
132
+ })"
133
+ @crop-photo-confirmed.window="onCropConfirmed($event.detail)"
134
+ <% end %>>
135
+ <h2 class="text-lg font-bold text-heading mb-2">Image</h2>
136
+ <% if @uploads_available %>
137
+ <p class="text-sm text-muted mb-4">
138
+ Cropped to 1200 × 630, the shape every unfurl uses. It is served from a permanent URL, because
139
+ apps cache the picture and fetch it again days later.
140
+ </p>
141
+ <div class="flex flex-wrap gap-3 items-center">
142
+ <button type="button" @click="open()" class="btn btn-primary btn-sm" data-link-preview-upload>
143
+ <%= setting.image_attached? ? "Replace image" : "Upload image" %>
144
+ </button>
145
+ <% if setting.persisted? && setting.image_attached? %>
146
+ <%= button_to "Remove image", admin_link_preview_image_path, method: :delete,
147
+ class: "btn btn-outline btn-sm",
148
+ form: { data: { turbo_confirm: "Remove the default link-preview image?" } } %>
149
+ <% end %>
150
+ </div>
151
+ <%# The upload host writes the cropped file into THIS input and submits it
152
+ (see studio/emails/show for why the pair is load-bearing). %>
153
+ <%= form_with url: admin_link_preview_path, method: :patch,
154
+ html: { multipart: true, id: "link-preview-image-form", "x-ref": "form" } do %>
155
+ <%= file_field_tag "site_identity[image]", class: "hidden", "x-ref": "fileInput",
156
+ accept: Studio::SiteIdentity::IMAGE_TYPES.join(",") %>
157
+ <% end %>
158
+ <% else %>
159
+ <p class="text-sm text-muted">
160
+ Uploading needs Active Storage, which this app does not load. Pages still use the static
161
+ <span class="font-mono"><%= Studio.link_preview_fallback_image %></span>.
162
+ </p>
163
+ <% end %>
164
+ </div>
165
+
166
+ <%# -- The words --------------------------------------------------------- %>
167
+ <%# scope: :site_identity is REQUIRED: a namespaced model's param key
168
+ is studio_site_identity, which the controller never reads. %>
169
+ <%= form_with(model: setting, scope: :site_identity, url: admin_link_preview_path,
170
+ method: :patch, local: true) do |f| %>
171
+ <div class="card p-6 mb-6">
172
+ <h2 class="text-lg font-bold text-heading mb-1">Site title and description</h2>
173
+ <p class="text-sm text-muted mb-4">
174
+ What <%= Studio.app_name %> is, in its own words. Link previews use them, and so can the rest
175
+ of the app (<span class="font-mono">Studio.site_identity</span>): a meta description, share
176
+ text, an email footer.
177
+ </p>
178
+ <div class="mb-4">
179
+ <%= f.label :title, "Title", class: "block text-sm font-bold text-heading mb-1" %>
180
+ <%= f.text_field :title, class: "input w-full", placeholder: fallback_title,
181
+ maxlength: Studio::SiteIdentity::TITLE_MAX, data: { link_preview_input: "title" } %>
182
+ <p class="text-xs text-muted mt-1">Blank uses the drafted default: <%= fallback_title %>.</p>
183
+ </div>
184
+ <div>
185
+ <%= f.label :description, "Description", class: "block text-sm font-bold text-heading mb-1" %>
186
+ <%= f.text_area :description, rows: 3, class: "input w-full", placeholder: fallback_desc.presence,
187
+ maxlength: Studio::SiteIdentity::DESCRIPTION_MAX, data: { link_preview_input: "description" } %>
188
+ <p class="text-xs text-muted mt-1">One or two sentences. Unfurls show about two lines.</p>
189
+ </div>
190
+ </div>
191
+ <div class="flex justify-end mb-6">
192
+ <%= f.submit "Save", class: "btn btn-primary" %>
193
+ </div>
194
+ <% end %>
195
+
196
+ <% if @uploads_available %>
197
+ <%= render "studio/cropper_assets" %>
198
+ <%# Optional chaining on current() is load-bearing: the outer template
199
+ unmounts one tick AFTER the stack empties (see studio/emails/index). %>
200
+ <%= render "studio/modals/scoped_host", store: "linkPreviewModals" do %>
201
+ <template x-if="$store.linkPreviewModals.current()?.id === 'crop-photo'">
202
+ <div><%= render "studio/modals/crop_photo", store: "linkPreviewModals" %></div>
203
+ </template>
204
+ <template x-if="$store.linkPreviewModals.current()?.id === 'saving'">
205
+ <div><%= render "studio/modals/saving", store: "linkPreviewModals" %></div>
206
+ </template>
207
+ <% end %>
208
+ <% end %>
209
+ <% end %>
210
+ </div>
211
+
212
+ <script>
213
+ // LIVE CARD. Typing in the title or description repaints the card before
214
+ // anything is saved, falling back to what a blank field would really send.
215
+ (function () {
216
+ var page = document.querySelector("[data-link-preview-page]");
217
+ if (!page) return;
218
+ var fallbacks = {
219
+ title: page.dataset.fallbackTitle || "",
220
+ description: page.dataset.fallbackDescription || ""
221
+ };
222
+ function paint(input) {
223
+ var key = input.dataset.linkPreviewInput;
224
+ var target = page.querySelector("[data-link-preview-card-" + key + "]");
225
+ if (!target) return;
226
+ var value = input.value.trim();
227
+ target.textContent = value || fallbacks[key];
228
+ }
229
+ page.querySelectorAll("[data-link-preview-input]").forEach(function (input) {
230
+ input.addEventListener("input", function () { paint(input); });
231
+ });
232
+ })();
233
+ </script>
@@ -0,0 +1,30 @@
1
+ # The SITE IDENTITY for one app: the title, description and image that say what
2
+ # the app is. An unfurl (iMessage, Slack, Discord, X...) shows them for any page
3
+ # that does not name its own, and Studio.site_identity hands the same copy to any
4
+ # other reader. The operator edits them at /admin/link_preview.
5
+ #
6
+ # One row per app (Studio.app_name), like studio_geo_settings. The IMAGE is not a
7
+ # column: it is an Active Storage attachment (Studio::SiteIdentity#image),
8
+ # which rides the host's active_storage_* tables.
9
+ #
10
+ # INSTALLING THIS TABLE TURNS THE ENGINE'S HEAD TAGS ON under the default
11
+ # Studio.link_preview_tags = :auto — unless a template under the app's
12
+ # app/views writes its own og:title/og:image, in which case :auto stays off
13
+ # until the app deletes them (or sets link_preview_tags = true).
14
+ class CreateStudioSiteIdentities < ActiveRecord::Migration[7.2]
15
+ def change
16
+ create_table :studio_site_identities do |t|
17
+ t.string :app_name, null: false
18
+ t.string :title
19
+ t.text :description
20
+
21
+ # Sluggable, like every other Studio settings row.
22
+ t.string :slug
23
+
24
+ t.timestamps
25
+ end
26
+
27
+ add_index :studio_site_identities, :app_name, unique: true
28
+ add_index :studio_site_identities, :slug, unique: true
29
+ end
30
+ end