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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +114 -0
- data/README.md +2 -0
- data/app/controllers/concerns/studio/link_preview_bots.rb +67 -0
- data/app/controllers/studio/public_users_controller.rb +32 -0
- data/app/controllers/studio/site_identities_controller.rb +107 -0
- data/app/helpers/studio/link_preview_helper.rb +113 -0
- data/app/helpers/studio/public_user_helper.rb +61 -0
- data/app/models/studio/site_identity.rb +227 -0
- data/app/views/components/_avatar.html.erb +5 -3
- data/app/views/layouts/studio/_head.html.erb +6 -0
- data/app/views/layouts/studio/_link_preview_tags.html.erb +26 -0
- data/app/views/studio/public_users/not_found.html.erb +15 -0
- data/app/views/studio/public_users/show.html.erb +28 -0
- data/app/views/studio/site_identities/edit.html.erb +233 -0
- data/db/migrate/20260930120000_create_studio_site_identities.rb +30 -0
- data/lib/generators/studio/site_identity/site_identity_generator.rb +135 -0
- data/lib/studio/link_preview.rb +244 -0
- data/lib/studio/public_user.rb +65 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +140 -0
- metadata +15 -1
|
@@ -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
|
-
<%
|
|
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:
|
|
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 && 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
|