Conditionally render admin link for admin users

This commit is contained in:
2026-03-26 23:55:51 -05:00
parent afd76786e5
commit d85af6e2af
6 changed files with 145 additions and 65 deletions

File diff suppressed because one or more lines are too long

View File

@@ -3,9 +3,7 @@
import type { Metadata, Viewport } from 'next';
import NextError from 'next/error';
import { Geist, Geist_Mono } from 'next/font/google';
import '@/app/(frontend)/styles.css';
import { useEffect } from 'react';
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
@@ -14,7 +12,6 @@ import { env } from '@/env';
import { generateMetadata } from '@/lib/metadata';
import * as Sentry from '@sentry/nextjs';
import PlausibleProvider from 'next-plausible';
import { Button, ThemeProvider, Toaster } from '@gib/ui';
export const metadata: Metadata = generateMetadata();

View File

@@ -9,9 +9,6 @@ import { generateMetadata } from '@/lib/metadata';
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
import PlausibleProvider from 'next-plausible';
import { ThemeProvider, Toaster } from '@gib/ui';
import { preloadQuery } from 'convex/nextjs';
import { api } from '@gib/backend/convex/_generated/api.js';
export const metadata: Metadata = generateMetadata();
@@ -31,12 +28,11 @@ const geistMono = Geist_Mono({
variable: '--font-geist-mono',
});
const RootLayout = async ({
const RootLayout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
const preloadedUser = await preloadQuery(api.auth.getUser, {});
return (
<ConvexAuthNextjsServerProvider>
<PlausibleProvider
@@ -55,9 +51,7 @@ const RootLayout = async ({
>
<ConvexClientProvider>
<div className='flex min-h-screen flex-col'>
<Header
preloadedUser={preloadedUser}
/>
<Header />
{children}
<Footer />
</div>

View File

@@ -21,7 +21,7 @@ import {
Input,
} from '@gib/ui';
interface AvatarUploadProps {
type AvatarUploadProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>;
}

View File

@@ -1,26 +1,51 @@
'use client';
import type { ComponentProps } from 'react';
import { type Preloaded, usePreloadedQuery } from 'convex/react';
import type { NavItem } from './navigation';
import { Kanit } from 'next/font/google';
import Image from 'next/image';
import Link from 'next/link';
import { Coffee, Server, User, Wrench } from 'lucide-react';
import { Controls } from './controls';
import { useQuery } from 'convex/react';
import { api } from '@gib/backend/convex/_generated/api.js';
type HeaderProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>;
headerProps?: ComponentProps<'header'>;
};
import { Controls } from './controls';
import { DesktopNavigation, MobileNavigation } from './navigation';
const kanitSans = Kanit({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
});
export default function Header({preloadedUser, headerProps}: HeaderProps) {
const user = usePreloadedQuery(preloadedUser);
const Header = (headerProps: ComponentProps<'header'>) => {
const user = useQuery(api.auth.getUser, {});
const navItems: NavItem[] = [
{
href: '/#features',
icon: Wrench,
label: 'Features',
},
{
href: '/#tech-stack',
icon: Server,
label: 'Stack',
},
{
href: 'https://git.gbrown.org/gib/convex-monorepo',
icon: Coffee,
label: 'Repository',
external: true,
},
];
if (user?.isAdmin) {
navItems.push({
href: '/admin',
icon: User,
label: 'Admin',
external: true,
});
}
return (
<header
className='border-border/40 bg-background/95 supports-backdrop-filter:bg-background/60 sticky top-0 z-50 w-full border-b backdrop-blur'
@@ -37,57 +62,24 @@ export default function Header({preloadedUser, headerProps}: HeaderProps) {
alt='Convex Monorepo'
width={50}
height={50}
className='w-10 lg:w-15 invert dark:invert-0'
className='w-10 invert lg:w-15 dark:invert-0'
/>
<span
className={`font-extrabold hidden md:inline sm:text-lg sm:mb-1 lg:mb-3 lg:text-4xl xl:text-5xl ${kanitSans.className}`}
className={`hidden font-extrabold sm:mb-1 sm:text-lg md:inline lg:mb-3 lg:text-4xl xl:text-5xl ${kanitSans.className}`}
>
convex-monorepo
</span>
</Link>
{/* Navigation */}
<nav className='hidden items-center gap-4 md:gap-6 text-xs lg:text-base font-medium sm:flex'>
<Link
href='/#features'
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
>
<Wrench width={18} height={18} />
Features
</Link>
<Link
href='/#tech-stack'
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
>
<Server width={18} height={18} />
Stack
</Link>
<Link
href='https://git.gbrown.org/gib/convex-monorepo'
target='_blank'
rel='noopener noreferrer'
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
>
<Coffee width={20} height={20} />
Repository
</Link>
{user?.isAdmin && (
<Link
href='/admin'
target='_blank'
rel='noopener noreferrer'
className='text-foreground/60 hover:text-foreground
flex items-center gap-2 transition-colors'
>
<User width={18} height={18} />
Admin
</Link>
)}
</nav>
<DesktopNavigation items={navItems} />
{/* Controls (Theme + Auth) */}
<Controls />
<div className='flex items-center gap-2'>
<Controls />
<MobileNavigation items={navItems} />
</div>
</div>
</header>
);
}
};
export default Header;

View File

@@ -0,0 +1,97 @@
'use client';
import type { LucideIcon } from 'lucide-react';
import Link from 'next/link';
import { ExternalLink, Menu } from 'lucide-react';
import {
Button,
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@gib/ui';
export type NavItem = {
href: string;
icon: LucideIcon;
label: string;
external?: boolean;
};
type NavigationProps = {
items: NavItem[];
};
const DesktopNavigation = ({ items }: NavigationProps) => {
return (
<nav className='hidden items-center gap-4 text-xs font-medium sm:flex md:gap-6 lg:text-base'>
{items.map(({ href, icon: Icon, label, external }) => (
<Link
key={label}
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
className='text-foreground/60 hover:text-foreground flex items-center gap-2 transition-colors'
>
<Icon width={18} height={18} />
{label}
</Link>
))}
</nav>
);
};
const MobileNavigation = ({ items }: NavigationProps) => {
return (
<Sheet>
<SheetTrigger asChild>
<Button
variant='outline'
size='icon-sm'
className='sm:hidden'
aria-label='Open navigation menu'
>
<Menu className='size-4.5' />
<span className='sr-only'>Open navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side='right' className='w-[min(88vw,22rem)] px-0'>
<SheetHeader className='border-border/60 from-background to-muted/40 border-b bg-linear-to-br from-35% px-5 py-5 text-left'>
<SheetTitle className='text-left text-lg'>Navigation</SheetTitle>
<SheetDescription className='text-left'>
Quick access to the links that collapse out of the header.
</SheetDescription>
</SheetHeader>
<div className='flex flex-col gap-3 px-4 py-5'>
{items.map(({ href, icon: Icon, label, external }) => (
<SheetClose asChild key={label}>
<Link
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
className='bg-card hover:bg-muted/70 border-border/60 text-card-foreground flex items-center justify-between rounded-2xl border px-4 py-3 transition-colors'
>
<span className='flex items-center gap-3'>
<span className='bg-muted text-foreground flex h-9 w-9 items-center justify-center rounded-xl'>
<Icon className='size-4.5' />
</span>
<span className='text-sm font-medium'>{label}</span>
</span>
{external ? (
<ExternalLink className='text-muted-foreground size-4' />
) : null}
</Link>
</SheetClose>
))}
</div>
</SheetContent>
</Sheet>
);
};
export { DesktopNavigation, MobileNavigation };