studio-engine 0.4.1
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 +7 -0
- data/CHANGELOG.md +61 -0
- data/Gemfile +18 -0
- data/LICENSE +21 -0
- data/README.md +93 -0
- data/app/controllers/concerns/studio/error_handling.rb +149 -0
- data/app/controllers/error_logs_controller.rb +16 -0
- data/app/controllers/navbar_controller.rb +6 -0
- data/app/controllers/omniauth_callbacks_controller.rb +17 -0
- data/app/controllers/registrations_controller.rb +25 -0
- data/app/controllers/schema_controller.rb +14 -0
- data/app/controllers/sessions_controller.rb +65 -0
- data/app/controllers/theme_settings_controller.rb +35 -0
- data/app/helpers/studio_theme_helper.rb +15 -0
- data/app/jobs/error_log_cleanup_job.rb +8 -0
- data/app/models/concerns/sluggable.rb +17 -0
- data/app/models/error_log.rb +45 -0
- data/app/models/image_cache.rb +11 -0
- data/app/models/theme_setting.rb +30 -0
- data/app/views/components/_admin_dropdown.html.erb +14 -0
- data/app/views/components/_avatar.html.erb +13 -0
- data/app/views/components/_avatar_cropper.html.erb +135 -0
- data/app/views/components/_badge.html.erb +35 -0
- data/app/views/components/_card.html.erb +4 -0
- data/app/views/components/_copy_button.html.erb +10 -0
- data/app/views/components/_empty_state.html.erb +7 -0
- data/app/views/components/_google_logo.html.erb +1 -0
- data/app/views/components/_input.html.erb +13 -0
- data/app/views/components/_json_debug.html.erb +14 -0
- data/app/views/components/_progress_bar.html.erb +9 -0
- data/app/views/components/_theme_toggle.html.erb +10 -0
- data/app/views/components/_theme_toggle_morph.html.erb +15 -0
- data/app/views/components/_user_nav.html.erb +136 -0
- data/app/views/error_logs/index.html.erb +76 -0
- data/app/views/error_logs/show.html.erb +65 -0
- data/app/views/layouts/_navbar.html.erb +83 -0
- data/app/views/layouts/studio/_flash.html.erb +256 -0
- data/app/views/layouts/studio/_head.html.erb +78 -0
- data/app/views/navbar/show.html.erb +147 -0
- data/app/views/registrations/new.html.erb +80 -0
- data/app/views/schema/index.html.erb +85 -0
- data/app/views/sessions/_sso_continue.html.erb +18 -0
- data/app/views/sessions/new.html.erb +79 -0
- data/app/views/theme_settings/edit.html.erb +376 -0
- data/lib/studio/color_scale.rb +80 -0
- data/lib/studio/engine.rb +12 -0
- data/lib/studio/image_cache.rb +152 -0
- data/lib/studio/s3.rb +72 -0
- data/lib/studio/theme_resolver.rb +99 -0
- data/lib/studio/username_generator.rb +21 -0
- data/lib/studio/version.rb +3 -0
- data/lib/studio-engine.rb +5 -0
- data/lib/studio.rb +134 -0
- data/studio-engine.gemspec +30 -0
- data/tailwind/studio.tailwind.config.js +94 -0
- metadata +189 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<%# Base navbar partial — apps can override by creating their own app/views/layouts/_navbar.html.erb
|
|
2
|
+
Locals (all optional):
|
|
3
|
+
show_logged_in — override for logged_in? (default: real session)
|
|
4
|
+
preview — if true, disables scroll handler and sticky positioning
|
|
5
|
+
balance_html — raw HTML for balance display (passed through to _user_nav)
|
|
6
|
+
extra_icons_html — raw HTML for extra icon buttons (passed through to _user_nav)
|
|
7
|
+
show_logout_link — boolean, show "Log out" link in user nav (passed through to _user_nav)
|
|
8
|
+
%>
|
|
9
|
+
<% show_user = local_assigns.fetch(:show_logged_in, logged_in?) %>
|
|
10
|
+
<% is_preview = local_assigns.fetch(:preview, false) %>
|
|
11
|
+
<% balance_html = local_assigns.fetch(:balance_html, nil) %>
|
|
12
|
+
<% extra_icons_html = local_assigns.fetch(:extra_icons_html, nil) %>
|
|
13
|
+
<% show_logout_link = local_assigns.fetch(:show_logout_link, false) %>
|
|
14
|
+
<%
|
|
15
|
+
words = Studio.app_name.split
|
|
16
|
+
last_word = words.pop
|
|
17
|
+
prefix = words.join(" ")
|
|
18
|
+
logo_path = Studio.logo_for("Navbar Logo")
|
|
19
|
+
%>
|
|
20
|
+
|
|
21
|
+
<header x-data="{ scrolled: false }" <%= '@scroll.window="scrolled = scrolled ? (window.scrollY > 20) : (window.scrollY > 60)"'.html_safe unless is_preview %>
|
|
22
|
+
class="<%= is_preview ? 'bg-page' : 'sticky top-0 z-50 bg-page transition-shadow duration-300' %>"
|
|
23
|
+
:class="scrolled && 'shadow-lg border-b border-subtle is-scrolled'">
|
|
24
|
+
<style>
|
|
25
|
+
.user-nav-col { width: 14rem; }
|
|
26
|
+
.nav-title { display: flex; gap: 0.25em; align-items: baseline; }
|
|
27
|
+
@media (min-width: 400px) { .user-nav-col { width: 15rem; } }
|
|
28
|
+
@media (min-width: 768px) { .user-nav-col { width: 20rem; } }
|
|
29
|
+
@media (max-width: 767px) {
|
|
30
|
+
.nav-title { font-size: 1.25rem !important; flex-direction: column; gap: 0; line-height: 1.15; transition: font-size 0.3s; }
|
|
31
|
+
.nav-title span:first-child { margin-bottom: -4px; }
|
|
32
|
+
.nav-title span:last-child { font-size: 1.5rem; transition: font-size 0.3s; }
|
|
33
|
+
.nav-logo-link { gap: 0.5rem !important; }
|
|
34
|
+
.is-scrolled .nav-title { font-size: 1rem !important; }
|
|
35
|
+
.is-scrolled .nav-title span:last-child { font-size: 1.15rem !important; }
|
|
36
|
+
.is-scrolled .nav-logo { width: 2.5rem !important; height: 2.5rem !important; }
|
|
37
|
+
}
|
|
38
|
+
@media (max-width: 399px) {
|
|
39
|
+
.nav-logo-link { gap: 0.25rem !important; }
|
|
40
|
+
.nav-title { font-size: 1.1rem !important; transition: font-size 0.3s; }
|
|
41
|
+
.nav-title span:last-child { font-size: 1.3rem; transition: font-size 0.3s; }
|
|
42
|
+
.is-scrolled .nav-title { font-size: 0.9rem !important; }
|
|
43
|
+
.is-scrolled .nav-title span:last-child { font-size: 1rem !important; }
|
|
44
|
+
}
|
|
45
|
+
</style>
|
|
46
|
+
<div class="flex items-center <%= is_preview ? 'py-6' : 'transition-all duration-300' %>"
|
|
47
|
+
<%= ":class=\"scrolled ? 'py-2' : 'py-6'\"".html_safe unless is_preview %>>
|
|
48
|
+
<%# Left side: logo + nav %>
|
|
49
|
+
<div class="flex-1 px-4">
|
|
50
|
+
<div class="flex items-center gap-6">
|
|
51
|
+
<%= link_to root_path, class: "nav-logo-link inline-flex items-center gap-3 group" do %>
|
|
52
|
+
<% if logo_path %>
|
|
53
|
+
<img src="<%= logo_path %>" alt="<%= Studio.app_name %>" class="nav-logo rounded-full shadow-lg <%= is_preview ? 'w-12 h-12' : 'group-hover:scale-105 transition-all duration-300' %>"
|
|
54
|
+
<%= "x-bind:class=\"scrolled ? 'w-8 h-8' : 'w-12 h-12'\"".html_safe unless is_preview %>>
|
|
55
|
+
<% end %>
|
|
56
|
+
<h1 class="nav-title font-extrabold text-heading tracking-tight <%= is_preview ? 'text-3xl' : 'transition-all duration-300' %>"
|
|
57
|
+
<%= "x-bind:class=\"scrolled ? 'text-xl' : 'text-3xl'\"".html_safe unless is_preview %>><span><%= prefix %></span><span class="text-primary"><%= last_word %></span></h1>
|
|
58
|
+
<% end %>
|
|
59
|
+
<%# Desktop nav — apps should override this partial to add links here %>
|
|
60
|
+
<nav class="hidden md:flex items-center gap-4">
|
|
61
|
+
</nav>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
<%# Right side: user nav (matches sidebar width) %>
|
|
65
|
+
<div class="user-nav-col flex-shrink-0 pl-0 pr-4 md:px-4">
|
|
66
|
+
<% if show_user %>
|
|
67
|
+
<%= render "components/user_nav", balance_html: balance_html, extra_icons_html: extra_icons_html, show_logout_link: show_logout_link %>
|
|
68
|
+
<% else %>
|
|
69
|
+
<div class="flex items-center justify-end gap-3">
|
|
70
|
+
<span class="hidden md:flex"><%= render "components/theme_toggle_morph" %></span>
|
|
71
|
+
<%= link_to "Log in", login_path, class: "btn btn-primary" %>
|
|
72
|
+
</div>
|
|
73
|
+
<% end %>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
<%# Mobile sub-navbar: compact row hidden on desktop — apps should override to add links %>
|
|
77
|
+
<div class="flex md:hidden items-center gap-3 px-4 py-1.5 border-t border-subtle bg-surface-alt">
|
|
78
|
+
<span class="ml-auto flex items-center gap-3">
|
|
79
|
+
<%= render "components/admin_dropdown" %>
|
|
80
|
+
<%= render "components/theme_toggle_morph" %>
|
|
81
|
+
</span>
|
|
82
|
+
</div>
|
|
83
|
+
</header>
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<%# Toast notification system — floating toasts that overlay the page %>
|
|
2
|
+
<%# window.dispatchEvent(new CustomEvent('toast', { detail: { %>
|
|
3
|
+
<%# type: 'notice', -- 'notice' or 'alert' %>
|
|
4
|
+
<%# title: 'Header', -- bold title (auto: "Success"/"Error" if omitted) %>
|
|
5
|
+
<%# message: 'Subtext', -- description text %>
|
|
6
|
+
<%# image: '/avatar.jpg', -- optional image URL (shows icon if omitted) %>
|
|
7
|
+
<%# dismissible: true, -- show X button (default: true) %>
|
|
8
|
+
<%# buttons: [{ label, style, onclick }], -- optional action buttons %>
|
|
9
|
+
<%# duration: 4000 -- auto-dismiss ms (default: 4000, 0 = stay, auto 0 when buttons) %>
|
|
10
|
+
<%# blurShadow: false -- frosted glass halo behind the toast %>
|
|
11
|
+
<%# } })) %>
|
|
12
|
+
|
|
13
|
+
<style>
|
|
14
|
+
.toast-shadow-all {
|
|
15
|
+
box-shadow: 0 0 30px rgba(0,0,0,0.4), 0 0 10px rgba(0,0,0,0.2);
|
|
16
|
+
}
|
|
17
|
+
.toast-shadow-top {
|
|
18
|
+
box-shadow: 0 -8px 30px rgba(0,0,0,0.4), -8px 0 30px rgba(0,0,0,0.4), 8px 0 30px rgba(0,0,0,0.4);
|
|
19
|
+
}
|
|
20
|
+
.toast-shadow-bottom {
|
|
21
|
+
box-shadow: 0 8px 30px rgba(0,0,0,0.4), -8px 0 30px rgba(0,0,0,0.4), 8px 0 30px rgba(0,0,0,0.4);
|
|
22
|
+
}
|
|
23
|
+
.toast-card {
|
|
24
|
+
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
25
|
+
}
|
|
26
|
+
.dark .toast-card {
|
|
27
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
28
|
+
}
|
|
29
|
+
.toast-blur-glow {
|
|
30
|
+
position: absolute;
|
|
31
|
+
inset: -3rem -4rem;
|
|
32
|
+
border-radius: 2rem;
|
|
33
|
+
filter: blur(30px);
|
|
34
|
+
pointer-events: none;
|
|
35
|
+
}
|
|
36
|
+
.toast-blur-glow {
|
|
37
|
+
background: rgba(0, 0, 0, 0.06);
|
|
38
|
+
}
|
|
39
|
+
.dark .toast-blur-glow {
|
|
40
|
+
background: rgba(var(--color-primary-500-rgb), 0.12);
|
|
41
|
+
}
|
|
42
|
+
.toast-wrapper {
|
|
43
|
+
position: relative;
|
|
44
|
+
transition: transform 0.5s ease,
|
|
45
|
+
opacity 0.4s ease,
|
|
46
|
+
margin-top 0.4s ease,
|
|
47
|
+
max-height 0.4s ease;
|
|
48
|
+
}
|
|
49
|
+
.toast-page-blur {
|
|
50
|
+
position: fixed;
|
|
51
|
+
top: 0;
|
|
52
|
+
left: 0;
|
|
53
|
+
width: 100%;
|
|
54
|
+
height: 50%;
|
|
55
|
+
backdrop-filter: blur(12px);
|
|
56
|
+
-webkit-backdrop-filter: blur(12px);
|
|
57
|
+
mask-image: radial-gradient(ellipse 70% 80% at 50% 0%, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.3) 40%, rgba(0,0,0,0) 100%);
|
|
58
|
+
-webkit-mask-image: radial-gradient(ellipse 70% 80% at 50% 0%, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.3) 40%, rgba(0,0,0,0) 100%);
|
|
59
|
+
pointer-events: none;
|
|
60
|
+
z-index: 55;
|
|
61
|
+
transition: opacity 0.5s ease;
|
|
62
|
+
background: rgba(255, 255, 255, 0.15);
|
|
63
|
+
}
|
|
64
|
+
.dark .toast-page-blur {
|
|
65
|
+
background: rgba(255, 255, 255, 0.5);
|
|
66
|
+
}
|
|
67
|
+
</style>
|
|
68
|
+
|
|
69
|
+
<% messages = flash.select { |type, _| %w[notice alert].include?(type) } %>
|
|
70
|
+
<% initial_json = messages.map { |type, msg| { type: type, message: msg } }.to_json %>
|
|
71
|
+
|
|
72
|
+
<div x-data="toastManager(<%= initial_json %>)"
|
|
73
|
+
@toast.window="add($event.detail)">
|
|
74
|
+
|
|
75
|
+
<%# ── Page blur — half-ellipse backdrop blur behind toasts ── %>
|
|
76
|
+
<div class="toast-page-blur"
|
|
77
|
+
:style="'opacity: ' + (hasVisible() ? '1' : '0')"></div>
|
|
78
|
+
|
|
79
|
+
<div id="toast-container"
|
|
80
|
+
class="fixed top-0 left-1/2 flex flex-col items-center pointer-events-none"
|
|
81
|
+
style="z-index: 60; transform: translateX(-50%); padding: 1rem; width: 100%; max-width: 30rem;">
|
|
82
|
+
|
|
83
|
+
<template x-for="(toast, index) in toasts" :key="toast.id">
|
|
84
|
+
<div :style="toastStyle(index)"
|
|
85
|
+
class="toast-wrapper w-full"
|
|
86
|
+
@click="handlePeekClick(index)">
|
|
87
|
+
|
|
88
|
+
<%# ── Blur glow (optional) — uses filter:blur on a colored div, not backdrop-filter ── %>
|
|
89
|
+
<template x-if="toast.blurShadow">
|
|
90
|
+
<div class="toast-blur-glow"></div>
|
|
91
|
+
</template>
|
|
92
|
+
|
|
93
|
+
<%# ── Toast card — z-index above all blur backdrops ── %>
|
|
94
|
+
<div class="relative pointer-events-auto bg-surface rounded-lg p-4 w-full flex gap-3 toast-card"
|
|
95
|
+
:class="toastShadowClass(index)"
|
|
96
|
+
role="status">
|
|
97
|
+
|
|
98
|
+
<%# ── Image or icon ── %>
|
|
99
|
+
<template x-if="toast.image">
|
|
100
|
+
<img :src="toast.image" class="w-10 h-10 rounded-full object-cover shrink-0" style="margin-top: 2px;">
|
|
101
|
+
</template>
|
|
102
|
+
<template x-if="!toast.image">
|
|
103
|
+
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-inset shrink-0" style="margin-top: 2px;">
|
|
104
|
+
<svg x-show="toast.type === 'notice'" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" style="color: var(--color-success)" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
105
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
|
|
106
|
+
</svg>
|
|
107
|
+
<svg x-show="toast.type !== 'notice'" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" style="color: var(--color-danger)" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
108
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0zm-9 3.75h.008v.008H12v-.008z" />
|
|
109
|
+
</svg>
|
|
110
|
+
</div>
|
|
111
|
+
</template>
|
|
112
|
+
|
|
113
|
+
<%# ── Content ── %>
|
|
114
|
+
<div class="flex-1 min-w-0">
|
|
115
|
+
<div class="flex items-start justify-between gap-2">
|
|
116
|
+
<span x-text="toast.title" class="text-heading font-semibold text-sm"></span>
|
|
117
|
+
<template x-if="toast.dismissible">
|
|
118
|
+
<button @click.stop="dismiss(toast.id)"
|
|
119
|
+
class="text-muted hover:text-heading shrink-0 cursor-pointer"
|
|
120
|
+
aria-label="Dismiss">
|
|
121
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
122
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
123
|
+
</svg>
|
|
124
|
+
</button>
|
|
125
|
+
</template>
|
|
126
|
+
</div>
|
|
127
|
+
<template x-if="toast.message">
|
|
128
|
+
<p x-text="toast.message" class="text-secondary text-sm" style="margin-top: 2px;"></p>
|
|
129
|
+
</template>
|
|
130
|
+
<template x-if="toast.buttons && toast.buttons.length > 0">
|
|
131
|
+
<div class="flex gap-2" style="margin-top: 0.75rem;">
|
|
132
|
+
<template x-for="btn in toast.buttons" :key="btn.label">
|
|
133
|
+
<button @click.stop="if (btn.onclick) btn.onclick(); dismiss(toast.id)"
|
|
134
|
+
class="btn btn-sm"
|
|
135
|
+
:class="btn.style === 'primary' ? 'btn-primary' : btn.style === 'danger' ? 'btn-danger' : 'btn-outline'">
|
|
136
|
+
<span x-text="btn.label"></span>
|
|
137
|
+
</button>
|
|
138
|
+
</template>
|
|
139
|
+
</div>
|
|
140
|
+
</template>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</template>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<script>
|
|
149
|
+
if (!window.toastManager) {
|
|
150
|
+
window.toastManager = function(initial) {
|
|
151
|
+
return {
|
|
152
|
+
toasts: [],
|
|
153
|
+
_nextId: 0,
|
|
154
|
+
expanded: false,
|
|
155
|
+
|
|
156
|
+
init() {
|
|
157
|
+
if (initial && initial.length) {
|
|
158
|
+
initial.forEach((msg, i) => {
|
|
159
|
+
setTimeout(() => this.add(msg), i * 100 + 50);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
add(detail) {
|
|
165
|
+
var id = ++this._nextId;
|
|
166
|
+
var type = detail.type || 'notice';
|
|
167
|
+
var buttons = detail.buttons || [];
|
|
168
|
+
this.toasts.unshift({
|
|
169
|
+
id: id,
|
|
170
|
+
type: type,
|
|
171
|
+
title: detail.title || (type === 'notice' ? 'Success' : 'Error'),
|
|
172
|
+
message: detail.message || '',
|
|
173
|
+
image: detail.image || null,
|
|
174
|
+
dismissible: detail.dismissible !== false,
|
|
175
|
+
blurShadow: detail.blurShadow || false,
|
|
176
|
+
buttons: buttons,
|
|
177
|
+
visible: false
|
|
178
|
+
});
|
|
179
|
+
// Cap at 5 toasts — dismiss oldest when exceeded
|
|
180
|
+
while (this.toasts.length > 5) {
|
|
181
|
+
this.toasts.pop();
|
|
182
|
+
}
|
|
183
|
+
this.expanded = false;
|
|
184
|
+
this.$nextTick(() => {
|
|
185
|
+
var t = this.toasts.find(function(t) { return t.id === id; });
|
|
186
|
+
if (t) t.visible = true;
|
|
187
|
+
});
|
|
188
|
+
var duration = detail.duration != null ? detail.duration : (buttons.length ? 0 : 4000);
|
|
189
|
+
if (duration > 0) {
|
|
190
|
+
var self = this;
|
|
191
|
+
setTimeout(function() { self.dismiss(id); }, duration);
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
dismiss(id) {
|
|
196
|
+
var toast = this.toasts.find(function(t) { return t.id === id; });
|
|
197
|
+
if (toast && toast.visible) {
|
|
198
|
+
toast.visible = false;
|
|
199
|
+
var self = this;
|
|
200
|
+
setTimeout(function() {
|
|
201
|
+
self.toasts = self.toasts.filter(function(t) { return t.id !== id; });
|
|
202
|
+
// Reset expanded when down to 1 or fewer toasts
|
|
203
|
+
if (self.toasts.filter(function(t) { return t.visible; }).length <= 1) {
|
|
204
|
+
self.expanded = false;
|
|
205
|
+
}
|
|
206
|
+
}, 400);
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
toastShadowClass(index) {
|
|
211
|
+
var visibleCount = this.toasts.filter(function(t) { return t.visible; }).length;
|
|
212
|
+
if (visibleCount <= 1) return 'toast-shadow-all';
|
|
213
|
+
if (index === 0) return 'toast-shadow-top';
|
|
214
|
+
return 'toast-shadow-bottom';
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
hasVisible() {
|
|
218
|
+
return this.toasts.some(function(t) { return t.visible; });
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
handlePeekClick(index) {
|
|
222
|
+
if (!this.expanded && index > 0 && this.toasts.length > 1) {
|
|
223
|
+
this.expanded = true;
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
toastStyle(index) {
|
|
228
|
+
var toast = this.toasts[index];
|
|
229
|
+
|
|
230
|
+
// Leaving — gentle shrink and fade
|
|
231
|
+
if (!toast.visible) {
|
|
232
|
+
return 'opacity: 0; transform: scale(0.7); max-height: 0; margin-top: 0; padding: 0; overflow: hidden;';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
var visibleCount = this.toasts.filter(function(t) { return t.visible; }).length;
|
|
236
|
+
var isCollapsed = !this.expanded && visibleCount > 1;
|
|
237
|
+
|
|
238
|
+
// Full view — latest toast or expanded mode
|
|
239
|
+
if (!isCollapsed || index === 0) {
|
|
240
|
+
return 'opacity: 1; transform: scale(1) translateY(0); margin-top: ' + (index > 0 ? '0.35rem' : '0') + '; max-height: 20rem;';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Collapsed peek — thin strip showing card edge
|
|
244
|
+
if (index <= 2) {
|
|
245
|
+
var scale = 1 - (index * 0.03);
|
|
246
|
+
var opacity = 0.7 - ((index - 1) * 0.15);
|
|
247
|
+
return 'display: flex; flex-direction: column; justify-content: flex-end; transform: scale(' + scale + ') translateY(0); max-height: 0.5rem; overflow: hidden; opacity: ' + opacity + '; margin-top: 0.15rem; pointer-events: auto; cursor: pointer;';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Hidden — beyond visible stack
|
|
251
|
+
return 'max-height: 0; overflow: hidden; opacity: 0; margin-top: 0; pointer-events: none;';
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
</script>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
(function(){
|
|
3
|
+
if (localStorage.getItem('theme') !== 'light') {
|
|
4
|
+
document.documentElement.classList.add('dark');
|
|
5
|
+
}
|
|
6
|
+
})();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
10
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
11
|
+
<%= csrf_meta_tags %>
|
|
12
|
+
<%= csp_meta_tag %>
|
|
13
|
+
<link rel="icon" type="image/png" href="/favicon.png">
|
|
14
|
+
|
|
15
|
+
<%= yield :head %>
|
|
16
|
+
|
|
17
|
+
<%= studio_theme_css_tag %>
|
|
18
|
+
|
|
19
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
20
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
21
|
+
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
|
22
|
+
|
|
23
|
+
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
24
|
+
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js"></script>
|
|
25
|
+
<script>
|
|
26
|
+
window.fireSuccessConfetti = function() {
|
|
27
|
+
if (typeof confetti === 'undefined') return;
|
|
28
|
+
var colors = window.CONFETTI_COLORS || ['#4BAF50', '#8E82FE', '#06D6A0', '#FF7C47', '#FFD700', '#00BFFF', '#FF6B9D', '#C084FC'];
|
|
29
|
+
confetti({ particleCount: 150, spread: 100, origin: { x: 0.5, y: 0.5 }, colors: colors, zIndex: 9999, startVelocity: 45, gravity: 0.8, ticks: 300, scalar: 1.2 });
|
|
30
|
+
setTimeout(function() { confetti({ particleCount: 80, angle: 60, spread: 60, origin: { x: 0, y: 0.6 }, colors: colors, zIndex: 9999, startVelocity: 55, gravity: 1, ticks: 250 }); }, 150);
|
|
31
|
+
setTimeout(function() { confetti({ particleCount: 80, angle: 120, spread: 60, origin: { x: 1, y: 0.6 }, colors: colors, zIndex: 9999, startVelocity: 55, gravity: 1, ticks: 250 }); }, 150);
|
|
32
|
+
setTimeout(function() { confetti({ particleCount: 100, spread: 160, origin: { x: 0.5, y: 0.3 }, colors: colors, zIndex: 9999, startVelocity: 30, gravity: 1.2, ticks: 200, scalar: 0.8 }); }, 400);
|
|
33
|
+
};
|
|
34
|
+
</script>
|
|
35
|
+
<script>
|
|
36
|
+
// Nav spinner — scale morph between theme toggle and loading spinner
|
|
37
|
+
// Minimum display time prevents quick flashes
|
|
38
|
+
window._navSpinnerShownAt = 0;
|
|
39
|
+
window._navSpinnerMinMs = 2500;
|
|
40
|
+
window.showNavSpinner = function() {
|
|
41
|
+
window._navSpinnerShownAt = Date.now();
|
|
42
|
+
document.querySelectorAll('.nav-toggle-icon').forEach(function(e) { e.style.opacity = '0'; e.style.transform = 'scale(0) rotate(90deg)'; });
|
|
43
|
+
document.querySelectorAll('.nav-spinner-icon').forEach(function(e) { e.style.opacity = '1'; e.style.transform = 'scale(1) rotate(0deg)'; });
|
|
44
|
+
};
|
|
45
|
+
window.hideNavSpinner = function() {
|
|
46
|
+
var elapsed = Date.now() - window._navSpinnerShownAt;
|
|
47
|
+
var remaining = Math.max(0, window._navSpinnerMinMs - elapsed);
|
|
48
|
+
setTimeout(function() {
|
|
49
|
+
document.querySelectorAll('.nav-toggle-icon').forEach(function(e) { e.style.opacity = '1'; e.style.transform = 'scale(1) rotate(0deg)'; });
|
|
50
|
+
document.querySelectorAll('.nav-spinner-icon').forEach(function(e) { e.style.opacity = '0'; e.style.transform = 'scale(0) rotate(-90deg)'; });
|
|
51
|
+
}, remaining);
|
|
52
|
+
};
|
|
53
|
+
// Reset spinner state before Turbo caches the page
|
|
54
|
+
document.addEventListener('turbo:before-cache', function() {
|
|
55
|
+
document.querySelectorAll('.nav-toggle-icon').forEach(function(e) { e.style.opacity = '1'; e.style.transform = 'scale(1) rotate(0deg)'; });
|
|
56
|
+
document.querySelectorAll('.nav-spinner-icon').forEach(function(e) { e.style.opacity = '0'; e.style.transform = 'scale(0) rotate(-90deg)'; });
|
|
57
|
+
});
|
|
58
|
+
</script>
|
|
59
|
+
<script>
|
|
60
|
+
document.addEventListener('alpine:init', () => {
|
|
61
|
+
Alpine.store('devMode', localStorage.getItem('devMode') === 'true');
|
|
62
|
+
Alpine.store('theme', {
|
|
63
|
+
value: localStorage.getItem('theme') || 'dark',
|
|
64
|
+
get isDark() { return this.value === 'dark'; },
|
|
65
|
+
toggle() {
|
|
66
|
+
document.documentElement.classList.add('theme-transition');
|
|
67
|
+
document.documentElement.classList.toggle('dark');
|
|
68
|
+
this.value = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
|
69
|
+
localStorage.setItem('theme', this.value);
|
|
70
|
+
setTimeout(() => document.documentElement.classList.remove('theme-transition'), 300);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
|
|
77
|
+
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
|
78
|
+
<%= javascript_importmap_tags %>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<% content_for(:title, "Navbar Review") %>
|
|
2
|
+
|
|
3
|
+
<style>
|
|
4
|
+
/* Preview container base */
|
|
5
|
+
.navbar-preview {
|
|
6
|
+
overflow: hidden;
|
|
7
|
+
border: 2px solid var(--color-border-strong);
|
|
8
|
+
border-radius: 0.5rem;
|
|
9
|
+
pointer-events: none;
|
|
10
|
+
transition: width 0.15s ease;
|
|
11
|
+
}
|
|
12
|
+
.navbar-preview header { position: static !important; }
|
|
13
|
+
|
|
14
|
+
/* Mobile previews: force mobile layout (undo md: classes active at desktop viewport) */
|
|
15
|
+
.navbar-preview.is-mobile .hidden.md\:flex { display: none !important; }
|
|
16
|
+
.navbar-preview.is-mobile .hidden.md\:block { display: none !important; }
|
|
17
|
+
.navbar-preview.is-mobile .flex.md\:hidden { display: flex !important; }
|
|
18
|
+
.navbar-preview.is-mobile .user-nav-col { padding-left: 0 !important; padding-right: 1rem !important; }
|
|
19
|
+
.navbar-preview.is-mobile .nav-title { flex-direction: column !important; gap: 0 !important; line-height: 1.15 !important; }
|
|
20
|
+
.navbar-preview.is-mobile .nav-title span:first-child { margin-bottom: -4px !important; }
|
|
21
|
+
.navbar-preview.is-mobile .nav-title span:last-child { font-size: 1.5rem !important; }
|
|
22
|
+
.navbar-preview.is-mobile .nav-logo-link { gap: 0.5rem !important; }
|
|
23
|
+
|
|
24
|
+
/* Tiny (< 400px) */
|
|
25
|
+
.navbar-preview.bp-tiny .nav-title { font-size: 1.1rem !important; }
|
|
26
|
+
.navbar-preview.bp-tiny .nav-title span:last-child { font-size: 1.3rem !important; }
|
|
27
|
+
.navbar-preview.bp-tiny .nav-logo-link { gap: 0.25rem !important; }
|
|
28
|
+
.navbar-preview.bp-tiny .user-nav-col { width: 14rem !important; }
|
|
29
|
+
.navbar-preview.bp-tiny .username-cap { max-width: 5rem !important; }
|
|
30
|
+
|
|
31
|
+
/* Small (400-767px) */
|
|
32
|
+
.navbar-preview.bp-small .nav-title { font-size: 1.25rem !important; }
|
|
33
|
+
.navbar-preview.bp-small .user-nav-col { width: 15rem !important; }
|
|
34
|
+
.navbar-preview.bp-small .username-cap { max-width: 6rem !important; }
|
|
35
|
+
|
|
36
|
+
/* Desktop (768px+) */
|
|
37
|
+
.navbar-preview.is-desktop .user-nav-col { width: 20rem !important; }
|
|
38
|
+
.navbar-preview.is-desktop .username-cap { max-width: 7rem !important; }
|
|
39
|
+
|
|
40
|
+
/* Transitions for scrolled toggle */
|
|
41
|
+
.navbar-preview .nav-logo { transition: width 0.3s, height 0.3s; }
|
|
42
|
+
.navbar-preview .nav-title,
|
|
43
|
+
.navbar-preview .nav-title span:last-child { transition: font-size 0.3s; }
|
|
44
|
+
.navbar-preview .py-6 { transition: padding 0.3s; }
|
|
45
|
+
.navbar-preview header { transition: box-shadow 0.3s, border-color 0.3s; }
|
|
46
|
+
.navbar-preview [data-balance-display] { transition: font-size 0.3s; }
|
|
47
|
+
|
|
48
|
+
/* Scrolled state: base (all breakpoints) */
|
|
49
|
+
.navbar-preview.is-scrolled-preview header { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); border-bottom: 1px solid var(--color-border-subtle); }
|
|
50
|
+
.navbar-preview.is-scrolled-preview .py-6 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; }
|
|
51
|
+
.navbar-preview.is-scrolled-preview .nav-logo { width: 2rem !important; height: 2rem !important; }
|
|
52
|
+
.navbar-preview.is-scrolled-preview .nav-title { font-size: 1.25rem !important; }
|
|
53
|
+
.navbar-preview.is-scrolled-preview [data-balance-display] { font-size: 1.125rem !important; }
|
|
54
|
+
|
|
55
|
+
/* Scrolled state: mobile overrides */
|
|
56
|
+
.navbar-preview.is-mobile.is-scrolled-preview .nav-logo { width: 2.5rem !important; height: 2.5rem !important; }
|
|
57
|
+
.navbar-preview.bp-tiny.is-scrolled-preview .nav-title { font-size: 0.9rem !important; }
|
|
58
|
+
.navbar-preview.bp-tiny.is-scrolled-preview .nav-title span:last-child { font-size: 1rem !important; }
|
|
59
|
+
.navbar-preview.bp-small.is-scrolled-preview .nav-title { font-size: 1rem !important; }
|
|
60
|
+
.navbar-preview.bp-small.is-scrolled-preview .nav-title span:last-child { font-size: 1.15rem !important; }
|
|
61
|
+
|
|
62
|
+
/* Slider with device marker */
|
|
63
|
+
.slider-wrap { position: relative; }
|
|
64
|
+
.slider-wrap input[type="range"] { width: 100%; accent-color: var(--color-cta); }
|
|
65
|
+
.navbar-card { margin-bottom: 1px; }
|
|
66
|
+
.slider-marker {
|
|
67
|
+
position: absolute;
|
|
68
|
+
top: 50%;
|
|
69
|
+
transform: translate(-50%, -50%);
|
|
70
|
+
width: 2px;
|
|
71
|
+
height: 14px;
|
|
72
|
+
background: var(--color-cta);
|
|
73
|
+
pointer-events: none;
|
|
74
|
+
border-radius: 1px;
|
|
75
|
+
}
|
|
76
|
+
</style>
|
|
77
|
+
|
|
78
|
+
<div class="py-8 space-y-10" x-data="{
|
|
79
|
+
previewName: '',
|
|
80
|
+
updateNames() {
|
|
81
|
+
var els = document.querySelectorAll('.navbar-preview [data-username-display]');
|
|
82
|
+
var name = this.previewName;
|
|
83
|
+
els.forEach(function(el) {
|
|
84
|
+
el.textContent = name || el.getAttribute('data-original-name');
|
|
85
|
+
var data = Alpine.$data(el);
|
|
86
|
+
if (data) { data.overflows = el.scrollWidth > el.clientWidth; }
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
init() {
|
|
90
|
+
var els = document.querySelectorAll('.navbar-preview [data-username-display]');
|
|
91
|
+
els.forEach(function(el) { el.setAttribute('data-original-name', el.textContent.trim()); });
|
|
92
|
+
}
|
|
93
|
+
}" x-effect="updateNames()">
|
|
94
|
+
<div>
|
|
95
|
+
<h1 class="text-2xl font-bold text-heading">Navbar Review</h1>
|
|
96
|
+
<div class="flex items-center gap-4 mt-1">
|
|
97
|
+
<p class="text-sm text-muted">Desktop (1200px+) is the live navbar above.</p>
|
|
98
|
+
<div class="flex items-center gap-2">
|
|
99
|
+
<label class="text-xs text-muted font-medium">Username:</label>
|
|
100
|
+
<input type="text" x-model="previewName" placeholder="<%= current_user.display_name %>"
|
|
101
|
+
class="input-field text-xs !py-1 !px-2 !w-36">
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<% breakpoints = [
|
|
107
|
+
{ name: "Tiny", min: 320, max: 399, classes: "is-mobile bp-tiny", device: "iPhone 15", device_w: 390 },
|
|
108
|
+
{ name: "Small", min: 400, max: 767, classes: "is-mobile bp-small", device: "iPhone 16 Pro Max", device_w: 430 },
|
|
109
|
+
{ name: "Tablet", min: 768, max: 1200, classes: "is-desktop", device: "iPad Pro 13\"", device_w: 1032 },
|
|
110
|
+
] %>
|
|
111
|
+
|
|
112
|
+
<% [
|
|
113
|
+
{ title: "Logged-In View", show_logged_in: true },
|
|
114
|
+
{ title: "Pre-Login View", show_logged_in: false },
|
|
115
|
+
].each do |section| %>
|
|
116
|
+
<section class="space-y-6">
|
|
117
|
+
<h2 class="text-lg font-semibold text-heading"><%= section[:title] %></h2>
|
|
118
|
+
<div class="grid gap-8">
|
|
119
|
+
<% breakpoints.each do |bp|
|
|
120
|
+
marker_pct = ((bp[:device_w] - bp[:min]).to_f / (bp[:max] - bp[:min]) * 100).round(1)
|
|
121
|
+
%>
|
|
122
|
+
<div x-data="{ w: <%= bp[:device_w] %>, scrolled: false }" class="navbar-card">
|
|
123
|
+
<div class="flex items-center gap-3 mb-2">
|
|
124
|
+
<span class="text-sm font-bold text-heading"><%= bp[:name] %></span>
|
|
125
|
+
<span class="text-xs text-muted font-mono" x-text="w + 'px'"><%= bp[:device_w] %>px</span>
|
|
126
|
+
<div class="flex-1 max-w-xs slider-wrap">
|
|
127
|
+
<input type="range" min="<%= bp[:min] %>" max="<%= bp[:max] %>" x-model="w">
|
|
128
|
+
<div class="slider-marker" style="left: <%= marker_pct %>%;"></div>
|
|
129
|
+
</div>
|
|
130
|
+
<button @click="w = <%= bp[:device_w] %>" class="text-xs text-primary hover:text-primary-300 font-medium transition whitespace-nowrap">
|
|
131
|
+
<%= bp[:device] %> (<%= bp[:device_w] %>px)
|
|
132
|
+
</button>
|
|
133
|
+
<span class="text-xs text-muted font-mono"><%= bp[:min] %>-<%= bp[:max] %>px</span>
|
|
134
|
+
<button @click="scrolled = !scrolled" class="text-xs font-bold px-2 py-0.5 rounded transition"
|
|
135
|
+
:class="scrolled ? 'bg-primary text-white' : 'bg-surface-alt text-secondary hover:text-heading'">
|
|
136
|
+
Scrolled
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
<div class="navbar-preview <%= bp[:classes] %>" :class="scrolled && 'is-scrolled-preview'" :style="'width: ' + w + 'px'">
|
|
140
|
+
<%= render "layouts/navbar", preview: true, show_logged_in: section[:show_logged_in] %>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
<% end %>
|
|
144
|
+
</div>
|
|
145
|
+
</section>
|
|
146
|
+
<% end %>
|
|
147
|
+
</div>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<%
|
|
2
|
+
logo_path = Studio.logo_for("Auth Logo")
|
|
3
|
+
words = Studio.app_name.split
|
|
4
|
+
last_word = words.pop
|
|
5
|
+
prefix = words.join(" ")
|
|
6
|
+
%>
|
|
7
|
+
|
|
8
|
+
<div class="min-h-[70vh] flex items-center justify-center">
|
|
9
|
+
<div class="w-full max-w-md">
|
|
10
|
+
<div class="text-center mb-8">
|
|
11
|
+
<% if logo_path %>
|
|
12
|
+
<img src="<%= logo_path %>" alt="<%= Studio.app_name %>" class="w-20 h-20 rounded-full shadow-lg mx-auto mb-4">
|
|
13
|
+
<% end %>
|
|
14
|
+
<h1 class="text-3xl font-extrabold text-heading tracking-tight"><%= prefix %> <span class="text-primary"><%= last_word %></span></h1>
|
|
15
|
+
<p class="text-secondary mt-2">Create your account</p>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="card p-8 shadow">
|
|
19
|
+
<% if @user.errors.any? %>
|
|
20
|
+
<div class="mb-4 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-300 text-sm">
|
|
21
|
+
<% @user.errors.full_messages.each do |msg| %>
|
|
22
|
+
<p><%= msg %></p>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<%= form_with model: @user, url: signup_path, method: :post do |f| %>
|
|
28
|
+
<% if Studio.registration_params.include?(:name) %>
|
|
29
|
+
<div class="mb-5">
|
|
30
|
+
<label for="user_name" class="block text-sm text-secondary mb-2 font-medium">Name</label>
|
|
31
|
+
<%= f.text_field :name, id: "user_name", autofocus: true,
|
|
32
|
+
class: "input-field",
|
|
33
|
+
placeholder: "Your name" %>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
36
|
+
|
|
37
|
+
<div class="mb-5">
|
|
38
|
+
<label for="user_email" class="block text-sm text-secondary mb-2 font-medium">Email</label>
|
|
39
|
+
<%= f.email_field :email, id: "user_email", autofocus: !Studio.registration_params.include?(:name),
|
|
40
|
+
class: "input-field",
|
|
41
|
+
placeholder: "you@example.com" %>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div class="mb-5">
|
|
45
|
+
<label for="user_password" class="block text-sm text-secondary mb-2 font-medium">Password</label>
|
|
46
|
+
<%= f.password_field :password, id: "user_password",
|
|
47
|
+
class: "input-field",
|
|
48
|
+
placeholder: "Choose a password" %>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<div class="mb-6">
|
|
52
|
+
<label for="user_password_confirmation" class="block text-sm text-secondary mb-2 font-medium">Confirm Password</label>
|
|
53
|
+
<%= f.password_field :password_confirmation, id: "user_password_confirmation",
|
|
54
|
+
class: "input-field",
|
|
55
|
+
placeholder: "Confirm your password" %>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<button type="submit" class="btn btn-primary btn-lg w-full">
|
|
59
|
+
Sign Up
|
|
60
|
+
</button>
|
|
61
|
+
<% end %>
|
|
62
|
+
|
|
63
|
+
<div class="relative my-6">
|
|
64
|
+
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-subtle"></div></div>
|
|
65
|
+
<div class="relative flex justify-center text-sm"><span class="bg-surface px-3 text-secondary">or</span></div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<%= button_to "/auth/google_oauth2", method: :post, data: { turbo: false },
|
|
69
|
+
class: "btn btn-google btn-lg w-full gap-3" do %>
|
|
70
|
+
<%= render "components/google_logo" %>
|
|
71
|
+
Sign up with Google
|
|
72
|
+
<% end %>
|
|
73
|
+
|
|
74
|
+
<p class="text-center text-secondary text-sm mt-6">
|
|
75
|
+
Already have an account?
|
|
76
|
+
<%= link_to "Log in", login_path, class: "text-primary hover:text-primary-300 font-medium underline underline-offset-2" %>
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|