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",
"requireCode": "Please enter a valid verification code.",
"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 { ReactComponent as Logo } from '@/assets/icons/logo.svg';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
function CheckEmail ({ email, redirectTo }: {
email: string;
@@ -26,7 +25,6 @@ function CheckEmail ({ email, redirectTo }: {
}
setLoading(true);
const id = toast.loading(t('signing'));
try {
await service?.signInOTP({
@@ -43,7 +41,6 @@ function CheckEmail ({ email, redirectTo }: {
}
} finally {
setLoading(false);
toast.dismiss(id);
}
};
@@ -88,11 +85,12 @@ function CheckEmail ({ email, redirectTo }: {
/>
<Button
loading={loading}
onClick={handleSubmit}
size={'lg'}
className={'w-[320px]'}
>
{t('continueToSignIn')}
{loading ? t('verifying') : t('continueToSignIn')}
</Button>
</div>
) : <Button

View File

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

View File

@@ -1,3 +1,4 @@
import { Progress } from '@/components/ui/progress';
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
@@ -20,6 +21,7 @@ const buttonVariants = cva(
ghost:
'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',
loading: 'opacity-50 cursor-not-allowed',
},
size: {
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-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: {
variant: 'default',
size: 'default',
loading: false,
},
},
);
const Button = React.forwardRef<HTMLButtonElement, React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}>(({
className,
variant,
size,
loading,
asChild = false,
children,
...props
}, ref) => {
const Comp = asChild ? Slot : 'button';
@@ -53,9 +62,22 @@ const Button = React.forwardRef<HTMLButtonElement, React.ComponentProps<'button'
<Comp
ref={ref}
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}
/>
>
{loading && (
// eslint-disable-next-line
// @ts-ignore
<Progress variant={variant} />
)}
{children}
</Comp>
);
});

View File

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