studio-engine 0.83.0 → 0.85.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 +126 -0
- data/README.md +157 -3
- data/app/controllers/magic_links_controller.rb +1 -1
- data/app/controllers/registrations_controller.rb +1 -1
- data/app/helpers/studio/site_footer_helper.rb +18 -10
- data/app/views/registrations/new.html.erb +1 -1
- data/app/views/sessions/_sso_continue.html.erb +1 -1
- data/app/views/sessions/new.html.erb +6 -3
- data/app/views/studio/_confirm_interstitial.html.erb +1 -1
- data/app/views/studio/booking/_assets.html.erb +43 -9
- data/app/views/studio/booking/_frame.html.erb +12 -9
- data/app/views/studio/bookings/show.html.erb +4 -2
- data/app/views/studio/site_footer/_assets.html.erb +33 -10
- data/app/views/studio/site_footer/_footer.html.erb +4 -3
- data/app/views/studio/site_footer/_link.html.erb +3 -1
- data/app/views/style/_site_footer.html.erb +35 -12
- data/lib/active_storage/service/studio_trash_s3_service.rb +64 -0
- data/lib/studio/auth_labels.rb +72 -0
- data/lib/studio/booking.rb +164 -4
- data/lib/studio/s3/trash.rb +248 -0
- data/lib/studio/s3.rb +56 -3
- data/lib/studio/site_footer.rb +63 -7
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +45 -5
- data/lib/tasks/studio_trash.rake +83 -0
- metadata +6 -2
|
@@ -11,20 +11,43 @@
|
|
|
11
11
|
border-top: 1px solid var(--ftr-line); }
|
|
12
12
|
.ftr a { transition: color .15s ease, opacity .15s ease; }
|
|
13
13
|
.ftr-wrap { max-width: 72rem; margin-inline: auto; padding-inline: 1rem; }
|
|
14
|
-
.ftr-link { color: inherit; opacity: .78; text-decoration: none;
|
|
14
|
+
.ftr-link { color: inherit; opacity: .78; text-decoration: none; }
|
|
15
|
+
/* A label that is an address (an email address, a URL) stays on one line: a browser
|
|
16
|
+
would otherwise wrap it after a hyphen or a slash. Its track is at least as
|
|
17
|
+
wide as it is (the grid below), and the tracks beside it give way. It is
|
|
18
|
+
broken only on a screen narrower than any phone, where no layout holds it. */
|
|
19
|
+
.ftr-link-solid { white-space: nowrap; }
|
|
20
|
+
@media (max-width: 299px) { .ftr-link-solid { white-space: normal; overflow-wrap: anywhere; } }
|
|
15
21
|
.ftr-link-disabled { opacity: .38; cursor: not-allowed; }
|
|
16
22
|
.ftr-link-plain { opacity: .78; }
|
|
17
23
|
.ftr-link:hover, .ftr-link:focus-visible { opacity: 1; color: var(--ftr-primary); }
|
|
18
24
|
|
|
19
|
-
/* Brand, then the link columns.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
/* Brand, then the link columns (.ftr-col), on a grid with explicit tracks.
|
|
26
|
+
below 768px: the brand across the top, the link columns two to a row
|
|
27
|
+
from 768px: the brand across the top, every link column in one row
|
|
28
|
+
from 1024px: the brand in that row too, at 1.7 shares
|
|
29
|
+
A track is `Nfr`, never `minmax(0, Nfr)`: an fr track is at least as wide as
|
|
30
|
+
its longest unbreakable word, so the column holding an email address grows
|
|
31
|
+
to hold it and its neighbours give way. Nothing moves to another row.
|
|
32
|
+
--ftr-tracks is one track per link column (Studio::SiteFooter.tracks): the
|
|
33
|
+
first is wider by default, and a column's `width:` hint sets its own.
|
|
34
|
+
A footer with more than four link columns (.ftr-cols-many) keeps the brand
|
|
35
|
+
across the top and wraps the columns four to a row. */
|
|
36
|
+
.ftr-cols { display: grid; gap: 3rem 2rem; padding-block: 4rem; grid-template-columns: 1.2fr 1fr; }
|
|
23
37
|
.ftr-brand { grid-column: 1 / -1; }
|
|
24
|
-
|
|
38
|
+
/* The smallest phones (320px): two columns still share a row beside an email
|
|
39
|
+
address, by a narrower gutter and a slightly smaller address. */
|
|
40
|
+
@media (max-width: 359px) {
|
|
41
|
+
.ftr-cols { column-gap: 1rem; }
|
|
42
|
+
.ftr-col .ftr-link-solid { font-size: .875rem; }
|
|
43
|
+
}
|
|
44
|
+
@media (min-width: 768px) {
|
|
45
|
+
.ftr-cols { grid-template-columns: var(--ftr-tracks, 1fr); }
|
|
46
|
+
.ftr-cols-many { grid-template-columns: repeat(4, 1fr); }
|
|
47
|
+
}
|
|
25
48
|
@media (min-width: 1024px) {
|
|
26
|
-
.ftr-cols { grid-template-columns:
|
|
27
|
-
.ftr-brand { grid-column: auto; }
|
|
49
|
+
.ftr-cols:not(.ftr-cols-many) { grid-template-columns: 1.7fr var(--ftr-tracks,); }
|
|
50
|
+
.ftr-cols:not(.ftr-cols-many) .ftr-brand { grid-column: auto; }
|
|
28
51
|
}
|
|
29
52
|
.ftr-home { display: inline-flex; align-items: center; gap: .75rem; color: inherit; text-decoration: none; }
|
|
30
53
|
.ftr-logo { width: 3rem; height: 3rem; object-fit: contain; }
|
|
@@ -47,9 +70,9 @@
|
|
|
47
70
|
.ftr-list { display: grid; gap: 1rem; margin: 0; padding: 0; list-style: none; }
|
|
48
71
|
|
|
49
72
|
.ftr-location { border-top: 1px solid var(--ftr-line); padding-block: 3.5rem 2rem; text-align: center; }
|
|
50
|
-
.ftr-location-title { margin: 0; font-size: 1.875rem; font-weight: 800; letter-spacing: -.025em; color: var(--ftr-ink); }
|
|
73
|
+
.ftr-location-title { margin: 0; font-size: 1.875rem; line-height: 2.25rem; font-weight: 800; letter-spacing: -.025em; color: var(--ftr-ink); }
|
|
51
74
|
.ftr-address { margin-top: 1rem; font-style: normal; line-height: 1.625; }
|
|
52
|
-
.ftr-legal { padding-block: 2rem; text-align: center; font-size: .875rem; }
|
|
75
|
+
.ftr-legal { padding-block: 2rem; text-align: center; font-size: .875rem; line-height: 1.25rem; }
|
|
53
76
|
.ftr-legal p { margin: 0; }
|
|
54
77
|
.ftr-legal .ftr-copyright { margin-top: .5rem; color: var(--color-text-muted, inherit); }
|
|
55
78
|
/* With no map between them, a rule separates the legal line from what is above. */
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
<%= studio_site_footer_assets %>
|
|
14
14
|
<footer class="ftr" data-site-footer>
|
|
15
15
|
<div class="ftr-wrap">
|
|
16
|
-
|
|
16
|
+
<% tracks = Studio::SiteFooter.tracks(columns) %>
|
|
17
|
+
<div class="ftr-cols<%= " ftr-cols-many" if columns.size > Studio::SiteFooter::MAX_ROW_COLUMNS %>"<% if tracks %> style="--ftr-tracks: <%= tracks %>"<% end %>>
|
|
17
18
|
<div class="ftr-brand">
|
|
18
19
|
<% if facts[:logo] || facts[:wordmark] %>
|
|
19
20
|
<a href="<%= facts[:home_path] %>" class="ftr-home" aria-label="<%= facts[:name] %>">
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
<p class="ftr-tagline"><%= facts[:tagline] %></p>
|
|
30
31
|
<% end %>
|
|
31
32
|
<% if facts[:email] %>
|
|
32
|
-
<p class="ftr-email"><a href="mailto:<%= facts[:email] %>" class="ftr-link"><%= facts[:email] %></a></p>
|
|
33
|
+
<p class="ftr-email"><a href="mailto:<%= facts[:email] %>" class="ftr-link ftr-link-solid"><%= facts[:email] %></a></p>
|
|
33
34
|
<% end %>
|
|
34
35
|
<% if facts[:social].any? %>
|
|
35
36
|
<ul class="ftr-socials" aria-label="Social profiles">
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
</div>
|
|
49
50
|
|
|
50
51
|
<% columns.each do |column| %>
|
|
51
|
-
<nav aria-label="<%= column[:heading] || "Footer" %>">
|
|
52
|
+
<nav class="ftr-col" aria-label="<%= column[:heading] || "Footer" %>">
|
|
52
53
|
<% if column[:heading] %>
|
|
53
54
|
<h2 class="ftr-heading"><%= column[:heading] %></h2>
|
|
54
55
|
<% end %>
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
<% elsif link[:disabled] %>
|
|
9
9
|
<span class="ftr-link-disabled" aria-disabled="true" title="Coming soon"><%= link[:label] %></span>
|
|
10
10
|
<% else %>
|
|
11
|
-
|
|
11
|
+
<%# An email address or a URL as a label is one word however many hyphens and
|
|
12
|
+
slashes it holds: .ftr-link-solid keeps it on one line. %>
|
|
13
|
+
<%= link_to link[:label], link[:href], { class: Studio::SiteFooter.solid_label?(link[:label]) ? "ftr-link ftr-link-solid" : "ftr-link" }
|
|
12
14
|
.merge(link[:external] ? { target: "_blank", rel: "noopener" } : {})
|
|
13
15
|
.merge(link[:booking] && booking ? { data: { booking_popup: true, studio_booking: true } } : {}) %>
|
|
14
16
|
<% end %>
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
<%# The Site footer and booking group of the Tricks board (admin/style).
|
|
2
2
|
The primitives a public site ends with: the footer, its map, and the Google
|
|
3
|
-
Calendar booking frame, popup and link (docs/SITE_FOOTER.md
|
|
3
|
+
Calendar booking frame, popup and link (docs/SITE_FOOTER.md,
|
|
4
|
+
docs/BOOKING.md). Each card
|
|
4
5
|
gives the helper to call; the preview below them is the real footer partial,
|
|
5
6
|
rendered with THIS app's facts when it has declared any and with a sample
|
|
6
7
|
when it has not, so the group is never empty.
|
|
7
8
|
|
|
8
|
-
The booking frame is
|
|
9
|
-
Google for a calendar every time an admin scrolls past.
|
|
9
|
+
The booking frame is DRAWN, not embedded: a style guide should not ask
|
|
10
|
+
Google for a calendar every time an admin scrolls past. Its two states, whole
|
|
11
|
+
and cropped, are quarter-scale drawings of a made-up schedule (a 720px page
|
|
12
|
+
whose box runs from 200 to 600), cropped by the same arithmetic the real
|
|
13
|
+
wrapper uses (Studio::Booking.crop). No real schedule's numbers are here. %>
|
|
10
14
|
<%
|
|
11
15
|
own_facts = studio_site_footer_facts
|
|
12
16
|
sample = {
|
|
@@ -20,6 +24,12 @@
|
|
|
20
24
|
}
|
|
21
25
|
preview = own_facts || Studio::SiteFooter.resolve(sample, self, logo: Studio.logo_for("Footer Logo"))
|
|
22
26
|
booking = studio_booking?
|
|
27
|
+
own_crop = studio_booking_crop
|
|
28
|
+
sample_crop = Studio::Booking.crop(top: 200, bottom: 600, frame_height: 720)
|
|
29
|
+
scale = 0.25
|
|
30
|
+
# The made-up page, top to bottom: [label, from, to, is it the box?].
|
|
31
|
+
sample_page = [ [ "Google's header", 0, 200, false ], [ "Select an appointment time", 200, 600, true ],
|
|
32
|
+
[ "Google's credit lines", 600, 720, false ] ]
|
|
23
33
|
%>
|
|
24
34
|
<section id="site-footer" class="space-y-5" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
|
|
25
35
|
<div class="space-y-1">
|
|
@@ -34,7 +44,8 @@
|
|
|
34
44
|
This app:
|
|
35
45
|
footer <strong class="text-body"><%= own_facts ? "declared" : "not declared" %></strong> ·
|
|
36
46
|
booking <strong class="text-body"><%= booking ? "on" : "off" %></strong> ·
|
|
37
|
-
booking page <strong class="text-body"><%= Studio.draw_booking_routes ? "drawn" : "not drawn" %></strong>
|
|
47
|
+
booking page <strong class="text-body"><%= Studio.draw_booking_routes ? "drawn" : "not drawn" %></strong> ·
|
|
48
|
+
frame <strong class="text-body"><%= own_crop ? "cropped at rest" : "whole at rest" %></strong>
|
|
38
49
|
</p>
|
|
39
50
|
</div>
|
|
40
51
|
|
|
@@ -59,14 +70,26 @@
|
|
|
59
70
|
<% end %>
|
|
60
71
|
<% end %>
|
|
61
72
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
<% [ [ "studio_booking_frame", nil, "whole",
|
|
74
|
+
%(studio_booking_frame # whole at rest: the default, and crop: false),
|
|
75
|
+
"Whole at rest, the default. Google's page as Google draws it: its header, the slot picker, its credit lines." ],
|
|
76
|
+
[ "config.booking_crop", sample_crop, "cropped",
|
|
77
|
+
%(config.booking_crop = { top: 200, bottom: 600, frame_height: 720 } # bin/booking-crop-measure prints yours),
|
|
78
|
+
"Cropped at rest to the slot picker, from numbers measured on the app's own schedule. Opens to the whole page when it is used." ]
|
|
79
|
+
].each do |klass, crop, state, usage, caption| %>
|
|
80
|
+
<%= render layout: "style/specimen",
|
|
81
|
+
locals: { klass: klass, disabled: !booking, disabled_label: "no booking_url", usage: usage } do %>
|
|
82
|
+
<div class="w-full space-y-3" data-booking-specimen="<%= state %>">
|
|
83
|
+
<div style="margin-inline: auto; width: 11rem; overflow: hidden; background: #fff; border-radius: .5rem; border: 1px solid rgb(127 127 127 / .35); height: <%= ((crop ? crop[:window] : 720) * scale).round %>px;">
|
|
84
|
+
<div style="margin-top: -<%= ((crop ? crop[:offset] : 0) * scale).round %>px;">
|
|
85
|
+
<% sample_page.each do |label, from, to, box| %>
|
|
86
|
+
<div style="box-sizing: border-box; height: <%= ((to - from) * scale).round %>px; display: flex; align-items: center; justify-content: center; padding: 0 .5rem; text-align: center; font-size: .625rem; line-height: 1.2; color: #374151;<%= box ? " margin: 0 .35rem; border: 1px solid rgb(17 24 39 / .45); border-radius: .35rem; font-weight: 700;" : " background: rgb(17 24 39 / .06);" %>"><%= label %></div>
|
|
87
|
+
<% end %>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
<p class="text-sm text-secondary text-center"><%= caption %></p>
|
|
91
|
+
</div>
|
|
92
|
+
<% end %>
|
|
70
93
|
<% end %>
|
|
71
94
|
</div>
|
|
72
95
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/service/s3_service"
|
|
4
|
+
require_relative "../../studio/s3"
|
|
5
|
+
|
|
6
|
+
module ActiveStorage
|
|
7
|
+
# Active Storage's S3 service with a three-day grace period on delete. An app
|
|
8
|
+
# opts in from config/storage.yml by naming it instead of S3:
|
|
9
|
+
#
|
|
10
|
+
# r2_production:
|
|
11
|
+
# service: StudioTrashS3
|
|
12
|
+
# bucket: turf-monster-production
|
|
13
|
+
# endpoint: <%= ENV["R2_ENDPOINT"] %>
|
|
14
|
+
# ...
|
|
15
|
+
#
|
|
16
|
+
# Active Storage resolves `service: StudioTrashS3` by requiring
|
|
17
|
+
# "active_storage/service/studio_trash_s3_service", which is why this file
|
|
18
|
+
# lives at lib/active_storage/service/ on the gem's load path. Nothing
|
|
19
|
+
# autoloads it; Zeitwerk never sees lib/.
|
|
20
|
+
#
|
|
21
|
+
# delete(key) — what Blob#purge and a replaced attachment end in — MOVES the
|
|
22
|
+
# object under trash/ (Studio::S3::Trash) instead of deleting it. By the time
|
|
23
|
+
# it runs the blob row is already destroyed, so what the trash copy records
|
|
24
|
+
# about the blob (content type, byte size, checksum, filename when the upload
|
|
25
|
+
# carried one) is read back off the object itself.
|
|
26
|
+
#
|
|
27
|
+
# delete_prefixed(prefix) stays a HARD delete. Active Storage calls it for
|
|
28
|
+
# "variants/<key>/": derived images, regenerated on demand from the original,
|
|
29
|
+
# which is the object that matters and the one trashed.
|
|
30
|
+
#
|
|
31
|
+
# Both refuse a "*-production" bucket from a non-production process.
|
|
32
|
+
class Service::StudioTrashS3Service < Service::S3Service
|
|
33
|
+
def delete(key)
|
|
34
|
+
instrument :delete, key: key do
|
|
35
|
+
Studio::S3.guard_production_bucket!(bucket.name)
|
|
36
|
+
Studio::S3::Trash.trash!(client: client.client, bucket: bucket.name, key: key,
|
|
37
|
+
env: Studio::S3.deletion_environment)
|
|
38
|
+
rescue StandardError => error
|
|
39
|
+
# Logged, then RE-RAISED. Blob#purge has already destroyed the row, so a
|
|
40
|
+
# raise here leaks the object rather than losing it: the failure that
|
|
41
|
+
# costs storage, never the one that costs a user's file.
|
|
42
|
+
log_trash_failure(key, error)
|
|
43
|
+
raise
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def delete_prefixed(prefix)
|
|
48
|
+
Studio::S3.guard_production_bucket!(bucket.name)
|
|
49
|
+
super
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def log_trash_failure(key, error)
|
|
55
|
+
message = "[StudioTrashS3] trash of #{bucket.name}/#{key} failed, object left in place: " \
|
|
56
|
+
"#{error.class}: #{error.message}"
|
|
57
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
58
|
+
Rails.logger.error(message)
|
|
59
|
+
else
|
|
60
|
+
warn message
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The words the engine's own auth pages say for "sign in", derived from the one
|
|
4
|
+
# setting the navbar already reads, Studio.sign_in_label (default "Log in";
|
|
5
|
+
# lib/studio/navbar_identity.rb). An app that says "Sign in" in its navbar gets
|
|
6
|
+
# "Sign in" on /login, /signup and the magic-link pages too, never a mix.
|
|
7
|
+
#
|
|
8
|
+
# Every form is derived from the label as written:
|
|
9
|
+
#
|
|
10
|
+
# label "Sign in" link "Sign in" button "Sign In"
|
|
11
|
+
# prompt "Sign in to continue"
|
|
12
|
+
# to_app "Sign in to Cyvasse"
|
|
13
|
+
# or_below "or sign in below"
|
|
14
|
+
# link_noun "sign-in link"
|
|
15
|
+
#
|
|
16
|
+
# THE DEFAULT IS PINNED, NOT DERIVED. With the default label the pages print
|
|
17
|
+
# exactly what they printed before this setting reached them, and that text was
|
|
18
|
+
# itself mixed: "Log in to continue" and "Log In" beside "Sign in to <App>",
|
|
19
|
+
# "or sign in below" and "sign-in link". So the three forms that said "sign in"
|
|
20
|
+
# under the default keep saying it while the label is the default, and follow the
|
|
21
|
+
# label only once an app configures another one. An app that never configures
|
|
22
|
+
# the label renders byte-identical pages (test/integration/auth_page_labels_render_test.rb).
|
|
23
|
+
#
|
|
24
|
+
# Pure Ruby, so the unit suite covers it without the dummy app.
|
|
25
|
+
require_relative "navbar_identity"
|
|
26
|
+
|
|
27
|
+
module Studio
|
|
28
|
+
module AuthLabels
|
|
29
|
+
DEFAULT = NavbarIdentity::DEFAULT_SIGN_IN_LABEL
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def configured?(label)
|
|
34
|
+
label.to_s != DEFAULT
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The sign-up page's "Already have an account?" link.
|
|
38
|
+
def link(label)
|
|
39
|
+
label.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The login form's submit button, in title case: "Log In", "Sign In".
|
|
43
|
+
def button(label)
|
|
44
|
+
label.to_s.split(" ").map { |word| capitalize_first(word) }.join(" ")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The login page's line under the app name.
|
|
48
|
+
def prompt(label)
|
|
49
|
+
"#{label} to continue"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The magic-link confirm page's fallback button.
|
|
53
|
+
def to_app(label, app_name)
|
|
54
|
+
"#{configured?(label) ? label : 'Sign in'} to #{app_name}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The divider under the SSO "Continue as" button.
|
|
58
|
+
def or_below(label)
|
|
59
|
+
configured?(label) ? "or #{label.to_s.downcase} below" : "or sign in below"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The emailed link's noun: the magic-link button and the "sent" notice.
|
|
63
|
+
def link_noun(label)
|
|
64
|
+
configured?(label) ? "#{label.to_s.downcase.tr(' ', '-')} link" : "sign-in link"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Only the first letter changes, so an already-capital word is untouched.
|
|
68
|
+
def capitalize_first(word)
|
|
69
|
+
word.sub(/\A\p{Ll}/) { |c| c.upcase }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/studio/booking.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Studio.booking_url — the app's Google Calendar appointment schedule —
|
|
4
|
-
#
|
|
5
|
-
# docs/
|
|
3
|
+
# Studio.booking_url — the app's Google Calendar appointment schedule — the two
|
|
4
|
+
# URLs the booking primitives derive from it, and the frame's crop
|
|
5
|
+
# (Studio.booking_crop). Pure Ruby. See docs/BOOKING.md.
|
|
6
6
|
#
|
|
7
7
|
# The configured value is the schedule's PUBLIC page, the URL Google gives you
|
|
8
8
|
# to share, without `?gv=true`:
|
|
@@ -15,15 +15,175 @@ module Studio
|
|
|
15
15
|
module Booking
|
|
16
16
|
EMBED_PARAM = "gv=true"
|
|
17
17
|
|
|
18
|
+
# THE CROP. At rest the inline frame can show only Google's "Select an
|
|
19
|
+
# appointment time" box, with Google's header above it and its credit lines
|
|
20
|
+
# below clipped away. Where that box sits is a fact about ONE schedule (its
|
|
21
|
+
# header is as tall as its title, description and meeting line; its box is as
|
|
22
|
+
# tall as its fullest day), so the app measures its own and declares it:
|
|
23
|
+
#
|
|
24
|
+
# config.booking_crop = { top: 200, bottom: 600, frame_height: 720 }
|
|
25
|
+
#
|
|
26
|
+
# All three are pixels in Google's own page, at 640px wide or more:
|
|
27
|
+
#
|
|
28
|
+
# top: the box's top edge. Stable for a schedule.
|
|
29
|
+
# bottom: the box's bottom edge ON ITS FULLEST DAY. Not stable: the box
|
|
30
|
+
# grows a row per appointment slot, so it is shorter on a day
|
|
31
|
+
# with fewer slots left. Declare the tallest it gets.
|
|
32
|
+
# frame_height: the whole page's height on that fullest day. The frame is
|
|
33
|
+
# this tall once it opens.
|
|
34
|
+
#
|
|
35
|
+
# `bin/booking-crop-measure <booking url>` prints the Hash.
|
|
36
|
+
#
|
|
37
|
+
# The window is [top - CROP_MARGIN, bottom + CROP_MARGIN], held inside the
|
|
38
|
+
# frame. Because `bottom` is the tallest the box gets, a day with fewer slots
|
|
39
|
+
# shows a strip of Google's credit lines under a shorter box; it never cuts
|
|
40
|
+
# the box. A value that cannot be a crop (not a number, negative, bottom not
|
|
41
|
+
# below top, a box that ends outside the frame, a window that hides nothing)
|
|
42
|
+
# is reported once and the frame shows whole.
|
|
43
|
+
CROP_MARGIN = 6
|
|
44
|
+
CROP_KEYS = %i[top bottom frame_height].freeze
|
|
45
|
+
REPORTED_LIMIT = 100
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
# Where a refused crop is reported: a callable taking the message. The
|
|
49
|
+
# default writes one warning to the Rails log (or to stderr without Rails).
|
|
50
|
+
attr_writer :reporter
|
|
51
|
+
|
|
52
|
+
def reporter
|
|
53
|
+
@reporter ||= lambda do |message|
|
|
54
|
+
if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
55
|
+
::Rails.logger.warn(message)
|
|
56
|
+
else
|
|
57
|
+
warn(message)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reported
|
|
63
|
+
@reported ||= {}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
18
67
|
module_function
|
|
19
68
|
|
|
69
|
+
def validate_crop!(declared)
|
|
70
|
+
return if declared.nil? || declared == false || declared.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"Studio.booking_crop must be nil, false, or a Hash of top:, bottom: and frame_height: " \
|
|
74
|
+
"(got #{declared.inspect}). See docs/BOOKING.md."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The crop to render: { offset:, window:, frame_height: } in whole pixels, or
|
|
78
|
+
# nil for the whole frame at rest. `offset` is how far the frame is pulled up
|
|
79
|
+
# behind its wrapper and `window` is the wrapper's height.
|
|
80
|
+
def crop(declared)
|
|
81
|
+
return nil if declared.nil? || declared == false
|
|
82
|
+
return refuse_crop(declared, "it is not a Hash") unless declared.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
values = CROP_KEYS.to_h { |key| [key, declared[key] || declared[key.to_s]] }
|
|
85
|
+
missing = values.reject { |_key, value| value.is_a?(Numeric) && value.respond_to?(:round) && value.finite? }.keys
|
|
86
|
+
return refuse_crop(declared, "#{missing.join(', ')} must be a number") unless missing.empty?
|
|
87
|
+
|
|
88
|
+
top, bottom, frame_height = values.values_at(*CROP_KEYS).map(&:round)
|
|
89
|
+
return refuse_crop(declared, "top is negative") if top.negative?
|
|
90
|
+
return refuse_crop(declared, "bottom is not below top") unless bottom > top
|
|
91
|
+
return refuse_crop(declared, "bottom is outside the frame") if bottom > frame_height
|
|
92
|
+
|
|
93
|
+
offset = [top - CROP_MARGIN, 0].max
|
|
94
|
+
window = [bottom + CROP_MARGIN, frame_height].min - offset
|
|
95
|
+
return refuse_crop(declared, "the window is the whole frame") unless window < frame_height
|
|
96
|
+
|
|
97
|
+
{ offset: offset, window: window, frame_height: frame_height }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The crop as the custom properties the wrapper carries, so two frames with
|
|
101
|
+
# different crops can share a page (studio/booking/_assets reads them).
|
|
102
|
+
def crop_style(crop)
|
|
103
|
+
return nil if crop.nil?
|
|
104
|
+
|
|
105
|
+
"--booking-crop-offset: #{crop[:offset]}px; --booking-crop-window: #{crop[:window]}px; " \
|
|
106
|
+
"--booking-frame-height: #{crop[:frame_height]}px"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def refuse_crop(declared, why)
|
|
110
|
+
report_once("[studio.booking] booking crop #{declared.inspect} is ignored (#{why}); the frame shows whole. " \
|
|
111
|
+
"See docs/BOOKING.md.")
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A LOCAL PATH, and nothing else, may be the booking page: it is written into
|
|
116
|
+
# hrefs (the footer's booking links, `studio_booking_link`). It begins with
|
|
117
|
+
# one "/" and carries no control character. That refuses a scheme
|
|
118
|
+
# ("javascript:", "https:") and a protocol-relative "//host", which a browser
|
|
119
|
+
# reads as another site.
|
|
120
|
+
def local_path?(path)
|
|
121
|
+
string = path.to_s
|
|
122
|
+
string.start_with?("/") && !string.start_with?("//") && !string.match?(/[\x00-\x1f\x7f\\]/)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Studio.booking_path: nil, a local path, or a callable receiving the view.
|
|
126
|
+
def validate_path!(declared)
|
|
127
|
+
return if declared.nil? || declared.respond_to?(:call)
|
|
128
|
+
return if declared.is_a?(String) && (declared.strip.empty? || local_path?(declared.strip))
|
|
129
|
+
|
|
130
|
+
raise ArgumentError,
|
|
131
|
+
"Studio.booking_path must be nil, a path beginning with one \"/\", or a callable receiving the view " \
|
|
132
|
+
"(got #{declared.inspect}). See docs/BOOKING.md."
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The declared value as it is stored: a stripped path, a callable, or nil.
|
|
136
|
+
def normalize_path(declared)
|
|
137
|
+
validate_path!(declared)
|
|
138
|
+
return declared unless declared.is_a?(String)
|
|
139
|
+
|
|
140
|
+
declared.strip.empty? ? nil : declared.strip
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The booking page's path for a view: the app's own (`declared`, a path or
|
|
144
|
+
# a callable receiving the view), else the engine's /schedule when it is
|
|
145
|
+
# `drawn`, else nil. A callable may decline with nil. What a callable returns
|
|
146
|
+
# is held to the same rule as a declared String: anything but a local path is
|
|
147
|
+
# reported once and treated as no answer.
|
|
148
|
+
def path_for(declared, view, drawn: false)
|
|
149
|
+
unless declared.nil?
|
|
150
|
+
path = (declared.respond_to?(:call) ? declared.call(view) : declared).to_s.strip
|
|
151
|
+
return path if local_path?(path)
|
|
152
|
+
|
|
153
|
+
refuse_path(path) unless path.empty?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
drawn && view.respond_to?(:studio_booking_path) ? view.studio_booking_path : nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def refuse_path(path)
|
|
160
|
+
report_once("[studio.booking] booking_path #{path.inspect} is ignored: it is not a local path " \
|
|
161
|
+
"(one leading \"/\"). See docs/BOOKING.md.")
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def report_once(message)
|
|
166
|
+
seen = Studio::Booking.reported
|
|
167
|
+
return if seen.key?(message)
|
|
168
|
+
|
|
169
|
+
seen[message] = true if seen.size < REPORTED_LIMIT
|
|
170
|
+
Studio::Booking.reporter.call(message)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# True when `current` (a request path) is the page `declared` names. A query
|
|
174
|
+
# string, a fragment and a trailing slash on either side do not count.
|
|
175
|
+
def same_path?(current, declared)
|
|
176
|
+
a, b = [current, declared].map { |path| path.to_s.strip.sub(/[?#].*\z/m, "").sub(%r{(?<=.)/+\z}, "") }
|
|
177
|
+
!a.empty? && a == b
|
|
178
|
+
end
|
|
179
|
+
|
|
20
180
|
def validate!(url)
|
|
21
181
|
return if url.nil? || url.to_s.strip.empty?
|
|
22
182
|
return if url.to_s.strip.match?(%r{\Ahttps://\S+\z})
|
|
23
183
|
|
|
24
184
|
raise ArgumentError,
|
|
25
185
|
"Studio.booking_url must be an https URL (got #{url.inspect}). Use the appointment " \
|
|
26
|
-
"schedule's public link. See docs/
|
|
186
|
+
"schedule's public link. See docs/BOOKING.md."
|
|
27
187
|
end
|
|
28
188
|
|
|
29
189
|
# The public page, with any embed parameter removed. nil when unset.
|