Autofocus login inputs, nice successful login animation
This commit is contained in:
parent
21f75a4a3f
commit
617d47d949
3 changed files with 57 additions and 38 deletions
|
@ -1,6 +1,7 @@
|
||||||
import type { mtcute, TelegramAccount } from 'mtcute-repl-worker/client'
|
import type { mtcute, TelegramAccount } from 'mtcute-repl-worker/client'
|
||||||
|
import type { Setter } from 'solid-js'
|
||||||
import { unknownToError } from '@fuman/utils'
|
import { unknownToError } from '@fuman/utils'
|
||||||
import { LucideChevronRight } from 'lucide-solid'
|
import { LucideChevronRight, MessageSquareMore } from 'lucide-solid'
|
||||||
import { workerInvoke, workerOn } from 'mtcute-repl-worker/client'
|
import { workerInvoke, workerOn } from 'mtcute-repl-worker/client'
|
||||||
import { createEffect, createSignal, For, Match, onCleanup, onMount, Show, Switch } from 'solid-js'
|
import { createEffect, createSignal, For, Match, onCleanup, onMount, Show, Switch } from 'solid-js'
|
||||||
import { Button } from '../../../lib/components/ui/button.tsx'
|
import { Button } from '../../../lib/components/ui/button.tsx'
|
||||||
|
@ -20,12 +21,13 @@ export type LoginStep =
|
||||||
| 'done'
|
| 'done'
|
||||||
export interface StepContext {
|
export interface StepContext {
|
||||||
qr: void
|
qr: void
|
||||||
phone: void
|
phone: { setInputRef: Setter<HTMLInputElement | undefined> }
|
||||||
otp: {
|
otp: {
|
||||||
|
setInputRef: Setter<HTMLInputElement | undefined>
|
||||||
phone: string
|
phone: string
|
||||||
code: mtcute.SentCode
|
code: mtcute.SentCode
|
||||||
}
|
}
|
||||||
password: void
|
password: { setInputRef: Setter<HTMLInputElement | undefined> }
|
||||||
done: { account: TelegramAccount }
|
done: { account: TelegramAccount }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,7 +107,6 @@ function PhoneNumberStep(props: StepProps<'phone'>) {
|
||||||
const [phone, setPhone] = createSignal('')
|
const [phone, setPhone] = createSignal('')
|
||||||
const [error, setError] = createSignal<string | undefined>()
|
const [error, setError] = createSignal<string | undefined>()
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
|
|
||||||
|
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
@ -120,8 +121,9 @@ function PhoneNumberStep(props: StepProps<'phone'>) {
|
||||||
})
|
})
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
props.setStep('otp', {
|
props.setStep('otp', {
|
||||||
code,
|
|
||||||
phone: phone(),
|
phone: phone(),
|
||||||
|
code,
|
||||||
|
setInputRef: props.ctx.setInputRef,
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
@ -133,7 +135,6 @@ function PhoneNumberStep(props: StepProps<'phone'>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onCleanup(() => abortController.abort())
|
onCleanup(() => abortController.abort())
|
||||||
createEffect(() => inputRef()?.focus())
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-full flex-col items-center justify-center">
|
<div class="flex h-full flex-col items-center justify-center">
|
||||||
|
@ -161,7 +162,7 @@ function PhoneNumberStep(props: StepProps<'phone'>) {
|
||||||
onChange={setPhone}
|
onChange={setPhone}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
disabled={loading()}
|
disabled={loading()}
|
||||||
ref={setInputRef}
|
ref={props.ctx.setInputRef}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
|
@ -237,8 +238,9 @@ function OtpStep(props: StepProps<'otp'>) {
|
||||||
})
|
})
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
props.setStep('otp', {
|
props.setStep('otp', {
|
||||||
code,
|
setInputRef: props.ctx.setInputRef,
|
||||||
phone: props.ctx.phone,
|
phone: props.ctx.phone,
|
||||||
|
code,
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
@ -274,29 +276,43 @@ function OtpStep(props: StepProps<'otp'>) {
|
||||||
const description = () => {
|
const description = () => {
|
||||||
switch (props.ctx.code.type) {
|
switch (props.ctx.code.type) {
|
||||||
case 'app':
|
case 'app':
|
||||||
return 'We have sent you a one-time code to your Telegram app'
|
return 'We have sent you a one-time code to your Telegram app.'
|
||||||
case 'sms':
|
case 'sms':
|
||||||
case 'sms_word':
|
case 'sms_word':
|
||||||
case 'sms_phrase':
|
case 'sms_phrase':
|
||||||
return 'We have sent you a one-time code to your phone'
|
return 'We have sent you a one-time code to your phone.'
|
||||||
case 'fragment':
|
case 'fragment':
|
||||||
return 'We have sent you a one-time code to your Fragment anonymous number'
|
return 'We have sent you a one-time code to your Fragment anonymous number.'
|
||||||
case 'call':
|
case 'call':
|
||||||
return 'We are calling you to dictate your one-time code'
|
return 'We are calling you to dictate your one-time code.'
|
||||||
case 'flash_call':
|
case 'flash_call':
|
||||||
case 'missed_call':
|
case 'missed_call':
|
||||||
return `We are calling you, put the last ${props.ctx.code.length} digits of the number we're calling you from`
|
return `We are calling you, put the last ${props.ctx.code.length} digits of the number we're calling you from.`
|
||||||
case 'email':
|
case 'email':
|
||||||
return 'We have sent you an email with a one-time code'
|
return 'We have sent you an email with a one-time code.'
|
||||||
case 'email_required':
|
case 'email_required':
|
||||||
return 'Email setup is required, please do it in your Telegram app'
|
return 'Email setup is required, please do it in your Telegram app.'
|
||||||
default:
|
default:
|
||||||
return `Unknown code type: ${props.ctx.code.type}`
|
return `Unknown code type: ${props.ctx.code.type}.`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [innerInputRef, setInnerInputRef] = createSignal<HTMLInputElement | undefined>()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key >= '0' && e.key <= '9') {
|
||||||
|
innerInputRef()?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
onCleanup(() => window.removeEventListener('keydown', handleKeyDown))
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col items-center justify-center">
|
<div class="flex flex-col items-center justify-center">
|
||||||
|
<MessageSquareMore class="size-16 pb-2" strokeWidth={1.5} />
|
||||||
<h2 class="text-xl font-bold">
|
<h2 class="text-xl font-bold">
|
||||||
{props.ctx.phone}
|
{props.ctx.phone}
|
||||||
</h2>
|
</h2>
|
||||||
|
@ -310,7 +326,7 @@ function OtpStep(props: StepProps<'otp'>) {
|
||||||
<div class="mt-4 text-center text-sm text-muted-foreground">
|
<div class="mt-4 text-center text-sm text-muted-foreground">
|
||||||
{description()}
|
{description()}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex flex-col">
|
<div class="mt-4 flex flex-col items-center text-center">
|
||||||
<Show
|
<Show
|
||||||
when={props.ctx.code.type !== 'sms_phrase' && props.ctx.code.type !== 'sms_word'}
|
when={props.ctx.code.type !== 'sms_phrase' && props.ctx.code.type !== 'sms_word'}
|
||||||
fallback={(
|
fallback={(
|
||||||
|
@ -327,7 +343,7 @@ function OtpStep(props: StepProps<'otp'>) {
|
||||||
handleSubmit()
|
handleSubmit()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
ref={setInputRef}
|
ref={(e) => { setInputRef(e); setInnerInputRef(e) }}
|
||||||
/>
|
/>
|
||||||
</TextFieldFrame>
|
</TextFieldFrame>
|
||||||
<TextFieldErrorMessage>{error()}</TextFieldErrorMessage>
|
<TextFieldErrorMessage>{error()}</TextFieldErrorMessage>
|
||||||
|
@ -342,7 +358,7 @@ function OtpStep(props: StepProps<'otp'>) {
|
||||||
>
|
>
|
||||||
<OTPFieldInput
|
<OTPFieldInput
|
||||||
disabled={loading()}
|
disabled={loading()}
|
||||||
ref={setInputRef}
|
ref={(e) => { setInputRef(e); setInnerInputRef(e) }}
|
||||||
onKeyPress={(e) => {
|
onKeyPress={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
handleSubmit()
|
handleSubmit()
|
||||||
|
@ -361,13 +377,11 @@ function OtpStep(props: StepProps<'otp'>) {
|
||||||
</OTPFieldGroup>
|
</OTPFieldGroup>
|
||||||
</OTPField>
|
</OTPField>
|
||||||
{error() && (
|
{error() && (
|
||||||
<div class="mt-1 text-sm text-error-foreground">
|
<div class="mt-1 text-sm text-error-foreground">{error()}</div>
|
||||||
{error()}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="mt-2 flex w-full justify-between">
|
<div class="mt-2 flex items-center align-middle">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
@ -379,14 +393,6 @@ function OtpStep(props: StepProps<'otp'>) {
|
||||||
` (${countdown()})`
|
` (${countdown()})`
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={loading()}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -474,9 +480,9 @@ function DoneStep(props: StepProps<'done'>) {
|
||||||
<div class="flex flex-col items-center justify-center">
|
<div class="flex flex-col items-center justify-center">
|
||||||
<AccountAvatar
|
<AccountAvatar
|
||||||
account={props.ctx.account}
|
account={props.ctx.account}
|
||||||
class="mb-4 size-24 shadow-sm"
|
class="mb-4 size-24 animate-scale-up shadow-sm fill-mode-forwards"
|
||||||
/>
|
/>
|
||||||
<div class="text-center font-medium">
|
<div class="animate-fade-out-down text-center font-medium fill-mode-forwards">
|
||||||
Welcome,
|
Welcome,
|
||||||
{' '}
|
{' '}
|
||||||
{props.ctx.account.name}
|
{props.ctx.account.name}
|
||||||
|
@ -500,21 +506,23 @@ export function LoginForm(props: {
|
||||||
props.onStepChange?.(step, ctx())
|
props.onStepChange?.(step, ctx())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={cn('flex h-full flex-col items-center justify-center gap-4', props.class)}>
|
<div class={cn('flex h-full flex-col items-center justify-center gap-4', props.class)}>
|
||||||
<TransitionSlideLtr mode="outin">
|
<TransitionSlideLtr onAfterExit={() => inputRef()?.focus()} mode="outin">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={step() === 'qr'}>
|
<Match when={step() === 'qr'}>
|
||||||
<QrLoginStep accountId={props.accountId} setStep={setStepWithCtx} />
|
<QrLoginStep accountId={props.accountId} setStep={setStepWithCtx} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={step() === 'phone'}>
|
<Match when={step() === 'phone'}>
|
||||||
<PhoneNumberStep accountId={props.accountId} setStep={setStepWithCtx} />
|
<PhoneNumberStep accountId={props.accountId} setStep={setStepWithCtx} ctx={{ setInputRef }} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={step() === 'otp'}>
|
<Match when={step() === 'otp'}>
|
||||||
<OtpStep accountId={props.accountId} setStep={setStepWithCtx} ctx={ctx().otp!} />
|
<OtpStep accountId={props.accountId} setStep={setStepWithCtx} ctx={{ ...ctx().otp!, setInputRef }} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={step() === 'password'}>
|
<Match when={step() === 'password'}>
|
||||||
<PasswordStep accountId={props.accountId} setStep={setStepWithCtx} />
|
<PasswordStep accountId={props.accountId} setStep={setStepWithCtx} ctx={{ setInputRef }} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={step() === 'done'}>
|
<Match when={step() === 'done'}>
|
||||||
<DoneStep
|
<DoneStep
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { JSX } from 'solid-js'
|
import type { JSX } from 'solid-js'
|
||||||
import { Transition } from 'solid-transition-group'
|
import { Transition } from 'solid-transition-group'
|
||||||
|
|
||||||
export function TransitionSlideLtr(props: { mode?: 'outin' | 'inout', children: JSX.Element }) {
|
export function TransitionSlideLtr(props: { mode?: 'outin' | 'inout', onAfterExit?: (element: Element) => void, children: JSX.Element }) {
|
||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
mode={props.mode}
|
mode={props.mode}
|
||||||
|
@ -9,6 +9,7 @@ export function TransitionSlideLtr(props: { mode?: 'outin' | 'inout', children:
|
||||||
exitActiveClass="transition-[transform, opacity] duration-150 ease-in-out motion-reduce:transition-none"
|
exitActiveClass="transition-[transform, opacity] duration-150 ease-in-out motion-reduce:transition-none"
|
||||||
enterClass="translate-x-5 opacity-0"
|
enterClass="translate-x-5 opacity-0"
|
||||||
exitToClass="-translate-x-5 opacity-0"
|
exitToClass="-translate-x-5 opacity-0"
|
||||||
|
onAfterExit={props.onAfterExit}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
|
@ -89,6 +89,14 @@ module.exports = {
|
||||||
'0%, 70%, 100%': { opacity: 1 },
|
'0%, 70%, 100%': { opacity: 1 },
|
||||||
'20%, 50%': { opacity: 0 },
|
'20%, 50%': { opacity: 0 },
|
||||||
},
|
},
|
||||||
|
'scale-up': {
|
||||||
|
'0%': { transform: 'scale(1)' },
|
||||||
|
'100%': { transform: 'scale(2) translateY(10px)' },
|
||||||
|
},
|
||||||
|
'fade-out-down': {
|
||||||
|
'0%': { opacity: 1, transform: 'scale(1)' },
|
||||||
|
'100%': { opacity: 0, transform: 'scale(0.8) translateY(16px)' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fontSize: {
|
fontSize: {
|
||||||
'2xs': ['0.625rem', { lineHeight: '1rem' }],
|
'2xs': ['0.625rem', { lineHeight: '1rem' }],
|
||||||
|
@ -99,6 +107,8 @@ module.exports = {
|
||||||
'content-show': 'content-show 0.2s ease-out',
|
'content-show': 'content-show 0.2s ease-out',
|
||||||
'content-hide': 'content-hide 0.2s ease-out',
|
'content-hide': 'content-hide 0.2s ease-out',
|
||||||
'caret-blink': 'caret-blink 1.2s ease-out infinite',
|
'caret-blink': 'caret-blink 1.2s ease-out infinite',
|
||||||
|
'scale-up': 'scale-up 1.0s cubic-bezier(0.33, 1, 0.68, 1) 1s',
|
||||||
|
'fade-out-down': 'fade-out-down 0.25s cubic-bezier(0.33, 1, 0.68, 1) 1s',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue