astryx-dracula 0.1.3 → 0.2.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.
- package/BRAND.md +1 -1
- package/README.md +2 -0
- package/USAGE.md +13 -0
- package/astryx-dracula.js +4 -4
- package/astryx.integration.mjs +3 -0
- package/package.json +4 -2
- package/templates/ai-chat-landing.template.mjs +6 -0
- package/templates/ai-chat-landing.tsx +476 -0
- package/templates/ai-chat.template.mjs +6 -0
- package/templates/ai-chat.tsx +762 -0
- package/templates/blank.template.mjs +6 -0
- package/templates/blank.tsx +16 -0
- package/templates/centered-hero.template.mjs +6 -0
- package/templates/centered-hero.tsx +188 -0
- package/templates/classic-gallery.template.mjs +6 -0
- package/templates/classic-gallery.tsx +181 -0
- package/templates/contact-form.template.mjs +6 -0
- package/templates/contact-form.tsx +292 -0
- package/templates/dashboard-portfolio.template.mjs +6 -0
- package/templates/dashboard-portfolio.tsx +800 -0
- package/templates/dashboard.template.mjs +6 -0
- package/templates/dashboard.tsx +774 -0
- package/templates/detail-page.template.mjs +6 -0
- package/templates/detail-page.tsx +695 -0
- package/templates/documentation-design.template.mjs +6 -0
- package/templates/documentation-design.tsx +751 -0
- package/templates/documentation-technical.template.mjs +6 -0
- package/templates/documentation-technical.tsx +292 -0
- package/templates/documentation.template.mjs +6 -0
- package/templates/documentation.tsx +347 -0
- package/templates/editor.template.mjs +6 -0
- package/templates/editor.tsx +959 -0
- package/templates/file-explorer.template.mjs +6 -0
- package/templates/file-explorer.tsx +577 -0
- package/templates/form-two-column.template.mjs +6 -0
- package/templates/form-two-column.tsx +314 -0
- package/templates/gallery-hero.template.mjs +6 -0
- package/templates/gallery-hero.tsx +287 -0
- package/templates/ide.template.mjs +6 -0
- package/templates/ide.tsx +533 -0
- package/templates/incident-console.template.mjs +6 -0
- package/templates/incident-console.tsx +584 -0
- package/templates/kanban-board.template.mjs +6 -0
- package/templates/kanban-board.tsx +746 -0
- package/templates/library.template.mjs +6 -0
- package/templates/library.tsx +489 -0
- package/templates/login-card.template.mjs +6 -0
- package/templates/login-card.tsx +200 -0
- package/templates/login-split.template.mjs +6 -0
- package/templates/login-split.tsx +318 -0
- package/templates/login-sso.template.mjs +6 -0
- package/templates/login-sso.tsx +277 -0
- package/templates/login.template.mjs +6 -0
- package/templates/login.tsx +98 -0
- package/templates/messaging-shell.template.mjs +6 -0
- package/templates/messaging-shell.tsx +675 -0
- package/templates/mixed-gallery.template.mjs +6 -0
- package/templates/mixed-gallery.tsx +190 -0
- package/templates/payment-form.template.mjs +6 -0
- package/templates/payment-form.tsx +990 -0
- package/templates/product-detail.template.mjs +6 -0
- package/templates/product-detail.tsx +366 -0
- package/templates/product-gallery.template.mjs +6 -0
- package/templates/product-gallery.tsx +173 -0
- package/templates/settings-dialog.template.mjs +6 -0
- package/templates/settings-dialog.tsx +920 -0
- package/templates/settings-sidebar.template.mjs +6 -0
- package/templates/settings-sidebar.tsx +952 -0
- package/templates/settings.template.mjs +6 -0
- package/templates/settings.tsx +235 -0
- package/templates/shell-nav.template.mjs +6 -0
- package/templates/shell-nav.tsx +306 -0
- package/templates/shell-side-nav.template.mjs +6 -0
- package/templates/shell-side-nav.tsx +238 -0
- package/templates/shell-top-nav.template.mjs +6 -0
- package/templates/shell-top-nav.tsx +243 -0
- package/templates/side-gallery.template.mjs +6 -0
- package/templates/side-gallery.tsx +179 -0
- package/templates/table-grouped.template.mjs +6 -0
- package/templates/table-grouped.tsx +1174 -0
- package/templates/table-page-chart.template.mjs +6 -0
- package/templates/table-page-chart.tsx +693 -0
- package/templates/table-page-heatmap-status.template.mjs +6 -0
- package/templates/table-page-heatmap-status.tsx +547 -0
- package/templates/table-page-shoe-store-heatmap.template.mjs +6 -0
- package/templates/table-page-shoe-store-heatmap.tsx +1038 -0
- package/templates/table-page.template.mjs +6 -0
- package/templates/table-page.tsx +445 -0
- package/templates/table.template.mjs +6 -0
- package/templates/table.tsx +84 -0
- package/templates/theme-showcase.template.mjs +6 -0
- package/templates/theme-showcase.tsx +1282 -0
- package/theme.css +4 -4
- package/tokens.css +4 -4
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import {useState, type CSSProperties} from 'react';
|
|
4
|
+
import {VStack, HStack, StackItem} from '@astryxdesign/core/Layout';
|
|
5
|
+
import {Grid} from '@astryxdesign/core/Grid';
|
|
6
|
+
import {Center} from '@astryxdesign/core/Center';
|
|
7
|
+
import {Card} from '@astryxdesign/core/Card';
|
|
8
|
+
import {Section} from '@astryxdesign/core/Section';
|
|
9
|
+
import {Text, Heading} from '@astryxdesign/core/Text';
|
|
10
|
+
import {Icon} from '@astryxdesign/core/Icon';
|
|
11
|
+
import {EmptyState} from '@astryxdesign/core/EmptyState';
|
|
12
|
+
import {TextInput} from '@astryxdesign/core/TextInput';
|
|
13
|
+
import {Button} from '@astryxdesign/core/Button';
|
|
14
|
+
import {Link} from '@astryxdesign/core/Link';
|
|
15
|
+
import {Divider} from '@astryxdesign/core/Divider';
|
|
16
|
+
|
|
17
|
+
// ============= ICONS (verified lucide-react exports) =============
|
|
18
|
+
// Moon is the castle brand mark (see login, login-card); CircleCheck ←
|
|
19
|
+
// CheckCircleIcon. The SquaresPlusIcon brand mark has no place here.
|
|
20
|
+
import {Moon, CircleCheck} from 'lucide-react';
|
|
21
|
+
|
|
22
|
+
// Brand sign-in marks — bespoke inline glyphs, no icon-library equivalent.
|
|
23
|
+
const AppleIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
|
24
|
+
<svg
|
|
25
|
+
viewBox="0 0 24 24"
|
|
26
|
+
fill="currentColor"
|
|
27
|
+
width={16}
|
|
28
|
+
height={16}
|
|
29
|
+
aria-hidden="true"
|
|
30
|
+
{...props}>
|
|
31
|
+
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
|
|
32
|
+
</svg>
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const GoogleIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
|
36
|
+
<svg
|
|
37
|
+
viewBox="0 0 24 24"
|
|
38
|
+
fill="currentColor"
|
|
39
|
+
width={16}
|
|
40
|
+
height={16}
|
|
41
|
+
aria-hidden="true"
|
|
42
|
+
{...props}>
|
|
43
|
+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
|
|
44
|
+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
|
45
|
+
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
|
46
|
+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
|
47
|
+
</svg>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Cover art is inline Dracula SVG — moonlit rooftops in brand tokens, no
|
|
51
|
+
// external image. Fills the panel via slice, like the placeholder before it.
|
|
52
|
+
const coverArt: CSSProperties = {
|
|
53
|
+
width: '100%',
|
|
54
|
+
height: '100%',
|
|
55
|
+
display: 'block',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const nightCoverArt = (
|
|
59
|
+
<svg
|
|
60
|
+
viewBox="0 0 400 300"
|
|
61
|
+
preserveAspectRatio="xMidYMid slice"
|
|
62
|
+
style={coverArt}
|
|
63
|
+
role="img"
|
|
64
|
+
aria-label="Harvest moon over the castle rooftops">
|
|
65
|
+
<rect width="400" height="300" fill="var(--dracula-bg)" />
|
|
66
|
+
<circle cx="68" cy="52" r="2" fill="var(--dracula-fg)" />
|
|
67
|
+
<circle cx="140" cy="36" r="1.5" fill="var(--dracula-comment)" />
|
|
68
|
+
<circle cx="330" cy="70" r="2" fill="var(--dracula-fg)" />
|
|
69
|
+
<circle cx="292" cy="34" r="1.5" fill="var(--dracula-comment)" />
|
|
70
|
+
<circle cx="360" cy="140" r="1.5" fill="var(--dracula-comment)" />
|
|
71
|
+
<circle cx="200" cy="118" r="52" fill="var(--dracula-yellow)" />
|
|
72
|
+
<circle cx="182" cy="104" r="44" fill="var(--dracula-bg)" />
|
|
73
|
+
<g
|
|
74
|
+
fill="var(--dracula-current-line)"
|
|
75
|
+
stroke="var(--dracula-comment)"
|
|
76
|
+
strokeWidth="3"
|
|
77
|
+
strokeLinejoin="round">
|
|
78
|
+
<rect x="20" y="220" width="110" height="80" />
|
|
79
|
+
<path d="M20 220 L75 178 L130 220 Z" />
|
|
80
|
+
<rect x="150" y="200" width="100" height="100" />
|
|
81
|
+
<path d="M150 200 L200 162 L250 200 Z" />
|
|
82
|
+
<rect x="270" y="228" width="110" height="72" />
|
|
83
|
+
<path d="M270 228 L325 190 L380 228 Z" />
|
|
84
|
+
</g>
|
|
85
|
+
<g fill="var(--dracula-purple)">
|
|
86
|
+
<rect x="58" y="244" width="14" height="18" />
|
|
87
|
+
<rect x="188" y="224" width="14" height="18" />
|
|
88
|
+
<rect x="312" y="250" width="14" height="18" />
|
|
89
|
+
</g>
|
|
90
|
+
</svg>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Grid emits minmax(MIN, 1fr) where MIN is a hard floor, so MIN plus the
|
|
94
|
+
// grid inset and page padding must fit the narrowest phone or the column is
|
|
95
|
+
// clipped. 320 − 2×24 (page) − 2×16 (stacked inset) = 240.
|
|
96
|
+
const COLUMN_MIN_WIDTH = 240;
|
|
97
|
+
// repeat:'fit' (auto-fit) collapses the two columns to one — expanding to fill —
|
|
98
|
+
// below 2×MIN + 32(gap) = 512px. The container query reorders the image and
|
|
99
|
+
// tightens the inset at that same point, keyed to the card width (not the
|
|
100
|
+
// window) so it never desyncs.
|
|
101
|
+
// minHeight:100% fills the host so the centered card never leaves an unpainted
|
|
102
|
+
// band; padding keeps it off the surface edges.
|
|
103
|
+
const pageStyle: CSSProperties = {
|
|
104
|
+
minHeight: '100%',
|
|
105
|
+
backgroundColor: 'var(--color-background-body)',
|
|
106
|
+
padding: 'var(--spacing-6)',
|
|
107
|
+
};
|
|
108
|
+
const cardWrap: CSSProperties = {
|
|
109
|
+
width: '100%',
|
|
110
|
+
maxWidth: 1000,
|
|
111
|
+
marginInline: 'auto',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// The container query lives in a plain <style> tag so it needs NO CSS compiler.
|
|
115
|
+
// - Pad the grid, not the Card: the form's Section escapes Card's
|
|
116
|
+
// --container-padding-* vars, which would cancel the inset on the form side.
|
|
117
|
+
// container-type makes the grid the query container for the stack point.
|
|
118
|
+
// - repeat:'fit' (auto-fit) collapses the two columns to one below 511px; the
|
|
119
|
+
// query reorders the image (order:-1) and tightens the inset at that point,
|
|
120
|
+
// keyed to the card width (not the window) so it never desyncs.
|
|
121
|
+
const LOGIN_SPLIT_CSS = `
|
|
122
|
+
.login-split-grid {
|
|
123
|
+
container-type: inline-size;
|
|
124
|
+
container-name: login-split;
|
|
125
|
+
padding: var(--spacing-8);
|
|
126
|
+
}
|
|
127
|
+
.login-split-image {
|
|
128
|
+
width: 100%;
|
|
129
|
+
order: 0;
|
|
130
|
+
}
|
|
131
|
+
@container login-split (max-width: 511px) {
|
|
132
|
+
.login-split-grid {
|
|
133
|
+
padding: var(--spacing-4);
|
|
134
|
+
}
|
|
135
|
+
.login-split-image {
|
|
136
|
+
order: -1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
`;
|
|
140
|
+
|
|
141
|
+
export default function LoginSplit() {
|
|
142
|
+
const [email, setEmail] = useState('');
|
|
143
|
+
const [password, setPassword] = useState('');
|
|
144
|
+
const [loginFailed, setLoginFailed] = useState(false);
|
|
145
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
146
|
+
const [isSuccess, setIsSuccess] = useState(false);
|
|
147
|
+
|
|
148
|
+
const handleLogin = () => {
|
|
149
|
+
if (!email || !password) {
|
|
150
|
+
setLoginFailed(true);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
setIsLoading(true);
|
|
154
|
+
setLoginFailed(false);
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
setIsLoading(false);
|
|
157
|
+
setIsSuccess(true);
|
|
158
|
+
}, 2000);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<Center axis="both" style={pageStyle}>
|
|
163
|
+
<style>{LOGIN_SPLIT_CSS}</style>
|
|
164
|
+
<VStack gap={4} width="100%">
|
|
165
|
+
<div style={cardWrap}>
|
|
166
|
+
<Card padding={0} width="100%">
|
|
167
|
+
<Grid
|
|
168
|
+
columns={{minWidth: COLUMN_MIN_WIDTH, repeat: 'fit'}}
|
|
169
|
+
gap={8}
|
|
170
|
+
align="stretch"
|
|
171
|
+
className="login-split-grid">
|
|
172
|
+
{/* Form */}
|
|
173
|
+
<Section variant="transparent" padding={0} height="100%">
|
|
174
|
+
<VStack gap={4} height="100%">
|
|
175
|
+
<HStack gap={2} vAlign="center">
|
|
176
|
+
<Icon icon={Moon} color="accent" />
|
|
177
|
+
<Text type="body" weight="bold">
|
|
178
|
+
Castle Dracula
|
|
179
|
+
</Text>
|
|
180
|
+
</HStack>
|
|
181
|
+
|
|
182
|
+
<StackItem size="fill">
|
|
183
|
+
<Center axis="vertical" height="100%">
|
|
184
|
+
{isSuccess ? (
|
|
185
|
+
<EmptyState
|
|
186
|
+
title="You're in for the night"
|
|
187
|
+
description="Drifting to your crypt…"
|
|
188
|
+
icon={<Icon icon={CircleCheck} size="lg" />}
|
|
189
|
+
/>
|
|
190
|
+
) : (
|
|
191
|
+
<VStack gap={4} hAlign="stretch" width="100%">
|
|
192
|
+
<VStack gap={1}>
|
|
193
|
+
<Heading level={2}>
|
|
194
|
+
Welcome back to the night
|
|
195
|
+
</Heading>
|
|
196
|
+
<Text type="body" color="secondary">
|
|
197
|
+
Sign in to your crypt
|
|
198
|
+
</Text>
|
|
199
|
+
</VStack>
|
|
200
|
+
|
|
201
|
+
<VStack gap={2}>
|
|
202
|
+
<TextInput
|
|
203
|
+
label="Email"
|
|
204
|
+
isLabelHidden
|
|
205
|
+
type="email"
|
|
206
|
+
placeholder="you@castle.dracula"
|
|
207
|
+
value={email}
|
|
208
|
+
onChange={setEmail}
|
|
209
|
+
size="lg"
|
|
210
|
+
/>
|
|
211
|
+
<VStack gap={1}>
|
|
212
|
+
<TextInput
|
|
213
|
+
label="Password"
|
|
214
|
+
isLabelHidden
|
|
215
|
+
placeholder="Whisper your password"
|
|
216
|
+
type="password"
|
|
217
|
+
value={password}
|
|
218
|
+
onChange={(v: string) => {
|
|
219
|
+
setPassword(v);
|
|
220
|
+
setLoginFailed(false);
|
|
221
|
+
}}
|
|
222
|
+
size="lg"
|
|
223
|
+
status={
|
|
224
|
+
loginFailed
|
|
225
|
+
? {
|
|
226
|
+
type: 'error',
|
|
227
|
+
message:
|
|
228
|
+
'Wrong incantation. Try again.',
|
|
229
|
+
}
|
|
230
|
+
: undefined
|
|
231
|
+
}
|
|
232
|
+
/>
|
|
233
|
+
{loginFailed && (
|
|
234
|
+
<VStack hAlign="end">
|
|
235
|
+
<Link
|
|
236
|
+
href="#/templates/login-split"
|
|
237
|
+
size="sm"
|
|
238
|
+
color="secondary"
|
|
239
|
+
type="supporting">
|
|
240
|
+
Forgot your password?
|
|
241
|
+
</Link>
|
|
242
|
+
</VStack>
|
|
243
|
+
)}
|
|
244
|
+
</VStack>
|
|
245
|
+
</VStack>
|
|
246
|
+
|
|
247
|
+
<Button
|
|
248
|
+
label="Enter the night"
|
|
249
|
+
variant="primary"
|
|
250
|
+
size="lg"
|
|
251
|
+
isLoading={isLoading}
|
|
252
|
+
onClick={handleLogin}
|
|
253
|
+
/>
|
|
254
|
+
|
|
255
|
+
<Divider label="Or continue with" />
|
|
256
|
+
|
|
257
|
+
<Grid columns={2} gap={3} justify="stretch">
|
|
258
|
+
<Button
|
|
259
|
+
label="Login with Apple"
|
|
260
|
+
variant="secondary"
|
|
261
|
+
icon={<AppleIcon />}
|
|
262
|
+
size="lg"
|
|
263
|
+
/>
|
|
264
|
+
<Button
|
|
265
|
+
label="Login with Google"
|
|
266
|
+
variant="secondary"
|
|
267
|
+
icon={<GoogleIcon />}
|
|
268
|
+
size="lg"
|
|
269
|
+
/>
|
|
270
|
+
</Grid>
|
|
271
|
+
</VStack>
|
|
272
|
+
)}
|
|
273
|
+
</Center>
|
|
274
|
+
</StackItem>
|
|
275
|
+
|
|
276
|
+
{!isSuccess && (
|
|
277
|
+
<Text type="supporting" color="secondary">
|
|
278
|
+
New to the castle?{' '}
|
|
279
|
+
<Link href="#/templates/login-split" type="supporting">
|
|
280
|
+
Sign up
|
|
281
|
+
</Link>
|
|
282
|
+
</Text>
|
|
283
|
+
)}
|
|
284
|
+
</VStack>
|
|
285
|
+
</Section>
|
|
286
|
+
|
|
287
|
+
{/* Cover art — the transparent Card clips it to rounded
|
|
288
|
+
corners (overflow:clip + radius), so the art needs no radius. */}
|
|
289
|
+
<div className="login-split-image">
|
|
290
|
+
<Card
|
|
291
|
+
variant="transparent"
|
|
292
|
+
padding={0}
|
|
293
|
+
width="100%"
|
|
294
|
+
height="100%">
|
|
295
|
+
{nightCoverArt}
|
|
296
|
+
</Card>
|
|
297
|
+
</div>
|
|
298
|
+
</Grid>
|
|
299
|
+
</Card>
|
|
300
|
+
</div>
|
|
301
|
+
|
|
302
|
+
<VStack hAlign="center">
|
|
303
|
+
<Text type="supporting" color="secondary">
|
|
304
|
+
By clicking continue, you agree to our{' '}
|
|
305
|
+
<Link href="#/templates/login-split" type="supporting">
|
|
306
|
+
Terms of Service
|
|
307
|
+
</Link>{' '}
|
|
308
|
+
and{' '}
|
|
309
|
+
<Link href="#/templates/login-split" type="supporting">
|
|
310
|
+
Privacy Policy
|
|
311
|
+
</Link>
|
|
312
|
+
.
|
|
313
|
+
</Text>
|
|
314
|
+
</VStack>
|
|
315
|
+
</VStack>
|
|
316
|
+
</Center>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import {useState, type CSSProperties} from 'react';
|
|
4
|
+
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
|
5
|
+
import {Center} from '@astryxdesign/core/Center';
|
|
6
|
+
import {Text, Heading} from '@astryxdesign/core/Text';
|
|
7
|
+
import {TextInput} from '@astryxdesign/core/TextInput';
|
|
8
|
+
import {Button} from '@astryxdesign/core/Button';
|
|
9
|
+
import {Card} from '@astryxdesign/core/Card';
|
|
10
|
+
import {Section} from '@astryxdesign/core/Section';
|
|
11
|
+
import {Link} from '@astryxdesign/core/Link';
|
|
12
|
+
import {Divider} from '@astryxdesign/core/Divider';
|
|
13
|
+
import {Icon} from '@astryxdesign/core/Icon';
|
|
14
|
+
import {Avatar} from '@astryxdesign/core/Avatar';
|
|
15
|
+
|
|
16
|
+
// ============= ICONS (verified lucide-react exports) =============
|
|
17
|
+
// ShieldCheck ← ShieldCheckIcon.
|
|
18
|
+
import {ShieldCheck} from 'lucide-react';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Styles
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
// Standalone auth page paints its own body background (no host shell).
|
|
25
|
+
const pageStyle: CSSProperties = {
|
|
26
|
+
minHeight: '100%',
|
|
27
|
+
backgroundColor: 'var(--color-background-body)',
|
|
28
|
+
padding: 'var(--spacing-6)',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type SSOProvider = {
|
|
32
|
+
name: string;
|
|
33
|
+
abbr: string;
|
|
34
|
+
};
|
|
35
|
+
const SSO_PROVIDERS: Record<string, SSOProvider> = {
|
|
36
|
+
'google.com': {name: 'Google Workspace', abbr: 'G'},
|
|
37
|
+
'microsoft.com': {name: 'Microsoft Entra ID', abbr: 'M'},
|
|
38
|
+
'okta.com': {name: 'Okta', abbr: 'O'},
|
|
39
|
+
'meta.com': {name: 'Meta SSO', abbr: 'M'},
|
|
40
|
+
'apple.com': {name: 'Apple Business', abbr: 'A'},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function getProvider(email: string) {
|
|
44
|
+
const domain = email.split('@')[1]?.toLowerCase();
|
|
45
|
+
return domain ? (SSO_PROVIDERS[domain] ?? null) : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isValidEmail(email: string) {
|
|
49
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Component
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
type Step = 'email' | 'sso-confirm' | 'password-fallback';
|
|
57
|
+
|
|
58
|
+
export default function LoginSso() {
|
|
59
|
+
const [step, setStep] = useState<Step>('email');
|
|
60
|
+
const [email, setEmail] = useState('');
|
|
61
|
+
const [password, setPassword] = useState('');
|
|
62
|
+
const [loginFailed, setLoginFailed] = useState(false);
|
|
63
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
64
|
+
|
|
65
|
+
const provider = getProvider(email);
|
|
66
|
+
const emailValid = isValidEmail(email);
|
|
67
|
+
|
|
68
|
+
const handleContinue = () => {
|
|
69
|
+
if (!emailValid) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (provider) {
|
|
73
|
+
setStep('sso-confirm');
|
|
74
|
+
} else {
|
|
75
|
+
setStep('password-fallback');
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const handleBack = () => {
|
|
80
|
+
setStep('email');
|
|
81
|
+
setLoginFailed(false);
|
|
82
|
+
setIsLoading(false);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleSignIn = () => {
|
|
86
|
+
if (!password) {
|
|
87
|
+
setLoginFailed(true);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
setIsLoading(true);
|
|
91
|
+
setLoginFailed(false);
|
|
92
|
+
setTimeout(() => {
|
|
93
|
+
setIsLoading(false);
|
|
94
|
+
setLoginFailed(true);
|
|
95
|
+
}, 2000);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<Center axis="both" style={pageStyle}>
|
|
100
|
+
<Card padding={8} width="100%" maxWidth={400}>
|
|
101
|
+
<VStack gap={4} hAlign="stretch">
|
|
102
|
+
{/* ── Step 1: Email entry ── */}
|
|
103
|
+
{step === 'email' && (
|
|
104
|
+
<>
|
|
105
|
+
<VStack gap={1} hAlign="center">
|
|
106
|
+
<Heading level={2}>Welcome back to the night</Heading>
|
|
107
|
+
<Text type="body" color="secondary">
|
|
108
|
+
Whisper your details to enter the night
|
|
109
|
+
</Text>
|
|
110
|
+
</VStack>
|
|
111
|
+
|
|
112
|
+
<VStack gap={2}>
|
|
113
|
+
<TextInput
|
|
114
|
+
label="Work email"
|
|
115
|
+
isLabelHidden
|
|
116
|
+
type="email"
|
|
117
|
+
placeholder="you@castle.dracula"
|
|
118
|
+
value={email}
|
|
119
|
+
onChange={setEmail}
|
|
120
|
+
size="lg"
|
|
121
|
+
onKeyDown={(e: React.KeyboardEvent) => {
|
|
122
|
+
if (e.key === 'Enter') {
|
|
123
|
+
handleContinue();
|
|
124
|
+
}
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
<TextInput
|
|
128
|
+
label="Password"
|
|
129
|
+
isLabelHidden
|
|
130
|
+
type="password"
|
|
131
|
+
placeholder="Whisper your password"
|
|
132
|
+
value={password}
|
|
133
|
+
onChange={setPassword}
|
|
134
|
+
size="lg"
|
|
135
|
+
/>
|
|
136
|
+
</VStack>
|
|
137
|
+
|
|
138
|
+
<Link href="#/templates/login-sso" size="sm" color="secondary" type="supporting">
|
|
139
|
+
Having trouble signing in?
|
|
140
|
+
</Link>
|
|
141
|
+
|
|
142
|
+
<Button
|
|
143
|
+
label="Enter the night"
|
|
144
|
+
variant="primary"
|
|
145
|
+
size="lg"
|
|
146
|
+
onClick={handleContinue}
|
|
147
|
+
isDisabled={!emailValid}
|
|
148
|
+
/>
|
|
149
|
+
|
|
150
|
+
<Divider label="Or sign in with" />
|
|
151
|
+
|
|
152
|
+
<Button
|
|
153
|
+
label="Continue with SSO"
|
|
154
|
+
variant="secondary"
|
|
155
|
+
size="lg"
|
|
156
|
+
onClick={handleContinue}
|
|
157
|
+
isDisabled={!emailValid}
|
|
158
|
+
/>
|
|
159
|
+
|
|
160
|
+
<VStack hAlign="center">
|
|
161
|
+
<Text type="supporting" color="secondary">
|
|
162
|
+
New to the castle?{' '}
|
|
163
|
+
<Link href="#/templates/login-sso" type="supporting">
|
|
164
|
+
Request access
|
|
165
|
+
</Link>
|
|
166
|
+
</Text>
|
|
167
|
+
</VStack>
|
|
168
|
+
</>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{/* ── Step 2a: SSO provider detected ── */}
|
|
172
|
+
{step === 'sso-confirm' && provider && (
|
|
173
|
+
<>
|
|
174
|
+
<VStack gap={2} hAlign="center">
|
|
175
|
+
<Avatar name={provider.name} size={48} />
|
|
176
|
+
<Heading level={2}>Sign in with {provider.name}</Heading>
|
|
177
|
+
<Text type="body" color="secondary">
|
|
178
|
+
You will be redirected back after signing in.
|
|
179
|
+
</Text>
|
|
180
|
+
</VStack>
|
|
181
|
+
|
|
182
|
+
<Card padding={0}>
|
|
183
|
+
<Section variant="muted" padding={4}>
|
|
184
|
+
<HStack gap={2} vAlign="center">
|
|
185
|
+
<Icon icon={ShieldCheck} color="secondary" />
|
|
186
|
+
<VStack gap={0}>
|
|
187
|
+
<Text type="label">{provider.name}</Text>
|
|
188
|
+
<Text type="supporting" color="secondary">
|
|
189
|
+
{email}
|
|
190
|
+
</Text>
|
|
191
|
+
</VStack>
|
|
192
|
+
</HStack>
|
|
193
|
+
</Section>
|
|
194
|
+
</Card>
|
|
195
|
+
|
|
196
|
+
<VStack gap={3}>
|
|
197
|
+
<Button
|
|
198
|
+
label={`Continue with ${provider.name}`}
|
|
199
|
+
variant="primary"
|
|
200
|
+
size="lg"
|
|
201
|
+
isLoading={isLoading}
|
|
202
|
+
onClick={() => setIsLoading(true)}
|
|
203
|
+
/>
|
|
204
|
+
<Button
|
|
205
|
+
label="Use a different email"
|
|
206
|
+
variant="ghost"
|
|
207
|
+
size="lg"
|
|
208
|
+
onClick={handleBack}
|
|
209
|
+
/>
|
|
210
|
+
</VStack>
|
|
211
|
+
</>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{/* ── Step 2b: No SSO — password fallback ── */}
|
|
215
|
+
{step === 'password-fallback' && (
|
|
216
|
+
<>
|
|
217
|
+
<VStack gap={1} hAlign="center">
|
|
218
|
+
<Heading level={2}>Welcome back to the night</Heading>
|
|
219
|
+
<Text type="body" color="secondary">
|
|
220
|
+
{email}
|
|
221
|
+
</Text>
|
|
222
|
+
</VStack>
|
|
223
|
+
|
|
224
|
+
<VStack gap={4}>
|
|
225
|
+
<VStack gap={1}>
|
|
226
|
+
<TextInput
|
|
227
|
+
label="Password"
|
|
228
|
+
type="password"
|
|
229
|
+
value={password}
|
|
230
|
+
size="lg"
|
|
231
|
+
onChange={(v: string) => {
|
|
232
|
+
setPassword(v);
|
|
233
|
+
setLoginFailed(false);
|
|
234
|
+
}}
|
|
235
|
+
status={
|
|
236
|
+
loginFailed
|
|
237
|
+
? {
|
|
238
|
+
type: 'error',
|
|
239
|
+
message: 'Wrong incantation. Try again.',
|
|
240
|
+
}
|
|
241
|
+
: undefined
|
|
242
|
+
}
|
|
243
|
+
/>
|
|
244
|
+
{loginFailed && (
|
|
245
|
+
<VStack hAlign="end">
|
|
246
|
+
<Link
|
|
247
|
+
href="#/templates/login-sso"
|
|
248
|
+
size="sm"
|
|
249
|
+
color="secondary"
|
|
250
|
+
type="supporting">
|
|
251
|
+
Forgot password?
|
|
252
|
+
</Link>
|
|
253
|
+
</VStack>
|
|
254
|
+
)}
|
|
255
|
+
</VStack>
|
|
256
|
+
|
|
257
|
+
<Button
|
|
258
|
+
label="Enter the night"
|
|
259
|
+
variant="primary"
|
|
260
|
+
size="lg"
|
|
261
|
+
isLoading={isLoading}
|
|
262
|
+
onClick={handleSignIn}
|
|
263
|
+
/>
|
|
264
|
+
<Button
|
|
265
|
+
label="Use a different email"
|
|
266
|
+
variant="ghost"
|
|
267
|
+
size="lg"
|
|
268
|
+
onClick={handleBack}
|
|
269
|
+
/>
|
|
270
|
+
</VStack>
|
|
271
|
+
</>
|
|
272
|
+
)}
|
|
273
|
+
</VStack>
|
|
274
|
+
</Card>
|
|
275
|
+
</Center>
|
|
276
|
+
);
|
|
277
|
+
}
|