app-template/app/web/src/components/UserMenu.tsx

126 lines
4.3 KiB
TypeScript

/**
* 顶栏用户菜单:接 `auth.me` / `auth.logout`,未登录显示登录/注册入口。
*/
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import {
Avatar,
AvatarFallback,
AvatarImage,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
toast,
} from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createTRPCReactClient, trpc } from "../lib/trpc";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
const trpcClient = createTRPCReactClient();
export interface UserMenuProps {
locale?: Locale | undefined;
name?: string | undefined;
email?: string | undefined;
avatarUrl?: string | undefined;
}
function home(locale: Locale): string {
return locale === "zh-CN" ? "/zh-CN" : "/en";
}
export default function UserMenu({ locale = "zh-CN", avatarUrl }: UserMenuProps) {
return (
<QueryClientProvider client={queryClient}>
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<UserMenuInner locale={locale} avatarUrl={avatarUrl} />
</trpc.Provider>
</QueryClientProvider>
);
}
function UserMenuInner({ locale, avatarUrl }: { locale: Locale; avatarUrl?: string | undefined }) {
const me = trpc.auth.me.useQuery();
const logout = trpc.auth.logout.useMutation({
onSuccess: () => {
toast.success(t(locale, "auth.msg.logoutOk"));
queryClient.clear();
window.location.reload();
},
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
});
const base = home(locale);
const user = me.data?.user ?? null;
if (me.isPending) {
return <span className="u-mono hidden text-[11px] sm:block opacity-60">…</span>;
}
if (!user) {
return (
<span className="flex items-center gap-2">
<a className="u-linelink" href={`${base}/auth/login`}>
{t(locale, "auth.menu.login")}
</a>
<a className="u-linelink hidden sm:inline" href={`${base}/auth/register`}>
{t(locale, "auth.menu.register")}
</a>
</span>
);
}
const name = user.name;
const initials = name.slice(0, 1);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="u-btn u-btn--ghost px-2">
<Avatar className="size-6">
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback
className="u-mono text-[10px]"
style={{ background: "var(--sink)", color: "var(--edge-as-text)" }}
>
{initials}
</AvatarFallback>
</Avatar>
<span className="u-mono hidden text-[11px] sm:block">{name}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{name}</span>
<span className="u-mono text-[11px] opacity-70">{user.email}</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a href={`${base}/dashboard`}>{t(locale, "auth.menu.dashboard")}</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={`${base}/settings`}>{t(locale, "auth.menu.settings")}</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
logout.mutate();
}}
disabled={logout.isPending}
>
{t(locale, "auth.menu.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}