studio-engine 0.29.1 → 0.31.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 +254 -0
- data/README.md +5 -4
- data/app/assets/tailwind/studio_engine/engine.css +18 -0
- data/app/controllers/concerns/studio/error_handling.rb +7 -2
- data/app/controllers/concerns/studio/link_consumption.rb +131 -11
- data/app/controllers/concerns/studio/magic_link_issuing.rb +15 -20
- data/app/controllers/magic_links_controller.rb +13 -40
- data/app/controllers/registrations_controller.rb +3 -1
- data/app/controllers/studio/links_controller.rb +17 -19
- data/app/controllers/studio/local_reviews_controller.rb +4 -4
- data/app/helpers/studio_sidebar_helper.rb +22 -0
- data/app/mailers/user_mailer.rb +8 -7
- data/app/models/studio/link.rb +55 -2
- data/app/views/components/_link_sidebar.html.erb +179 -0
- data/app/views/components/_link_sidebar_trigger.html.erb +21 -0
- data/app/views/components/_sidebar_panel.html.erb +63 -0
- data/app/views/components/_user_nav.html.erb +13 -7
- data/app/views/layouts/_navbar.html.erb +29 -5
- data/app/views/navbar/show.html.erb +4 -1
- data/app/views/studio/_confirm_interstitial.html.erb +4 -3
- data/app/views/studio/banners/_button.html.erb +10 -2
- data/app/views/studio/banners/_email_status_button.html.erb +25 -6
- data/app/views/studio/banners/_environment.html.erb +38 -13
- data/db/migrate/20260620000002_allow_null_image_cache_owner.rb +10 -0
- data/lib/studio/environment_banner.rb +56 -0
- data/lib/studio/link_resolution.rb +139 -0
- data/lib/studio/link_token.rb +13 -4
- data/lib/studio/sidebar_sections.rb +34 -0
- data/lib/studio/theme_resolver.rb +4 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +108 -28
- data/studio-engine.gemspec +5 -5
- metadata +14 -9
- data/app/services/magic_link.rb +0 -122
- data/app/views/magic_links/confirm.html.erb +0 -2
|
@@ -1,24 +1,21 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Requesting a magic link — the passwordless email path's front door.
|
|
2
2
|
#
|
|
3
|
-
# POST /magic_link
|
|
4
|
-
# GET /magic_link/:token — "Confirm sign-in" interstitial (does NOT consume)
|
|
5
|
-
# POST /magic_link/:token — consume it: log in OR create the account
|
|
3
|
+
# POST /magic_link — request a link (email [, return_to])
|
|
6
4
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# which
|
|
5
|
+
# That is the whole controller. The token-bearing half lives at /l/<token>
|
|
6
|
+
# (Studio::LinksController): one token format, one place it burns. Before
|
|
7
|
+
# 0.31.0 this class also owned a /magic_link/:token confirm+consume pair for
|
|
8
|
+
# the retired :signed store, which was a second door onto the same lock.
|
|
11
9
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
10
|
+
# create-or-login: clicking the emailed link IS proof of email ownership, so an
|
|
11
|
+
# email that collides with a Google/wallet-only account that was never
|
|
12
|
+
# email-verified is safely signed in at consume time and stamped
|
|
13
|
+
# email_verified_at (unlike from_omniauth, which refuses that collision
|
|
14
|
+
# precisely because it lacked this proof).
|
|
16
15
|
class MagicLinksController < ApplicationController
|
|
17
|
-
include Studio::LinkConsumption
|
|
18
16
|
include Studio::MagicLinkIssuing
|
|
19
17
|
|
|
20
18
|
skip_before_action :require_authentication
|
|
21
|
-
layout false, only: :confirm
|
|
22
19
|
|
|
23
20
|
# Respond uniformly for any well-formed email. Under create-or-login every
|
|
24
21
|
# address is "valid" (it logs in or signs up), so there is nothing to
|
|
@@ -26,7 +23,7 @@ class MagicLinksController < ApplicationController
|
|
|
26
23
|
def create
|
|
27
24
|
email = params[:email].to_s.strip.downcase
|
|
28
25
|
if email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
29
|
-
token = issue_magic_link(email,
|
|
26
|
+
token = issue_magic_link(email, Studio::LinkToken.sanitize_path(params[:return_to]))
|
|
30
27
|
Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
|
|
31
28
|
end
|
|
32
29
|
respond_to do |format|
|
|
@@ -35,31 +32,7 @@ class MagicLinksController < ApplicationController
|
|
|
35
32
|
end
|
|
36
33
|
end
|
|
37
34
|
|
|
38
|
-
#
|
|
39
|
-
# preview clients frequently prefetch emailed URLs with GET/HEAD; if GET burned
|
|
40
|
-
# the token, the human's first real click could already be invalid. The page
|
|
41
|
-
# renders a CSRF-protected form that a browser auto-POSTs to #consume.
|
|
42
|
-
def confirm
|
|
43
|
-
# strict-origin strips the token-bearing path from subresource Referer
|
|
44
|
-
# headers while preserving a usable Origin header for Rails' CSRF origin
|
|
45
|
-
# check on the consume POST.
|
|
46
|
-
response.set_header("Referrer-Policy", "strict-origin")
|
|
47
|
-
@token = params[:token]
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# POST /magic_link/:token is the authoritative consume. This is the only place
|
|
51
|
-
# the single-use token is burned.
|
|
52
|
-
def consume
|
|
53
|
-
response.set_header("Referrer-Policy", "strict-origin")
|
|
54
|
-
result = MagicLink.consume(params[:token])
|
|
55
|
-
user = User.find_by(email: result.email)
|
|
56
|
-
user ? sign_in_existing(user, result) : sign_up_new(result)
|
|
57
|
-
rescue MagicLink::InvalidToken
|
|
58
|
-
redirect_to login_path, alert: "That sign-in link is invalid or has expired. Request a fresh one below."
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# issue_magic_link (mint in the configured store) comes from
|
|
35
|
+
# issue_magic_link (mint a Studio::Link row) comes from
|
|
62
36
|
# Studio::MagicLinkIssuing, shared with UserMailer — which builds the URL that
|
|
63
37
|
# consumes it — so the mint and its landing URL cannot drift apart.
|
|
64
|
-
# sign_in_existing / sign_up_new / safe_path come from Studio::LinkConsumption.
|
|
65
38
|
end
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
class RegistrationsController < ApplicationController
|
|
2
|
+
include Studio::MagicLinkIssuing
|
|
3
|
+
|
|
2
4
|
skip_before_action :require_authentication
|
|
3
5
|
|
|
4
6
|
def new
|
|
@@ -13,7 +15,7 @@ class RegistrationsController < ApplicationController
|
|
|
13
15
|
unless Studio.auth_method?(:password)
|
|
14
16
|
email = (params.dig(:user, :email) || params[:email]).to_s.strip.downcase
|
|
15
17
|
if email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
16
|
-
token =
|
|
18
|
+
token = issue_magic_link(email, nil)
|
|
17
19
|
Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
|
|
18
20
|
end
|
|
19
21
|
return redirect_to login_path, notice: "Check your inbox — we just emailed you a sign-in link."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Studio
|
|
2
|
-
# The
|
|
3
|
-
# Studio::Link#kind:
|
|
2
|
+
# The short-token link entry point — GET/POST /l/<token>, the only magic-link
|
|
3
|
+
# door the engine draws. Dispatches by Studio::Link#kind:
|
|
4
4
|
#
|
|
5
5
|
# magic_link → scanner-safe confirm interstitial (GET, inert) that auto-POSTs
|
|
6
6
|
# to #consume, the ONLY place the single-use token is burned +
|
|
@@ -9,6 +9,10 @@ module Studio
|
|
|
9
9
|
# the link's target (or root). Reusable + safe to prefetch, so
|
|
10
10
|
# GET does the work (no POST step).
|
|
11
11
|
#
|
|
12
|
+
# Every decision about what a magic-link click DOES lives in
|
|
13
|
+
# Studio::LinkConsumption / Studio::LinkResolution, not here — including the
|
|
14
|
+
# invariant that a dead link leaves the visitor's session untouched.
|
|
15
|
+
#
|
|
12
16
|
# Namespaced (not top-level Links) because mcritchie-studio already owns a
|
|
13
17
|
# public /links linktree (top-level LinksController). Apps needing richer
|
|
14
18
|
# post-consume routing (contest landing, picks rehydration, age-gate) define
|
|
@@ -25,30 +29,24 @@ module Studio
|
|
|
25
29
|
response.set_header("Referrer-Policy", "strict-origin")
|
|
26
30
|
@link = Studio::Link.find_by(token: params[:token])
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
when "magic_link"
|
|
30
|
-
@token = params[:token]
|
|
31
|
-
render :confirm
|
|
32
|
-
when "referral"
|
|
32
|
+
if @link&.kind == "referral"
|
|
33
33
|
capture_referral(@link)
|
|
34
|
-
redirect_to(@link.target || root_path)
|
|
35
|
-
else
|
|
36
|
-
redirect_to login_path, alert: "That link is invalid or has expired. Request a fresh one below."
|
|
34
|
+
return redirect_to(@link.target || root_path)
|
|
37
35
|
end
|
|
36
|
+
|
|
37
|
+
# A magic link, or a token with no row behind it. preview_magic_link is
|
|
38
|
+
# inert — it never burns — and settles a dead link itself rather than
|
|
39
|
+
# sending the visitor through a spinner that only POSTs to learn the same.
|
|
40
|
+
@token = params[:token]
|
|
41
|
+
render :confirm if preview_magic_link(@link&.kind == "magic_link" ? @link : nil) == :live
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
# POST /l/:token — authoritative magic-link consume. Only magic_link kinds are
|
|
41
|
-
# consumable here; referral links are reusable and handled entirely on GET
|
|
45
|
+
# consumable here; referral links are reusable and handled entirely on GET,
|
|
46
|
+
# so a referral token scoped out below reads as an unknown token.
|
|
42
47
|
def consume
|
|
43
48
|
response.set_header("Referrer-Policy", "strict-origin")
|
|
44
|
-
|
|
45
|
-
raise Studio::Link::InvalidToken, "not a magic link" unless link&.kind == "magic_link"
|
|
46
|
-
|
|
47
|
-
link.consume! # burns the single-use token; raises if already used / expired
|
|
48
|
-
user = User.find_by(email: link.email)
|
|
49
|
-
user ? sign_in_existing(user, link) : sign_up_new(link)
|
|
50
|
-
rescue Studio::Link::InvalidToken
|
|
51
|
-
redirect_to login_path, alert: "That sign-in link is invalid or has expired. Request a fresh one below."
|
|
49
|
+
consume_magic_link(Studio::Link.magic_links.find_by(token: params[:token]))
|
|
52
50
|
end
|
|
53
51
|
|
|
54
52
|
private
|
|
@@ -30,10 +30,10 @@ module Studio
|
|
|
30
30
|
email = Studio::LinkToken.normalize_email(params[:email])
|
|
31
31
|
return redirect_to(login_path, alert: MISSING_EMAIL) unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
32
32
|
|
|
33
|
-
# return_to is passed through raw:
|
|
34
|
-
# path on the way in (Studio::Link.create_magic_link
|
|
35
|
-
#
|
|
36
|
-
#
|
|
33
|
+
# return_to is passed through raw: the store sanitizes it to a same-origin
|
|
34
|
+
# path on the way in (Studio::Link.create_magic_link calls
|
|
35
|
+
# Studio::LinkToken.sanitize_path). Re-sanitizing here would be a second
|
|
36
|
+
# spelling of a rule that already has one owner — and a mutation run
|
|
37
37
|
# confirmed it guards nothing the store does not already guard.
|
|
38
38
|
token = issue_magic_link(email, params[:return_to])
|
|
39
39
|
redirect_to magic_link_url_for(token), allow_other_host: false
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# The link-sidebar's view seam. Exposed to host views automatically (the
|
|
2
|
+
# engine is non-isolated), so the navbar and any host layout can gate on
|
|
3
|
+
# studio_sidebar? without wiring. Sections resolve once per render pass —
|
|
4
|
+
# the resolver may call a host lambda that walks models or routes.
|
|
5
|
+
module StudioSidebarHelper
|
|
6
|
+
def studio_sidebar_sections
|
|
7
|
+
@studio_sidebar_sections ||= Studio.sidebar_sections_for(self)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def studio_sidebar?
|
|
11
|
+
studio_sidebar_sections.any?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The admin dropdown and the sidebar trigger share the cog glyph — showing
|
|
15
|
+
# both reads as a double gear. When the viewer's resolved sections carry an
|
|
16
|
+
# admin-flagged entry, the sidebar IS the admin menu (its title says so) and
|
|
17
|
+
# the engine components skip the dropdown. Declaring only public sections
|
|
18
|
+
# keeps the dropdown, so admins never lose Theme/Navbar/Error Logs.
|
|
19
|
+
def studio_sidebar_replaces_admin_menu?
|
|
20
|
+
studio_sidebar_sections.any? { |section| section[:admin] }
|
|
21
|
+
end
|
|
22
|
+
end
|
data/app/mailers/user_mailer.rb
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
class UserMailer < ApplicationMailer
|
|
2
|
-
# magic_link_url_for — the URL that consumes the token
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# cannot drift apart.
|
|
2
|
+
# magic_link_url_for — the URL that consumes the token. Shared with the
|
|
3
|
+
# issuers that MINT it (MagicLinksController, RegistrationsController,
|
|
4
|
+
# Studio::LocalReviewsController) so the two halves cannot drift apart.
|
|
6
5
|
include Studio::MagicLinkIssuing
|
|
7
6
|
|
|
8
7
|
# Branded shell (banner + card) for engine-sent UserMailer emails. An app with
|
|
@@ -10,9 +9,11 @@ class UserMailer < ApplicationMailer
|
|
|
10
9
|
layout "branded_mailer"
|
|
11
10
|
|
|
12
11
|
# Passwordless sign-in link. `email` is a raw string (the recipient may not
|
|
13
|
-
# have an account yet).
|
|
14
|
-
#
|
|
15
|
-
#
|
|
12
|
+
# have an account yet). The token is a Studio::Link row's short token — 16
|
|
13
|
+
# URL-safe characters, single-use, expiring — and the email + return_to it
|
|
14
|
+
# stands for stay in the row, off the wire. Clicking the link logs the
|
|
15
|
+
# recipient in or creates their account. App-name-aware so the same template
|
|
16
|
+
# serves every Studio app.
|
|
16
17
|
#
|
|
17
18
|
# Engine GENERIC base. An app needing richer copy (e.g. turf-monster's
|
|
18
19
|
# contest-aware variant) defines its own UserMailer, which wins.
|
data/app/models/studio/link.rb
CHANGED
|
@@ -11,13 +11,20 @@ module Studio
|
|
|
11
11
|
# discriminator. Replaces the engine's stateless MessageVerifier MagicLink
|
|
12
12
|
# service for mcritchie-studio so both apps share one short-token scheme.
|
|
13
13
|
#
|
|
14
|
-
# Like Studio::EmailDelivery, the table lives in each consumer app
|
|
15
|
-
#
|
|
14
|
+
# Like Studio::EmailDelivery, the table lives in each consumer app — installed
|
|
15
|
+
# by `bin/rails studio_engine:install:migrations`, never hand-copied (a hand
|
|
16
|
+
# copy collides with the task's own copy on `class CreateStudioLinks`). This
|
|
17
|
+
# model is shipped by the gem.
|
|
16
18
|
class Link < ApplicationRecord
|
|
17
19
|
self.table_name = "studio_links"
|
|
18
20
|
|
|
19
21
|
class InvalidToken < StandardError; end
|
|
20
22
|
|
|
23
|
+
# The app enabled :magic_link but never installed the table. Raised in place
|
|
24
|
+
# of a bare PG::UndefinedTable so the first person to hit it reads the fix
|
|
25
|
+
# instead of an adapter error — see mint!.
|
|
26
|
+
class MissingTable < StandardError; end
|
|
27
|
+
|
|
21
28
|
belongs_to :linkable, polymorphic: true, optional: true
|
|
22
29
|
|
|
23
30
|
validates :kind, inclusion: { in: Studio::LinkToken::KINDS }
|
|
@@ -76,6 +83,16 @@ module Studio
|
|
|
76
83
|
|
|
77
84
|
# create! with a fresh random token, retrying the (astronomically rare)
|
|
78
85
|
# unique-index collision a couple of times before surfacing the error.
|
|
86
|
+
#
|
|
87
|
+
# The MissingTable rescue exists because every consumer pins the engine as
|
|
88
|
+
# `~> 0.x`, which admits any release below 1.0 — so an app that never
|
|
89
|
+
# installed this table picks up the row store on its next `bundle update`
|
|
90
|
+
# whether or not anyone adopted it deliberately. Its boot is fine (nothing
|
|
91
|
+
# touches the table until someone signs in), and the failure then lands as
|
|
92
|
+
# a bare PG::UndefinedTable on a real person's sign-in. Naming the fix
|
|
93
|
+
# there is the difference between a five-minute repair and an outage
|
|
94
|
+
# nobody can read. The check is `table_exists?`, not a message match, so
|
|
95
|
+
# it holds on any adapter and never swallows an unrelated failure.
|
|
79
96
|
def mint!(attrs)
|
|
80
97
|
3.times do
|
|
81
98
|
return create!(attrs.merge(token: Studio::LinkToken.generate))
|
|
@@ -83,6 +100,14 @@ module Studio
|
|
|
83
100
|
next
|
|
84
101
|
end
|
|
85
102
|
create!(attrs.merge(token: Studio::LinkToken.generate))
|
|
103
|
+
rescue ActiveRecord::StatementInvalid
|
|
104
|
+
raise if table_exists?
|
|
105
|
+
|
|
106
|
+
raise MissingTable,
|
|
107
|
+
"studio-engine magic links need the studio_links table, and #{Studio.app_name} has no " \
|
|
108
|
+
"such table. Run `bin/rails studio_engine:install:migrations && bin/rails db:migrate` " \
|
|
109
|
+
"(install ALL of them). Do not hand-copy the migration — it collides with the task's " \
|
|
110
|
+
"own copy on `class CreateStudioLinks`."
|
|
86
111
|
end
|
|
87
112
|
end
|
|
88
113
|
|
|
@@ -106,6 +131,34 @@ module Studio
|
|
|
106
131
|
self
|
|
107
132
|
end
|
|
108
133
|
|
|
134
|
+
# The non-raising sibling of #consume!, and the one the click flow uses.
|
|
135
|
+
# Returns whether THIS caller won the burn — false for a link that was
|
|
136
|
+
# already used, has expired, or lost the atomic race to a concurrent click.
|
|
137
|
+
#
|
|
138
|
+
# Why a boolean and not the exception: "the link was dead" is not an error
|
|
139
|
+
# here, it is one of the two normal outcomes, and the branch it feeds
|
|
140
|
+
# (Studio::LinkResolution) needs the answer as data. Reloads on a loss so
|
|
141
|
+
# the caller reads the row's settled state (consumed_at / expires_at) rather
|
|
142
|
+
# than the copy it held before the race.
|
|
143
|
+
def burn
|
|
144
|
+
consume!
|
|
145
|
+
true
|
|
146
|
+
rescue InvalidToken
|
|
147
|
+
reload
|
|
148
|
+
false
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# How a failed burn should be described. Only meaningful once #burn has
|
|
152
|
+
# returned false (or on a link that was never burned at all).
|
|
153
|
+
def dead_status
|
|
154
|
+
return :used if single_use? && consumed?
|
|
155
|
+
return :expired if expired?
|
|
156
|
+
|
|
157
|
+
# Neither flag is set but the burn did not land: a concurrent click won
|
|
158
|
+
# it between our read and our write. Same story for the reader.
|
|
159
|
+
:used
|
|
160
|
+
end
|
|
161
|
+
|
|
109
162
|
def single_use?
|
|
110
163
|
Studio::LinkToken.single_use?(kind)
|
|
111
164
|
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
<%# The link sidebar — the engine's out-of-the-box navigation panel (lifted
|
|
2
|
+
from the mcritchie-studio hub). Renders the host's Studio.sidebar_sections
|
|
3
|
+
as a slide-out link tree in dual desktop/mobile panels, and carries its
|
|
4
|
+
own store bridge so a single render is self-sufficient. Render it once,
|
|
5
|
+
outside any sticky/backdrop-filter header (a fixed panel inside one
|
|
6
|
+
positions against the header, not the viewport); the engine navbar does
|
|
7
|
+
this automatically when sections are declared. %>
|
|
8
|
+
<% sections = studio_sidebar_sections %>
|
|
9
|
+
<% sidebar_admin = respond_to?(:admin?) && admin? %>
|
|
10
|
+
<% close_action = "$store.sidebars.linkTreeOpen = false" %>
|
|
11
|
+
|
|
12
|
+
<style>
|
|
13
|
+
/* Clip horizontal overflow so the off-canvas start/end frame of the slide
|
|
14
|
+
never flashes a scrollbar. `clip` (not `hidden`) does not create a scroll
|
|
15
|
+
container, so sticky headers keep working. Scoped here — not engine.css —
|
|
16
|
+
so apps that declare no sections get zero global CSS change. */
|
|
17
|
+
html { overflow-x: clip; }
|
|
18
|
+
</style>
|
|
19
|
+
|
|
20
|
+
<script>
|
|
21
|
+
// Store bridge (engine-owned): registers the Alpine `sidebars` store and
|
|
22
|
+
// keeps it healthy across Turbo navigations and bfcache restores. The
|
|
23
|
+
// panel's open/closed state is driven ENTIRELY by the store flag via
|
|
24
|
+
// x-show + x-transition — this bridge only flips the flag, never `display`
|
|
25
|
+
// directly, because an imperative display:none bakes an inline style that
|
|
26
|
+
// makes Alpine skip its enter transition (the panel pops instead of
|
|
27
|
+
// sliding). On turbo:before-cache / bfcache the flag drops so a restored
|
|
28
|
+
// page is closed. The document-level capture handlers make the trigger
|
|
29
|
+
// work even for clicks that land before Alpine initializes.
|
|
30
|
+
(function () {
|
|
31
|
+
function ensureLinkSidebarStore() {
|
|
32
|
+
if (!window.Alpine) return null;
|
|
33
|
+
|
|
34
|
+
var sidebars = Alpine.store('sidebars');
|
|
35
|
+
if (!sidebars) {
|
|
36
|
+
Alpine.store('sidebars', { linkTreeOpen: false });
|
|
37
|
+
return Alpine.store('sidebars');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (typeof sidebars.linkTreeOpen === 'undefined') {
|
|
41
|
+
sidebars.linkTreeOpen = false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return sidebars;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function setLinkSidebarOpen(open) {
|
|
48
|
+
var sidebars = ensureLinkSidebarStore();
|
|
49
|
+
if (sidebars) sidebars.linkTreeOpen = open;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function closeLinkSidebar() {
|
|
53
|
+
setLinkSidebarOpen(false);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function handleLinkSidebarClick(event) {
|
|
57
|
+
var closeTrigger = event.target.closest('[data-link-sidebar-close]');
|
|
58
|
+
if (closeTrigger) {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
event.stopPropagation();
|
|
61
|
+
event.stopImmediatePropagation();
|
|
62
|
+
closeLinkSidebar();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
var trigger = event.target.closest('[data-link-sidebar-trigger], [data-username-display], [data-profile-image-toggle]');
|
|
67
|
+
if (trigger && trigger.matches('[aria-controls~="studio-link-sidebar"]')) {
|
|
68
|
+
var sidebars = ensureLinkSidebarStore();
|
|
69
|
+
if (!sidebars) return;
|
|
70
|
+
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
event.stopPropagation();
|
|
73
|
+
event.stopImmediatePropagation();
|
|
74
|
+
sidebars.linkTreeOpen = !sidebars.linkTreeOpen;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
var sidebars = ensureLinkSidebarStore();
|
|
79
|
+
if (!sidebars || !sidebars.linkTreeOpen) return;
|
|
80
|
+
if (event.target.closest('#studio-link-sidebar, #studio-link-sidebar-mobile')) return;
|
|
81
|
+
|
|
82
|
+
closeLinkSidebar();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function handleLinkSidebarKeydown(event) {
|
|
86
|
+
if (event.key === 'Escape') closeLinkSidebar();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!window.__studioLinkSidebarBridge) {
|
|
90
|
+
window.__studioLinkSidebarBridge = true;
|
|
91
|
+
document.addEventListener('alpine:init', ensureLinkSidebarStore);
|
|
92
|
+
document.addEventListener('turbo:load', ensureLinkSidebarStore);
|
|
93
|
+
document.addEventListener('turbo:before-cache', closeLinkSidebar);
|
|
94
|
+
window.addEventListener('pageshow', function (event) {
|
|
95
|
+
if (event.persisted) closeLinkSidebar();
|
|
96
|
+
});
|
|
97
|
+
document.addEventListener('click', handleLinkSidebarClick, true);
|
|
98
|
+
document.addEventListener('keydown', handleLinkSidebarKeydown, true);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
ensureLinkSidebarStore();
|
|
102
|
+
})();
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<% body_html = capture do %>
|
|
106
|
+
<nav class="flex-1 min-h-0 overflow-y-auto py-2" aria-label="Studio links">
|
|
107
|
+
<% sections.each do |section| %>
|
|
108
|
+
<div class="px-4 pt-4 pb-1 label-upper font-bold text-muted flex items-center gap-2">
|
|
109
|
+
<span><%= section[:title] %></span>
|
|
110
|
+
<% if section[:admin] %>
|
|
111
|
+
<span class="rounded border border-primary/20 bg-primary/10 px-1.5 py-0.5 text-[10px] font-mono text-primary">ADMIN</span>
|
|
112
|
+
<% end %>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<% section[:links].each do |link| %>
|
|
116
|
+
<% target = link[:target].presence %>
|
|
117
|
+
<%= link_to link[:href],
|
|
118
|
+
class: "group flex items-start gap-3 px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-inset transition",
|
|
119
|
+
target: target,
|
|
120
|
+
rel: target == "_blank" ? "noopener" : nil,
|
|
121
|
+
"@click": close_action do %>
|
|
122
|
+
<% if link[:hover_emoji].present? %>
|
|
123
|
+
<%= render "components/emoji_swap",
|
|
124
|
+
base: link[:emoji],
|
|
125
|
+
hover: link[:hover_emoji],
|
|
126
|
+
class_name: "text-lg leading-none" %>
|
|
127
|
+
<% else %>
|
|
128
|
+
<span class="text-lg leading-none flex-shrink-0"><%= link[:emoji] %></span>
|
|
129
|
+
<% end %>
|
|
130
|
+
<span class="min-w-0">
|
|
131
|
+
<span class="block font-medium truncate"><%= link[:label] %></span>
|
|
132
|
+
<% if link[:desc].present? %>
|
|
133
|
+
<span class="block text-xs text-muted leading-snug mt-0.5"><%= link[:desc] %></span>
|
|
134
|
+
<% end %>
|
|
135
|
+
</span>
|
|
136
|
+
<% end %>
|
|
137
|
+
<% end %>
|
|
138
|
+
<% end %>
|
|
139
|
+
</nav>
|
|
140
|
+
<% end %>
|
|
141
|
+
|
|
142
|
+
<% footer_html = capture do %>
|
|
143
|
+
<% if logged_in? %>
|
|
144
|
+
<div class="border-t border-subtle p-2">
|
|
145
|
+
<%= link_to logout_path,
|
|
146
|
+
class: "flex items-center gap-3 rounded-md px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 transition",
|
|
147
|
+
"@click": close_action do %>
|
|
148
|
+
<span>👋</span><span>Log out</span>
|
|
149
|
+
<% end %>
|
|
150
|
+
</div>
|
|
151
|
+
<% end %>
|
|
152
|
+
<% end %>
|
|
153
|
+
|
|
154
|
+
<%= render "components/sidebar_panel",
|
|
155
|
+
id: "studio-link-sidebar",
|
|
156
|
+
title: sidebar_admin ? "Admin Menu" : "Links",
|
|
157
|
+
open: "$store.sidebars.linkTreeOpen",
|
|
158
|
+
body_html: body_html,
|
|
159
|
+
footer_html: footer_html,
|
|
160
|
+
close_action: close_action,
|
|
161
|
+
outside_action: close_action,
|
|
162
|
+
escape_action: close_action,
|
|
163
|
+
display_class: "hidden md:flex",
|
|
164
|
+
role: "navigation",
|
|
165
|
+
aria_label: sidebar_admin ? "Admin menu" : "Links menu" %>
|
|
166
|
+
|
|
167
|
+
<%= render "components/sidebar_panel",
|
|
168
|
+
id: "studio-link-sidebar-mobile",
|
|
169
|
+
title: sidebar_admin ? "Admin Menu" : "Links",
|
|
170
|
+
open: "$store.sidebars.linkTreeOpen",
|
|
171
|
+
body_html: body_html,
|
|
172
|
+
footer_html: footer_html,
|
|
173
|
+
close_action: close_action,
|
|
174
|
+
outside_action: close_action,
|
|
175
|
+
escape_action: close_action,
|
|
176
|
+
display_class: "flex md:hidden",
|
|
177
|
+
width_class: "w-full",
|
|
178
|
+
role: "navigation",
|
|
179
|
+
aria_label: sidebar_admin ? "Admin menu" : "Links menu" %>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<%# Link-sidebar toggle button. `class_name` prepends responsive placement
|
|
2
|
+
classes (the navbar mounts one instance `hidden md:inline-flex` in the
|
|
3
|
+
desktop icon rail and one in the mobile sub-navbar row). The click is
|
|
4
|
+
handled twice on purpose: the Alpine @click for normal operation, and the
|
|
5
|
+
document-level capture bridge in _link_sidebar for pre-Alpine clicks. %>
|
|
6
|
+
<% extra_classes = local_assigns.fetch(:class_name, "") %>
|
|
7
|
+
<% sidebar_admin = respond_to?(:admin?) && admin? %>
|
|
8
|
+
<button type="button"
|
|
9
|
+
@click.stop="$store.sidebars.linkTreeOpen = !$store.sidebars.linkTreeOpen"
|
|
10
|
+
class="<%= "#{extra_classes} " if extra_classes.present? %>inline-flex h-9 w-9 items-center justify-center rounded-md text-secondary hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 transition"
|
|
11
|
+
title="<%= sidebar_admin ? 'Admin Menu' : 'Links' %>"
|
|
12
|
+
aria-label="<%= sidebar_admin ? 'Toggle admin menu' : 'Toggle links menu' %>"
|
|
13
|
+
aria-controls="studio-link-sidebar studio-link-sidebar-mobile"
|
|
14
|
+
aria-haspopup="dialog"
|
|
15
|
+
:aria-expanded="$store.sidebars && $store.sidebars.linkTreeOpen ? 'true' : 'false'"
|
|
16
|
+
data-link-sidebar-trigger="true">
|
|
17
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
|
18
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
|
19
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
|
20
|
+
</svg>
|
|
21
|
+
</button>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<%# Shared fixed right-sidebar shell (lifted from the mcritchie-studio hub).
|
|
2
|
+
Alpine-driven: `open` is an Alpine expression; the panel slides in from
|
|
3
|
+
off-screen right under x-transition and positions beneath the sticky
|
|
4
|
+
header via the --nav-h custom property _head.html.erb publishes. %>
|
|
5
|
+
<% id = local_assigns.fetch(:id, nil) %>
|
|
6
|
+
<% title = local_assigns.fetch(:title, nil) %>
|
|
7
|
+
<% open = local_assigns.fetch(:open) %>
|
|
8
|
+
<% actions_html = local_assigns.fetch(:actions_html, nil) %>
|
|
9
|
+
<% body_html = local_assigns.fetch(:body_html, nil) %>
|
|
10
|
+
<% footer_html = local_assigns.fetch(:footer_html, nil) %>
|
|
11
|
+
<% close_action = local_assigns.fetch(:close_action, nil) %>
|
|
12
|
+
<% outside_action = local_assigns.fetch(:outside_action, nil) %>
|
|
13
|
+
<% escape_action = local_assigns.fetch(:escape_action, nil) %>
|
|
14
|
+
<% display_class = local_assigns.fetch(:display_class, "hidden md:flex") %>
|
|
15
|
+
<% width_class = local_assigns.fetch(:width_class, "w-80 max-w-full") %>
|
|
16
|
+
<% z_class = local_assigns.fetch(:z_class, "studio-link-sidebar-layer") %>
|
|
17
|
+
<% panel_class = local_assigns.fetch(:panel_class, "") %>
|
|
18
|
+
<% role = local_assigns.fetch(:role, "complementary") %>
|
|
19
|
+
<% aria_label = local_assigns.fetch(:aria_label, title) %>
|
|
20
|
+
<% labelledby = id.present? && title.present? ? "#{id}-title" : nil %>
|
|
21
|
+
|
|
22
|
+
<aside
|
|
23
|
+
<% if id.present? %>id="<%= id %>"<% end %>
|
|
24
|
+
x-cloak
|
|
25
|
+
x-show="<%= open %>"
|
|
26
|
+
<%= %(#{outside_action.present? ? "@click.outside=\"#{outside_action}\"" : ""}).html_safe %>
|
|
27
|
+
<%= %(#{escape_action.present? ? "@keydown.escape.window=\"#{escape_action}\"" : ""}).html_safe %>
|
|
28
|
+
<%= %(#{close_action.present? ? "@turbo:before-cache.window=\"#{close_action}\"" : ""}).html_safe %>
|
|
29
|
+
x-transition:enter="transition ease-out duration-300"
|
|
30
|
+
x-transition:enter-start="translate-x-full"
|
|
31
|
+
x-transition:enter-end="translate-x-0"
|
|
32
|
+
x-transition:leave="transition ease-in duration-200"
|
|
33
|
+
x-transition:leave-start="translate-x-0"
|
|
34
|
+
x-transition:leave-end="translate-x-full"
|
|
35
|
+
class="<%= display_class %> fixed right-0 <%= width_class %> bg-surface border-l border-subtle shadow-2xl <%= z_class %> flex-col <%= panel_class %>"
|
|
36
|
+
style="top:var(--nav-h, 6rem);height:calc(100% - var(--nav-h, 6rem))"
|
|
37
|
+
role="<%= role %>"
|
|
38
|
+
<% if labelledby %>aria-labelledby="<%= labelledby %>"<% elsif aria_label.present? %>aria-label="<%= aria_label %>"<% end %>>
|
|
39
|
+
<div class="flex flex-col h-full">
|
|
40
|
+
<% if title.present? || actions_html.present? || close_action.present? %>
|
|
41
|
+
<div class="flex items-center justify-between p-4 border-b border-subtle">
|
|
42
|
+
<% if title.present? %>
|
|
43
|
+
<h3 <% if labelledby %>id="<%= labelledby %>"<% end %> class="text-lg font-bold text-heading"><%= title %></h3>
|
|
44
|
+
<% end %>
|
|
45
|
+
<div class="flex items-center gap-2 ml-auto">
|
|
46
|
+
<%= actions_html if actions_html.present? %>
|
|
47
|
+
<% if close_action.present? %>
|
|
48
|
+
<button type="button"
|
|
49
|
+
@click="<%= close_action %>"
|
|
50
|
+
class="flex items-center justify-center w-8 h-8 rounded-full text-secondary hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 transition"
|
|
51
|
+
aria-label="Close sidebar"
|
|
52
|
+
data-link-sidebar-close="true">
|
|
53
|
+
<span class="text-xl leading-none">×</span>
|
|
54
|
+
</button>
|
|
55
|
+
<% end %>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
<% end %>
|
|
59
|
+
|
|
60
|
+
<%= body_html if body_html.present? %>
|
|
61
|
+
<%= footer_html if footer_html.present? %>
|
|
62
|
+
</div>
|
|
63
|
+
</aside>
|
|
@@ -56,7 +56,9 @@
|
|
|
56
56
|
<%= studio_nav_slot.call(balance_slot, balance_html) %>
|
|
57
57
|
<div class="flex items-center gap-2 ml-auto self-end">
|
|
58
58
|
<%= studio_nav_slot.call(extra_icons_slot, extra_icons_html) %>
|
|
59
|
-
|
|
59
|
+
<%# Skipped when the link sidebar carries the admin menu — two cog
|
|
60
|
+
glyphs side by side read as a double gear. %>
|
|
61
|
+
<%= render "components/admin_dropdown" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
|
|
60
62
|
<%= render "components/theme_toggle_morph" %>
|
|
61
63
|
</div>
|
|
62
64
|
<% account_link = defined?(account_path) ? account_path : "#" %>
|
|
@@ -65,12 +67,16 @@
|
|
|
65
67
|
<% end %>
|
|
66
68
|
</div>
|
|
67
69
|
<% div2_content = studio_nav_slot.call(div2_slot, div2_html) %>
|
|
70
|
+
<% server_level = current_user.respond_to?(:level) && current_user.level.present? ? current_user.level : nil %>
|
|
71
|
+
<% wallet_connected = current_user.respond_to?(:truncated_solana) && current_user.try(:solana_connected?) %>
|
|
68
72
|
<% if div2_content.present? %>
|
|
69
73
|
<%# Div 2 replaced by the consumer's slot (div2_slot or legacy div2_html). %>
|
|
70
74
|
<%= div2_content %>
|
|
71
|
-
<%
|
|
72
|
-
<%# Div 2 (Smaller): wallet address (left) + level (right) with progress
|
|
73
|
-
|
|
75
|
+
<% elsif server_level || wallet_connected || show_logout_link %>
|
|
76
|
+
<%# Div 2 (Smaller): wallet address (left) + level (right) with progress
|
|
77
|
+
bar. Renders only when it has something to show — an off-chain,
|
|
78
|
+
level-less user without a logout link gets NO second row, so the
|
|
79
|
+
nav collapses to one line instead of an empty progress strip. %>
|
|
74
80
|
<div x-data="{
|
|
75
81
|
barWidth: 0,
|
|
76
82
|
displayLevel: <%= server_level || 1 %>,
|
|
@@ -123,7 +129,7 @@
|
|
|
123
129
|
<%# Text layer 1: muted (visible outside bar) %>
|
|
124
130
|
<div class="absolute inset-0 flex items-center justify-between px-1.5 font-mono text-muted z-[1] nav-bar-text">
|
|
125
131
|
<span>
|
|
126
|
-
<% if
|
|
132
|
+
<% if wallet_connected %>
|
|
127
133
|
<%= current_user.truncated_solana %>
|
|
128
134
|
<% end %>
|
|
129
135
|
</span>
|
|
@@ -136,7 +142,7 @@
|
|
|
136
142
|
<%# Text layer 2: white (clip-path reveals where bar covers) %>
|
|
137
143
|
<div class="absolute inset-0 flex items-center justify-between px-1.5 font-mono text-white z-[2] nav-bar-text" :style="{ 'clip-path': 'inset(0 ' + (100 - barWidth) + '% 0 0)' }">
|
|
138
144
|
<span>
|
|
139
|
-
<% if
|
|
145
|
+
<% if wallet_connected %>
|
|
140
146
|
<%= current_user.truncated_solana %>
|
|
141
147
|
<% end %>
|
|
142
148
|
</span>
|
|
@@ -167,7 +173,7 @@
|
|
|
167
173
|
<%= render "components/avatar", user: current_user, size: "nav" %>
|
|
168
174
|
<% end %>
|
|
169
175
|
<% else %>
|
|
170
|
-
<%= render "components/admin_dropdown" %>
|
|
176
|
+
<%= render "components/admin_dropdown" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
|
|
171
177
|
<%= render "components/theme_toggle_morph" %>
|
|
172
178
|
<div class="flex items-center gap-3 ml-auto">
|
|
173
179
|
<%= link_to "Log in", login_path, class: "text-heading hover:text-primary text-sm font-semibold transition" %>
|