Skip to content

feat: reorganize navigation menu for mobile view #298

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Nov 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/masterbots.ai/components/auth/user-login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { UserMenu } from '@/components/layout/header/user-menu'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
import { isTokenExpired } from 'mb-lib'
import { ProfileSidebar } from '@/components/layout/sidebar/profile-sidebar'

export function UserLogin() {
const { data: session, status } = useSession()
Expand All @@ -25,7 +26,7 @@ export function UserLogin() {
return <LoginButton />
}

return <UserMenu user={session.user} />
return <ProfileSidebar user={session.user} />
}

return <LoginButton />
Expand Down
23 changes: 13 additions & 10 deletions apps/masterbots.ai/components/layout/header/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,19 @@ export function Header() {
<SidebarToggle />
</React.Suspense>
<HeaderLink href="/" text="MB" />
<IconSeparator className="size-6 text-muted-foreground/50" />
<HeaderLink href="/c" text="Chat" />
<HeaderLink href="/" text="Browse" />

{appConfig.devMode && (
<>
<HeaderLink href="/wordware" text="Ww" />
<HeaderLink href="/c/p" text="Pro" />
</>
)}

{/* Navigation links - Hidden on mobile */}
<div className="hidden lg:flex lg:items-center">
<IconSeparator className="size-6 text-muted-foreground/50" />
<HeaderLink href="/c" text="Chat" />

{appConfig.devMode && (
<>
<HeaderLink href="/c/p" text="Pro" />
<HeaderLink href="/wordware" text="Ww" />
</>
)}
</div>
</div>
<div className="flex items-center justify-end space-x-2 gap-2">
<ThemeToggle />
Expand Down
161 changes: 161 additions & 0 deletions apps/masterbots.ai/components/layout/sidebar/profile-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
'use client'

import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { LogOut } from 'lucide-react'
import { signOut } from 'next-auth/react'
import Image from 'next/image'
import { appConfig } from 'mb-env'
import type { Session } from 'next-auth'
import { useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { toSlug } from 'mb-lib'

interface ProfileSidebarProps {
user: Session['user'] & {
hasuraJwt?: string
}
}

function getUserInitials(name: string) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Add input validation to getUserInitials to handle edge cases

The function should handle empty strings, strings with only spaces, or single-word names without throwing errors.

Suggested change
function getUserInitials(name: string) {
function getUserInitials(name: string = ''): string {
if (!name?.trim()) return '';

const [firstName, lastName] = name.split(' ')
return lastName ? `${firstName[0]}${lastName[0]}` : firstName.slice(0, 2)
}
Comment on lines +20 to +23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add input validation and handle edge cases in getUserInitials.

The current implementation could throw errors for edge cases. Consider these additional improvements:

-function getUserInitials(name: string) {
+function getUserInitials(name: string = ''): string {
+  if (!name?.trim()) return '';
   const [firstName, lastName] = name.split(' ')
-  return lastName ? `${firstName[0]}${lastName[0]}` : firstName.slice(0, 2)
+  return lastName && firstName?.[0] && lastName?.[0]
+    ? `${firstName[0]}${lastName[0]}`
+    : (firstName?.slice(0, 2) || '')
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function getUserInitials(name: string) {
const [firstName, lastName] = name.split(' ')
return lastName ? `${firstName[0]}${lastName[0]}` : firstName.slice(0, 2)
}
function getUserInitials(name: string = ''): string {
if (!name?.trim()) return '';
const [firstName, lastName] = name.split(' ')
return lastName && firstName?.[0] && lastName?.[0]
? `${firstName[0]}${lastName[0]}`
: (firstName?.slice(0, 2) || '')
}


export function ProfileSidebar({ user }: ProfileSidebarProps) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider refactoring the component to reduce duplication and improve code organization through component extraction and data structures

The component has some unnecessary complexity that can be simplified while maintaining functionality:

  1. Extract the duplicated avatar logic into a reusable component:
function UserAvatar({ user, size = 'md' }: { user: User, size?: 'sm' | 'md' }) {
  const sizes = {
    sm: 'size-8',
    md: 'size-10'
  }

  return user?.image ? (
    <Image
      className={`rounded-full ${sizes[size]}`}
      src={user.image}
      alt={user.name ?? 'Avatar'} 
      height={size === 'sm' ? 32 : 40}
      width={size === 'sm' ? 32 : 40}
      priority
    />
  ) : (
    <div className={`flex items-center justify-center text-sm font-medium uppercase rounded-full ${sizes[size]} bg-muted/50`}>
      {user?.name ? getUserInitials(user?.name) : null}
    </div>
  )
}
  1. Remove unnecessary setTimeout from logout:
const handleLogout = useCallback(async () => {
  try {
    setIsOpen(false)
    await signOut({ callbackUrl: '/' })
  } catch (error) {
    console.error('Logout error:', error)
    window.location.href = '/'
  }
}, [])
  1. Simplify navigation links using a data structure:
const navLinks = [
  { label: 'Chat', path: '/c' },
  ...(appConfig.devMode ? [
    { label: 'Pro', path: '/c/p' },
    { label: 'Ww', path: '/wordware' }
  ] : []),
  { label: 'Browse', path: '/' }
]

// In JSX:
<nav className="flex flex-col p-4 lg:hidden">
  {navLinks.map(({ label, path }) => (
    <Button
      key={path}
      variant="ghost"
      className="w-full justify-start text-sm"
      onClick={() => handleNavigation(path)}
    >
      {label}
    </Button>
  ))}
</nav>

These changes reduce code duplication and improve maintainability while keeping all functionality intact.

const [isOpen, setIsOpen] = useState(false)
const router = useRouter()

const handleNavigation = (path: string) => {
setIsOpen(false)
router.push(path)
}

const handleLogout = useCallback(async () => {
try {
setIsOpen(false)
await new Promise(resolve => setTimeout(resolve, 100))
await signOut({ callbackUrl: '/' })
} catch (error) {
console.error('Logout error:', error)
window.location.href = '/'
}
}, [])
Comment on lines +34 to +43
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Improve logout handler implementation.

The current implementation has several concerns:

  1. The artificial delay using setTimeout is unnecessary and could cause race conditions
  2. The error handling should be more specific about what went wrong
 const handleLogout = useCallback(async () => {
   try {
     setIsOpen(false) 
-    await new Promise(resolve => setTimeout(resolve, 100))
     await signOut({ callbackUrl: '/' })
   } catch (error) {
-    console.error('Logout error:', error)
+    console.error('Failed to sign out:', error instanceof Error ? error.message : 'Unknown error')
     window.location.href = '/'
   }
 }, [])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleLogout = useCallback(async () => {
try {
setIsOpen(false)
await new Promise(resolve => setTimeout(resolve, 100))
await signOut({ callbackUrl: '/' })
} catch (error) {
console.error('Logout error:', error)
window.location.href = '/'
}
}, [])
const handleLogout = useCallback(async () => {
try {
setIsOpen(false)
await signOut({ callbackUrl: '/' })
} catch (error) {
console.error('Failed to sign out:', error instanceof Error ? error.message : 'Unknown error')
window.location.href = '/'
}
}, [])


const goToProfile = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
const userSlug = toSlug(user.name || '')
if (userSlug) {
setIsOpen(false)
router.push(`/u/${userSlug}/t`)
}
}, [router, user.name])


return (
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger asChild>
<Button variant="ghost" className="pl-0 rounded-full">
{user?.image ? (
<Image
className="transition-opacity duration-300 rounded-full select-none size-8 bg-foreground/10 ring-1 ring-zinc-100/10 hover:opacity-80"
src={user?.image ? user.image : ''}
alt={user.name ?? 'Avatar'}
height={32}
width={32}
priority
/>
) : (
<div className="flex items-center justify-center text-xs font-medium uppercase rounded-full select-none size-7 shrink-0 bg-muted/50 text-muted-foreground">
{user?.name ? getUserInitials(user?.name) : null}
</div>
)}
<span className="ml-2 hidden md:inline-block">{user?.name}</span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[300px] sm:w-[400px] p-0">
<div className="flex flex-col h-full">
{/* Profile Header */}
<div className="p-4 border-b">
<Button
onClick={goToProfile}
variant="sideBarProfile"
size="sideBarProfile"
>
{user?.image ? (
<Image
className="rounded-full size-10"
src={user.image}
alt={user.name ?? 'Avatar'}
height={40}
width={40}
priority
/>
) : (
<div className="flex items-center justify-center text-sm font-medium uppercase rounded-full size-10 bg-muted/50">
{user?.name ? getUserInitials(user?.name) : null}
</div>
)}
<div className="space-y-1">
<p className="text-sm font-medium">{user?.name}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p>
</div>
</Button>
</div>

{/* Navigation Links - Only visible on mobile */}
<nav className="flex flex-col p-4 lg:hidden">
<Button
variant="ghost"
className="w-full justify-start text-sm"
onClick={() => handleNavigation('/c')}
>
Chat
</Button>

{appConfig.devMode && (
<Button
variant="ghost"
className="w-full justify-start text-sm"
onClick={() => handleNavigation('/c/p')}
>
Pro
</Button>
)}

<Button
variant="ghost"
className="w-full justify-start text-sm"
onClick={() => handleNavigation('/')}
>
Browse
</Button>

{appConfig.devMode && (
<Button
variant="ghost"
className="w-full justify-start text-sm"
onClick={() => handleNavigation('/wordware')}
>
Ww
</Button>
)}
</nav>

{/* Logout Button */}
<div className="mt-auto p-4 border-t">
<Button
variant="ghost"
className="w-full justify-start text-sm"
onClick={handleLogout}
>
<LogOut className="size-4 mr-2" />
Log Out
</Button>
</div>
</div>
</SheetContent>
</Sheet>
)
}
6 changes: 4 additions & 2 deletions apps/masterbots.ai/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ const buttonVariants = cva(
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'shadow-none hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 shadow-none hover:underline',
icon: 'flex size-8 shrink-0 select-none items-center justify-center rounded-full border shadow cursor-pointer'
icon: 'flex size-8 shrink-0 select-none items-center justify-center rounded-full border shadow cursor-pointer',
sideBarProfile: 'bg-transparent border-0 shadow-none justify-start',
},
size: {
default: 'h-8 px-4 py-2',
sm: 'h-8 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'size-8 p-0'
icon: 'size-8 p-0',
sideBarProfile: 'size-full',
}
},
defaultVariants: {
Expand Down
4 changes: 2 additions & 2 deletions apps/masterbots.ai/components/ui/dropdown-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
className
)}
{...props}
Expand All @@ -42,7 +42,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow animate-in 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',
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow animate-in 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}
Expand Down