studio-engine 0.89.0 → 0.91.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 +33 -0
- data/README.md +4 -19
- data/app/assets/tailwind/studio_engine/engine-motion.css +61 -11
- data/app/controllers/studio/emails_controller.rb +1 -1
- data/app/services/studio/email_catalog.rb +2 -2
- data/app/services/studio/email_image.rb +1 -2
- data/app/views/studio/_hold_button.html.erb +163 -1
- data/app/views/studio/public_users/show.html.erb +1 -1
- data/app/views/style/_tricks.html.erb +7 -3
- data/app/views/style/index.html.erb +5 -3
- data/lib/studio/engine.rb +2 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +4 -22
- data/studio-engine.gemspec +1 -1
- metadata +1 -9
- data/app/controllers/concerns/studio/impersonation.rb +0 -90
- data/app/controllers/studio/email_images_controller.rb +0 -76
- data/app/models/concerns/studio/broadcastable.rb +0 -40
- data/app/models/concerns/studio/user_profile.rb +0 -111
- data/app/views/components/_card.html.erb +0 -4
- data/app/views/components/_input.html.erb +0 -13
- data/app/views/components/_progress_bar.html.erb +0 -9
- data/app/views/studio/email_images/index.html.erb +0 -74
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0cf7b3c1fa31a2eb5f9746838c8c4976d026920c986504c674f760c57c73a33d
|
|
4
|
+
data.tar.gz: c39625c99d384e68eedfee1a31dde4816e6f3aa2f004f6ab2f88c4e10d447b00
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bfeb555efeae9e981014cf5afb73c8e1eb7b01c90d92dc0c1ba6838d5c1acc950ddc908846be93d15d23ac07a9f5acf277c103c3d95b4611e85b758de5c940f0
|
|
7
|
+
data.tar.gz: 728932ef4510055ab9b8cd3d6f472abe228ee632febfe007251beb242aec114a1e11c77381a63e44167fb7817c79d8d6716d24687e4261b4c660968e23ffef8c
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.91.0 — 2026-10-06
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`fizz_portal: true` on `studio/hold_button`** lifts the bubbles out of a card
|
|
12
|
+
that clips them: the layers move to `<body>` in a fixed box that tracks the
|
|
13
|
+
button through scroll and resize, paints one rung above the button's highest
|
|
14
|
+
ancestor z-index, and mirrors its state and palette. The default is off, so
|
|
15
|
+
every existing caller renders as before. The style guide's live hold button
|
|
16
|
+
uses it.
|
|
17
|
+
|
|
18
|
+
## 0.90.0 — 2026-10-06
|
|
19
|
+
|
|
20
|
+
### Removed
|
|
21
|
+
|
|
22
|
+
- **`components/card`, `components/input` and `components/progress_bar`.** No
|
|
23
|
+
app renders these partials. The `card` and `input-field` utilities they wrapped
|
|
24
|
+
still ship; write the classes on your own markup.
|
|
25
|
+
- **`Studio::Impersonation`** and its four settings,
|
|
26
|
+
`impersonation_target_session_key`, `impersonation_actor_session_key`,
|
|
27
|
+
`impersonation_started_at_session_key` and `impersonation_max_minutes`. No app
|
|
28
|
+
includes the concern; each owns its Act As session. The
|
|
29
|
+
`studio/banners/impersonation` banner stays.
|
|
30
|
+
- **`Studio::Broadcastable`.** No app includes it. Wrap a broadcast in
|
|
31
|
+
`Studio::Cable.safe_broadcast { broadcast_replace_to ... }` instead
|
|
32
|
+
(`docs/CABLE.md`).
|
|
33
|
+
- **`Studio::UserProfile`.** No app includes it; each `User` defines
|
|
34
|
+
`display_name`, `avatar_initials` and `avatar_color` itself. The initial-circle
|
|
35
|
+
palette moves to `Studio::AVATAR_COLORS`.
|
|
36
|
+
- **`/admin/email_images`**, `Studio::EmailImagesController`, its view and the
|
|
37
|
+
`admin_email_images_path` and `admin_email_image_path` helpers. `/admin/emails`
|
|
38
|
+
replaces the page, and no app links or tests it.
|
|
39
|
+
|
|
7
40
|
## 0.89.0 — 2026-10-05
|
|
8
41
|
|
|
9
42
|
### Changed
|
data/README.md
CHANGED
|
@@ -34,7 +34,7 @@ resolved.
|
|
|
34
34
|
- **Session drift**: every page carries a session stamp and loads `window.StudioSession`, which notices when another tab signs in or out, the session expires, or the server revokes it; `session:changed` / `session:mismatch` events, cross-tab sync, an identity-source plug-in interface, and `expectChange` holds for deliberate switches. The rehydrate endpoint (`GET /session/state`) is opt-in. See [`docs/SESSION_DRIFT.md`](docs/SESSION_DRIFT.md).
|
|
35
35
|
- **Theme system**: Dynamic CSS custom properties generated from 7 role colors (primary, dark, light, success, accent, warning, danger). Dark/light mode toggle. Admin theme editor at `/admin/theme`.
|
|
36
36
|
- **UI primitives**: Shared component partials and CSS primitives such as `components/emoji_swap` for nav/sidebar emoji hover transitions.
|
|
37
|
-
- **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls
|
|
37
|
+
- **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls and `studio/banners/impersonation`.
|
|
38
38
|
- **Sluggable concern**: `before_save :set_slug` with `to_param` for human-readable URLs
|
|
39
39
|
- **ThemeSetting model**: Per-app DB overrides with fallback to config defaults
|
|
40
40
|
- **Geo**: `Studio::GeoDetection` places every visitor (IP → country + subdivision, session-cached), `Studio::GeoSetting` stores the operator's blocked countries and regions, `require_geo_allowed` locks whichever surfaces an app chooses, and the shared badge + `/admin/geo` manager ship with it. See [`docs/GEO.md`](docs/GEO.md).
|
|
@@ -169,22 +169,8 @@ with their own users and return route:
|
|
|
169
169
|
stop_path: admin_stop_impersonating_path %>
|
|
170
170
|
```
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
```ruby
|
|
176
|
-
class ApplicationController < ActionController::Base
|
|
177
|
-
include Studio::ErrorHandling
|
|
178
|
-
include Studio::Impersonation
|
|
179
|
-
end
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
The concern adds `true_user`, `impersonated_user`, `impersonating?`,
|
|
183
|
-
`start_impersonation_session(target_user, actor:)`, and
|
|
184
|
-
`clear_impersonation_session`. Consumer apps still own the authorization rule,
|
|
185
|
-
audit log, enter/exit controller actions, and any app-specific safeguards such
|
|
186
|
-
as binding session-token checks to `true_user` or disabling wallet-only
|
|
187
|
-
privileges while impersonating.
|
|
172
|
+
Each app owns its Act As session, authorization rule, audit log and enter/exit
|
|
173
|
+
actions; the engine supplies only the banner.
|
|
188
174
|
|
|
189
175
|
## UI Primitives
|
|
190
176
|
|
|
@@ -702,8 +688,7 @@ matching slot is absent, so existing call sites render unchanged.
|
|
|
702
688
|
Every consuming app can render the same admin page — **`/admin/emails`** —
|
|
703
689
|
listing each transactional email it sends with the banner riding at the top of
|
|
704
690
|
that email, and whether that banner is the **inherited default** or an
|
|
705
|
-
**app-owned override**.
|
|
706
|
-
deprecated but still renders for one release.
|
|
691
|
+
**app-owned override**.
|
|
707
692
|
|
|
708
693
|
**The page is opt-in:**
|
|
709
694
|
|
|
@@ -1315,6 +1315,8 @@ body.modal-open { overflow: visible; }
|
|
|
1315
1315
|
span.hold-stack positioning + z-order context
|
|
1316
1316
|
span.hold-fizz resting bubble layer (behind the button)
|
|
1317
1317
|
span.hold-fizz.hold-fizz-extra hover-only layer (lively level only)
|
|
1318
|
+
span.hold-fizz-portal wraps both layers with fizz_portal: true, and
|
|
1319
|
+
moves to <body> (see THE PORTAL below)
|
|
1318
1320
|
button.hold-btn the button itself, painted OVER the bubbles
|
|
1319
1321
|
div.hold-icon progress ring + tick
|
|
1320
1322
|
ul.hold-text four sliding labels: idle/hold/done/blocked
|
|
@@ -1731,14 +1733,17 @@ body.modal-open { overflow: visible; }
|
|
|
1731
1733
|
z-index: 1;
|
|
1732
1734
|
}
|
|
1733
1735
|
|
|
1734
|
-
.hold-stack > .hold-fizz
|
|
1736
|
+
.hold-stack > .hold-fizz,
|
|
1737
|
+
.hold-stack > .hold-fizz-portal,
|
|
1738
|
+
.hold-fizz-portal > .hold-fizz {
|
|
1735
1739
|
position: absolute;
|
|
1736
1740
|
inset: 0;
|
|
1737
1741
|
z-index: 0;
|
|
1738
1742
|
pointer-events: none;
|
|
1739
1743
|
}
|
|
1740
1744
|
|
|
1741
|
-
.hold-stack .fizz-bit
|
|
1745
|
+
.hold-stack .fizz-bit,
|
|
1746
|
+
.hold-fizz-portal .fizz-bit {
|
|
1742
1747
|
position: absolute;
|
|
1743
1748
|
width: var(--fs, 4px);
|
|
1744
1749
|
height: var(--fs, 4px);
|
|
@@ -1760,17 +1765,20 @@ body.modal-open { overflow: visible; }
|
|
|
1760
1765
|
/* Hover and the nudge shake both mean "look at me" — boil for the moment.
|
|
1761
1766
|
Calm only: lively rests at this boil already and answers hover its own way,
|
|
1762
1767
|
below, so it must not pick this rule up (it outranks lively's rest rule). */
|
|
1763
|
-
.hold-stack:not(.fizz-lively):has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) .fizz-bit
|
|
1768
|
+
.hold-stack:not(.fizz-lively):has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) .fizz-bit,
|
|
1769
|
+
.hold-fizz-portal:not(.fizz-lively).is-hover:not(.is-process):not(.is-loading):not(.is-success):not(.is-error) .fizz-bit {
|
|
1764
1770
|
animation: fizz-boil calc(var(--ft, 1.8s) * 0.62) linear calc(var(--fd, 0s) * 0.35) infinite;
|
|
1765
1771
|
}
|
|
1766
1772
|
|
|
1767
|
-
.hold-stack.fizz-lively .fizz-bit
|
|
1773
|
+
.hold-stack.fizz-lively .fizz-bit,
|
|
1774
|
+
.hold-fizz-portal.fizz-lively .fizz-bit {
|
|
1768
1775
|
animation: fizz-boil calc(var(--ft, 1.8s) * 0.62) linear calc(var(--fd, 0s) * 0.35) infinite;
|
|
1769
1776
|
}
|
|
1770
1777
|
|
|
1771
1778
|
/* The hover layer keeps running while hidden. It has to: killing the animation
|
|
1772
1779
|
would snap the bubbles to their base opacity: 0 and leave nothing to fade. */
|
|
1773
|
-
.hold-stack > .hold-fizz-extra
|
|
1780
|
+
.hold-stack > .hold-fizz-extra,
|
|
1781
|
+
.hold-fizz-portal > .hold-fizz-extra {
|
|
1774
1782
|
opacity: 0;
|
|
1775
1783
|
transition: opacity 0.45s ease;
|
|
1776
1784
|
}
|
|
@@ -1778,29 +1786,69 @@ body.modal-open { overflow: visible; }
|
|
|
1778
1786
|
/* The guard list is as much the point as the hover: a held, submitting,
|
|
1779
1787
|
confirmed or blocked button speaks for itself, so the hover crowd fades away
|
|
1780
1788
|
and leaves the ring and the burst to read cleanly. */
|
|
1781
|
-
.hold-stack.fizz-lively:has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) > .hold-fizz-extra
|
|
1789
|
+
.hold-stack.fizz-lively:has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) > .hold-fizz-extra,
|
|
1790
|
+
.hold-fizz-portal.fizz-lively.is-hover:not(.is-process):not(.is-loading):not(.is-success):not(.is-error) > .hold-fizz-extra {
|
|
1782
1791
|
opacity: 1;
|
|
1783
1792
|
}
|
|
1784
1793
|
|
|
1785
1794
|
.hold-stack:has(> .hold-btn.nudge) .fizz-bit,
|
|
1786
|
-
.hold-stack:has(> .hold-btn.nudge-soft) .fizz-bit
|
|
1795
|
+
.hold-stack:has(> .hold-btn.nudge-soft) .fizz-bit,
|
|
1796
|
+
.hold-fizz-portal.is-nudge .fizz-bit,
|
|
1797
|
+
.hold-fizz-portal.is-nudge-soft .fizz-bit {
|
|
1787
1798
|
animation: fizz-boil calc(var(--ft, 1.8s) * 0.55) linear calc(var(--fd, 0s) * 0.25) infinite;
|
|
1788
1799
|
}
|
|
1789
1800
|
|
|
1790
1801
|
.hold-stack:has(> .hold-btn.process) .fizz-bit,
|
|
1791
|
-
.hold-stack:has(> .hold-btn.loading) .fizz-bit
|
|
1802
|
+
.hold-stack:has(> .hold-btn.loading) .fizz-bit,
|
|
1803
|
+
.hold-fizz-portal.is-process .fizz-bit,
|
|
1804
|
+
.hold-fizz-portal.is-loading .fizz-bit {
|
|
1792
1805
|
animation: fizz-boil calc(var(--ft, 1.8s) * 0.45) linear calc(var(--fd, 0s) * 0.2) infinite;
|
|
1793
1806
|
}
|
|
1794
1807
|
|
|
1795
|
-
.hold-stack:has(> .hold-btn.success) .fizz-bit
|
|
1808
|
+
.hold-stack:has(> .hold-btn.success) .fizz-bit,
|
|
1809
|
+
.hold-fizz-portal.is-success .fizz-bit {
|
|
1796
1810
|
animation: fizz-burst 1.1s cubic-bezier(0.12, 0.72, 0.24, 1) forwards;
|
|
1797
1811
|
}
|
|
1798
1812
|
|
|
1799
|
-
.hold-stack:has(> .hold-btn.error) .fizz-bit
|
|
1813
|
+
.hold-stack:has(> .hold-btn.error) .fizz-bit,
|
|
1814
|
+
.hold-fizz-portal.is-error .fizz-bit {
|
|
1800
1815
|
animation: none;
|
|
1801
1816
|
opacity: 0;
|
|
1802
1817
|
}
|
|
1803
1818
|
|
|
1819
|
+
/* THE PORTAL (studio/hold_button, fizz_portal: true). A button inside an
|
|
1820
|
+
overflow-hidden card has its bubbles cut off at the card's edge, because the
|
|
1821
|
+
layer is absolutely positioned inside the card's clip. With the option set,
|
|
1822
|
+
the button's script moves the layers out of the stack into one fixed box on
|
|
1823
|
+
<body> that tracks the stack's rect, so the bubbles rise past the card's edge
|
|
1824
|
+
instead.
|
|
1825
|
+
|
|
1826
|
+
Out of the stack, the state rules above cannot read the button through
|
|
1827
|
+
:has(), so the script mirrors its state onto the box as .is-process,
|
|
1828
|
+
.is-loading, .is-success, .is-error, .is-nudge, .is-nudge-soft and .is-hover,
|
|
1829
|
+
and copies the stack's --fizz-c-* palette onto it.
|
|
1830
|
+
|
|
1831
|
+
It paints one rung above the highest z-index among the button's ancestors
|
|
1832
|
+
(never below --z-raised), which the script sets inline. A page's own sticky
|
|
1833
|
+
or fixed bar must therefore sit ABOVE --z-raised (--z-sticky is the rung for
|
|
1834
|
+
one); a bar at z 20 or lower, such as Tailwind's sticky z-10 or z-20, ties or
|
|
1835
|
+
loses to the box, and the bubbles paint over it. Painting above the
|
|
1836
|
+
button would put bubbles on its face, so the clip path cuts the button's own
|
|
1837
|
+
rect out of the box: the bubbles show only outside it, as they do in the
|
|
1838
|
+
stack. The 50vmax margin is the room they have to rise. */
|
|
1839
|
+
.hold-fizz-portal.is-portaled {
|
|
1840
|
+
position: fixed;
|
|
1841
|
+
top: 0;
|
|
1842
|
+
left: 0;
|
|
1843
|
+
z-index: var(--z-raised, 20);
|
|
1844
|
+
pointer-events: none;
|
|
1845
|
+
clip-path: polygon(evenodd,
|
|
1846
|
+
-50vmax -50vmax, calc(100% + 50vmax) -50vmax,
|
|
1847
|
+
calc(100% + 50vmax) calc(100% + 50vmax), -50vmax calc(100% + 50vmax),
|
|
1848
|
+
-50vmax -50vmax,
|
|
1849
|
+
0 0, 100% 0, 100% 100%, 0 100%, 0 0);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1804
1852
|
@keyframes hold-tick {
|
|
1805
1853
|
100% { transform: rotate(-90deg) translate(0, -5px) scale(var(--progress-scale)); }
|
|
1806
1854
|
}
|
|
@@ -1867,7 +1915,9 @@ body.modal-open { overflow: visible; }
|
|
|
1867
1915
|
it not to. */
|
|
1868
1916
|
@media (prefers-reduced-motion: reduce) {
|
|
1869
1917
|
.hold-stack .fizz-bit,
|
|
1870
|
-
.hold-stack .hold-fizz-extra
|
|
1918
|
+
.hold-stack .hold-fizz-extra,
|
|
1919
|
+
.hold-fizz-portal .fizz-bit,
|
|
1920
|
+
.hold-fizz-portal .hold-fizz-extra {
|
|
1871
1921
|
animation: none !important;
|
|
1872
1922
|
opacity: 0 !important;
|
|
1873
1923
|
transition: none !important;
|
|
@@ -7,7 +7,7 @@ module Studio
|
|
|
7
7
|
# It lists Studio::EmailCatalog's registry — one row per registered email, each
|
|
8
8
|
# showing its live banner and whether that banner is the INHERITED engine
|
|
9
9
|
# default or an APP-OWNED override — and writes an override through the shared
|
|
10
|
-
# crop modal.
|
|
10
|
+
# crop modal.
|
|
11
11
|
#
|
|
12
12
|
# An app whose host never set Studio.s3_bucket_prefix cannot store an override.
|
|
13
13
|
# That is a read-only page, not an error: uploads_available? gates the write
|
|
@@ -762,8 +762,8 @@ module Studio
|
|
|
762
762
|
# THE SAME QUESTION background_url ASKS, so it must ask it the same way.
|
|
763
763
|
# This guard read engine_artwork? while background_url read layered?, and the
|
|
764
764
|
# two answer differently for a host that registers its own background: the
|
|
765
|
-
# mailer sent the layered banner while
|
|
766
|
-
#
|
|
765
|
+
# mailer sent the layered banner while the detail page's "Artwork" frame
|
|
766
|
+
# drew the flat asset. That frame is where
|
|
767
767
|
# "Modify image" lives, and an upload writes the row background_url reads
|
|
768
768
|
# FIRST — so the operator was shown one picture and told it was the one the
|
|
769
769
|
# button would replace.
|
|
@@ -20,8 +20,7 @@ module Studio
|
|
|
20
20
|
# stays green. A host that did reference it gets a NameError, not a silent
|
|
21
21
|
# wrong answer.
|
|
22
22
|
#
|
|
23
|
-
# Delete it once no consumer's main names it
|
|
24
|
-
# /admin/email_images is on.
|
|
23
|
+
# Delete it once no consumer's main names it.
|
|
25
24
|
#
|
|
26
25
|
# Deliberately explicit rather than method_missing: a typo should still raise
|
|
27
26
|
# NoMethodError here, and the delegated surface should be readable as a list.
|
|
@@ -39,6 +39,13 @@
|
|
|
39
39
|
layer, for a button that should not compete.
|
|
40
40
|
fizz_colors - Up to 12 CSS colors the bubbles wear, as a static
|
|
41
41
|
array (default: none = the built-in candy palette).
|
|
42
|
+
fizz_portal - Lift the bubbles out of the stack into a fixed layer on
|
|
43
|
+
<body> that tracks the button (default: false). For a
|
|
44
|
+
button inside an overflow-hidden card, whose bubbles
|
|
45
|
+
are otherwise cut off at the card's edge: with it set
|
|
46
|
+
they rise past the edge. The button, its timers and its
|
|
47
|
+
states are the same either way. See THE PORTAL in
|
|
48
|
+
engine-motion.css.
|
|
42
49
|
fizz_bind - Alpine expression bound to the stack's :style, for a
|
|
43
50
|
palette that changes at runtime (default: none). The
|
|
44
51
|
turf-monster's board passes "fizzPalette", mapping
|
|
@@ -65,6 +72,7 @@
|
|
|
65
72
|
<% fizz_colors ||= nil %>
|
|
66
73
|
<% fizz_bind ||= nil %>
|
|
67
74
|
<% fizz_level ||= :lively %>
|
|
75
|
+
<% fizz_portal = fizz && local_assigns[:fizz_portal] %>
|
|
68
76
|
<%# Static palette (gallery, or any call site with fixed colors). A runtime
|
|
69
77
|
palette rides fizz_bind instead. %>
|
|
70
78
|
<% fizz_style = Array(fizz_colors).first(Studio::FizzHelper::SLOTS)
|
|
@@ -75,11 +83,17 @@
|
|
|
75
83
|
transform makes it a stacking context), so the bubbles have to be a
|
|
76
84
|
sibling the button paints over — they only show where they escape its
|
|
77
85
|
edges, like carbonation coming off the sides. %>
|
|
78
|
-
<span class="hold-stack<%= " fizz-lively" if fizz_level.to_s == "lively" %>"<% if fizz_bind %> :style="<%= fizz_bind %>"<% end %><% if fizz_style.present? %> style="<%= fizz_style %>"<% end %>>
|
|
86
|
+
<span class="hold-stack<%= " fizz-lively" if fizz_level.to_s == "lively" %>"<% if fizz_bind %> :style="<%= fizz_bind %>"<% end %><% if fizz_style.present? %> style="<%= fizz_style %>"<% end %><% if fizz_portal %> data-fizz-portal x-init="window.studioFizzPortal && window.studioFizzPortal.mount($el)"<% end %>>
|
|
79
87
|
<%# Fizz layer — bubbles that simmer at rest, boil while the button is held,
|
|
80
88
|
and burst outward on success. Purely decorative, so it is aria-hidden and
|
|
81
89
|
pointer-events:none; the table behind it is seeded per hold_id (see Studio::FizzHelper). %>
|
|
82
90
|
<% if fizz %>
|
|
91
|
+
<%# With fizz_portal the layers ride one wrapper, which the script below
|
|
92
|
+
moves to <body>. Until it does, the wrapper sits in the stack exactly
|
|
93
|
+
where the layers would. %>
|
|
94
|
+
<% if fizz_portal %>
|
|
95
|
+
<span class="hold-fizz-portal<%= " fizz-lively" if fizz_level.to_s == "lively" %>" aria-hidden="true">
|
|
96
|
+
<% end %>
|
|
83
97
|
<%= render "studio/fizz_layer", seed: hold_id %>
|
|
84
98
|
<%# Lively's hover doubles the COUNT, not the speed: a second scatter that
|
|
85
99
|
rests invisible, fades in over the gaps in the first, and fades back
|
|
@@ -87,6 +101,9 @@
|
|
|
87
101
|
<% if fizz_level.to_s == "lively" %>
|
|
88
102
|
<%= render "studio/fizz_layer", seed: "#{hold_id}~extra", extra: true %>
|
|
89
103
|
<% end %>
|
|
104
|
+
<% if fizz_portal %>
|
|
105
|
+
</span>
|
|
106
|
+
<% end %>
|
|
90
107
|
<% end %>
|
|
91
108
|
<button class="hold-btn"
|
|
92
109
|
data-hold-id="<%= hold_id %>"
|
|
@@ -236,6 +253,151 @@
|
|
|
236
253
|
Object.keys(nudgeTimers).forEach(function(id) { stopNudgeCycle(id); });
|
|
237
254
|
});
|
|
238
255
|
|
|
256
|
+
// ── The fizz portal (fizz_portal: true) ─────────────────────────────
|
|
257
|
+
// Moves a stack's bubble layers to the document body in one fixed box that tracks the
|
|
258
|
+
// stack's rect, so a card's overflow clip cannot cut them off. The box
|
|
259
|
+
// mirrors the button's state classes and the stack's palette, because the
|
|
260
|
+
// CSS can no longer read either through the stack. engine-motion.css,
|
|
261
|
+
// THE PORTAL, holds the other half.
|
|
262
|
+
var PORTAL_STATES = ['process', 'loading', 'success', 'error', 'nudge', 'nudge-soft'];
|
|
263
|
+
var portals = [];
|
|
264
|
+
|
|
265
|
+
// One rung above the highest z-index among the stack's ancestors, so the
|
|
266
|
+
// bubbles clear the card, the panel or the modal the button sits in,
|
|
267
|
+
// and never below --z-raised.
|
|
268
|
+
function portalZ(stack) {
|
|
269
|
+
var floor = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--z-raised'), 10);
|
|
270
|
+
var top = isNaN(floor) ? 20 : floor;
|
|
271
|
+
for (var n = stack.parentElement; n && n !== document.body; n = n.parentElement) {
|
|
272
|
+
var z = parseInt(getComputedStyle(n).zIndex, 10);
|
|
273
|
+
if (!isNaN(z) && z + 1 > top) top = z + 1;
|
|
274
|
+
}
|
|
275
|
+
return top;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function syncPortalState(p) {
|
|
279
|
+
PORTAL_STATES.forEach(function(s) {
|
|
280
|
+
p.box.classList.toggle('is-' + s, p.button.classList.contains(s));
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function syncPortalPalette(p) {
|
|
285
|
+
var cs = getComputedStyle(p.stack);
|
|
286
|
+
for (var i = 1; i <= <%= Studio::FizzHelper::SLOTS %>; i++) {
|
|
287
|
+
var v = cs.getPropertyValue('--fizz-c-' + i).trim();
|
|
288
|
+
if (v) p.box.style.setProperty('--fizz-c-' + i, v);
|
|
289
|
+
else p.box.style.removeProperty('--fizz-c-' + i);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function placePortal(p) {
|
|
294
|
+
if (!p.stack.isConnected) { unmountPortal(p, false); return; }
|
|
295
|
+
var r = p.stack.getBoundingClientRect();
|
|
296
|
+
var shown = p.visible && r.width > 0 && r.height > 0;
|
|
297
|
+
p.box.style.visibility = shown ? '' : 'hidden';
|
|
298
|
+
if (shown) {
|
|
299
|
+
p.box.style.transform = 'translate(' + r.left + 'px, ' + r.top + 'px)';
|
|
300
|
+
p.box.style.width = r.width + 'px';
|
|
301
|
+
p.box.style.height = r.height + 'px';
|
|
302
|
+
if (p.parked) { clearInterval(p.parked); p.parked = null; }
|
|
303
|
+
p.frame = requestAnimationFrame(function() { placePortal(p); });
|
|
304
|
+
} else {
|
|
305
|
+
p.frame = null;
|
|
306
|
+
// Parked: no frames run, so nothing would notice the stack leaving the
|
|
307
|
+
// DOM (an x-if, a Turbo stream, a closed modal) until the next page
|
|
308
|
+
// load. A slow check tears the box and its observers down when it does.
|
|
309
|
+
if (!p.parked) {
|
|
310
|
+
p.parked = setInterval(function() {
|
|
311
|
+
if (!p.stack.isConnected) unmountPortal(p, false);
|
|
312
|
+
}, 1000);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function unmountPortal(p, restore) {
|
|
318
|
+
if (p.frame) cancelAnimationFrame(p.frame);
|
|
319
|
+
if (p.parked) clearInterval(p.parked);
|
|
320
|
+
p.observers.forEach(function(o) { o.disconnect(); });
|
|
321
|
+
p.button.removeEventListener('mouseenter', p.enter);
|
|
322
|
+
p.button.removeEventListener('mouseleave', p.leave);
|
|
323
|
+
if (restore && p.stack.isConnected) {
|
|
324
|
+
// Back where the server rendered it, with nothing the script added,
|
|
325
|
+
// so a cached snapshot restores to the markup it came from.
|
|
326
|
+
p.box.className = p.box.className.replace(/\s*\bis-[\w-]+/g, '');
|
|
327
|
+
p.box.removeAttribute('style');
|
|
328
|
+
p.box.removeAttribute('data-fizz-portal-for');
|
|
329
|
+
p.stack.insertBefore(p.box, p.stack.firstChild);
|
|
330
|
+
} else {
|
|
331
|
+
p.box.remove();
|
|
332
|
+
}
|
|
333
|
+
delete p.stack._fizzPortal;
|
|
334
|
+
portals = portals.filter(function(q) { return q !== p; });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function mountPortal(stack) {
|
|
338
|
+
if (stack._fizzPortal) return stack._fizzPortal;
|
|
339
|
+
var box = stack.querySelector(':scope > .hold-fizz-portal');
|
|
340
|
+
var button = stack.querySelector(':scope > .hold-btn');
|
|
341
|
+
if (!box || !button) return null;
|
|
342
|
+
|
|
343
|
+
var p = { stack: stack, box: box, button: button, visible: true, frame: null, parked: null, observers: [] };
|
|
344
|
+
stack._fizzPortal = p;
|
|
345
|
+
portals.push(p);
|
|
346
|
+
|
|
347
|
+
document.body.appendChild(box);
|
|
348
|
+
box.classList.add('is-portaled');
|
|
349
|
+
box.setAttribute('data-fizz-portal-for', button.dataset.holdId || 'hold');
|
|
350
|
+
box.style.zIndex = portalZ(stack);
|
|
351
|
+
syncPortalState(p);
|
|
352
|
+
syncPortalPalette(p);
|
|
353
|
+
|
|
354
|
+
p.enter = function() { box.classList.add('is-hover'); };
|
|
355
|
+
p.leave = function() { box.classList.remove('is-hover'); };
|
|
356
|
+
button.addEventListener('mouseenter', p.enter);
|
|
357
|
+
button.addEventListener('mouseleave', p.leave);
|
|
358
|
+
|
|
359
|
+
var states = new MutationObserver(function() { syncPortalState(p); });
|
|
360
|
+
states.observe(button, { attributes: true, attributeFilter: ['class'] });
|
|
361
|
+
var palette = new MutationObserver(function() { syncPortalPalette(p); });
|
|
362
|
+
palette.observe(stack, { attributes: true, attributeFilter: ['style'] });
|
|
363
|
+
p.observers.push(states, palette);
|
|
364
|
+
|
|
365
|
+
// Track the rect every frame while the stack is on screen: a visible
|
|
366
|
+
// portal reads one rect per frame. Park the box while the stack is not
|
|
367
|
+
// on screen (scrolled away, inside a closed panel, or clipped out of a
|
|
368
|
+
// scroll container): no frames, only a once-a-second check that the
|
|
369
|
+
// stack is still in the DOM.
|
|
370
|
+
if ('IntersectionObserver' in window) {
|
|
371
|
+
var seen = new IntersectionObserver(function(entries) {
|
|
372
|
+
p.visible = entries[entries.length - 1].isIntersecting;
|
|
373
|
+
if (!p.stack.isConnected) { unmountPortal(p, false); return; }
|
|
374
|
+
if (p.visible && !p.frame) placePortal(p);
|
|
375
|
+
if (!p.visible) placePortal(p);
|
|
376
|
+
});
|
|
377
|
+
seen.observe(stack);
|
|
378
|
+
p.observers.push(seen);
|
|
379
|
+
}
|
|
380
|
+
placePortal(p);
|
|
381
|
+
return p;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function mountAllPortals() {
|
|
385
|
+
portals.slice().forEach(function(p) { if (!p.stack.isConnected) unmountPortal(p, false); });
|
|
386
|
+
document.querySelectorAll('.hold-stack[data-fizz-portal]').forEach(mountPortal);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
window.studioFizzPortal = {
|
|
390
|
+
mount: mountPortal,
|
|
391
|
+
mountAll: mountAllPortals,
|
|
392
|
+
count: function() { return portals.length; }
|
|
393
|
+
};
|
|
394
|
+
document.addEventListener('turbo:load', mountAllPortals);
|
|
395
|
+
document.addEventListener('turbo:before-cache', function() {
|
|
396
|
+
portals.slice().forEach(function(p) { unmountPortal(p, true); });
|
|
397
|
+
});
|
|
398
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountAllPortals);
|
|
399
|
+
else mountAllPortals();
|
|
400
|
+
|
|
239
401
|
window.holdBtnStart = function(el) {
|
|
240
402
|
var holdId = el.dataset.holdId || 'hold';
|
|
241
403
|
var scope = el.closest('[x-data]');
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
<%= render "components/avatar", user: @public_user, size: "xl", alt: username %>
|
|
20
20
|
<% else %>
|
|
21
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::
|
|
22
|
+
<% colors = Studio::AVATAR_COLORS %>
|
|
23
23
|
<div class="w-28 h-28 text-4xl rounded-full flex items-center justify-center font-bold text-white"
|
|
24
24
|
style="background-color: <%= colors[Digest::MD5.hexdigest(username).hex % colors.size] %>"><%= username[0].upcase %></div>
|
|
25
25
|
<% end %>
|
|
@@ -344,6 +344,8 @@
|
|
|
344
344
|
brand — and every color also reads a <code class="font-mono text-2xs">--hold-*</code>
|
|
345
345
|
input, so one instance can be re-themed from any ancestor.
|
|
346
346
|
<code class="font-mono text-2xs">prefers-reduced-motion</code> switches the bubbles off.
|
|
347
|
+
Inside a card that clips, pass <code class="font-mono text-2xs">fizz_portal: true</code>
|
|
348
|
+
and the bubbles rise past the card’s edge instead of stopping at it.
|
|
347
349
|
</p>
|
|
348
350
|
</div>
|
|
349
351
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
@@ -360,12 +362,14 @@
|
|
|
360
362
|
</div>
|
|
361
363
|
<% end %>
|
|
362
364
|
|
|
363
|
-
<%# 2. The button itself, live: press and hold for two seconds.
|
|
365
|
+
<%# 2. The button itself, live: press and hold for two seconds. The card
|
|
366
|
+
clips, so this one sets fizz_portal and its bubbles rise past the
|
|
367
|
+
card's edge. %>
|
|
364
368
|
<%= render layout: "style/specimen",
|
|
365
369
|
locals: { klass: ".hold-btn (studio/hold_button)",
|
|
366
|
-
usage: %(render "studio/hold_button", hold_id: "confirm", duration: 2000) } do %>
|
|
370
|
+
usage: %(render "studio/hold_button", hold_id: "confirm", duration: 2000, fizz_portal: true) } do %>
|
|
367
371
|
<div class="w-full max-w-xs" x-data="{ submitting: false }">
|
|
368
|
-
<%= render "studio/hold_button", hold_id: "style-hold", duration: 2000,
|
|
372
|
+
<%= render "studio/hold_button", hold_id: "style-hold", duration: 2000, fizz_portal: true,
|
|
369
373
|
default_text: "Hold to Confirm", hold_text: "Almost there…", success_text: "Confirmed!" %>
|
|
370
374
|
<button type="button" class="btn btn-neutral btn-sm mt-3 mx-auto block"
|
|
371
375
|
@click="document.querySelector('.hold-btn[data-hold-id=style-hold]').classList.remove('process','success','error','loading')">Reset</button>
|
|
@@ -65,9 +65,11 @@
|
|
|
65
65
|
|
|
66
66
|
<%# Section nav — sticky under the host navbar (--nav-h, 0 fallback). A pill per
|
|
67
67
|
section; the row scrolls horizontally on narrow screens, its edges softened
|
|
68
|
-
by the .fade-edge-x primitive. Order leads with Theme (the color foundation).
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
by the .fade-edge-x primitive. Order leads with Theme (the color foundation).
|
|
69
|
+
It sits at --z-sticky, above --z-raised, so the live hold button's portal
|
|
70
|
+
bubbles scroll under it rather than over it. %>
|
|
71
|
+
<nav class="sticky -mx-4 px-4 py-3 bg-surface border-y border-subtle"
|
|
72
|
+
style="top: var(--nav-h, 0px); z-index: var(--z-sticky, 30)" aria-label="Style guide sections">
|
|
71
73
|
<div class="fade-edge-x overflow-x-auto">
|
|
72
74
|
<div class="flex items-center gap-2 min-w-max">
|
|
73
75
|
<% sections.each do |anchor, label| %>
|
data/lib/studio/engine.rb
CHANGED
|
@@ -113,8 +113,8 @@ module Studio
|
|
|
113
113
|
# skip :require_authentication, its mailers and jobs subclass ActionMailer
|
|
114
114
|
# and ActiveJob, and the two concerns roots (app/controllers/concerns,
|
|
115
115
|
# app/models/concerns) are roots of their OWN to Zeitwerk: excluding
|
|
116
|
-
# app/models does not exclude app/models/concerns, and Sluggable
|
|
117
|
-
#
|
|
116
|
+
# app/models does not exclude app/models/concerns, and Sluggable sits
|
|
117
|
+
# there. They stay lazily loadable, and nothing in a
|
|
118
118
|
# host that draws no Studio.routes asks for them.
|
|
119
119
|
#
|
|
120
120
|
# Read off the engine's own eager-load paths rather than listed, so a root
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -839,6 +839,10 @@ module Studio
|
|
|
839
839
|
# Studio::ProfilesController to get it would couple the two for no reason.
|
|
840
840
|
FIRST_NAME_MAX_LENGTH = 40
|
|
841
841
|
|
|
842
|
+
# The house palette for initial-circle backgrounds. /u/:username hashes the
|
|
843
|
+
# username into it, so one person draws the same circle on every render.
|
|
844
|
+
AVATAR_COLORS = %w[#EF4444 #F97316 #EAB308 #22C55E #06B6D4 #3B82F6 #8B5CF6 #EC4899].freeze
|
|
845
|
+
|
|
842
846
|
# How long a WHOLE typed answer may be — one string holding a full name, which
|
|
843
847
|
# the onboarding step stores as users.name and SPLITS into the two halves
|
|
844
848
|
# above (Studio::NameParts).
|
|
@@ -914,14 +918,6 @@ module Studio
|
|
|
914
918
|
true
|
|
915
919
|
end
|
|
916
920
|
|
|
917
|
-
# Optional admin Act As / impersonation session conventions. Consumers that
|
|
918
|
-
# include Studio::Impersonation get current_user layered over true_user with
|
|
919
|
-
# these session keys, but still own authorization, audit logging, and routes.
|
|
920
|
-
mattr_accessor :impersonation_target_session_key, default: :impersonated_user_id
|
|
921
|
-
mattr_accessor :impersonation_actor_session_key, default: :true_admin_id
|
|
922
|
-
mattr_accessor :impersonation_started_at_session_key, default: :impersonation_started_at
|
|
923
|
-
mattr_accessor :impersonation_max_minutes, default: 30
|
|
924
|
-
|
|
925
921
|
# Cap for the host app's local (development + test) log file, in bytes.
|
|
926
922
|
# nil means "use the engine's defaults" — Studio::Engine::DEVELOPMENT_LOG_MAX_BYTES
|
|
927
923
|
# and ::TEST_LOG_MAX_BYTES. An Integer sets your own cap; `false` opts out and
|
|
@@ -1530,9 +1526,6 @@ module Studio
|
|
|
1530
1526
|
# stores this app's own override; destroy drops it back to the inherited
|
|
1531
1527
|
# default. Surfaced from each app's admin sidebar.
|
|
1532
1528
|
#
|
|
1533
|
-
# /admin/email_images redirects here but KEEPS its admin_email_images_path
|
|
1534
|
-
# helper, so a shipped host sidebar link on the old helper still resolves
|
|
1535
|
-
# (same treatment as /admin/design_system -> /admin/style).
|
|
1536
1529
|
# OPT-IN, and it has to be. turf-monster ALREADY owns /admin/emails —
|
|
1537
1530
|
# `namespace :admin { get "emails", as: :emails }` (its EmailCatalog
|
|
1538
1531
|
# manager) — which claims the SAME path and the SAME helper names,
|
|
@@ -1592,17 +1585,6 @@ module Studio
|
|
|
1592
1585
|
as: :onboarding_skip_first_name
|
|
1593
1586
|
end
|
|
1594
1587
|
|
|
1595
|
-
# DEPRECATED, kept for ONE release. Not a redirect: consumer-ci.yml runs
|
|
1596
|
-
# each consumer's DEFAULT-BRANCH suite against this engine, and both
|
|
1597
|
-
# mcritchie-studio and turf-monster have tests on `main` that GET this page
|
|
1598
|
-
# and PATCH through admin_email_image_path. Redirecting (or deleting) here
|
|
1599
|
-
# reddens their lanes the moment the PR opens, and no change inside the
|
|
1600
|
-
# engine PR can fix it. Each app's adoption task moves its link + tests; a
|
|
1601
|
-
# later engine minor deletes these two routes with the controller and view.
|
|
1602
|
-
get "admin/email_images", to: "studio/email_images#index", as: :admin_email_images
|
|
1603
|
-
patch "admin/email_images/:variant", to: "studio/email_images#update", as: :admin_email_image,
|
|
1604
|
-
constraints: { variant: /[a-z_]+/ }
|
|
1605
|
-
|
|
1606
1588
|
# Surveys (docs/SURVEYS.md). Both halves OPT-IN — see
|
|
1607
1589
|
# Studio.draw_survey_routes and Studio.draw_admin_survey_routes.
|
|
1608
1590
|
if Studio.draw_survey_routes
|
data/studio-engine.gemspec
CHANGED
|
@@ -40,7 +40,7 @@ Gem::Specification.new do |spec|
|
|
|
40
40
|
spec.add_dependency "mini_magick", "~> 5.0"
|
|
41
41
|
spec.add_dependency "resend", "~> 1.1"
|
|
42
42
|
# Realtime: the redis cable/cache/Sidekiq adapter (Studio::Redis) + Turbo Streams
|
|
43
|
-
# broadcasting (Studio::
|
|
43
|
+
# broadcasting (Studio::Cable). `redis` is the dependency whose ABSENCE
|
|
44
44
|
# 500'd a host app's task board — declaring it here makes that impossible to repeat.
|
|
45
45
|
#
|
|
46
46
|
# The `< 6` ceiling is ActionCable's, not ours. Its redis pubsub adapter
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.91.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -323,7 +323,6 @@ files:
|
|
|
323
323
|
- app/controllers/concerns/studio/board/reorderable.rb
|
|
324
324
|
- app/controllers/concerns/studio/error_handling.rb
|
|
325
325
|
- app/controllers/concerns/studio/geo_detection.rb
|
|
326
|
-
- app/controllers/concerns/studio/impersonation.rb
|
|
327
326
|
- app/controllers/concerns/studio/link_consumption.rb
|
|
328
327
|
- app/controllers/concerns/studio/link_preview_bots.rb
|
|
329
328
|
- app/controllers/concerns/studio/magic_link_issuing.rb
|
|
@@ -338,7 +337,6 @@ files:
|
|
|
338
337
|
- app/controllers/solana_sessions_controller.rb
|
|
339
338
|
- app/controllers/studio/admin_surveys_controller.rb
|
|
340
339
|
- app/controllers/studio/bookings_controller.rb
|
|
341
|
-
- app/controllers/studio/email_images_controller.rb
|
|
342
340
|
- app/controllers/studio/emails_controller.rb
|
|
343
341
|
- app/controllers/studio/geo_settings_controller.rb
|
|
344
342
|
- app/controllers/studio/knowledge_docs_controller.rb
|
|
@@ -373,8 +371,6 @@ files:
|
|
|
373
371
|
- app/mailers/user_mailer.rb
|
|
374
372
|
- app/models/concerns/sluggable.rb
|
|
375
373
|
- app/models/concerns/studio/board/rankable.rb
|
|
376
|
-
- app/models/concerns/studio/broadcastable.rb
|
|
377
|
-
- app/models/concerns/studio/user_profile.rb
|
|
378
374
|
- app/models/current.rb
|
|
379
375
|
- app/models/error_log.rb
|
|
380
376
|
- app/models/image_cache.rb
|
|
@@ -399,18 +395,15 @@ files:
|
|
|
399
395
|
- app/views/components/_avatar.html.erb
|
|
400
396
|
- app/views/components/_avatar_cropper.html.erb
|
|
401
397
|
- app/views/components/_badge.html.erb
|
|
402
|
-
- app/views/components/_card.html.erb
|
|
403
398
|
- app/views/components/_copy_button.html.erb
|
|
404
399
|
- app/views/components/_emoji_swap.html.erb
|
|
405
400
|
- app/views/components/_empty_state.html.erb
|
|
406
401
|
- app/views/components/_geo_badge.html.erb
|
|
407
402
|
- app/views/components/_geo_signpost.html.erb
|
|
408
403
|
- app/views/components/_google_logo.html.erb
|
|
409
|
-
- app/views/components/_input.html.erb
|
|
410
404
|
- app/views/components/_json_debug.html.erb
|
|
411
405
|
- app/views/components/_link_sidebar.html.erb
|
|
412
406
|
- app/views/components/_link_sidebar_trigger.html.erb
|
|
413
|
-
- app/views/components/_progress_bar.html.erb
|
|
414
407
|
- app/views/components/_sidebar_panel.html.erb
|
|
415
408
|
- app/views/components/_theme_toggle.html.erb
|
|
416
409
|
- app/views/components/_theme_toggle_morph.html.erb
|
|
@@ -458,7 +451,6 @@ files:
|
|
|
458
451
|
- app/views/studio/booking/_frame.html.erb
|
|
459
452
|
- app/views/studio/booking/_popup.html.erb
|
|
460
453
|
- app/views/studio/bookings/show.html.erb
|
|
461
|
-
- app/views/studio/email_images/index.html.erb
|
|
462
454
|
- app/views/studio/emails/_banner_editor.html.erb
|
|
463
455
|
- app/views/studio/emails/_banner_preview.html.erb
|
|
464
456
|
- app/views/studio/emails/_banner_scale.html.erb
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
require "time"
|
|
2
|
-
|
|
3
|
-
module Studio
|
|
4
|
-
module Impersonation
|
|
5
|
-
extend ActiveSupport::Concern
|
|
6
|
-
|
|
7
|
-
included do
|
|
8
|
-
if respond_to?(:helper_method)
|
|
9
|
-
helper_method :true_user, :impersonated_user, :impersonating?, :impersonation_started_at
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
private
|
|
14
|
-
|
|
15
|
-
# The real session owner. While acting as another user, this remains the
|
|
16
|
-
# admin/operator account whose session token should be verified.
|
|
17
|
-
def true_user
|
|
18
|
-
return @true_user if defined?(@true_user)
|
|
19
|
-
|
|
20
|
-
@true_user = User.find_by(id: session[Studio.session_key])
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# The currently visible user. Consumers with extra wallet or privilege state
|
|
24
|
-
# can override this and still call true_user/impersonating? from the concern.
|
|
25
|
-
def current_user
|
|
26
|
-
return @current_user if defined?(@current_user)
|
|
27
|
-
|
|
28
|
-
@current_user = impersonating? ? impersonated_user : true_user
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def impersonated_user
|
|
32
|
-
return @impersonated_user if defined?(@impersonated_user)
|
|
33
|
-
|
|
34
|
-
@impersonated_user = User.find_by(id: session[Studio.impersonation_target_session_key])
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def impersonating?
|
|
38
|
-
return @impersonating if defined?(@impersonating)
|
|
39
|
-
|
|
40
|
-
@impersonating = compute_impersonating?
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def start_impersonation_session(target_user, actor: true_user)
|
|
44
|
-
session[Studio.impersonation_actor_session_key] = actor.id
|
|
45
|
-
session[Studio.impersonation_target_session_key] = target_user.id
|
|
46
|
-
session[Studio.impersonation_started_at_session_key] = Time.current.iso8601
|
|
47
|
-
reset_impersonation_memoization
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def clear_impersonation_session
|
|
51
|
-
session.delete(Studio.impersonation_actor_session_key)
|
|
52
|
-
session.delete(Studio.impersonation_target_session_key)
|
|
53
|
-
session.delete(Studio.impersonation_started_at_session_key)
|
|
54
|
-
reset_impersonation_memoization
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def impersonation_started_at
|
|
58
|
-
raw = session[Studio.impersonation_started_at_session_key]
|
|
59
|
-
return nil if raw.blank?
|
|
60
|
-
|
|
61
|
-
return raw if raw.is_a?(Time)
|
|
62
|
-
|
|
63
|
-
zone = Time.zone if Time.respond_to?(:zone)
|
|
64
|
-
zone ? zone.parse(raw.to_s) : Time.parse(raw.to_s)
|
|
65
|
-
rescue ArgumentError, TypeError
|
|
66
|
-
nil
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def compute_impersonating?
|
|
70
|
-
return false if session[Studio.impersonation_target_session_key].blank?
|
|
71
|
-
return false unless true_user&.respond_to?(:admin?) && true_user.admin?
|
|
72
|
-
return false unless impersonation_started_at
|
|
73
|
-
return false if impersonation_started_at < Studio.impersonation_max_minutes.to_i.minutes.ago
|
|
74
|
-
|
|
75
|
-
target = impersonated_user
|
|
76
|
-
target.present? &&
|
|
77
|
-
(!target.respond_to?(:admin?) || !target.admin?) &&
|
|
78
|
-
target.id != true_user.id
|
|
79
|
-
rescue StandardError
|
|
80
|
-
false
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def reset_impersonation_memoization
|
|
84
|
-
remove_instance_variable(:@current_user) if defined?(@current_user)
|
|
85
|
-
remove_instance_variable(:@true_user) if defined?(@true_user)
|
|
86
|
-
remove_instance_variable(:@impersonated_user) if defined?(@impersonated_user)
|
|
87
|
-
remove_instance_variable(:@impersonating) if defined?(@impersonating)
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
end
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
module Studio
|
|
2
|
-
# DEPRECATED — superseded by Studio::EmailsController (/admin/emails), which
|
|
3
|
-
# shows every registered email with its live image, says whether that image is
|
|
4
|
-
# the inherited default or app-owned, and uploads through the crop modal.
|
|
5
|
-
#
|
|
6
|
-
# Kept alive for ONE release on purpose, not by neglect. consumer-ci.yml checks
|
|
7
|
-
# out each consumer repo with no `ref:` — i.e. its DEFAULT BRANCH — and runs
|
|
8
|
-
# that suite against the engine PR. mcritchie-studio and turf-monster both have
|
|
9
|
-
# tests on `main` that drive this page and its admin_email_image_path helper,
|
|
10
|
-
# so deleting it here would redden their lanes from the moment the PR opens,
|
|
11
|
-
# and nothing inside the engine PR could fix it: the consumer PRs would have to
|
|
12
|
-
# travel accepted -> release -> main in BOTH apps before this could go green.
|
|
13
|
-
#
|
|
14
|
-
# So the retirement is staged: this release adds /admin/emails and leaves this
|
|
15
|
-
# page working; each app's adoption task moves its link and its tests; a later
|
|
16
|
-
# engine minor deletes this file, its view, and its two routes once no
|
|
17
|
-
# consumer's main references them.
|
|
18
|
-
#
|
|
19
|
-
# It is not frozen in its broken state, though — see #index.
|
|
20
|
-
class EmailImagesController < ApplicationController
|
|
21
|
-
before_action :require_admin
|
|
22
|
-
|
|
23
|
-
MAX_BYTES = 8.megabytes
|
|
24
|
-
|
|
25
|
-
def index
|
|
26
|
-
@variants = Studio::EmailCatalog.variants
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# The canonical page this one is being retired in favour of, or nil when this
|
|
30
|
-
# app has not drawn it. Rendered as a banner at the top of the old view so an
|
|
31
|
-
# admin who lands here by a stale link is walked forward.
|
|
32
|
-
#
|
|
33
|
-
# The nil case is REAL, not defensive padding: /admin/emails is opt-in
|
|
34
|
-
# (Studio.draw_admin_emails_routes, default off because turf-monster owns the
|
|
35
|
-
# name), so on any app that has not opted in the helper does not exist and a
|
|
36
|
-
# bare call raises NameError — which 500s this page. Consumer CI caught
|
|
37
|
-
# exactly that on mcritchie-studio.
|
|
38
|
-
#
|
|
39
|
-
# Asks the ROUTER rather than the config flag: the flag is read at route-draw
|
|
40
|
-
# time, so a host that flips it afterwards would still have no route, and the
|
|
41
|
-
# router is the thing that actually knows.
|
|
42
|
-
def successor_path
|
|
43
|
-
return nil unless Rails.application.routes.named_routes.key?(:admin_emails)
|
|
44
|
-
|
|
45
|
-
admin_emails_path
|
|
46
|
-
end
|
|
47
|
-
helper_method :successor_path
|
|
48
|
-
|
|
49
|
-
# PATCH /admin/email_images/:variant — upload/replace a banner.
|
|
50
|
-
def update
|
|
51
|
-
variant = params[:variant].to_s
|
|
52
|
-
return head :not_found unless Studio::EmailCatalog.known?(variant)
|
|
53
|
-
|
|
54
|
-
file = params[:image]
|
|
55
|
-
unless valid_image?(file)
|
|
56
|
-
message = file.blank? ? "Choose an image to upload." : "Use a PNG, JPG, or WebP under 8 MB."
|
|
57
|
-
return redirect_to admin_email_images_path, alert: message, status: :see_other
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
rescue_and_log do
|
|
61
|
-
Studio::EmailCatalog.store(variant, io: file, content_type: file.content_type)
|
|
62
|
-
redirect_to admin_email_images_path, notice: "#{Studio::EmailCatalog.label(variant)} banner updated."
|
|
63
|
-
end
|
|
64
|
-
rescue StandardError
|
|
65
|
-
redirect_to admin_email_images_path, alert: "Couldn't save the image. Please try again.", status: :see_other
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
private
|
|
69
|
-
|
|
70
|
-
def valid_image?(file)
|
|
71
|
-
file.respond_to?(:content_type) &&
|
|
72
|
-
file.content_type.to_s.start_with?("image/") &&
|
|
73
|
-
file.respond_to?(:size) && file.size.to_i.positive? && file.size <= MAX_BYTES
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
end
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Studio
|
|
4
|
-
# Mixin for ActiveRecord models that broadcast Turbo Streams. It wraps
|
|
5
|
-
# turbo-rails' broadcast_*_to methods in Studio::Cable.safe_broadcast, so a cable
|
|
6
|
-
# failure (a Redis hiccup, a missing/misconfigured adapter) can NEVER break the
|
|
7
|
-
# model save / after_commit that triggered the broadcast — the SEV-1 guard, in
|
|
8
|
-
# one place. The host model already has the raw broadcast_*_to methods (turbo-rails
|
|
9
|
-
# includes Turbo::Broadcastable into ActiveRecord::Base); these are the SAFE
|
|
10
|
-
# variants every app should broadcast through.
|
|
11
|
-
#
|
|
12
|
-
# class Task < ApplicationRecord
|
|
13
|
-
# include Studio::Broadcastable
|
|
14
|
-
# after_create_commit { safe_broadcast_replace_to [:board], target: "card_#{id}",
|
|
15
|
-
# partial: "tasks/card", locals: { task: self } }
|
|
16
|
-
# end
|
|
17
|
-
module Broadcastable
|
|
18
|
-
extend ActiveSupport::Concern
|
|
19
|
-
|
|
20
|
-
def safe_broadcast_replace_to(*args, **kwargs, &block)
|
|
21
|
-
Studio::Cable.safe_broadcast { broadcast_replace_to(*args, **kwargs, &block) }
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def safe_broadcast_update_to(*args, **kwargs, &block)
|
|
25
|
-
Studio::Cable.safe_broadcast { broadcast_update_to(*args, **kwargs, &block) }
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def safe_broadcast_append_to(*args, **kwargs, &block)
|
|
29
|
-
Studio::Cable.safe_broadcast { broadcast_append_to(*args, **kwargs, &block) }
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def safe_broadcast_prepend_to(*args, **kwargs, &block)
|
|
33
|
-
Studio::Cable.safe_broadcast { broadcast_prepend_to(*args, **kwargs, &block) }
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def safe_broadcast_remove_to(*args, **kwargs, &block)
|
|
37
|
-
Studio::Cable.safe_broadcast { broadcast_remove_to(*args, **kwargs, &block) }
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
end
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "digest"
|
|
4
|
-
|
|
5
|
-
module Studio
|
|
6
|
-
# The four things every Studio app's User has had to define for itself: how to
|
|
7
|
-
# name a person on screen, and how to draw them when they have no picture.
|
|
8
|
-
#
|
|
9
|
-
# WHY THIS EXISTS. The engine's own components/_avatar partial calls
|
|
10
|
-
# `display_name`, `avatar_initials` and `avatar_color` — and the engine has
|
|
11
|
-
# never provided any of them. Every consumer wrote its own; mcritchie-industries'
|
|
12
|
-
# user.rb even carries a comment explaining that it must, because the engine's
|
|
13
|
-
# nav renders the partial. Three copies, and by 2026-08-14 they had drifted:
|
|
14
|
-
#
|
|
15
|
-
# mcritchie-studio name → email prefix (capitalized) → wallet → "anon"
|
|
16
|
-
# mcritchie-industries name → email prefix (raw) → "User"
|
|
17
|
-
# turf-monster username → name → email prefix (cap) → wallet → "anon"
|
|
18
|
-
#
|
|
19
|
-
# Same intent, three answers, and two different words for the same empty state.
|
|
20
|
-
# That is the drift a concern removes.
|
|
21
|
-
#
|
|
22
|
-
# ON THE MERGED CHAIN. It is turf-monster's — the richest of the three — with
|
|
23
|
-
# `first_name` folded in after `name`, because /profile now lets people set that
|
|
24
|
-
# field and a name someone just typed should be the name they see. Apps missing
|
|
25
|
-
# a link in the chain simply skip it; the chain is respond_to?-guarded end to
|
|
26
|
-
# end, so mcritchie-industries' eight-column users table walks it without
|
|
27
|
-
# raising. One consequence worth stating rather than burying: an MI user with no
|
|
28
|
-
# name now falls back to "anon" instead of "User", and an email-derived name is
|
|
29
|
-
# capitalized. That is MI adopting the house standard, and its adoption task
|
|
30
|
-
# owns the change.
|
|
31
|
-
#
|
|
32
|
-
# EVERY METHOD IS OVERRIDABLE. These are plain instance methods from an included
|
|
33
|
-
# module, so a host that defines its own `display_name` in the class body wins
|
|
34
|
-
# outright. Standardizing the default is the goal; forbidding an app from having
|
|
35
|
-
# an opinion is not.
|
|
36
|
-
module UserProfile
|
|
37
|
-
extend ActiveSupport::Concern
|
|
38
|
-
|
|
39
|
-
# The house palette for initial-circle backgrounds. Identical in all three
|
|
40
|
-
# apps today, which is the clearest possible signal it belonged here.
|
|
41
|
-
AVATAR_COLORS = %w[#EF4444 #F97316 #EAB308 #22C55E #06B6D4 #3B82F6 #8B5CF6 #EC4899].freeze
|
|
42
|
-
|
|
43
|
-
# What to call this person on screen.
|
|
44
|
-
def display_name
|
|
45
|
-
studio_profile_username.presence ||
|
|
46
|
-
studio_profile_attr(:name).presence ||
|
|
47
|
-
studio_profile_attr(:first_name).presence ||
|
|
48
|
-
studio_profile_email_prefix.presence ||
|
|
49
|
-
studio_profile_wallet.presence ||
|
|
50
|
-
"anon"
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# One character for the initials circle. Deliberately NOT derived from
|
|
54
|
-
# display_name: that chain ends in a wallet address or the word "anon", and
|
|
55
|
-
# "a" for every anonymous account is worse than a neutral mark.
|
|
56
|
-
def avatar_initials
|
|
57
|
-
source = studio_profile_username.presence ||
|
|
58
|
-
studio_profile_attr(:name).presence ||
|
|
59
|
-
studio_profile_attr(:first_name).presence ||
|
|
60
|
-
studio_profile_email_prefix.presence
|
|
61
|
-
|
|
62
|
-
# `source[0]`, not ActiveSupport's `String#first`: this module is included
|
|
63
|
-
# into a host model, and nothing here should depend on which core_ext that
|
|
64
|
-
# host happens to have loaded. Plain Ruby costs nothing and cannot vanish.
|
|
65
|
-
source.presence.to_s[0]&.upcase || "?"
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# A stable colour for this account — same person, same circle, every render.
|
|
69
|
-
# Hashed rather than random for exactly that reason, and keyed off identity
|
|
70
|
-
# fields so it survives a display-name change.
|
|
71
|
-
def avatar_color
|
|
72
|
-
key = studio_profile_username.presence ||
|
|
73
|
-
studio_profile_attr(:name).presence ||
|
|
74
|
-
studio_profile_attr(:email).presence ||
|
|
75
|
-
id.to_s
|
|
76
|
-
|
|
77
|
-
AVATAR_COLORS[Digest::MD5.hexdigest(key.to_s).hex % AVATAR_COLORS.size]
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
private
|
|
81
|
-
|
|
82
|
-
# Read an attribute only if this host's model actually has it. The whole
|
|
83
|
-
# tolerance of the chain lives in this one method.
|
|
84
|
-
def studio_profile_attr(attribute)
|
|
85
|
-
return nil unless respond_to?(attribute)
|
|
86
|
-
|
|
87
|
-
public_send(attribute)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# `username` is turf-monster's on-chain handle and does not exist in the hub
|
|
91
|
-
# apps. It leads the chain where it exists because it is what that app's
|
|
92
|
-
# people call each other.
|
|
93
|
-
def studio_profile_username
|
|
94
|
-
studio_profile_attr(:username)
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def studio_profile_email_prefix
|
|
98
|
-
email = studio_profile_attr(:email)
|
|
99
|
-
return nil if email.blank?
|
|
100
|
-
|
|
101
|
-
email.to_s.split("@").first.to_s.capitalize
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# Defers to the host's own truncation rather than reformatting an address
|
|
105
|
-
# here — mcritchie-studio and turf-monster both define `truncated_solana`,
|
|
106
|
-
# and turf's has to pick between a web2 and a web3 address to do it.
|
|
107
|
-
def studio_profile_wallet
|
|
108
|
-
studio_profile_attr(:truncated_solana)
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
end
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
<%# locals: (form:, field:, label: nil, type: "text", placeholder: nil, rows: nil) %>
|
|
2
|
-
<div class="mb-5">
|
|
3
|
-
<% if label.present? %>
|
|
4
|
-
<label class="block text-sm text-secondary mb-2 font-medium"><%= label %></label>
|
|
5
|
-
<% end %>
|
|
6
|
-
<% if type.to_s == "textarea" %>
|
|
7
|
-
<%= form.text_area field, rows: rows || 4, class: "input-field", placeholder: placeholder %>
|
|
8
|
-
<% elsif type.to_s == "password" %>
|
|
9
|
-
<%= form.password_field field, class: "input-field", placeholder: placeholder %>
|
|
10
|
-
<% else %>
|
|
11
|
-
<%= form.text_field field, class: "input-field", placeholder: placeholder %>
|
|
12
|
-
<% end %>
|
|
13
|
-
</div>
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
<%# locals: (percent:, height: "h-3", color: "var(--color-cta)", label: nil, animated: false) %>
|
|
2
|
-
<div class="w-full <%= height %> rounded-full bg-surface-alt overflow-hidden">
|
|
3
|
-
<div class="h-full rounded-full<%= ' transition-all duration-500 ease-out' if animated %>"
|
|
4
|
-
style="width: <%= [percent.to_f, 100].min %>%; background-color: <%= color %>">
|
|
5
|
-
</div>
|
|
6
|
-
</div>
|
|
7
|
-
<% if label %>
|
|
8
|
-
<p class="text-xs text-muted mt-1"><%= label %></p>
|
|
9
|
-
<% end %>
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
<%# DEPRECATED page — superseded by /admin/emails (studio/emails/index).
|
|
2
|
-
Kept for one release so consumer suites on their default branch keep passing;
|
|
3
|
-
see Studio::EmailImagesController for the full staging note. %>
|
|
4
|
-
<% content_for(:title) { "Email images" } %>
|
|
5
|
-
<div class="max-w-2xl mx-auto px-4 py-8">
|
|
6
|
-
<header class="mb-6">
|
|
7
|
-
<h1 class="text-2xl font-bold">Email images</h1>
|
|
8
|
-
<p class="text-sm text-muted mt-1">
|
|
9
|
-
The banner shown at the top of branded transactional emails. Recommended
|
|
10
|
-
width 600px (it's displayed full-bleed at the top of the email card).
|
|
11
|
-
</p>
|
|
12
|
-
</header>
|
|
13
|
-
|
|
14
|
-
<%# Only when this app actually DREW /admin/emails — it is opt-in, and pointing
|
|
15
|
-
at a route that does not exist raises NameError and 500s this page. %>
|
|
16
|
-
<% if successor_path %>
|
|
17
|
-
<div class="mb-6 rounded-xl border border-subtle bg-inset px-4 py-3">
|
|
18
|
-
<p class="text-sm font-semibold text-heading">There's a better page now</p>
|
|
19
|
-
<p class="text-sm text-body mt-1">
|
|
20
|
-
<%= link_to "Emails", successor_path, class: "underline underline-offset-2" %>
|
|
21
|
-
lists every email this app sends with its live banner, says whether that
|
|
22
|
-
banner is the shared default or this app's own, and crops on upload.
|
|
23
|
-
This page is going away.
|
|
24
|
-
</p>
|
|
25
|
-
</div>
|
|
26
|
-
<% end %>
|
|
27
|
-
|
|
28
|
-
<% flash.each do |type, message| %>
|
|
29
|
-
<div class="mb-4 rounded-lg px-4 py-3 text-sm bg-surface border <%= type.to_s == "alert" ? "border-danger/30 text-danger-ink" : "border-success/30 text-success-ink" %>">
|
|
30
|
-
<%= message %>
|
|
31
|
-
</div>
|
|
32
|
-
<% end %>
|
|
33
|
-
|
|
34
|
-
<div class="space-y-6">
|
|
35
|
-
<% @variants.each do |variant, label| %>
|
|
36
|
-
<%# preview_url, not url: this page's ORIGINAL BUG was reading only the S3
|
|
37
|
-
override, so it announced "No image yet" for an email that was visibly
|
|
38
|
-
sending a banner from a committed repo asset. Even on its way out it
|
|
39
|
-
should tell the truth about what ships. %>
|
|
40
|
-
<% current_url = Studio::EmailCatalog.preview_url(variant) %>
|
|
41
|
-
<%# NOT `== :default` — that value split into :app_asset / :engine_default.
|
|
42
|
-
Comparing against a value nothing returns would silently never show
|
|
43
|
-
this note again. Anything that is not an upload is still inherited
|
|
44
|
-
from a file, which is all this retiring page needs to say. %>
|
|
45
|
-
<% inherited = current_url.present? && Studio::EmailCatalog.source(variant) != :app %>
|
|
46
|
-
<section class="rounded-xl border border-subtle p-5">
|
|
47
|
-
<h2 class="font-semibold mb-3"><%= label %></h2>
|
|
48
|
-
|
|
49
|
-
<div class="rounded-lg overflow-hidden border border-subtle mb-3" style="aspect-ratio: 600 / 240; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
|
|
50
|
-
<% if current_url %>
|
|
51
|
-
<%= image_tag current_url, class: "w-full h-full object-cover", alt: "#{label} email banner" %>
|
|
52
|
-
<% else %>
|
|
53
|
-
<div class="w-full h-full flex items-center justify-center text-xs text-white/80 text-center px-4">
|
|
54
|
-
No image yet — emails send without a banner until you upload one.
|
|
55
|
-
</div>
|
|
56
|
-
<% end %>
|
|
57
|
-
</div>
|
|
58
|
-
|
|
59
|
-
<% if inherited %>
|
|
60
|
-
<p class="text-xs text-muted mb-3">
|
|
61
|
-
This is the shared default that ships with the engine. Uploading here
|
|
62
|
-
makes the image this app's own.
|
|
63
|
-
</p>
|
|
64
|
-
<% end %>
|
|
65
|
-
|
|
66
|
-
<%= form_with url: admin_email_image_path(variant), method: :patch, html: { multipart: true }, class: "flex flex-wrap items-center gap-3" do %>
|
|
67
|
-
<%= file_field_tag :image, accept: "image/png,image/jpeg,image/webp",
|
|
68
|
-
class: "text-sm file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-white file:cursor-pointer" %>
|
|
69
|
-
<%= submit_tag(current_url ? "Replace image" : "Upload image", class: "btn btn-primary btn-sm") %>
|
|
70
|
-
<% end %>
|
|
71
|
-
</section>
|
|
72
|
-
<% end %>
|
|
73
|
-
</div>
|
|
74
|
-
</div>
|