|
| 1 | +/** |
| 2 | + * HomeTopNav |
| 3 | + * |
| 4 | + * Single-row top navigation bar for the Home (workspace) landing page. |
| 5 | + * |
| 6 | + * [Logo + Product] [Global search (⌘K)] |
| 7 | + * [Activity | Help] [Theme | Locale] [Workspace | User] |
| 8 | + * |
| 9 | + * The page title (rendered by `HomePage`) and any primary page actions live |
| 10 | + * in the page body, keeping the chrome compact. See |
| 11 | + * {@link ./HomeLayout.tsx} for composition. |
| 12 | + * |
| 13 | + * @module |
| 14 | + */ |
| 15 | + |
| 16 | +import { Link, useNavigate } from 'react-router-dom'; |
| 17 | +import { |
| 18 | + Avatar, |
| 19 | + AvatarImage, |
| 20 | + AvatarFallback, |
| 21 | + Button, |
| 22 | + DropdownMenu, |
| 23 | + DropdownMenuTrigger, |
| 24 | + DropdownMenuContent, |
| 25 | + DropdownMenuItem, |
| 26 | + DropdownMenuLabel, |
| 27 | + DropdownMenuSeparator, |
| 28 | + DropdownMenuGroup, |
| 29 | + Separator, |
| 30 | +} from '@object-ui/components'; |
| 31 | +import { |
| 32 | + Search, |
| 33 | + HelpCircle, |
| 34 | + LogOut, |
| 35 | + Settings, |
| 36 | + User as UserIcon, |
| 37 | + ChevronsUpDown, |
| 38 | + Boxes, |
| 39 | +} from 'lucide-react'; |
| 40 | +import { useAuth, getUserInitials } from '@object-ui/auth'; |
| 41 | +import { useObjectTranslation } from '@object-ui/i18n'; |
| 42 | +import { WorkspaceSwitcher } from '../../components/WorkspaceSwitcher'; |
| 43 | +import { ModeToggle } from '../../components/mode-toggle'; |
| 44 | +import { LocaleSwitcher } from '../../components/LocaleSwitcher'; |
| 45 | +import { ActivityFeed, type ActivityItem } from '../../components/ActivityFeed'; |
| 46 | + |
| 47 | +interface HomeTopNavProps { |
| 48 | + /** Optional activity feed items. When omitted an empty feed is rendered. */ |
| 49 | + activities?: ActivityItem[]; |
| 50 | +} |
| 51 | + |
| 52 | +/** Opens the global command palette via the ⌘K/CtrlK shortcut it listens for. */ |
| 53 | +function openCommandPalette() { |
| 54 | + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })); |
| 55 | +} |
| 56 | + |
| 57 | +export function HomeTopNav({ activities }: HomeTopNavProps) { |
| 58 | + const { t } = useObjectTranslation(); |
| 59 | + const { user, signOut } = useAuth(); |
| 60 | + const navigate = useNavigate(); |
| 61 | + |
| 62 | + return ( |
| 63 | + <header |
| 64 | + className="sticky top-0 z-40 w-full shrink-0 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 shadow-[0_1px_0_rgba(0,0,0,0.04)]" |
| 65 | + data-testid="home-top-nav" |
| 66 | + > |
| 67 | + {/* ──────────────────────────── Row 1: Global ──────────────────────────── */} |
| 68 | + <div className="flex h-12 w-full items-center gap-2 px-3 sm:px-4 md:px-6"> |
| 69 | + {/* Brand */} |
| 70 | + <Link |
| 71 | + to="/home" |
| 72 | + className="flex items-center gap-2 shrink-0 rounded-md px-1.5 py-1 hover:bg-accent/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" |
| 73 | + data-testid="home-brand" |
| 74 | + aria-label="ObjectStack" |
| 75 | + > |
| 76 | + <div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary text-primary-foreground"> |
| 77 | + <Boxes className="h-4 w-4" /> |
| 78 | + </div> |
| 79 | + <span className="hidden sm:inline text-sm font-semibold tracking-tight"> |
| 80 | + ObjectStack |
| 81 | + </span> |
| 82 | + </Link> |
| 83 | + |
| 84 | + {/* Search */} |
| 85 | + <button |
| 86 | + onClick={openCommandPalette} |
| 87 | + className="hidden lg:flex relative items-center gap-2 ml-auto w-72 xl:w-96 h-8 px-3 text-sm rounded-md border bg-muted/40 text-muted-foreground hover:bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" |
| 88 | + data-testid="home-search-trigger" |
| 89 | + > |
| 90 | + <Search className="h-4 w-4 shrink-0" /> |
| 91 | + <span className="flex-1 text-left truncate"> |
| 92 | + {t('console.searchRich', { |
| 93 | + defaultValue: 'Search apps, objects, records…', |
| 94 | + })} |
| 95 | + </span> |
| 96 | + <kbd className="pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground"> |
| 97 | + <span className="text-xs">⌘</span>K |
| 98 | + </kbd> |
| 99 | + </button> |
| 100 | + |
| 101 | + {/* Trailing icon cluster */} |
| 102 | + <div className="ml-auto lg:ml-2 flex items-center gap-0.5 sm:gap-1 shrink-0"> |
| 103 | + {/* Search — mobile/tablet */} |
| 104 | + <Button |
| 105 | + variant="ghost" |
| 106 | + size="icon" |
| 107 | + className="lg:hidden h-8 w-8 shrink-0" |
| 108 | + onClick={openCommandPalette} |
| 109 | + aria-label={t('console.search', { defaultValue: 'Search...' })} |
| 110 | + > |
| 111 | + <Search className="h-4 w-4" /> |
| 112 | + </Button> |
| 113 | + |
| 114 | + {/* Activity */} |
| 115 | + <div className="hidden sm:flex shrink-0 relative"> |
| 116 | + <ActivityFeed activities={activities ?? []} /> |
| 117 | + </div> |
| 118 | + |
| 119 | + {/* Help */} |
| 120 | + <Button |
| 121 | + variant="ghost" |
| 122 | + size="icon" |
| 123 | + asChild |
| 124 | + className="h-8 w-8 hidden md:inline-flex shrink-0" |
| 125 | + aria-label={t('sidebar.helpTooltip', { |
| 126 | + defaultValue: 'Help & Documentation', |
| 127 | + })} |
| 128 | + > |
| 129 | + <a |
| 130 | + href="https://docs.objectstack.ai" |
| 131 | + target="_blank" |
| 132 | + rel="noopener noreferrer" |
| 133 | + > |
| 134 | + <HelpCircle className="h-4 w-4" /> |
| 135 | + </a> |
| 136 | + </Button> |
| 137 | + |
| 138 | + <Separator |
| 139 | + orientation="vertical" |
| 140 | + className="hidden sm:block mx-0.5 h-5" |
| 141 | + /> |
| 142 | + |
| 143 | + {/* Theme + Locale */} |
| 144 | + <div className="hidden sm:flex shrink-0"> |
| 145 | + <ModeToggle /> |
| 146 | + </div> |
| 147 | + <div className="hidden sm:flex shrink-0"> |
| 148 | + <LocaleSwitcher /> |
| 149 | + </div> |
| 150 | + |
| 151 | + <Separator |
| 152 | + orientation="vertical" |
| 153 | + className="hidden sm:block mx-0.5 h-5" |
| 154 | + /> |
| 155 | + |
| 156 | + {/* Workspace switcher — self-sizing; collapses when it renders null */} |
| 157 | + <div className="hidden md:flex shrink-0 min-w-0 max-w-52"> |
| 158 | + <WorkspaceSwitcher |
| 159 | + onWorkspaceChange={() => { |
| 160 | + // Reload so adapter / permissions re-initialize with the new |
| 161 | + // tenant context, matching the sidebar's prior behavior. |
| 162 | + window.location.href = `${window.location.origin}/console/home`; |
| 163 | + }} |
| 164 | + /> |
| 165 | + </div> |
| 166 | + |
| 167 | + {/* User profile */} |
| 168 | + <DropdownMenu> |
| 169 | + <DropdownMenuTrigger asChild> |
| 170 | + <button |
| 171 | + className="flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" |
| 172 | + data-testid="home-user-menu-trigger" |
| 173 | + > |
| 174 | + <Avatar className="h-8 w-8 rounded-lg"> |
| 175 | + <AvatarImage |
| 176 | + src={user?.image ?? '/avatars/user.jpg'} |
| 177 | + alt={user?.name ?? 'User'} |
| 178 | + /> |
| 179 | + <AvatarFallback className="rounded-lg bg-primary text-primary-foreground text-xs"> |
| 180 | + {getUserInitials(user)} |
| 181 | + </AvatarFallback> |
| 182 | + </Avatar> |
| 183 | + <ChevronsUpDown className="hidden md:block h-4 w-4 text-muted-foreground" /> |
| 184 | + </button> |
| 185 | + </DropdownMenuTrigger> |
| 186 | + <DropdownMenuContent align="end" sideOffset={4} className="w-56"> |
| 187 | + <DropdownMenuLabel className="p-0 font-normal"> |
| 188 | + <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> |
| 189 | + <Avatar className="h-8 w-8 rounded-lg"> |
| 190 | + <AvatarImage |
| 191 | + src={user?.image ?? '/avatars/user.jpg'} |
| 192 | + alt={user?.name ?? 'User'} |
| 193 | + /> |
| 194 | + <AvatarFallback className="rounded-lg bg-primary text-primary-foreground"> |
| 195 | + {getUserInitials(user)} |
| 196 | + </AvatarFallback> |
| 197 | + </Avatar> |
| 198 | + <div className="grid flex-1 text-left text-sm leading-tight"> |
| 199 | + <span className="truncate font-semibold"> |
| 200 | + {user?.name ?? 'User'} |
| 201 | + </span> |
| 202 | + <span className="truncate text-xs text-muted-foreground"> |
| 203 | + {user?.email ?? ''} |
| 204 | + </span> |
| 205 | + </div> |
| 206 | + </div> |
| 207 | + </DropdownMenuLabel> |
| 208 | + <DropdownMenuSeparator /> |
| 209 | + <DropdownMenuGroup> |
| 210 | + <DropdownMenuItem |
| 211 | + onClick={() => navigate('/apps/setup/system/profile')} |
| 212 | + > |
| 213 | + <UserIcon className="mr-2 h-4 w-4" /> |
| 214 | + {t('user.profile', { defaultValue: 'Profile' })} |
| 215 | + </DropdownMenuItem> |
| 216 | + <DropdownMenuItem onClick={() => navigate('/apps/setup')}> |
| 217 | + <Settings className="mr-2 h-4 w-4" /> |
| 218 | + {t('sidebar.settings', { defaultValue: 'Settings' })} |
| 219 | + </DropdownMenuItem> |
| 220 | + </DropdownMenuGroup> |
| 221 | + <DropdownMenuSeparator /> |
| 222 | + <DropdownMenuItem |
| 223 | + className="text-destructive focus:text-destructive" |
| 224 | + onClick={() => signOut()} |
| 225 | + > |
| 226 | + <LogOut className="mr-2 h-4 w-4" /> |
| 227 | + {t('user.logout', { defaultValue: 'Log out' })} |
| 228 | + </DropdownMenuItem> |
| 229 | + </DropdownMenuContent> |
| 230 | + </DropdownMenu> |
| 231 | + </div> |
| 232 | + </div> |
| 233 | + </header> |
| 234 | + ); |
| 235 | +} |
0 commit comments