feat(ui): add loading spinners to async buttons in dialogs and forms
All checks were successful
Client CMS Check Build (NixCN CMS) TeamCity build finished
Backend Check Build (NixCN CMS) TeamCity build finished

Signed-off-by: Noa Virellia <noa@requiem.garden>
This commit is contained in:
2026-02-11 23:27:31 +08:00
parent cdd25236e4
commit 550254b844
7 changed files with 32 additions and 17 deletions

View File

@@ -6,7 +6,7 @@ import { Dialog } from '../ui/dialog';
import { EventJoinDialogView } from './event-join.dialog.view'; import { EventJoinDialogView } from './event-join.dialog.view';
export function EventJoinDialogContainer({ event, children }: { event: EventInfo; children: React.ReactNode }) { export function EventJoinDialogContainer({ event, children }: { event: EventInfo; children: React.ReactNode }) {
const { mutateAsync } = useJoinEvent(); const { mutateAsync, isPending } = useJoinEvent();
const join = useCallback(() => { const join = useCallback(() => {
mutateAsync({ body: { event_id: event.eventId } }).then(() => { mutateAsync({ body: { event_id: event.eventId } }).then(() => {
toast('加入活动成功'); toast('加入活动成功');
@@ -19,7 +19,7 @@ export function EventJoinDialogContainer({ event, children }: { event: EventInfo
return ( return (
<Dialog> <Dialog>
{children} {children}
<EventJoinDialogView event={event} onJoinEvent={join} /> <EventJoinDialogView event={event} onJoinEvent={join} isPending={isPending} />
</Dialog> </Dialog>
); );
} }

View File

@@ -1,8 +1,9 @@
import type { EventInfo } from './types'; import type { EventInfo } from './types';
import { Loader2 } from 'lucide-react';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog'; import { DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
export function EventJoinDialogView({ event, onJoinEvent }: { event: EventInfo; onJoinEvent: () => void }) { export function EventJoinDialogView({ event, onJoinEvent, isPending }: { event: EventInfo; onJoinEvent: () => void; isPending: boolean }) {
return ( return (
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -18,7 +19,7 @@ export function EventJoinDialogView({ event, onJoinEvent }: { event: EventInfo;
<DialogClose asChild> <DialogClose asChild>
<Button variant="outline"></Button> <Button variant="outline"></Button>
</DialogClose> </DialogClose>
<Button onClick={onJoinEvent}></Button> <Button onClick={onJoinEvent} disabled={isPending}>{isPending ? <Loader2 className="animate-spin" /> : '加入'}</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
); );

View File

@@ -1,5 +1,6 @@
import type { KycSubmission } from './kyc.types'; import type { KycSubmission } from './kyc.types';
import { useForm } from '@tanstack/react-form'; import { useForm } from '@tanstack/react-form';
import { Loader2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import z from 'zod'; import z from 'zod';
@@ -80,7 +81,7 @@ function CnridForm({ onSubmit }: { onSubmit: OnSubmit }) {
<form.Subscribe <form.Subscribe
selector={state => [state.canSubmit, state.isPristine, state.isSubmitting]} selector={state => [state.canSubmit, state.isPristine, state.isSubmitting]}
children={([canSubmit, isPristine, isSubmitting]) => ( children={([canSubmit, isPristine, isSubmitting]) => (
<Button type="submit" disabled={!canSubmit || isPristine || isSubmitting}>{isSubmitting ? '...' : '开始认证'}</Button> <Button type="submit" disabled={!canSubmit || isPristine || isSubmitting}>{isSubmitting ? <Loader2 className="animate-spin" /> : '开始认证'}</Button>
)} )}
/> />
</DialogFooter> </DialogFooter>

View File

@@ -2,6 +2,7 @@ import type { TurnstileInstance } from '@marsidev/react-turnstile';
import type { AuthorizeSearchParams } from '@/routes/authorize'; import type { AuthorizeSearchParams } from '@/routes/authorize';
import { Turnstile } from '@marsidev/react-turnstile'; import { Turnstile } from '@marsidev/react-turnstile';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { Loader2 } from 'lucide-react';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import NixOSLogo from '@/assets/nixos.svg?react'; import NixOSLogo from '@/assets/nixos.svg?react';
@@ -41,6 +42,7 @@ export function LoginForm({
}); });
}; };
const isLoading = isPending || token === null;
return ( return (
<div className={cn('flex flex-col gap-6', className)} {...props}> <div className={cn('flex flex-col gap-6', className)} {...props}>
<form ref={formRef} onSubmit={handleSubmit}> <form ref={formRef} onSubmit={handleSubmit}>
@@ -63,7 +65,8 @@ export function LoginForm({
/> />
</Field> </Field>
<Field> <Field>
<Button type="submit" disabled={token === null || isPending}> <Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
{token === null ? '等待 Turnstile' : isPending ? '发送中...' : '发送登录链接'} {token === null ? '等待 Turnstile' : isPending ? '发送中...' : '发送登录链接'}
</Button> </Button>
</Field> </Field>

View File

@@ -1,5 +1,6 @@
import type { ServiceUserUserInfoData } from '@/client'; import type { ServiceUserUserInfoData } from '@/client';
import { useForm } from '@tanstack/react-form'; import { useForm } from '@tanstack/react-form';
import { Loader2 } from 'lucide-react';
import { import {
useEffect, useEffect,
useState, useState,
@@ -164,9 +165,11 @@ export function EditProfileDialogView({ user, updateProfile }: { user: ServiceUs
<Button variant="outline"></Button> <Button variant="outline"></Button>
</DialogClose> </DialogClose>
<form.Subscribe <form.Subscribe
selector={state => [state.canSubmit]} selector={state => [state.canSubmit, state.isSubmitting]}
children={([canSubmit]) => ( children={([canSubmit, isSubmitting]) => (
<Button type="submit" disabled={!canSubmit}></Button> <Button type="submit" disabled={!canSubmit}>
{isSubmitting ? <Loader2 className="animate-spin" /> : '保存'}
</Button>
)} )}
/> />
</DialogFooter> </DialogFooter>

View File

@@ -6,7 +6,7 @@ import {
isEmpty, isEmpty,
isNil, isNil,
} from 'lodash-es'; } from 'lodash-es';
import { Mail, Pencil } from 'lucide-react'; import { Loader2, Mail, Pencil } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -18,6 +18,7 @@ import { EditProfileDialogContainer } from './edit-profile.dialog.container';
export function ProfileView({ user, onSaveBio }: { user: ServiceUserUserInfoData; onSaveBio: (bio: string) => Promise<void> }) { export function ProfileView({ user, onSaveBio }: { user: ServiceUserUserInfoData; onSaveBio: (bio: string) => Promise<void> }) {
const [bio, setBio] = useState<string | undefined>(() => base64ToUtf8(user.bio ?? '')); const [bio, setBio] = useState<string | undefined>(() => base64ToUtf8(user.bio ?? ''));
const [enableBioEdit, setEnableBioEdit] = useState(false); const [enableBioEdit, setEnableBioEdit] = useState(false);
const [isSubmittingBio, setIsSubmittingBio] = useState(false);
const IdentIcon = useMemo(() => { const IdentIcon = useMemo(() => {
const avatar = createAvatar(identicon, { const avatar = createAvatar(identicon, {
@@ -53,12 +54,12 @@ export function ProfileView({ user, onSaveBio }: { user: ServiceUserUserInfoData
{/* Bio */} {/* Bio */}
{enableBioEdit {enableBioEdit
? ( ? (
<MDEditor <MDEditor
value={bio} value={bio}
onChange={setBio} onChange={setBio}
height="100%" height="100%"
/> />
) )
: <div className="p-6 prose dark:prose-invert"><Markdown>{bio}</Markdown></div>} : <div className="p-6 prose dark:prose-invert"><Markdown>{bio}</Markdown></div>}
<Button <Button
className="absolute bottom-4 right-4" className="absolute bottom-4 right-4"
@@ -70,6 +71,7 @@ export function ProfileView({ user, onSaveBio }: { user: ServiceUserUserInfoData
else { else {
if (!isNil(bio)) { if (!isNil(bio)) {
try { try {
setIsSubmittingBio(true);
await onSaveBio(bio); await onSaveBio(bio);
setEnableBioEdit(false); setEnableBioEdit(false);
} }
@@ -77,13 +79,17 @@ export function ProfileView({ user, onSaveBio }: { user: ServiceUserUserInfoData
console.error(error); console.error(error);
toast.error('个人简介更新失败'); toast.error('个人简介更新失败');
} }
finally {
setIsSubmittingBio(false);
}
} }
} }
}} }}
size="icon-sm" size="icon-sm"
variant={enableBioEdit ? 'default' : 'outline'} variant={enableBioEdit ? 'default' : 'outline'}
disabled={isSubmittingBio}
> >
<Pencil /> {isSubmittingBio ? <Loader2 className="animate-spin" /> : <Pencil />}
</Button> </Button>
</section> </section>
</div> </div>

View File

@@ -22,5 +22,6 @@ export const Confirm: Story = {
args: { args: {
event: exampleEvent, event: exampleEvent,
onJoinEvent: () => { }, onJoinEvent: () => { },
isPending: false,
}, },
}; };