Initial commit for project Spoon!
Build and Push Next App / quality (push) Failing after 45s
Build and Push Next App / build-next (push) Has been skipped

This commit is contained in:
Gabriel Brown committed 2026-06-21 17:52:02 -05:00
commit cf7ff2ee4e
268 files changed
+32981

No files matched your search

@@ -0,0 +1,35 @@
import type { VariantProps } from 'class-variance-authority';
import type { ComponentProps } from 'react';
import { useAuthActions } from '@convex-dev/auth/react';
import { KeyRound } from 'lucide-react';
import type { buttonVariants } from '@spoon/ui';
import { Button } from '@spoon/ui';
interface Props {
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
type?: 'signIn' | 'signUp';
}
export const AuthentikSignInButton = ({
buttonProps,
type = 'signIn',
}: Props) => {
const { signIn } = useAuthActions();
return (
<Button
size='lg'
onClick={() => signIn('authentik')}
className='text-lg font-semibold'
{...buttonProps}
>
<div className='my-auto flex flex-row items-center gap-2'>
<KeyRound className='size-5' />
<p>{type === 'signIn' ? 'Continue' : 'Sign up'} with Authentik</p>
</div>
</Button>
);
};
@@ -0,0 +1 @@
export { AuthentikSignInButton } from './gibs-auth';
@@ -0,0 +1,218 @@
'use client';
import type { Preloaded } from 'convex/react';
import type { ChangeEvent } from 'react';
import { useRef, useState } from 'react';
import { useMutation, usePreloadedQuery, useQuery } from 'convex/react';
import { Loader2, Pencil, Upload, XIcon } from 'lucide-react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Avatar,
AvatarImage,
BasedAvatar,
Button,
CardContent,
ImageCrop,
ImageCropApply,
ImageCropContent,
Input,
} from '@spoon/ui';
type AvatarUploadProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>;
};
const dataUrlToBlob = async (
dataUrl: string,
): Promise<{ blob: Blob; type: string }> => {
const re = /^data:([^;,]+)[;,]/;
const m = re.exec(dataUrl);
const type = m?.[1] ?? 'image/png';
const res = await fetch(dataUrl);
const blob = await res.blob();
return { blob, type };
};
export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
const user = usePreloadedQuery(preloadedUser);
const [isUploading, setIsUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [croppedImage, setCroppedImage] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const updateUser = useMutation(api.auth.updateUser);
const currentImageUrl = useQuery(
api.files.getImageUrl,
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file.');
if (inputRef.current) inputRef.current.value = '';
return;
}
setSelectedFile(file);
setCroppedImage(null);
};
const handleReset = () => {
setSelectedFile(null);
setCroppedImage(null);
if (inputRef.current) inputRef.current.value = '';
};
const handleSave = async () => {
if (!croppedImage) {
toast.error('Please apply a crop first.');
return;
}
setIsUploading(true);
try {
const { blob, type } = await dataUrlToBlob(croppedImage);
const postUrl = await generateUploadUrl();
const result = await fetch(postUrl, {
method: 'POST',
headers: { 'Content-Type': type },
body: blob,
});
if (!result.ok) {
const msg = await result.text().catch(() => 'Upload failed.');
throw new Error(msg);
}
const uploadResponse = (await result.json()) as {
storageId: Id<'_storage'>;
};
await updateUser({ image: uploadResponse.storageId });
toast.success('Profile picture updated.');
handleReset();
} catch (error) {
console.error('Upload failed:', error);
toast.error('Upload failed. Please try again.');
} finally {
setIsUploading(false);
}
};
return (
<CardContent>
<div className='flex flex-col items-center gap-4'>
{/* Current avatar + trigger (hidden when cropping) */}
{!selectedFile && (
<div
className='group relative cursor-pointer'
onClick={() => inputRef.current?.click()}
>
<BasedAvatar
src={currentImageUrl ?? undefined}
fullName={user?.name}
className='h-42 w-42 text-6xl font-semibold'
userIconProps={{ size: 100 }}
/>
<div className='absolute inset-0 flex items-center justify-center rounded-full bg-black/0 transition-all group-hover:bg-black/50'>
<Upload
className='text-white opacity-0 transition-opacity group-hover:opacity-100'
size={24}
/>
</div>
<div className='absolute inset-1 flex items-end justify-end transition-all'>
<Pencil
className='text-white opacity-100 transition-opacity group-hover:opacity-0'
size={24}
/>
</div>
</div>
)}
{/* File input (hidden) */}
<Input
ref={inputRef}
id='avatar-upload'
type='file'
accept='image/*'
className='hidden'
onChange={handleFileChange}
disabled={isUploading}
/>
{/* Crop UI */}
{selectedFile && !croppedImage && (
<div className='flex flex-col items-center gap-3'>
<ImageCrop
aspect={1}
circularCrop
file={selectedFile}
maxImageSize={3 * 1024 * 1024} // 3MB guard
onCrop={setCroppedImage}
>
<ImageCropContent className='max-w-sm' />
<div className='flex items-center gap-2'>
<Button size='icon' variant='outline'>
<ImageCropApply className='h-full w-full scale-150' />
</Button>
<Button onClick={handleReset} size='icon' variant='destructive'>
<XIcon className='scale-150' />
</Button>
</div>
</ImageCrop>
</div>
)}
{/* Cropped preview + actions */}
{croppedImage && (
<div className='flex flex-col items-center gap-3'>
<Avatar className='h-42 w-42'>
<AvatarImage alt='Cropped preview' src={croppedImage} />
</Avatar>
<div className='flex items-center gap-1'>
<Button
onClick={handleSave}
disabled={isUploading}
variant='secondary'
className='px-4'
>
{isUploading ? (
<span className='inline-flex items-center gap-2'>
<Loader2 className='h-4 w-4 animate-spin' />
Saving...
</span>
) : (
'Save Avatar'
)}
</Button>
<Button
onClick={handleReset}
size='icon'
type='button'
variant='destructive'
>
<XIcon className='size-4' />
</Button>
</div>
</div>
)}
{/* Uploading indicator */}
{isUploading && !croppedImage && (
<div className='mt-2 flex items-center text-sm text-gray-500'>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Uploading...
</div>
)}
</div>
</CardContent>
);
};
@@ -0,0 +1,16 @@
'use client';
import { CardDescription, CardHeader, CardTitle } from '@spoon/ui';
const ProfileHeader = () => {
return (
<CardHeader>
<CardTitle className='text-xl'>Account Settings</CardTitle>
<CardDescription>
Update your profile information and manage your account preferences
</CardDescription>
</CardHeader>
);
};
export { ProfileHeader };
@@ -0,0 +1,5 @@
export { AvatarUpload } from './avatar-upload';
export { ProfileHeader } from './header';
export { ResetPasswordForm } from './reset-password';
export { SignOutForm } from './sign-out';
export { UserInfoForm } from './user-info';
@@ -0,0 +1,195 @@
'use client';
import type { Preloaded } from 'convex/react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction, usePreloadedQuery } from 'convex/react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
PASSWORD_MAX,
PASSWORD_MIN,
PASSWORD_REGEX,
} from '@spoon/backend/types';
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Separator,
SubmitButton,
} from '@spoon/ui';
const formSchema = z
.object({
currentPassword: z.string().regex(PASSWORD_REGEX, {
message: 'Incorrect current password. Does not meet requirements.',
}),
newPassword: z
.string()
.min(PASSWORD_MIN, {
message: 'New password must be at least 8 characters.',
})
.max(PASSWORD_MAX, {
message: 'New password must be less than 100 characters.',
})
.regex(/^\S+$/, {
message: 'Password must not contain whitespace.',
})
.regex(/[0-9]/, {
message: 'Password must contain at least one digit.',
})
.regex(/[a-z]/, {
message: 'Password must contain at least one lowercase letter.',
})
.regex(/[A-Z]/, {
message: 'Password must contain at least one uppercase letter.',
})
.regex(/[\p{P}\p{S}]/u, {
message: 'Password must contain at least one symbol.',
}),
confirmPassword: z.string(),
})
.refine((data) => data.currentPassword !== data.newPassword, {
message: 'New password must be different from current password.',
path: ['newPassword'],
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwords do not match.',
path: ['confirmPassword'],
});
interface ResetFormProps {
preloadedProvider: Preloaded<typeof api.auth.getUserProvider>;
}
export const ResetPasswordForm = ({ preloadedProvider }: ResetFormProps) => {
const userProvider = usePreloadedQuery(preloadedProvider);
const [loading, setLoading] = useState(false);
const changePassword = useAction(api.auth.updateUserPassword);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmPassword: '',
},
});
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
setLoading(true);
try {
const result = await changePassword({
currentPassword: values.currentPassword,
newPassword: values.newPassword,
});
if (result.success) {
form.reset();
toast.success('Password updated successfully.');
}
} catch (error) {
console.error('Error updating password:', error);
toast.error('Error updating password.');
} finally {
setLoading(false);
}
};
// Only show password reset for email/password auth users
if (userProvider !== 'email') {
return null;
}
return (
<>
<Separator />
<CardHeader>
<CardTitle>Change Password</CardTitle>
<CardDescription>
Update your password to keep your account secure
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='currentPassword'
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<Input
type='password'
{...field}
placeholder='Enter current password'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='newPassword'
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input
type='password'
{...field}
placeholder='Enter new password'
/>
</FormControl>
<FormDescription>
Must be at least 8 characters with uppercase, lowercase,
number, and symbol
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel>Confirm New Password</FormLabel>
<FormControl>
<Input
type='password'
{...field}
placeholder='Confirm new password'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-end pt-2'>
<SubmitButton disabled={loading} pendingText='Updating...'>
Update Password
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
</>
);
};
@@ -0,0 +1,53 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthActions } from '@convex-dev/auth/react';
import { LogOut } from 'lucide-react';
import {
Button,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@spoon/ui';
export const SignOutForm = () => {
const { signOut } = useAuthActions();
const router = useRouter();
const [isSigningOut, setIsSigningOut] = useState(false);
const handleSignOut = async () => {
setIsSigningOut(true);
try {
await signOut();
router.push('/');
} catch (error) {
console.error('Sign out error:', error);
setIsSigningOut(false);
}
};
return (
<>
<CardHeader>
<CardTitle>Sign Out</CardTitle>
<CardDescription>
End your current session and return to the home page
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant='destructive'
className='w-full'
onClick={handleSignOut}
disabled={isSigningOut}
>
<LogOut className='mr-2 h-4 w-4' />
{isSigningOut ? 'Signing Out...' : 'Sign Out'}
</Button>
</CardContent>
</>
);
};
@@ -0,0 +1,171 @@
'use client';
import type { Preloaded } from 'convex/react';
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, usePreloadedQuery } from 'convex/react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
SubmitButton,
} from '@spoon/ui';
const formSchema = z.object({
name: z
.string()
.trim()
.min(5, {
message: 'Full name is required & must be at least 5 characters.',
})
.max(50, {
message: 'Full name must be less than 50 characters.',
}),
email: z.email({
message: 'Please enter a valid email address.',
}),
});
interface UserInfoFormProps {
preloadedUser: Preloaded<typeof api.auth.getUser>;
preloadedProvider: Preloaded<typeof api.auth.getUserProvider>;
}
export const UserInfoForm = ({
preloadedUser,
preloadedProvider,
}: UserInfoFormProps) => {
const user = usePreloadedQuery(preloadedUser);
const userProvider = usePreloadedQuery(preloadedProvider);
const providerMap: Record<string, string> = {
unknown: 'Provider',
authentik: 'Authentik',
};
const [loading, setLoading] = useState(false);
const updateUser = useMutation(api.auth.updateUser);
const initialValues = useMemo<z.infer<typeof formSchema>>(
() => ({
name: user?.name ?? '',
email: user?.email ?? '',
}),
[user?.name, user?.email],
);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
values: initialValues,
});
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
if (!user) {
toast.error('User not found.');
return;
}
const name = values.name.trim();
const email = values.email.trim().toLowerCase();
const patch: Partial<{
name: string;
email: string;
}> = {};
if (name !== (user.name ?? '')) patch.name = name;
if (email !== (user.email ?? '')) patch.email = email;
if (Object.keys(patch).length === 0) {
toast.info('No changes to save.');
return;
}
setLoading(true);
try {
await updateUser(patch);
form.reset(patch);
toast.success('Profile updated successfully.');
} catch (error) {
console.error(error);
toast.error('Error updating profile.');
} finally {
setLoading(false);
}
};
return (
<>
<CardHeader>
<CardTitle>Account Information</CardTitle>
<CardDescription>Update your name and email address</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input {...field} placeholder='John Doe' />
</FormControl>
<FormDescription>Your public display name</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
type='email'
placeholder='[email protected]'
disabled={userProvider !== 'password'}
/>
</FormControl>
{userProvider === 'password' ? (
<FormDescription>
Your email address for account notifications
</FormDescription>
) : (
<FormDescription>
Email is managed through your{' '}
{providerMap[userProvider ?? 'unknown']} account
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-end pt-2'>
<SubmitButton disabled={loading} pendingText='Saving...'>
Save Changes
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
</>
);
};
@@ -0,0 +1,90 @@
import Link from 'next/link';
import { SpoonLogo } from '@/components/brand/logo';
export default function Footer() {
return (
<footer className='border-border/40 bg-muted/30 border-t'>
<div className='container mx-auto px-4 py-12'>
<div className='grid gap-8 md:grid-cols-4'>
<div className='md:col-span-2'>
<SpoonLogo className='mb-4' />
<p className='text-muted-foreground max-w-md text-sm'>
Spoon is a self-hostable fork maintenance dashboard for teams who
want to customize upstream projects without drifting away from
security fixes, product updates, and merge history.
</p>
</div>
<div>
<h4 className='mb-4 text-sm font-semibold'>Product</h4>
<ul className='space-y-2 text-sm'>
<li>
<Link
href='/dashboard'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Dashboard
</Link>
</li>
<li>
<Link
href='/spoons'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Spoons
</Link>
</li>
<li>
<Link
href='/updates'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Updates
</Link>
</li>
</ul>
</div>
<div>
<h4 className='mb-4 text-sm font-semibold'>Workspace</h4>
<ul className='space-y-2 text-sm'>
<li>
<Link
href='/agents'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Agents
</Link>
</li>
<li>
<Link
href='/profile'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Profile
</Link>
</li>
<li>
<Link
href='https://git.gbrown.org/gib/spoon'
target='_blank'
rel='noopener noreferrer'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Source
</Link>
</li>
</ul>
</div>
</div>
<div className='border-border/40 text-muted-foreground mt-12 border-t pt-8 text-center text-sm'>
<p>
Self-hostable fork maintenance for teams that stay close to
upstream.
</p>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,91 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuthActions } from '@convex-dev/auth/react';
import { useConvexAuth, useQuery } from 'convex/react';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
BasedAvatar,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@spoon/ui';
export const AvatarDropdown = () => {
const router = useRouter();
const { isLoading, isAuthenticated } = useConvexAuth();
const { signOut } = useAuthActions();
const user = useQuery(api.auth.getUser, {});
const currentImageUrl = useQuery(
api.files.getImageUrl,
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
);
if (isLoading) {
return (
<div className='flex items-center gap-2'>
<div className='bg-muted h-8 w-16 animate-pulse rounded-md' />
<div className='bg-muted h-9 w-9 animate-pulse rounded-full' />
</div>
);
}
if (!isAuthenticated) {
return (
<div className='flex items-center gap-2'>
<Button size='sm' asChild>
<Link href='/sign-in'>Sign In</Link>
</Button>
</div>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger>
<BasedAvatar
src={currentImageUrl}
fullName={user?.name}
className='h-9 w-9'
fallbackProps={{ className: 'text-sm font-semibold' }}
userIconProps={{ size: 20 }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{(user?.name ?? user?.email) && (
<>
<DropdownMenuLabel className='text-center font-bold'>
{user.name?.trim() ?? user.email?.trim()}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild>
<Link href='/profile' className='w-full cursor-pointer'>
Edit Profile
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<button
onClick={() =>
void signOut().then(() => {
router.push('/');
})
}
className='w-full cursor-pointer'
>
Sign Out
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,22 @@
'use client';
import type { ThemeToggleProps } from '@spoon/ui';
import { ThemeToggle } from '@spoon/ui';
import { AvatarDropdown } from './AvatarDropdown';
export const Controls = (themeToggleProps?: ThemeToggleProps) => {
return (
<div className='flex items-center gap-3'>
<ThemeToggle
size={1.1}
buttonProps={{
variant: 'ghost',
size: 'sm',
...themeToggleProps?.buttonProps,
}}
/>
<AvatarDropdown />
</div>
);
};
@@ -0,0 +1,71 @@
'use client';
import type { ComponentProps } from 'react';
import Link from 'next/link';
import { SpoonLogo } from '@/components/brand/logo';
import { useConvexAuth } from 'convex/react';
import { Bot, GitBranch, LayoutDashboard, ShieldCheck } from 'lucide-react';
import { Button } from '@spoon/ui';
import type { NavItem } from './navigation';
import { Controls } from './controls';
import { DesktopNavigation, MobileNavigation } from './navigation';
const Header = (headerProps: ComponentProps<'header'>) => {
const { isAuthenticated } = useConvexAuth();
const navItems: NavItem[] = [
{
href: '/#workflow',
icon: GitBranch,
label: 'How it works',
},
{
href: '/#maintenance',
icon: ShieldCheck,
label: 'Maintenance',
},
{
href: '/#agents',
icon: Bot,
label: 'Agents',
},
];
if (isAuthenticated) {
navItems.push({
href: '/dashboard',
icon: LayoutDashboard,
label: 'Dashboard',
});
}
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'
{...headerProps}
>
<div className='container mx-auto flex h-16 items-center justify-between px-4 md:px-6'>
<SpoonLogo />
<DesktopNavigation items={navItems} />
<div className='flex items-center gap-2'>
{isAuthenticated ? (
<Button size='sm' variant='outline' asChild>
<Link href='/spoons'>My Spoons</Link>
</Button>
) : (
<Button size='sm' className='hidden sm:inline-flex' asChild>
<Link href='/sign-in'>Get started</Link>
</Button>
)}
<Controls />
<MobileNavigation items={navItems} />
</div>
</div>
</header>
);
};
export default Header;
@@ -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 '@spoon/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-md 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-md'>
<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 };