PostHog loads after the page is idle through a same-origin /ingest proxy and follows the signed-in Convex user. Plausible uses the per-site script through a /pa proxy. Sentry keeps errors and traces only. Pages load only the @spoon/ui modules they import, and client code reads public env from publicEnv.ts so zod stays out of the browser. Both analytics integrations stay off until their env is set in Infisical prod.
101 lines
3.0 KiB
TypeScript
101 lines
3.0 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { useRouter } from 'next/navigation';
|
|
import { resetAnalyticsIdentity, useAnalyticsIdentity } from '@/lib/analytics';
|
|
import { isRemoteImageUrl } from '@/lib/avatar';
|
|
import { useAuthActions } from '@convex-dev/auth/react';
|
|
import { useConvexAuth, useQuery } from 'convex/react';
|
|
|
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
|
import {
|
|
BasedAvatar,
|
|
Button,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@spoon/ui';
|
|
|
|
export const AvatarDropdown = () => {
|
|
const router = useRouter();
|
|
const { isLoading, isAuthenticated } = useConvexAuth();
|
|
const { signOut } = useAuthActions();
|
|
const user = useQuery(api.auth.getUser, {});
|
|
useAnalyticsIdentity(user?._id);
|
|
const remoteImageUrl = isRemoteImageUrl(user?.image) ? user?.image : null;
|
|
const currentImageUrl = useQuery(
|
|
api.files.getImageUrl,
|
|
user?.image && !remoteImageUrl ? { storageId: user.image } : 'skip',
|
|
);
|
|
const avatarUrl = remoteImageUrl ?? currentImageUrl;
|
|
// Empty/whitespace names must fall through to the email, so intentionally
|
|
// treat a blank string as "no name" (nullish coalescing would keep it).
|
|
const trimmedName = user?.name?.trim();
|
|
const displayName = trimmedName?.length ? trimmedName : user?.email?.trim();
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className='flex items-center gap-2'>
|
|
<div className='bg-muted h-8 w-16 animate-pulse rounded-md' />
|
|
<div className='bg-muted h-9 w-9 animate-pulse rounded-full' />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isAuthenticated) {
|
|
return (
|
|
<div className='flex items-center gap-2'>
|
|
<Button size='sm' asChild>
|
|
<Link href='/sign-in'>Sign In</Link>
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger>
|
|
<BasedAvatar
|
|
src={avatarUrl}
|
|
fullName={user?.name}
|
|
className='h-9 w-9'
|
|
fallbackProps={{ className: 'text-sm font-semibold' }}
|
|
userIconProps={{ size: 20 }}
|
|
/>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align='end'>
|
|
{displayName && (
|
|
<>
|
|
<DropdownMenuLabel className='text-center font-bold'>
|
|
{displayName}
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
</>
|
|
)}
|
|
<DropdownMenuItem asChild>
|
|
<Link href='/settings/profile' className='w-full cursor-pointer'>
|
|
Edit Profile
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem asChild>
|
|
<button
|
|
onClick={() =>
|
|
void signOut().then(() => {
|
|
resetAnalyticsIdentity();
|
|
router.push('/');
|
|
})
|
|
}
|
|
className='w-full cursor-pointer'
|
|
>
|
|
Sign Out
|
|
</button>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
};
|