user menu on the sidebar

This commit is contained in:
Braydon 2024-09-19 02:06:19 -04:00
parent 77910a05ae
commit 8d7c41bac4
11 changed files with 472 additions and 22 deletions

BIN
bun.lockb

Binary file not shown.

@ -17,6 +17,7 @@
"@heroicons/react": "^2.1.5", "@heroicons/react": "^2.1.5",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",

@ -14,6 +14,11 @@ export type User = {
*/ */
username: string; username: string;
/**
* The hash to the avatar of this user, if any.
*/
avatar: string | undefined;
/** /**
* The tier of this user. * The tier of this user.
*/ */

@ -3,6 +3,9 @@ import Image from "next/image";
import { cva } from "class-variance-authority"; import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/**
* The variants of the branding.
*/
const brandingVariants = cva( const brandingVariants = cva(
"relative group-hover:opacity-75 hover:opacity-75 select-none transition-all transform-gpu", "relative group-hover:opacity-75 hover:opacity-75 select-none transition-all transform-gpu",
{ {
@ -45,7 +48,7 @@ const Branding = ({ href, size, className }: BrandingProps) => (
className={cn(brandingVariants({ size, className }))} className={cn(brandingVariants({ size, className }))}
href={href ?? "/"} href={href ?? "/"}
> >
<Image src="/media/logo.png" alt="PulseApp Logo" fill priority /> <Image src="/media/logo.png" alt="Pulse App Logo" fill priority />
</Link> </Link>
); );
export default Branding; export default Branding;

@ -14,6 +14,7 @@ import {
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { useOrganizationContext } from "@/app/provider/organization-provider"; import { useOrganizationContext } from "@/app/provider/organization-provider";
import { OrganizationState } from "@/app/store/organization-store"; import { OrganizationState } from "@/app/store/organization-store";
import { usePathname } from "next/navigation";
const links: SidebarLink[] = [ const links: SidebarLink[] = [
{ {
@ -53,10 +54,12 @@ const Links = (): ReactElement => {
const selectedOrganization: string | undefined = useOrganizationContext( const selectedOrganization: string | undefined = useOrganizationContext(
(state: OrganizationState) => state.selected (state: OrganizationState) => state.selected
); );
const path: string = usePathname();
return ( return (
<div className="mt-3.5 w-full flex flex-col gap-0.5"> <div className="mt-3.5 w-full flex flex-col gap-0.5 select-none">
{links.map((link: SidebarLink, index: number) => { {links.map((link: SidebarLink, index: number) => {
const active: boolean = index === 0; const href: string = `/dashboard/org/${selectedOrganization}${link.href}`;
const active: boolean = path.startsWith(href);
return ( return (
<SimpleTooltip <SimpleTooltip
key={index} key={index}
@ -68,7 +71,7 @@ const Links = (): ReactElement => {
"px-3 py-2 flex gap-2 items-center text-sm rounded-lg hover:bg-zinc-800 transition-all transform-gpu", "px-3 py-2 flex gap-2 items-center text-sm rounded-lg hover:bg-zinc-800 transition-all transform-gpu",
active && "font-medium bg-zinc-800" active && "font-medium bg-zinc-800"
)} )}
href={`/dashboard/org/${selectedOrganization}${link.href}`} href={href}
> >
<div className="relative w-5 h-5">{link.icon}</div> <div className="relative w-5 h-5">{link.icon}</div>
{link.name} {link.name}

@ -4,7 +4,6 @@ import * as React from "react";
import { ReactElement, useEffect, useState } from "react"; import { ReactElement, useEffect, useState } from "react";
import { ChevronsUpDownIcon } from "lucide-react"; import { ChevronsUpDownIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import InitialsAvatar from "react-initials-avatar";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@ -22,7 +21,7 @@ import { useOrganizationContext } from "@/app/provider/organization-provider";
import { OrganizationState } from "@/app/store/organization-store"; import { OrganizationState } from "@/app/store/organization-store";
import { Organization } from "@/app/types/org/organization"; import { Organization } from "@/app/types/org/organization";
import { CheckIcon } from "@heroicons/react/24/outline"; import { CheckIcon } from "@heroicons/react/24/outline";
import Image from "next/image"; import OrganizationLogo from "@/components/org/organization-logo";
/** /**
* The organization selector. * The organization selector.
@ -84,18 +83,10 @@ const OrganizationSelector = (): ReactElement => {
> >
{selected ? ( {selected ? (
<div className="flex gap-2.5 items-center"> <div className="flex gap-2.5 items-center">
<div className="relative p-0.5 w-5 h-5 rounded-full"> <OrganizationLogo
{selected.logo ? ( organization={selected}
<Image size="sm"
className="rounded-full" />
src={`${process.env.NEXT_PUBLIC_CDN_ENDPOINT}/organizations/${selected.logo}.webp`}
alt={`${selected.name}'s Logo`}
fill
/>
) : (
<InitialsAvatar name={selected.name} />
)}
</div>
{selected.name} {selected.name}
</div> </div>
) : ( ) : (
@ -114,7 +105,7 @@ const OrganizationSelector = (): ReactElement => {
(organization: Organization, index: number) => ( (organization: Organization, index: number) => (
<CommandItem <CommandItem
key={index} key={index}
className="flex justify-between" className="flex gap-2 items-center"
value={organization.name} value={organization.name}
onSelect={(currentValue: string) => onSelect={(currentValue: string) =>
selectOrganization( selectOrganization(
@ -126,10 +117,14 @@ const OrganizationSelector = (): ReactElement => {
) )
} }
> >
<OrganizationLogo
organization={organization}
size="sm"
/>
{organization.name} {organization.name}
{organization.snowflake === {organization.snowflake ===
selectedOrganization && ( selectedOrganization && (
<CheckIcon className="mr-2 w-4 h-4" /> <CheckIcon className="mx-auto w-4 h-4" />
)} )}
</CommandItem> </CommandItem>
) )

@ -11,7 +11,13 @@ import { useUserContext } from "@/app/provider/user-provider";
import { UserState } from "@/app/store/user-store"; import { UserState } from "@/app/store/user-store";
import { hasFlag } from "@/lib/user"; import { hasFlag } from "@/lib/user";
import { UserFlag } from "@/app/types/user/user-flag"; import { UserFlag } from "@/app/types/user/user-flag";
import UserMenu from "@/components/dashboard/sidebar/user-menu";
/**
* The sidebar to display on the dashboard.
*
* @return the sidebar jsx
*/
const Sidebar = (): ReactElement => { const Sidebar = (): ReactElement => {
const user: User | undefined = useUserContext( const user: User | undefined = useUserContext(
(state: UserState) => state.user (state: UserState) => state.user
@ -19,7 +25,10 @@ const Sidebar = (): ReactElement => {
return hasFlag(user as User, UserFlag.COMPLETED_ONBOARDING) ? ( return hasFlag(user as User, UserFlag.COMPLETED_ONBOARDING) ? (
<nav className="w-56 px-3 py-4 h-screen flex flex-col items-center bg-zinc-900 border-r"> <nav className="w-56 px-3 py-4 h-screen flex flex-col items-center bg-zinc-900 border-r">
{/* Header */} {/* Header */}
<Link className="flex gap-3 items-center group" href="/dashboard"> <Link
className="flex gap-3 items-center select-none group"
href="/dashboard"
>
<Branding size="xs" /> <Branding size="xs" />
<h1 className="text-xl font-bold group-hover:opacity-75 transition-all transform-gpu"> <h1 className="text-xl font-bold group-hover:opacity-75 transition-all transform-gpu">
Pulse App Pulse App
@ -30,7 +39,9 @@ const Sidebar = (): ReactElement => {
{/* Content */} {/* Content */}
<OrganizationSelector /> <OrganizationSelector />
<Links /> <Links />
<div className="mt-auto">USER</div> <div className="mt-auto">
<UserMenu />
</div>
</nav> </nav>
) : ( ) : (
<div /> <div />

@ -0,0 +1,84 @@
"use client";
import { ReactElement } from "react";
import { useUserContext } from "@/app/provider/user-provider";
import { UserState } from "@/app/store/user-store";
import { User } from "@/app/types/user/user";
import UserAvatar from "@/components/user/user-avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ArrowLeftEndOnRectangleIcon,
Cog6ToothIcon,
CreditCardIcon,
UserIcon,
} from "@heroicons/react/24/outline";
import Link from "next/link";
const UserMenu = (): ReactElement => {
const user: User | undefined = useUserContext(
(state: UserState) => state.user
);
return (
<DropdownMenu>
<DropdownMenuTrigger>
<div className="px-5 py-2 flex gap-3 items-center font-medium bg-background/30 border hover:opacity-75 rounded-lg transition-all transform-gpu">
<UserAvatar user={user as User} size="sm" />@
{user?.username}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-44">
{/* Content */}
<MyAccount />
{/* Logout */}
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2.5 text-red-500">
<ArrowLeftEndOnRectangleIcon className="w-5 h-5" />
<span>Logout</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
/**
* The my account section.
*
* @return the section jsx
*/
const MyAccount = (): ReactElement => (
<DropdownMenuGroup>
<DropdownMenuLabel className="select-none pointer-events-none">
My Account
</DropdownMenuLabel>
<DropdownMenuSeparator />
<Link href="/dashboard/user/profile">
<DropdownMenuItem className="gap-2.5">
<UserIcon className="w-5 h-5" />
<span>Profile</span>
</DropdownMenuItem>
</Link>
<Link href="/dashboard/user/billing">
<DropdownMenuItem className="gap-2.5">
<CreditCardIcon className="w-5 h-5" />
<span>Billing</span>
</DropdownMenuItem>
</Link>
<Link href="/dashboard/user/settings">
<DropdownMenuItem className="gap-2.5">
<Cog6ToothIcon className="w-5 h-5" />
<span>Settings</span>
</DropdownMenuItem>
</Link>
</DropdownMenuGroup>
);
export default UserMenu;

@ -0,0 +1,70 @@
import * as React from "react";
import { ReactElement } from "react";
import { cva } from "class-variance-authority";
import Image from "next/image";
import InitialsAvatar from "react-initials-avatar";
import { cn } from "@/lib/utils";
import { Organization } from "@/app/types/org/organization";
/**
* The variants of the logo.
*/
const logoVariants = cva("relative rounded-full", {
variants: {
size: {
sm: "w-5 h-5",
default: "w-10 h-10",
},
},
defaultVariants: {
size: "default",
},
});
/**
* The props for this component.
*/
type OrganizationLogoProps = {
/**
* The organization to show the logo for.
*/
organization: Organization;
/**
* The size of the logo.
*/
size?: "sm" | "default";
/**
* The optional class name to apply to the logo.
*/
className?: string;
};
/**
* A logo for an organization.
*
* @param organization the organization
* @param size the size
* @param className additional class names
* @return the organization jsx
*/
const OrganizationLogo = ({
organization,
size,
className,
}: OrganizationLogoProps): ReactElement => (
<div className={cn(logoVariants({ size, className }))}>
{organization.logo ? (
<Image
className="rounded-full"
src={`${process.env.NEXT_PUBLIC_CDN_ENDPOINT}/organizations/${organization.logo}.webp`}
alt={`${organization.name}'s Logo`}
fill
/>
) : (
<InitialsAvatar name={organization.name} />
)}
</div>
);
export default OrganizationLogo;

@ -0,0 +1,208 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from "@radix-ui/react-icons";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest opacity-60",
className
)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

@ -0,0 +1,70 @@
import * as React from "react";
import { ReactElement } from "react";
import { cva } from "class-variance-authority";
import Image from "next/image";
import InitialsAvatar from "react-initials-avatar";
import { cn } from "@/lib/utils";
import { User } from "@/app/types/user/user";
/**
* The variants of the avatar.
*/
const avatarVariants = cva("relative rounded-full", {
variants: {
size: {
sm: "w-6 h-6",
default: "w-10 h-10",
},
},
defaultVariants: {
size: "default",
},
});
/**
* The props for this component.
*/
type UserAvatarProps = {
/**
* The user to show the avatar for.
*/
user: User;
/**
* The size of the avatar.
*/
size?: "sm" | "default";
/**
* The optional class name to apply to the logo.
*/
className?: string;
};
/**
* An avatar for a user.
*
* @param user the user
* @param size the size
* @param className additional class names
* @return the avatar jsx
*/
const UserAvatar = ({
user,
size,
className,
}: UserAvatarProps): ReactElement => (
<div className={cn(avatarVariants({ size, className }))}>
{user.avatar ? (
<Image
className="rounded-full"
src={`${process.env.NEXT_PUBLIC_CDN_ENDPOINT}/avatars/${user.avatar}.webp`}
alt={`${user.username}'s Avatar`}
fill
/>
) : (
<InitialsAvatar name={user.username} />
)}
</div>
);
export default UserAvatar;