diff --git a/bun.lockb b/bun.lockb index d6784b2..dfdd62b 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 86ee8d7..c3618b6 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@heroicons/react": "^2.1.5", "@hookform/resolvers": "^3.9.0", "@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-popover": "^1.1.1", "@radix-ui/react-separator": "^1.1.0", diff --git a/src/app/types/user/user.ts b/src/app/types/user/user.ts index 4e14f7e..ce5cb85 100644 --- a/src/app/types/user/user.ts +++ b/src/app/types/user/user.ts @@ -14,6 +14,11 @@ export type User = { */ username: string; + /** + * The hash to the avatar of this user, if any. + */ + avatar: string | undefined; + /** * The tier of this user. */ diff --git a/src/components/branding.tsx b/src/components/branding.tsx index 8681e49..af11234 100644 --- a/src/components/branding.tsx +++ b/src/components/branding.tsx @@ -3,6 +3,9 @@ import Image from "next/image"; import { cva } from "class-variance-authority"; import { cn } from "@/lib/utils"; +/** + * The variants of the branding. + */ const brandingVariants = cva( "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 }))} href={href ?? "/"} > - PulseApp Logo + Pulse App Logo ); export default Branding; diff --git a/src/components/dashboard/sidebar/links.tsx b/src/components/dashboard/sidebar/links.tsx index e1c62f3..97a213f 100644 --- a/src/components/dashboard/sidebar/links.tsx +++ b/src/components/dashboard/sidebar/links.tsx @@ -14,6 +14,7 @@ import { } from "@heroicons/react/24/outline"; import { useOrganizationContext } from "@/app/provider/organization-provider"; import { OrganizationState } from "@/app/store/organization-store"; +import { usePathname } from "next/navigation"; const links: SidebarLink[] = [ { @@ -53,10 +54,12 @@ const Links = (): ReactElement => { const selectedOrganization: string | undefined = useOrganizationContext( (state: OrganizationState) => state.selected ); + const path: string = usePathname(); return ( -
+
{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 ( { "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" )} - href={`/dashboard/org/${selectedOrganization}${link.href}`} + href={href} >
{link.icon}
{link.name} diff --git a/src/components/dashboard/sidebar/organization-selector.tsx b/src/components/dashboard/sidebar/organization-selector.tsx index 5b2b641..adb9c86 100644 --- a/src/components/dashboard/sidebar/organization-selector.tsx +++ b/src/components/dashboard/sidebar/organization-selector.tsx @@ -4,7 +4,6 @@ import * as React from "react"; import { ReactElement, useEffect, useState } from "react"; import { ChevronsUpDownIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; -import InitialsAvatar from "react-initials-avatar"; import { Command, CommandEmpty, @@ -22,7 +21,7 @@ import { useOrganizationContext } from "@/app/provider/organization-provider"; import { OrganizationState } from "@/app/store/organization-store"; import { Organization } from "@/app/types/org/organization"; import { CheckIcon } from "@heroicons/react/24/outline"; -import Image from "next/image"; +import OrganizationLogo from "@/components/org/organization-logo"; /** * The organization selector. @@ -84,18 +83,10 @@ const OrganizationSelector = (): ReactElement => { > {selected ? (
-
- {selected.logo ? ( - {`${selected.name}'s - ) : ( - - )} -
+ {selected.name}
) : ( @@ -114,7 +105,7 @@ const OrganizationSelector = (): ReactElement => { (organization: Organization, index: number) => ( selectOrganization( @@ -126,10 +117,14 @@ const OrganizationSelector = (): ReactElement => { ) } > + {organization.name} {organization.snowflake === selectedOrganization && ( - + )} ) diff --git a/src/components/dashboard/sidebar/sidebar.tsx b/src/components/dashboard/sidebar/sidebar.tsx index d8326b7..1e32e90 100644 --- a/src/components/dashboard/sidebar/sidebar.tsx +++ b/src/components/dashboard/sidebar/sidebar.tsx @@ -11,7 +11,13 @@ import { useUserContext } from "@/app/provider/user-provider"; import { UserState } from "@/app/store/user-store"; import { hasFlag } from "@/lib/user"; 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 user: User | undefined = useUserContext( (state: UserState) => state.user @@ -19,7 +25,10 @@ const Sidebar = (): ReactElement => { return hasFlag(user as User, UserFlag.COMPLETED_ONBOARDING) ? ( ) : (
diff --git a/src/components/dashboard/sidebar/user-menu.tsx b/src/components/dashboard/sidebar/user-menu.tsx new file mode 100644 index 0000000..a1d5f05 --- /dev/null +++ b/src/components/dashboard/sidebar/user-menu.tsx @@ -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 ( + + +
+ @ + {user?.username} +
+
+ + {/* Content */} + + + {/* Logout */} + + + + Logout + + +
+ ); +}; + +/** + * The my account section. + * + * @return the section jsx + */ +const MyAccount = (): ReactElement => ( + + + My Account + + + + + + Profile + + + + + + Billing + + + + + + Settings + + + +); + +export default UserMenu; diff --git a/src/components/org/organization-logo.tsx b/src/components/org/organization-logo.tsx new file mode 100644 index 0000000..4f62896 --- /dev/null +++ b/src/components/org/organization-logo.tsx @@ -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 => ( +
+ {organization.logo ? ( + {`${organization.name}'s + ) : ( + + )} +
+); +export default OrganizationLogo; diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..a838229 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -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, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/src/components/user/user-avatar.tsx b/src/components/user/user-avatar.tsx new file mode 100644 index 0000000..08baa9e --- /dev/null +++ b/src/components/user/user-avatar.tsx @@ -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 => ( +
+ {user.avatar ? ( + {`${user.username}'s + ) : ( + + )} +
+); +export default UserAvatar;