summaryrefslogtreecommitdiff
path: root/src/components/auth
diff options
context:
space:
mode:
authorBertrand Yuan <bert.yuan@outlook.com>2025-12-15 23:48:10 +0800
committerBertrand Yuan <bert.yuan@outlook.com>2025-12-15 23:48:10 +0800
commit5b7ccf0b671e2999b62befc729a3e517a0433728 (patch)
tree8bf476dc7c75914c221042546840dc76267366df /src/components/auth
initial commit -- the front-end prototype
The initial code is base on Anirudh's work. More to see at: https://github.com/techwithanirudh/shadcn-blog Therefore, the code in this commit is under MIT license.
Diffstat (limited to 'src/components/auth')
-rw-r--r--src/components/auth/user-avatar.tsx56
-rw-r--r--src/components/auth/user-button.tsx127
2 files changed, 183 insertions, 0 deletions
diff --git a/src/components/auth/user-avatar.tsx b/src/components/auth/user-avatar.tsx
new file mode 100644
index 0000000..53b20b5
--- /dev/null
+++ b/src/components/auth/user-avatar.tsx
@@ -0,0 +1,56 @@
+import type { ComponentProps } from 'react';
+
+import { Icons } from '@/components/icons/icons';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import type { User } from '@/lib/auth-client';
+import { cn } from '@/lib/utils';
+
+export interface UserAvatarClassNames {
+ base?: string;
+ image?: string;
+ fallback?: string;
+ fallbackIcon?: string;
+}
+
+export interface UserAvatarProps {
+ user?: User | null;
+ classNames?: UserAvatarClassNames;
+}
+
+export function UserAvatar({
+ user,
+ classNames,
+ className,
+ ...props
+}: UserAvatarProps & ComponentProps<typeof Avatar>) {
+ const name = user?.name || user?.email;
+ const src = user?.image;
+
+ return (
+ <Avatar
+ key={src}
+ className={cn('rounded-md', className, classNames?.base)}
+ {...props}
+ >
+ <AvatarImage
+ alt={name ?? 'Avatar'}
+ className={cn('rounded-md', classNames?.image)}
+ src={src ?? undefined}
+ />
+
+ <AvatarFallback
+ className={cn(
+ 'rounded-md bg-transparent uppercase',
+ classNames?.fallback,
+ )}
+ delayMs={src ? 200 : 0}
+ >
+ {firstTwoCharacters(name) ?? (
+ <Icons.user className={cn('w-[55%]', classNames?.fallbackIcon)} />
+ )}
+ </AvatarFallback>
+ </Avatar>
+ );
+}
+
+const firstTwoCharacters = (name?: string | null) => name?.slice(0, 2);
diff --git a/src/components/auth/user-button.tsx b/src/components/auth/user-button.tsx
new file mode 100644
index 0000000..7caea4c
--- /dev/null
+++ b/src/components/auth/user-button.tsx
@@ -0,0 +1,127 @@
+'use client';
+
+import Link from 'next/link';
+
+import { Icons } from '@/components/icons/icons';
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Skeleton } from '@/components/ui/skeleton';
+import { signOut, useSession } from '@/lib/auth-client';
+import { cn } from '@/lib/utils';
+
+import type { UserAvatarClassNames } from './user-avatar';
+import { UserAvatar } from './user-avatar';
+
+export interface UserButtonClassNames {
+ base?: string;
+ skeleton?: string;
+ trigger?: {
+ base?: string;
+ avatar?: UserAvatarClassNames;
+ skeleton?: string;
+ };
+ content?: {
+ base?: string;
+ avatar?: UserAvatarClassNames;
+ menuItem?: string;
+ separator?: string;
+ };
+}
+
+export interface UserButtonProps {
+ className?: string;
+ classNames?: UserButtonClassNames;
+}
+
+export function UserButton({ className, classNames }: UserButtonProps) {
+ const { data: sessionData, isPending } = useSession();
+ const user = sessionData?.user ?? null;
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger
+ className={cn('rounded-md bg-transparent', classNames?.trigger?.base)}
+ asChild
+ >
+ <Button
+ variant='ghost'
+ className='size-auto rounded-md border-none bg-transparent p-1.5 hover:bg-accent dark:hover:bg-accent'
+ disabled={isPending}
+ >
+ {isPending ? (
+ <Skeleton
+ className={cn(
+ 'size-8 rounded-md',
+ className,
+ classNames?.base,
+ classNames?.skeleton,
+ classNames?.trigger?.skeleton,
+ )}
+ />
+ ) : (
+ <UserAvatar
+ className={cn('size-5', className, classNames?.base)}
+ classNames={classNames?.trigger?.avatar}
+ user={user}
+ />
+ )}
+ </Button>
+ </DropdownMenuTrigger>
+
+ <DropdownMenuContent
+ className={cn('max-w-64', classNames?.content?.base)}
+ onCloseAutoFocus={(e) => e.preventDefault()}
+ align='end'
+ >
+ {user ? (
+ <div className='flex items-center gap-2 p-2'>
+ <UserAvatar classNames={classNames?.content?.avatar} user={user} />
+
+ <div className='flex flex-col truncate'>
+ <div className='truncate font-medium text-sm'>
+ {user.name || user.email}
+ </div>
+
+ {user.name && (
+ <div className='truncate text-muted-foreground text-xs'>
+ {user.email}
+ </div>
+ )}
+ </div>
+ </div>
+ ) : (
+ <div className='px-2 py-1 text-muted-foreground text-xs'>Account</div>
+ )}
+
+ <DropdownMenuSeparator className={classNames?.content?.separator} />
+
+ {!user ? (
+ <>
+ <DropdownMenuItem className={classNames?.content?.menuItem} asChild>
+ <Link href={'/login'}>
+ <Icons.logIn className='size-4' />
+ Sign In
+ </Link>
+ </DropdownMenuItem>
+ </>
+ ) : (
+ <>
+ <DropdownMenuItem
+ className={classNames?.content?.menuItem}
+ onClick={() => signOut()}
+ >
+ <Icons.logOut className='size-4' />
+ Log Out
+ </DropdownMenuItem>
+ </>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+}