chore: add button loading state (#95)

* chore: add button loading state

* chore: waiting loading
This commit is contained in:
Kilu.He
2025-04-14 17:24:10 +08:00
committed by GitHub
parent a2e1da537d
commit 74ed80e945
5 changed files with 74 additions and 41 deletions

View File

@@ -3104,5 +3104,8 @@
"continueToSignIn": "Continue to sign in", "continueToSignIn": "Continue to sign in",
"requireCode": "Please enter a valid verification code.", "requireCode": "Please enter a valid verification code.",
"signing": "Signing in...", "signing": "Signing in...",
"invalidOTPCode": "The code is invalid or has expired. Please try again." "invalidOTPCode": "The code is invalid or has expired. Please try again.",
"verifying": "Verifying...",
"loading": "Loading...",
"tooManyRequests": "Too many requests, please try again later."
} }

View File

@@ -5,7 +5,6 @@ import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys';
import React, { useContext, useState } from 'react'; import React, { useContext, useState } from 'react';
import { ReactComponent as Logo } from '@/assets/icons/logo.svg'; import { ReactComponent as Logo } from '@/assets/icons/logo.svg';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
function CheckEmail ({ email, redirectTo }: { function CheckEmail ({ email, redirectTo }: {
email: string; email: string;
@@ -26,7 +25,6 @@ function CheckEmail ({ email, redirectTo }: {
} }
setLoading(true); setLoading(true);
const id = toast.loading(t('signing'));
try { try {
await service?.signInOTP({ await service?.signInOTP({
@@ -43,7 +41,6 @@ function CheckEmail ({ email, redirectTo }: {
} }
} finally { } finally {
setLoading(false); setLoading(false);
toast.dismiss(id);
} }
}; };
@@ -88,11 +85,12 @@ function CheckEmail ({ email, redirectTo }: {
/> />
<Button <Button
loading={loading}
onClick={handleSubmit} onClick={handleSubmit}
size={'lg'} size={'lg'}
className={'w-[320px]'} className={'w-[320px]'}
> >
{t('continueToSignIn')} {loading ? t('verifying') : t('continueToSignIn')}
</Button> </Button>
</div> </div>
) : <Button ) : <Button

View File

@@ -4,15 +4,19 @@ import { Input } from '@/components/ui/input';
import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys';
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import isEmail from 'validator/lib/isEmail'; import isEmail from 'validator/lib/isEmail';
function MagicLink ({ redirectTo }: { redirectTo: string }) { function MagicLink ({ redirectTo }: { redirectTo: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [email, setEmail] = React.useState<string>(''); const [email, setEmail] = React.useState<string>('');
const [, setLoading] = React.useState<boolean>(false); const [loading, setLoading] = React.useState<boolean>(false);
const [error, setError] = React.useState<string>('');
const [, setSearch] = useSearchParams();
const service = useContext(AFConfigContext)?.service; const service = useContext(AFConfigContext)?.service;
const handleSubmit = async () => { const handleSubmit = async () => {
if (loading) return;
const isValidEmail = isEmail(email); const isValidEmail = isEmail(email);
if (!isValidEmail) { if (!isValidEmail) {
@@ -20,29 +24,47 @@ function MagicLink ({ redirectTo }: { redirectTo: string }) {
return; return;
} }
setError('');
setLoading(true); setLoading(true);
try { void (async () => {
void service?.signInMagicLink({ try {
email, await service?.signInMagicLink({
redirectTo, email,
}); redirectTo,
});
// eslint-disable-next-line
} catch (e: any) {
if (e.code === 429 || e.response?.status === 429) {
toast.error(t('tooManyRequests'));
} else {
toast.error(e.message);
}
} finally {
setLoading(false);
}
})();
setSearch(prev => {
prev.set('email', email);
prev.set('action', 'checkEmail');
return prev;
});
window.location.href = `/login?action=checkEmail&email=${email}&redirectTo=${redirectTo}`;
} catch (e) {
toast.error(t('web.signInError'));
} finally {
setLoading(false);
}
}; };
return ( return (
<div className={'flex w-full flex-col items-center justify-center gap-3'}> <div className={'flex w-full flex-col items-center justify-center gap-3'}>
<Input <Input
size={'md'} size={'md'}
variant={error ? 'destructive' : 'default'}
helpText={error}
type={'email'} type={'email'}
className={'w-[320px]'} className={'w-[320px]'}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => {
setError('');
setEmail(e.target.value);
}}
value={email} value={email}
placeholder={t('signIn.pleaseInputYourEmail')} placeholder={t('signIn.pleaseInputYourEmail')}
onKeyDown={e => { onKeyDown={e => {
@@ -56,8 +78,9 @@ function MagicLink ({ redirectTo }: { redirectTo: string }) {
onClick={handleSubmit} onClick={handleSubmit}
size={'lg'} size={'lg'}
className={'w-[320px]'} className={'w-[320px]'}
loading={loading}
> >
{t('signIn.signInWithEmail')} {loading ? t('loading') : t('signIn.signInWithEmail')}
</Button> </Button>
</div> </div>
); );

View File

@@ -1,3 +1,4 @@
import { Progress } from '@/components/ui/progress';
import * as React from 'react'; import * as React from 'react';
import { Slot } from '@radix-ui/react-slot'; import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from 'class-variance-authority';
@@ -20,6 +21,7 @@ const buttonVariants = cva(
ghost: ghost:
'hover:bg-fill-primary-alpha-5 text-text-primary disabled:bg-fill-transparent disabled:text-text-tertiary', 'hover:bg-fill-primary-alpha-5 text-text-primary disabled:bg-fill-transparent disabled:text-text-tertiary',
link: 'hover:bg-transparent text-text-theme hover:text-text-theme-hover !h-fit', link: 'hover:bg-transparent text-text-theme hover:text-text-theme-hover !h-fit',
loading: 'opacity-50 cursor-not-allowed',
}, },
size: { size: {
sm: 'h-7 text-sm px-4 rounded-300 gap-2 font-normal', sm: 'h-7 text-sm px-4 rounded-300 gap-2 font-normal',
@@ -29,22 +31,29 @@ const buttonVariants = cva(
icon: 'size-7 p-1 text-icon-primary disabled:text-icon-tertiary', icon: 'size-7 p-1 text-icon-primary disabled:text-icon-tertiary',
'icon-lg': 'size-10 p-[10px] text-icon-primary disabled:text-icon-tertiary', 'icon-lg': 'size-10 p-[10px] text-icon-primary disabled:text-icon-tertiary',
}, },
loading: {
true: 'opacity-70 cursor-not-allowed hover:bg-fill-theme-thick',
false: '',
},
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: 'default',
size: 'default', size: 'default',
loading: false,
}, },
}, },
); );
const Button = React.forwardRef<HTMLButtonElement, React.ComponentProps<'button'> & const Button = React.forwardRef<HTMLButtonElement, React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean;
}>(({ }>(({
className, className,
variant, variant,
size, size,
loading,
asChild = false, asChild = false,
children,
...props ...props
}, ref) => { }, ref) => {
const Comp = asChild ? Slot : 'button'; const Comp = asChild ? Slot : 'button';
@@ -53,9 +62,22 @@ const Button = React.forwardRef<HTMLButtonElement, React.ComponentProps<'button'
<Comp <Comp
ref={ref} ref={ref}
data-slot="button" data-slot="button"
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className, loading }))}
onClick={e => {
if (loading) return;
if (props.onClick) {
props.onClick(e);
}
}}
{...props} {...props}
/> >
{loading && (
// eslint-disable-next-line
// @ts-ignore
<Progress variant={variant} />
)}
{children}
</Comp>
); );
}); });

View File

@@ -6,12 +6,6 @@ const progressVariants = cva(
'relative block', 'relative block',
{ {
variants: { variants: {
size: {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8',
xl: 'h-10 w-10',
},
variant: { variant: {
default: '', default: '',
success: '', success: '',
@@ -25,7 +19,6 @@ const progressVariants = cva(
}, },
}, },
defaultVariants: { defaultVariants: {
size: 'md',
variant: 'default', variant: 'default',
isIndeterminate: false, isIndeterminate: false,
}, },
@@ -33,7 +26,7 @@ const progressVariants = cva(
); );
const circleVariants = cva( const circleVariants = cva(
'fill-fill-transparent ', 'fill-fill-transparent h-5 w-5',
{ {
variants: { variants: {
variant: { variant: {
@@ -51,7 +44,7 @@ const circleVariants = cva(
); );
const progressCircleVariants = cva( const progressCircleVariants = cva(
'fill-fill-transparent transition-all', 'fill-fill-transparent transition-all h-5 w-5',
{ {
variants: { variants: {
variant: { variant: {
@@ -79,22 +72,16 @@ export interface ProgressProps
export function Progress ({ export function Progress ({
value, value,
size = 'md',
variant = 'default', variant = 'default',
strokeLinecap = 'round', strokeLinecap = 'round',
className, className,
...props ...props
}: ProgressProps) { }: ProgressProps) {
// Calculate dimensions based on size variant // Calculate dimensions based on size variant
const dimensions: number = { const dimensions: number = 20;
sm: 16,
md: 24, const strokeWidth = 2.5;
lg: 32,
xl: 40,
}[size as string] || 24;
const strokeWidth = Math.ceil(dimensions * 0.125); // 12.5% of dimensions
const radius = dimensions / 2 - strokeWidth; const radius = dimensions / 2 - strokeWidth;
const circumference = Math.ceil(2 * Math.PI * radius); const circumference = Math.ceil(2 * Math.PI * radius);
const isIndeterminate = value === undefined; const isIndeterminate = value === undefined;
@@ -111,7 +98,7 @@ export function Progress ({
return ( return (
<div <div
className={cn(progressVariants({ size, variant, isIndeterminate, className }))} className={cn(progressVariants({ variant, isIndeterminate, className }))}
{...props} {...props}
> >
<svg <svg