diff options
Diffstat (limited to 'src/components')
37 files changed, 2865 insertions, 0 deletions
diff --git a/src/components/active-link.tsx b/src/components/active-link.tsx new file mode 100644 index 0000000..bab9670 --- /dev/null +++ b/src/components/active-link.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { isActive } from '@/lib/is-active'; +import { cn } from '@/lib/utils'; +import Link, { type LinkProps } from 'next/link'; +import { usePathname } from 'next/navigation'; +import type { ReactNode } from 'react'; + +type ActiveLinkProps = LinkProps & { + children: ReactNode; + href: string; + target?: string; + rel?: string; + className?: string; + nested?: boolean; +}; + +export const ActiveLink = ({ + href, + children, + className, + nested = false, + ...props +}: ActiveLinkProps) => { + const pathname = usePathname(); + const active = isActive(href, pathname, nested); + + return ( + <Link + href={href} + target={props.target} + rel={props.rel} + className={cn( + 'text-muted-foreground text-sm transition-colors', + 'hover:text-foreground', + active && 'font-medium text-foreground', + className, + )} + {...props} + > + {children} + </Link> + ); +}; diff --git a/src/components/analytics.tsx b/src/components/analytics.tsx new file mode 100644 index 0000000..c3e296c --- /dev/null +++ b/src/components/analytics.tsx @@ -0,0 +1,20 @@ +import { env } from '@/env'; +import Script from 'next/script'; + +import { isProduction } from '@/lib/constants'; + +const Analytics = () => { + if (!isProduction) return null; + if (!env.NEXT_PUBLIC_UMAMI_URL) return null; + if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID) return null; + + return ( + <Script + async + data-website-id={env.NEXT_PUBLIC_UMAMI_WEBSITE_ID} + src={`${env.NEXT_PUBLIC_UMAMI_URL}/script.js`} + /> + ); +}; + +export default Analytics; diff --git a/src/components/auth/user-avatar.tsx b/src/components/auth/user-avatar.tsx new file mode 100644 index 0000000..53b20b5 --- /dev/null +++ b/src/components/auth/user-avatar.tsx @@ -0,0 +1,56 @@ +import type { ComponentProps } from 'react'; + +import { Icons } from '@/components/icons/icons'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import type { User } from '@/lib/auth-client'; +import { cn } from '@/lib/utils'; + +export interface UserAvatarClassNames { + base?: string; + image?: string; + fallback?: string; + fallbackIcon?: string; +} + +export interface UserAvatarProps { + user?: User | null; + classNames?: UserAvatarClassNames; +} + +export function UserAvatar({ + user, + classNames, + className, + ...props +}: UserAvatarProps & ComponentProps<typeof Avatar>) { + const name = user?.name || user?.email; + const src = user?.image; + + return ( + <Avatar + key={src} + className={cn('rounded-md', className, classNames?.base)} + {...props} + > + <AvatarImage + alt={name ?? 'Avatar'} + className={cn('rounded-md', classNames?.image)} + src={src ?? undefined} + /> + + <AvatarFallback + className={cn( + 'rounded-md bg-transparent uppercase', + classNames?.fallback, + )} + delayMs={src ? 200 : 0} + > + {firstTwoCharacters(name) ?? ( + <Icons.user className={cn('w-[55%]', classNames?.fallbackIcon)} /> + )} + </AvatarFallback> + </Avatar> + ); +} + +const firstTwoCharacters = (name?: string | null) => name?.slice(0, 2); diff --git a/src/components/auth/user-button.tsx b/src/components/auth/user-button.tsx new file mode 100644 index 0000000..7caea4c --- /dev/null +++ b/src/components/auth/user-button.tsx @@ -0,0 +1,127 @@ +'use client'; + +import Link from 'next/link'; + +import { Icons } from '@/components/icons/icons'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Skeleton } from '@/components/ui/skeleton'; +import { signOut, useSession } from '@/lib/auth-client'; +import { cn } from '@/lib/utils'; + +import type { UserAvatarClassNames } from './user-avatar'; +import { UserAvatar } from './user-avatar'; + +export interface UserButtonClassNames { + base?: string; + skeleton?: string; + trigger?: { + base?: string; + avatar?: UserAvatarClassNames; + skeleton?: string; + }; + content?: { + base?: string; + avatar?: UserAvatarClassNames; + menuItem?: string; + separator?: string; + }; +} + +export interface UserButtonProps { + className?: string; + classNames?: UserButtonClassNames; +} + +export function UserButton({ className, classNames }: UserButtonProps) { + const { data: sessionData, isPending } = useSession(); + const user = sessionData?.user ?? null; + + return ( + <DropdownMenu> + <DropdownMenuTrigger + className={cn('rounded-md bg-transparent', classNames?.trigger?.base)} + asChild + > + <Button + variant='ghost' + className='size-auto rounded-md border-none bg-transparent p-1.5 hover:bg-accent dark:hover:bg-accent' + disabled={isPending} + > + {isPending ? ( + <Skeleton + className={cn( + 'size-8 rounded-md', + className, + classNames?.base, + classNames?.skeleton, + classNames?.trigger?.skeleton, + )} + /> + ) : ( + <UserAvatar + className={cn('size-5', className, classNames?.base)} + classNames={classNames?.trigger?.avatar} + user={user} + /> + )} + </Button> + </DropdownMenuTrigger> + + <DropdownMenuContent + className={cn('max-w-64', classNames?.content?.base)} + onCloseAutoFocus={(e) => e.preventDefault()} + align='end' + > + {user ? ( + <div className='flex items-center gap-2 p-2'> + <UserAvatar classNames={classNames?.content?.avatar} user={user} /> + + <div className='flex flex-col truncate'> + <div className='truncate font-medium text-sm'> + {user.name || user.email} + </div> + + {user.name && ( + <div className='truncate text-muted-foreground text-xs'> + {user.email} + </div> + )} + </div> + </div> + ) : ( + <div className='px-2 py-1 text-muted-foreground text-xs'>Account</div> + )} + + <DropdownMenuSeparator className={classNames?.content?.separator} /> + + {!user ? ( + <> + <DropdownMenuItem className={classNames?.content?.menuItem} asChild> + <Link href={'/login'}> + <Icons.logIn className='size-4' /> + Sign In + </Link> + </DropdownMenuItem> + </> + ) : ( + <> + <DropdownMenuItem + className={classNames?.content?.menuItem} + onClick={() => signOut()} + > + <Icons.logOut className='size-4' /> + Log Out + </DropdownMenuItem> + </> + )} + </DropdownMenuContent> + </DropdownMenu> + ); +} diff --git a/src/components/blur-image.tsx b/src/components/blur-image.tsx new file mode 100644 index 0000000..92dcc30 --- /dev/null +++ b/src/components/blur-image.tsx @@ -0,0 +1,43 @@ +'use client'; +/** + * Copyright (c) Delba de Oliveira + * Source: https://github.com/delbaoliveira/website/blob/59e6f181ad75751342ceaa8931db4cbcef86b018/ui/BlurImage.tsx + */ +import { cn } from '@/lib/utils'; +import NextImage from 'next/image'; +import { useState } from 'react'; + +type ImageProps = { + imageClassName?: string; + lazy?: boolean; +} & React.ComponentProps<typeof NextImage>; + +const BlurImage = (props: ImageProps) => { + const { alt, src, className, imageClassName, lazy = true, ...rest } = props; + const [isLoading, setIsLoading] = useState(true); + + return ( + <div + className={cn('overflow-hidden', isLoading && 'animate-pulse', className)} + > + <NextImage + className={cn( + isLoading && 'scale-[1.02] blur-xl grayscale', + imageClassName, + )} + style={{ + transition: 'filter 700ms ease, scale 150ms ease', + }} + src={src} + alt={alt} + loading={lazy ? 'lazy' : undefined} + priority={!lazy} + quality={100} + onLoad={() => setIsLoading(false)} + {...rest} + /> + </div> + ); +}; + +export { BlurImage }; diff --git a/src/components/docs.tsx b/src/components/docs.tsx new file mode 100644 index 0000000..038a52b --- /dev/null +++ b/src/components/docs.tsx @@ -0,0 +1,64 @@ +import type { PageTree } from 'fumadocs-core/server'; +import { cn } from 'fumadocs-ui/components/api'; +import { + type SidebarOptions, + checkPageTree, + layoutVariables, +} from 'fumadocs-ui/layouts/docs/shared'; +import { type BaseLayoutProps, getLinks } from 'fumadocs-ui/layouts/shared'; +import { + type PageStyles, + StylesProvider, + TreeContextProvider, +} from 'fumadocs-ui/provider'; +import type { HTMLAttributes, ReactNode } from 'react'; +import { Header } from './sections/header'; + +export interface DocsLayoutProps extends BaseLayoutProps { + tree: PageTree.Root; + + sidebar?: Omit<Partial<SidebarOptions>, 'component' | 'enabled'>; + + containerProps?: HTMLAttributes<HTMLDivElement>; +} + +export const DocsLayout = ({ + nav = {}, + i18n = false, + ...props +}: DocsLayoutProps): ReactNode => { + checkPageTree(props.tree); + const links = getLinks(props.links ?? [], props.githubUrl); + + const variables = cn( + '[--fd-nav-height:3.5rem] [--fd-tocnav-height:36px] xl:[--fd-toc-width:268px] xl:[--fd-tocnav-height:0px]', + ); + + const pageStyles: PageStyles = { + tocNav: cn('lg:px-4 xl:hidden'), + toc: cn('max-xl:hidden'), + page: cn('mt-[var(--fd-nav-height)]'), + article: cn('mx-auto'), + }; + + return ( + <TreeContextProvider tree={props.tree}> + <main + id='nd-docs-layout' + {...props.containerProps} + className={cn( + 'flex w-full flex-1 flex-row pe-[var(--fd-layout-offset)]', + variables, + props.containerProps?.className, + )} + style={{ + ...layoutVariables, + ...props.containerProps?.style, + }} + > + <Header finalLinks={links} nav={nav} /> + <StylesProvider {...pageStyles}>{props.children}</StylesProvider> + </main> + </TreeContextProvider> + ); +}; diff --git a/src/components/icons/animated/check.tsx b/src/components/icons/animated/check.tsx new file mode 100644 index 0000000..24a246c --- /dev/null +++ b/src/components/icons/animated/check.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import type { Variants } from 'motion/react'; +import { motion, useAnimation } from 'motion/react'; +import type { HTMLAttributes } from 'react'; +import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; + +export interface CheckIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface CheckIconProps extends HTMLAttributes<HTMLDivElement> { + size?: number; +} + +const pathVariants: Variants = { + normal: { + opacity: 1, + pathLength: 1, + scale: 1, + transition: { + duration: 0.3, + opacity: { duration: 0.1 }, + }, + }, + animate: { + opacity: [0, 1], + pathLength: [0, 1], + scale: [0.5, 1], + transition: { + duration: 0.4, + opacity: { duration: 0.1 }, + }, + }, +}; + +const CheckIcon = forwardRef<CheckIconHandle, CheckIconProps>( + ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { + const controls = useAnimation(); + const isControlledRef = useRef(false); + + useImperativeHandle(ref, () => { + isControlledRef.current = true; + + return { + startAnimation: () => controls.start('animate'), + stopAnimation: () => controls.start('normal'), + }; + }); + + const handleMouseEnter = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + if (!isControlledRef.current) { + controls.start('animate'); + } else { + onMouseEnter?.(e); + } + }, + [controls, onMouseEnter], + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + if (!isControlledRef.current) { + controls.start('normal'); + } else { + onMouseLeave?.(e); + } + }, + [controls, onMouseLeave], + ); + + return ( + <div + className={cn( + 'flex cursor-pointer select-none items-center justify-center rounded-md p-2 transition-colors duration-200 hover:bg-accent', + className, + )} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + {...props} + > + {/* biome-ignore lint/a11y/noSvgWithoutTitle: <explanation> */} + <svg + xmlns='http://www.w3.org/2000/svg' + width={size} + height={size} + viewBox='0 0 24 24' + fill='none' + stroke='currentColor' + strokeWidth='2' + strokeLinecap='round' + strokeLinejoin='round' + > + <motion.path + variants={pathVariants} + initial='normal' + animate={controls} + d='M4 12 9 17L20 6' + /> + </svg> + </div> + ); + }, +); + +CheckIcon.displayName = 'CheckIcon'; + +export { CheckIcon }; diff --git a/src/components/icons/animated/upload.tsx b/src/components/icons/animated/upload.tsx new file mode 100644 index 0000000..19ac51e --- /dev/null +++ b/src/components/icons/animated/upload.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import type { Variants } from 'motion/react'; +import { motion, useAnimation } from 'motion/react'; +import type { HTMLAttributes } from 'react'; +import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; + +export interface UploadIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface UploadIconProps extends HTMLAttributes<HTMLDivElement> { + size?: number; +} + +const arrowVariants: Variants = { + normal: { y: 0 }, + animate: { + y: -2, + transition: { + type: 'spring', + stiffness: 200, + damping: 10, + mass: 1, + }, + }, +}; + +const UploadIcon = forwardRef<UploadIconHandle, UploadIconProps>( + ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { + const controls = useAnimation(); + const isControlledRef = useRef(false); + + useImperativeHandle(ref, () => { + isControlledRef.current = true; + + return { + startAnimation: () => controls.start('animate'), + stopAnimation: () => controls.start('normal'), + }; + }); + + const handleMouseEnter = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + if (!isControlledRef.current) { + controls.start('animate'); + } else { + onMouseEnter?.(e); + } + }, + [controls, onMouseEnter], + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + if (!isControlledRef.current) { + controls.start('normal'); + } else { + onMouseLeave?.(e); + } + }, + [controls, onMouseLeave], + ); + + return ( + <div + className={cn( + 'flex cursor-pointer select-none items-center justify-center rounded-md p-2 transition-colors duration-200 hover:bg-accent', + className, + )} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + {...props} + > + {/* biome-ignore lint/a11y/noSvgWithoutTitle: <explanation> */} + <svg + xmlns='http://www.w3.org/2000/svg' + width={size} + height={size} + viewBox='0 0 24 24' + fill='none' + stroke='currentColor' + strokeWidth='2' + strokeLinecap='round' + strokeLinejoin='round' + > + <path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' /> + <motion.g variants={arrowVariants} animate={controls}> + <polyline points='17 8 12 3 7 8' /> + <line x1='12' x2='12' y1='3' y2='15' /> + </motion.g> + </svg> + </div> + ); + }, +); + +UploadIcon.displayName = 'UploadIcon'; + +export { UploadIcon }; diff --git a/src/components/icons/icons.tsx b/src/components/icons/icons.tsx new file mode 100644 index 0000000..e861252 --- /dev/null +++ b/src/components/icons/icons.tsx @@ -0,0 +1,131 @@ +import type { Icon as LucideIcon, LucideProps } from 'lucide-react'; +import { + AlertTriangle, + ArrowRight, + ArrowUpRight, + Check, + CheckCircle, + ChevronDown, + ChevronLeft, + ChevronRight, + ClipboardCheck, + Code, + CreditCard, + File, + FileText, + HelpCircle, + Home, + Image, + Info, + Laptop, + Loader2, + LogIn, + LogOut, + Mail, + Menu, + Moon, + MoreVertical, + Newspaper, + Pizza, + Plus, + Rss, + SendHorizonal, + Settings, + ShareIcon, + SunMedium, + Tag, + Tags, + Trash, + User, + X, +} from 'lucide-react'; + +export type Icon = typeof LucideIcon; + +export const Icons = { + logo: Code, + close: X, + menu: Menu, + code: Code, + copied: ClipboardCheck, + success: CheckCircle, + spinner: Loader2, + chevronLeft: ChevronLeft, + chevronRight: ChevronRight, + trash: Trash, + tags: Tags, + tag: Tag, + share: ShareIcon, + posts: Newspaper, + post: FileText, + page: File, + media: Image, + settings: Settings, + billing: CreditCard, + ellipsis: MoreVertical, + add: Plus, + logIn: LogIn, + logOut: LogOut, + warning: AlertTriangle, + user: User, + arrowRight: ArrowRight, + help: HelpCircle, + pizza: Pizza, + sun: SunMedium, + moon: Moon, + laptop: Laptop, + home: Home, + info: Info, + arrowUpRight: ArrowUpRight, + chevronDown: ChevronDown, + mail: Mail, + send: SendHorizonal, + gitHub: ({ ...props }: LucideProps) => ( + <svg + aria-hidden='true' + focusable='false' + data-prefix='fab' + data-icon='github' + role='img' + xmlns='http://www.w3.org/2000/svg' + viewBox='0 0 496 512' + {...props} + > + <path + fill='currentColor' + d='M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z' + /> + </svg> + ), + google: ({ ...props }: LucideProps) => ( + <svg + aria-hidden='true' + focusable='false' + width='1em' + height='1em' + viewBox='0 0 256 262' + xmlns='http://www.w3.org/2000/svg' + preserveAspectRatio='xMidYMid' + {...props} + > + <path + d='M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027' + fill='#4285F4' + /> + <path + d='M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1' + fill='#34A853' + /> + <path + d='M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782' + fill='#FBBC05' + /> + <path + d='M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251' + fill='#EB4335' + /> + </svg> + ), + check: Check, + rss: Rss, +}; diff --git a/src/components/inline-link.tsx b/src/components/inline-link.tsx new file mode 100644 index 0000000..f507688 --- /dev/null +++ b/src/components/inline-link.tsx @@ -0,0 +1,28 @@ +import { cn } from '@/lib/utils'; +import Link from 'next/link'; +import type { ReactNode } from 'react'; + +export const InlineLink = ({ + href, + children, + blank = false, + className, +}: { + href: string; + children: ReactNode; + blank?: boolean; + className?: string; +}) => { + return ( + <Link + href={href} + className={cn( + 'text-fd-primary underline duration-300 hover:text-fd-primary/70', + className, + )} + target={blank ? '_blank' : undefined} + > + {children} + </Link> + ); +}; diff --git a/src/components/json-ld.tsx b/src/components/json-ld.tsx new file mode 100644 index 0000000..cd30274 --- /dev/null +++ b/src/components/json-ld.tsx @@ -0,0 +1,114 @@ +import { title as homeTitle } from '@/app/layout.config'; +import { owner } from '@/app/layout.config'; +import { baseUrl } from '@/lib/constants'; +import type { Post } from '@/lib/source'; +import type { BlogPosting, BreadcrumbList, Graph } from 'schema-dts'; + +export const PostJsonLd = ({ page }: { page: Post }) => { + if (!page) { + return null; + } + + const url = new URL(page.url, baseUrl.href).href; + + const post: BlogPosting = { + '@type': 'BlogPosting', + headline: page.data.title, + description: page.data.description, + image: new URL(`/og/${page.slugs.join('/')}/image.png`, baseUrl.href).href, + datePublished: new Date(page.data.date).toISOString(), + dateModified: page.data.lastModified + ? new Date(page.data.lastModified).toISOString() + : undefined, + mainEntityOfPage: { + '@type': 'WebPage', + '@id': url, + }, + author: { + '@type': 'Person', + name: page.data.author, + // url: 'https://techwithanirudh.com/', + }, + publisher: { + '@type': 'Person', + name: owner, + url: 'https://techwithanirudh.com/', + }, + }; + + const breadcrumbList: BreadcrumbList = { + '@type': 'BreadcrumbList', + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: homeTitle, + item: baseUrl.href, + }, + { + '@type': 'ListItem', + position: 2, + name: `${homeTitle} | Posts`, + item: new URL('/posts', baseUrl.href).href, + }, + { + '@type': 'ListItem', + position: 3, + name: page.data.title, + item: url, + }, + ], + }; + + const graph: Graph = { + '@context': 'https://schema.org', + '@graph': [post, breadcrumbList], + }; + + return ( + <script + type='application/ld+json' + // biome-ignore lint/security/noDangerouslySetInnerHtml: + dangerouslySetInnerHTML={{ __html: JSON.stringify(graph) }} + /> + ); +}; + +export const TagJsonLd = ({ tag }: { tag: string }) => { + const breadcrumbList: BreadcrumbList = { + '@type': 'BreadcrumbList', + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: homeTitle, + item: baseUrl.href, + }, + { + '@type': 'ListItem', + position: 2, + name: `${homeTitle} | Tags`, + item: new URL('/tags', baseUrl.href).href, + }, + { + '@type': 'ListItem', + position: 3, + name: `${homeTitle} | Posts tagged with ${tag}`, + item: new URL(`/tags/${tag}`, baseUrl.href).href, + }, + ], + }; + + const graph: Graph = { + '@context': 'https://schema.org', + '@graph': [breadcrumbList], + }; + + return ( + <script + type='application/ld+json' + // biome-ignore lint/security/noDangerouslySetInnerHtml: + dangerouslySetInnerHTML={{ __html: JSON.stringify(graph) }} + /> + ); +}; diff --git a/src/components/mdx-layout.tsx b/src/components/mdx-layout.tsx new file mode 100644 index 0000000..bc68a93 --- /dev/null +++ b/src/components/mdx-layout.tsx @@ -0,0 +1,51 @@ +import { PostComments } from '@/app/(home)/posts/[slug]/page.client'; +import type { TOCItemType } from 'fumadocs-core/server'; +import { InlineTOC } from 'fumadocs-ui/components/inline-toc'; +import type { ReactNode } from 'react'; +import { Section } from './section'; + +interface MdxLayoutProps { + children: ReactNode; + title: string; + toc?: TOCItemType[] | null; + comments?: boolean; + slug: string; +} + +export default function MdxLayout({ + children, + title, + toc, + comments, + slug, +}: MdxLayoutProps): ReactNode { + return ( + <> + <Section className='p-4 lg:p-6'> + <h1 className='text-center font-bold text-3xl leading-tight tracking-tighter md:text-4xl'> + {title} + </h1> + </Section> + + <Section className='h-full' sectionClassName='flex flex-1'> + <article className='flex min-h-full flex-col lg:flex-row'> + <div className='flex flex-1 flex-col gap-4'> + {toc?.length ? ( + <InlineTOC + items={toc} + className='rounded-none border-0 border-border/70 border-b border-dashed dark:border-border' + /> + ) : null} + <div className='prose min-w-0 flex-1 px-4'>{children}</div> + {comments ? ( + <PostComments + slug={slug} + className='[&_form>div]:!rounded-none rounded-none border-0 border-border/70 border-t border-dashed dark:border-border' + /> + ) : null} + </div> + </article> + </Section> + </> + ); +} diff --git a/src/components/newsletter-form.tsx b/src/components/newsletter-form.tsx new file mode 100644 index 0000000..7dd0cc0 --- /dev/null +++ b/src/components/newsletter-form.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useAction } from 'next-safe-action/hooks'; + +import { Button } from '@/components/ui/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import type { Newsletter } from '@/lib/validators'; +import { NewsletterSchema } from '@/lib/validators'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; + +import { Alert, AlertTitle } from '@/components/ui/alert'; + +import { subscribeUser } from '@/app/(home)/actions'; +import { Icons } from '@/components/icons/icons'; + +export const NewsletterForm = () => { + const form = useForm({ + resolver: zodResolver(NewsletterSchema), + defaultValues: { + email: '', + }, + }); + + const { execute, result, status } = useAction(subscribeUser); + + const onSubmit = (values: Newsletter) => { + execute(values); + }; + + return ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className='flex-1 space-y-4'> + <div className='flex h-full min-h-10 overflow-hidden rounded-md border bg-muted p-0'> + <div className='flex-1'> + <FormField + control={form.control} + name='email' + render={({ field }) => ( + <FormItem className='group h-full'> + <FormControl className='h-full group-has-[p]:pt-3'> + <Input + {...field} + disabled={status === 'executing'} + placeholder='Email address' + type='email' + className='h-full rounded-md rounded-r-none border-none px-4 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0' + /> + </FormControl> + <FormMessage className='ml-4 pb-2 text-xs' /> + </FormItem> + )} + /> + </div> + + <Button + disabled={status === 'executing'} + type='submit' + size='icon' + className='group size-auto w-15 rounded-md rounded-l-none px-3' + > + {status === 'executing' ? ( + <Icons.spinner className='size-4 animate-spin' /> + ) : ( + <Icons.send className='group-hover:-rotate-45 size-4 transition-transform' /> + )} + </Button> + </div> + + {status === 'hasSucceeded' && ( + <Alert className='border-emerald-500/15 bg-emerald-500/15 p-3 px-3 py-2 text-emerald-500 has-[>svg]:gap-x-1.5'> + <Icons.success size={16} /> + <AlertTitle className='mb-0 leading-normal'> + {result.data?.message ?? "Hmm... Our server didn't respond."} + </AlertTitle> + </Alert> + )} + {result.serverError && ( + <Alert className='border-destructive/15 bg-destructive/15 p-3 px-3 py-2 text-destructive has-[>svg]:gap-x-1.5 dark:border-destructive dark:bg-destructive dark:text-destructive-foreground'> + <Icons.warning className='size-4' /> + <AlertTitle className='mb-0 leading-normal'> + {result.serverError} + </AlertTitle> + </Alert> + )} + </form> + </Form> + ); +}; diff --git a/src/components/numbered-pagination.tsx b/src/components/numbered-pagination.tsx new file mode 100644 index 0000000..cf02ef1 --- /dev/null +++ b/src/components/numbered-pagination.tsx @@ -0,0 +1,131 @@ +'use client'; +import { Icons } from '@/components/icons/icons'; +import { buttonVariants } from '@/components/ui/button'; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, +} from '@/components/ui/pagination'; +import { usePagination } from '@/hooks/use-pagination'; +import { cn } from '@/lib/utils'; + +type NumberedPaginationProps = { + currentPage: number; + totalPages: number; + paginationItemsToDisplay?: number; + onPageChange: (page: number) => void; +}; + +function NumberedPagination({ + currentPage, + totalPages, + paginationItemsToDisplay = 5, + onPageChange, +}: NumberedPaginationProps) { + const { pages, showLeftEllipsis, showRightEllipsis } = usePagination({ + currentPage, + totalPages, + paginationItemsToDisplay, + }); + + const handlePageChange = (page: number) => (e: React.MouseEvent) => { + e.preventDefault(); + if (page >= 1 && page <= totalPages) { + onPageChange(page); + } + }; + + return ( + <Pagination> + <PaginationContent className='-space-x-px inline-flex w-full gap-0 rtl:space-x-reverse'> + <PaginationItem> + <PaginationLink + className={cn( + buttonVariants({ + variant: 'ghost', + }), + 'rounded-none shadow-none focus-visible:z-10 aria-disabled:pointer-events-none [&[aria-disabled]>svg]:opacity-50', + )} + href='#' + onClick={handlePageChange(currentPage - 1)} + aria-label='Go to previous page' + aria-disabled={currentPage === 1} + > + <Icons.chevronLeft size={16} strokeWidth={2} aria-hidden='true' /> + </PaginationLink> + </PaginationItem> + + <div className='inline-flex w-full justify-center '> + {showLeftEllipsis && ( + <PaginationItem> + <PaginationLink + className={cn( + buttonVariants({ + variant: 'ghost', + }), + 'pointer-events-none rounded-none shadow-none', + )} + > + ... + </PaginationLink> + </PaginationItem> + )} + + {pages.map((page) => ( + <PaginationItem key={page} className='w-max'> + <PaginationLink + className={cn( + buttonVariants({ + variant: page === currentPage ? 'default' : 'ghost', + }), + 'rounded-none border-0 shadow-none focus-visible:z-10', + page === currentPage && + 'min-w-full dark:bg-primary dark:hover:bg-primary/90', + )} + href='#' + onClick={handlePageChange(page)} + isActive={page === currentPage} + > + {page} + </PaginationLink> + </PaginationItem> + ))} + + {showRightEllipsis && ( + <PaginationItem> + <PaginationLink + className={cn( + buttonVariants({ + variant: 'ghost', + }), + 'pointer-events-none rounded-none shadow-none', + )} + > + ... + </PaginationLink> + </PaginationItem> + )} + </div> + <PaginationItem> + <PaginationLink + className={cn( + buttonVariants({ + variant: 'ghost', + }), + 'rounded-none shadow-none focus-visible:z-10 aria-disabled:pointer-events-none [&[aria-disabled]>svg]:opacity-50', + )} + href='#' + onClick={handlePageChange(currentPage + 1)} + aria-label='Go to next page' + aria-disabled={currentPage === totalPages} + > + <Icons.chevronRight size={16} strokeWidth={2} aria-hidden='true' /> + </PaginationLink> + </PaginationItem> + </PaginationContent> + </Pagination> + ); +} + +export { NumberedPagination }; diff --git a/src/components/posts/post-card.tsx b/src/components/posts/post-card.tsx new file mode 100644 index 0000000..9544aea --- /dev/null +++ b/src/components/posts/post-card.tsx @@ -0,0 +1,76 @@ +import { BlurImage } from '@/components/blur-image'; +import { CalendarIcon, UserIcon } from 'lucide-react'; +import Link from 'next/link'; +import type React from 'react'; +import Balancer from 'react-wrap-balancer'; + +interface PostCardProps { + title: string; + description: string; + image?: string | null; + url: string; + date: string; + author: string; + tags?: string[]; +} + +export const PostCard: React.FC<PostCardProps> = ({ + title, + description, + image, + url, + date, + author, + tags, +}) => { + return ( + <Link + href={url} + className='grid grid-cols-1 gap-4 bg-card/50 px-6 py-6 transition-colors hover:bg-card/80 md:grid-cols-3 xl:grid-cols-4' + > + <div className='order-2 flex h-full flex-col justify-between gap-4 md:order-1 md:col-span-2 xl:col-span-3'> + <div className='flex-1 gap-4'> + <h2 className='font-medium text-lg md:text-xl lg:text-2xl'> + {title} + </h2> + <p className='line-clamp-3 overflow-hidden text-ellipsis text-medium text-muted-foreground'> + <Balancer>{description}</Balancer> + </p> + </div> + <div className='flex flex-col justify-center gap-4'> + {/* <div className='flex flex-wrap gap-2'> + {tags?.map((tag) => ( + <> + <TagCard name={tag} key={tag} className='p-0 [&_svg]:size-4 [&_span]:text-muted-foreground gap-1' /> + {index < tags.length - 1 && <span className='text-muted-foreground'>•</span>} + </> + ))} + </div> */} + <div className='group inline-flex items-center gap-2 text-muted-foreground text-sm'> + <span className='inline-flex items-center gap-1 capitalize'> + <UserIcon className='size-4 transition-transform hover:scale-125' /> + {author} + </span> + <span>•</span> + <span className='inline-flex items-center gap-1'> + <CalendarIcon className='size-4 transition-transform hover:scale-125' /> + {date} + </span> + </div> + </div> + </div> + + {image && ( + <div className='group relative order-1 col-span-1 inline-flex items-center justify-center transition-transform hover:scale-105 md:order-2'> + <BlurImage + width={853} + height={554} + src={image} + alt={title} + className='relative rounded-lg' + /> + </div> + )} + </Link> + ); +}; diff --git a/src/components/section.tsx b/src/components/section.tsx new file mode 100644 index 0000000..cb7c2c2 --- /dev/null +++ b/src/components/section.tsx @@ -0,0 +1,44 @@ +import { cn } from '@/lib/utils'; +import { PlusIcon } from 'lucide-react'; +import type { HTMLAttributes } from 'react'; + +type SectionProps = { + sectionClassName?: string; +} & HTMLAttributes<HTMLDivElement>; + +const Cross = () => ( + <div className='relative h-6 w-6'> + <div className='absolute left-3 h-6 w-px bg-background' /> + <div className='absolute top-3 h-px w-6 bg-background' /> + + <div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2'> + <PlusIcon size={20} className='text-border/70 dark:text-border' /> + </div> + </div> +); + +export const Section = ({ + children, + sectionClassName, + className, + ...props +}: SectionProps) => ( + <section className={sectionClassName} {...props}> + <div className='container relative mx-auto'> + <div + className={cn( + 'border-border/70 border-dashed sm:border-x dark:border-border', + className, + )} + > + {children} + </div> + <div className='-bottom-3 -left-3 absolute z-10 hidden h-6 sm:block'> + <Cross /> + </div> + <div className='-bottom-3 -right-3 -translate-x-px absolute z-10 hidden h-6 sm:block'> + <Cross /> + </div> + </div> + </section> +); diff --git a/src/components/sections/footer.tsx b/src/components/sections/footer.tsx new file mode 100644 index 0000000..8d0d876 --- /dev/null +++ b/src/components/sections/footer.tsx @@ -0,0 +1,112 @@ +import { baseOptions, linkItems, postsPerPage } from '@/app/layout.config'; +import { InlineLink } from '@/components/inline-link'; +import { getSortedByDatePosts, getTags } from '@/lib/source'; +import { cn } from '@/lib/utils'; +import { getLinks } from 'fumadocs-ui/layouts/shared'; +import { ActiveLink } from '../active-link'; + +export function Footer() { + const links = getLinks(linkItems, baseOptions.githubUrl); + const navItems = links.filter((item) => + ['nav', 'all'].includes(item.on ?? 'all'), + ); + + const posts = getSortedByDatePosts(); + const tags = getTags(); + + return ( + <footer className={cn('flex flex-col gap-4')}> + <div + className={cn( + 'grid gap-8 text-muted-foreground text-sm sm:grid-cols-4', + 'container mx-auto sm:gap-16 sm:px-8 sm:py-16', + 'border-border/70 border-b border-dashed dark:border-border', + )} + > + <div className='flex flex-col gap-6'> + <p className='font-medium text-foreground'>Pages</p> + + <ul className='flex flex-col gap-3'> + <li className='flex items-center gap-2'> + <ActiveLink href={'/'}>Home</ActiveLink> + </li> + {navItems + .filter( + (item) => + item.type !== 'menu' && + item.type !== 'custom' && + item.type !== 'icon', + ) + .map((item, i) => ( + <li key={item.url}> + <ActiveLink key={i.toString()} href={item.url}> + {item.text} + </ActiveLink> + </li> + ))} + </ul> + </div> + + <div className='flex flex-col gap-6'> + <p className='font-medium text-foreground'>Posts</p> + + <ul className='flex flex-col gap-3'> + {posts.slice(0, postsPerPage).map((post, i) => ( + <li key={post.url}> + <ActiveLink key={i.toString()} href={post.url}> + {post.data.title} + </ActiveLink> + </li> + ))} + </ul> + </div> + + <div className='flex flex-col gap-6'> + <p className='font-medium text-foreground'>Tags</p> + + <ul className='flex flex-col gap-3'> + {tags.slice(0, postsPerPage).map((name, i) => ( + <li key={`/tags/${name}`}> + <ActiveLink key={i.toString()} href={`/tags/${name}`}> + <span className='capitalize'>{name}</span> + </ActiveLink> + </li> + ))} + </ul> + </div> + + <div className='flex flex-col gap-6'> + <p className='font-medium text-foreground'>Socials</p> + + <ul className='flex flex-col gap-3'> + {navItems + .filter((item) => item.type === 'icon') + .map((item, i) => ( + <li key={item.url}> + <InlineLink + key={i.toString()} + href={item.url} + className='inline-flex items-center gap-1.5 text-muted-foreground no-underline [&_svg]:size-4' + > + {item.icon} + {item.text} + </InlineLink> + </li> + ))} + </ul> + </div> + </div> + {/* <Design /> */} + </footer> + ); +} + +function Design() { + return ( + <div className='footer'> + <span className='footer-text font-mono'>john•doe</span> + <div className='footer-grid' /> + <div className='footer-gradient' /> + </div> + ); +} diff --git a/src/components/sections/header/index.tsx b/src/components/sections/header/index.tsx new file mode 100644 index 0000000..4280e01 --- /dev/null +++ b/src/components/sections/header/index.tsx @@ -0,0 +1,174 @@ +import Link from 'fumadocs-core/link'; +import { + LanguageToggle, + LanguageToggleText, +} from 'fumadocs-ui/components/layout/language-toggle'; +import { + LargeSearchToggle, + SearchToggle, +} from 'fumadocs-ui/components/layout/search-toggle'; +import { NavigationMenuList } from 'fumadocs-ui/components/ui/navigation-menu'; +import type { HomeLayoutProps } from 'fumadocs-ui/layouts/home'; +import { + NavbarLink, + NavbarMenu, + NavbarMenuContent, + NavbarMenuTrigger, +} from 'fumadocs-ui/layouts/home/navbar'; +import type { LinkItemType } from 'fumadocs-ui/layouts/links'; +import { SearchOnly } from 'fumadocs-ui/provider'; +import { ChevronDown, Languages } from 'lucide-react'; +import { ThemeToggle } from '../../theme-toggle'; +import { Menu, MenuContent, MenuLinkItem, MenuTrigger } from './menu'; +import { Navbar, NavbarMenuLink } from './navbar'; + +export const Header = ({ + nav: { enableSearch = true, ...nav } = {}, + i18n = false, + finalLinks, +}: HomeLayoutProps & { + finalLinks: LinkItemType[]; +}) => { + const navItems = finalLinks.filter((item) => + ['nav', 'all'].includes(item.on ?? 'all'), + ); + const menuItems = finalLinks.filter((item) => + ['menu', 'all'].includes(item.on ?? 'all'), + ); + + return ( + <Navbar> + <Link + href={nav.url ?? '/'} + className='inline-flex items-center gap-2.5 font-semibold' + > + {nav.title} + </Link> + {nav.children} + <NavigationMenuList className='ml-2 flex flex-row items-center gap-2 max-sm:hidden'> + {navItems + .filter((item) => !isSecondary(item)) + .map((item, i) => ( + <NavbarLinkItem + key={i.toString()} + item={item} + className='text-sm' + /> + ))} + </NavigationMenuList> + <div className='flex flex-1 flex-row items-center justify-end lg:gap-1.5'> + {enableSearch ? ( + <SearchOnly> + <SearchToggle className='lg:hidden' /> + <LargeSearchToggle className='w-full max-w-[240px] max-lg:hidden' /> + </SearchOnly> + ) : null} + <ThemeToggle className='max-lg:hidden' /> + {navItems.filter(isSecondary).map((item, i) => ( + <NavbarLinkItem + key={i.toString()} + item={item} + className='-me-1.5 max-lg:hidden' + /> + ))} + <Menu className='lg:hidden'> + <MenuTrigger className='group -me-2'> + <ChevronDown className='size-3 transition-transform duration-300 group-data-[state=open]:rotate-180' /> + </MenuTrigger> + <MenuContent className='sm:flex-row sm:items-center sm:justify-end'> + {menuItems + .filter((item) => !isSecondary(item)) + .map((item, i) => ( + <MenuLinkItem + key={i.toString()} + item={item} + className='sm:hidden' + /> + ))} + <div className='-ms-1.5 flex flex-row items-center gap-1.5 max-sm:mt-2'> + {menuItems.filter(isSecondary).map((item, i) => ( + <MenuLinkItem + key={i.toString()} + item={item} + className='-me-1.5' + /> + ))} + <div className='flex-1' /> + {i18n ? ( + <LanguageToggle> + <Languages className='size-5' /> + <LanguageToggleText /> + <ChevronDown className='size-3 text-fd-muted-foreground' /> + </LanguageToggle> + ) : null} + <ThemeToggle /> + </div> + </MenuContent> + </Menu> + </div> + </Navbar> + ); +}; + +const NavbarLinkItem = ({ + item, + ...props +}: { + item: LinkItemType; + className?: string; +}) => { + if (item.type === 'custom') return <div {...props}>{item.children}</div>; + + if (item.type === 'menu') { + const children = item.items.map((child, j) => { + if (child.type === 'custom') + return <div key={j.toString()}>{child.children}</div>; + + const { banner, footer, ...rest } = child.menu ?? {}; + + return ( + <NavbarMenuLink key={j.toString()} href={child.url} {...rest}> + {banner ?? + (child.icon ? ( + <div className='w-fit rounded-md border bg-fd-muted p-1 [&_svg]:size-4'> + {child.icon} + </div> + ) : null)} + <p className='-mb-1 font-medium text-sm'>{child.text}</p> + {child.description ? ( + <p className='text-[13px] text-fd-muted-foreground'> + {child.description} + </p> + ) : null} + {footer} + </NavbarMenuLink> + ); + }); + + return ( + <NavbarMenu> + <NavbarMenuTrigger {...props}> + {item.url ? <Link href={item.url}>{item.text}</Link> : item.text} + </NavbarMenuTrigger> + <NavbarMenuContent>{children}</NavbarMenuContent> + </NavbarMenu> + ); + } + + return ( + <NavbarLink + {...props} + item={item} + variant={item.type} + aria-label={item.type === 'icon' ? item.label : undefined} + > + {item.type === 'icon' ? item.icon : item.text} + </NavbarLink> + ); +}; + +const isSecondary = (item: LinkItemType): boolean => { + return ( + ('secondary' in item && item.secondary === true) || item.type === 'icon' + ); +}; diff --git a/src/components/sections/header/menu.tsx b/src/components/sections/header/menu.tsx new file mode 100644 index 0000000..57ddf12 --- /dev/null +++ b/src/components/sections/header/menu.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { cva } from 'class-variance-authority'; +import Link from 'fumadocs-core/link'; +import { cn } from 'fumadocs-ui/components/api'; +import { buttonVariants } from 'fumadocs-ui/components/ui/button'; +import { + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuTrigger, +} from 'fumadocs-ui/components/ui/navigation-menu'; +import { BaseLinkItem, type LinkItemType } from 'fumadocs-ui/layouts/links'; +import type { ComponentPropsWithoutRef } from 'react'; + +const menuItemVariants = cva('', { + variants: { + variant: { + main: 'inline-flex items-center gap-2 py-1.5 transition-colors hover:text-fd-popover-foreground/50 data-[active=true]:font-medium data-[active=true]:text-fd-primary [&_svg]:size-4', + icon: buttonVariants({ + size: 'icon', + color: 'ghost', + }), + button: buttonVariants({ + color: 'secondary', + className: 'gap-1.5 [&_svg]:size-4', + }), + }, + }, + defaultVariants: { + variant: 'main', + }, +}); + +export const MenuLinkItem = ({ + item, + ...props +}: { + item: LinkItemType; + className?: string; +}) => { + if (item.type === 'custom') + return <div className={cn('grid', props.className)}>{item.children}</div>; + + if (item.type === 'menu') { + const header = ( + <> + {item.icon} + {item.text} + </> + ); + + return ( + <div className={cn('mb-4 flex flex-col', props.className)}> + <p className='mb-1 text-fd-muted-foreground text-sm'> + {item.url ? ( + <NavigationMenuLink asChild> + <Link href={item.url}>{header}</Link> + </NavigationMenuLink> + ) : ( + header + )} + </p> + {item.items.map((child, i) => ( + <MenuLinkItem key={i.toString()} item={child} /> + ))} + </div> + ); + } + + return ( + <NavigationMenuLink asChild> + <BaseLinkItem + item={item} + className={cn( + menuItemVariants({ variant: item.type }), + props.className, + )} + aria-label={item.type === 'icon' ? item.label : undefined} + > + {item.icon} + {item.type === 'icon' ? undefined : item.text} + </BaseLinkItem> + </NavigationMenuLink> + ); +}; + +export const Menu = NavigationMenuItem; + +export const MenuTrigger = ({ + ...props +}: ComponentPropsWithoutRef<typeof NavigationMenuTrigger> & {}) => { + return ( + <NavigationMenuTrigger + {...props} + className={cn( + buttonVariants({ + size: 'icon', + color: 'ghost', + }), + props.className, + )} + > + {props.children} + </NavigationMenuTrigger> + ); +}; + +export const MenuContent = ( + props: ComponentPropsWithoutRef<typeof NavigationMenuContent>, +) => { + return ( + <NavigationMenuContent + {...props} + className={cn('flex flex-col p-4', props.className)} + > + {props.children} + </NavigationMenuContent> + ); +}; diff --git a/src/components/sections/header/navbar.tsx b/src/components/sections/header/navbar.tsx new file mode 100644 index 0000000..25850a0 --- /dev/null +++ b/src/components/sections/header/navbar.tsx @@ -0,0 +1,55 @@ +'use client'; + +import Link, { type LinkProps } from 'fumadocs-core/link'; +import { cn } from 'fumadocs-ui/components/api'; +import { + NavigationMenu, + NavigationMenuLink, + NavigationMenuViewport, +} from 'fumadocs-ui/components/ui/navigation-menu'; +import { type HTMLAttributes, useState } from 'react'; + +export const Navbar = (props: HTMLAttributes<HTMLElement>) => { + const [value, setValue] = useState(''); + + return ( + <NavigationMenu value={value} onValueChange={setValue} asChild> + <header + id='nd-nav' + {...props} + className={cn( + 'sticky top-[var(--fd-banner-height)] z-30 box-content w-full bg-fd-background/80 backdrop-blur-lg transition-colors', + 'border-border/70 border-b border-dashed dark:border-border', + // value.length > 0 ? 'shadow-lg' : 'shadow-xs', + props.className, + )} + > + <div + className={cn( + 'container mx-auto flex size-full h-14 flex-row items-center px-4 md:gap-1.5 lg:px-6', + 'border-border/70 border-dashed sm:border-x dark:border-border', + )} + > + {props.children} + </div> + <NavigationMenuViewport /> + </header> + </NavigationMenu> + ); +}; + +export const NavbarMenuLink = (props: LinkProps) => { + return ( + <NavigationMenuLink asChild> + <Link + {...props} + className={cn( + 'flex flex-col gap-2 rounded-lg border bg-fd-card p-3 transition-colors hover:bg-fd-accent/80 hover:text-fd-accent-foreground', + props.className, + )} + > + {props.children} + </Link> + </NavigationMenuLink> + ); +}; diff --git a/src/components/separator.tsx b/src/components/separator.tsx new file mode 100644 index 0000000..445d4f4 --- /dev/null +++ b/src/components/separator.tsx @@ -0,0 +1,10 @@ +import { cn } from '@/lib/utils'; +import { Section } from './section'; + +const Separator = ({ className }: { className?: string }) => ( + <Section> + <div className={cn('h-8 bg-dashed', className)} /> + </Section> +); + +export default Separator; diff --git a/src/components/tags/tag-card.tsx b/src/components/tags/tag-card.tsx new file mode 100644 index 0000000..a71e4c4 --- /dev/null +++ b/src/components/tags/tag-card.tsx @@ -0,0 +1,35 @@ +import { Icons } from '@/components/icons/icons'; +import { getPostsByTag } from '@/lib/source'; +import { cn } from '@/lib/utils'; +import Link from 'next/link'; + +export const TagCard = ({ + name, + displayCount = false, + className = '', +}: { + name: string; + displayCount?: boolean; + className?: string; +}) => { + const posts = getPostsByTag(name); + + return ( + <Link + href={`/tags/${name}`} + className={cn( + 'group inline-flex items-center gap-2 rounded-lg bg-card/50 px-3 py-2 text-sm transition-colors hover:bg-card/80', + className, + )} + > + <Icons.tag + size={18} + className='my-auto text-muted-foreground transition-transform group-hover:rotate-12' + /> + <span className='text-card-foreground'>{name}</span> + {displayCount && ( + <span className='ml-auto text-muted-foreground'>({posts.length})</span> + )} + </Link> + ); +}; diff --git a/src/components/tailwind-indicator.tsx b/src/components/tailwind-indicator.tsx new file mode 100644 index 0000000..24d0011 --- /dev/null +++ b/src/components/tailwind-indicator.tsx @@ -0,0 +1,14 @@ +export function TailwindIndicator() { + if (process.env.NODE_ENV === 'production') return null; + + return ( + <div className='fixed bottom-1 left-1 z-50 flex h-6 w-6 items-center justify-center rounded-full bg-accent p-3 font-mono text-foreground text-xs'> + <div className='block sm:hidden'>xs</div> + <div className='hidden sm:block md:hidden'>sm</div> + <div className='hidden md:block lg:hidden'>md</div> + <div className='hidden lg:block xl:hidden'>lg</div> + <div className='hidden xl:block 2xl:hidden'>xl</div> + <div className='hidden 2xl:block'>2xl</div> + </div> + ); +} diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx new file mode 100644 index 0000000..a271996 --- /dev/null +++ b/src/components/theme-provider.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { ThemeProvider as NextThemesProvider } from 'next-themes'; +import type * as React from 'react'; + +export function ThemeProvider({ + children, + ...props +}: React.ComponentProps<typeof NextThemesProvider>) { + return <NextThemesProvider {...props}>{children}</NextThemesProvider>; +} diff --git a/src/components/theme-toggle.tsx b/src/components/theme-toggle.tsx new file mode 100644 index 0000000..cca9d03 --- /dev/null +++ b/src/components/theme-toggle.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import { cva } from 'class-variance-authority'; +import { Airplay, Moon, Sun } from 'lucide-react'; +import { motion } from 'motion/react'; +import { useTheme } from 'next-themes'; +import { type HTMLAttributes, useLayoutEffect, useState } from 'react'; + +const themes = [ + { + key: 'light', + icon: Sun, + label: 'Light theme', + }, + { + key: 'dark', + icon: Moon, + label: 'Dark theme', + }, + { + key: 'system', + icon: Airplay, + label: 'System theme', + }, +]; + +const itemVariants = cva( + 'relative size-6.5 rounded-full p-1.5 text-fd-muted-foreground', + { + variants: { + active: { + true: 'text-fd-accent-foreground', + false: 'text-fd-muted-foreground', + }, + }, + }, +); + +type Theme = 'light' | 'dark' | 'system'; + +export function ThemeToggle({ + className, + mode = 'light-dark', + ...props +}: HTMLAttributes<HTMLDivElement> & { + mode?: 'light-dark' | 'light-dark-system'; +}) { + const { setTheme, theme: currentTheme, resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + const container = cn( + 'relative flex items-center rounded-full p-1 ring-1 ring-border', + className, + ); + + useLayoutEffect(() => { + setMounted(true); + }, []); + + const handleChangeTheme = async (theme: Theme) => { + function update() { + setTheme(theme); + } + + if (document.startViewTransition && theme !== resolvedTheme) { + document.documentElement.style.viewTransitionName = 'theme-transition'; + await document.startViewTransition(update).finished; + document.documentElement.style.viewTransitionName = ''; + } else { + update(); + } + }; + + const value = mounted + ? mode === 'light-dark' + ? resolvedTheme + : currentTheme + : null; + + return ( + <div + className={container} + onClick={() => { + if (mode !== 'light-dark') return; + handleChangeTheme(resolvedTheme === 'dark' ? 'light' : 'dark'); + }} + data-theme-toggle='' + aria-label={mode === 'light-dark' ? 'Toggle Theme' : undefined} + {...props} + > + {themes.map(({ key, icon: Icon, label }) => { + const isActive = value === key; + if (mode === 'light-dark' && key === 'system') return; + + return ( + <button + type='button' + key={key} + className={itemVariants({ active: isActive })} + onClick={() => { + if (mode === 'light-dark') return; + handleChangeTheme(key as Theme); + }} + aria-label={label} + > + {isActive && ( + <motion.div + layoutId='activeTheme' + className='absolute inset-0 rounded-full bg-accent' + transition={{ + type: 'spring', + duration: mode === 'light-dark' ? 1.5 : 1, + }} + /> + )} + <Icon + className={'relative m-auto size-full'} + fill={'currentColor'} + /> + </button> + ); + })} + </div> + ); +} diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..ab7f6a8 --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +'use client'; + +import * as AccordionPrimitive from '@radix-ui/react-accordion'; +import { ChevronDownIcon } from 'lucide-react'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Accordion({ + ...props +}: React.ComponentProps<typeof AccordionPrimitive.Root>) { + return <AccordionPrimitive.Root data-slot='accordion' {...props} />; +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps<typeof AccordionPrimitive.Item>) { + return ( + <AccordionPrimitive.Item + data-slot='accordion-item' + className={cn('border-b last:border-b-0', className)} + {...props} + /> + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) { + return ( + <AccordionPrimitive.Header className='flex'> + <AccordionPrimitive.Trigger + data-slot='accordion-trigger' + className={cn( + 'flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left font-medium text-sm outline-none transition-all hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180', + className, + )} + {...props} + > + {children} + <ChevronDownIcon className='pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200' /> + </AccordionPrimitive.Trigger> + </AccordionPrimitive.Header> + ); +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps<typeof AccordionPrimitive.Content>) { + return ( + <AccordionPrimitive.Content + data-slot='accordion-content' + className='overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down' + {...props} + > + <div className={cn('pt-0 pb-4', className)}>{children}</div> + </AccordionPrimitive.Content> + ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..8a3c55a --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import { type VariantProps, cva } from 'class-variance-authority'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const alertVariants = cva( + 'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', + { + variants: { + variant: { + default: 'bg-card text-card-foreground', + destructive: + 'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) { + return ( + <div + data-slot='alert' + role='alert' + className={cn(alertVariants({ variant }), className)} + {...props} + /> + ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='alert-title' + className={cn( + 'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', + className, + )} + {...props} + /> + ); +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( + <div + data-slot='alert-description' + className={cn( + 'col-start-2 grid justify-items-start gap-1 text-muted-foreground text-sm [&_p]:leading-relaxed', + className, + )} + {...props} + /> + ); +} + +export { Alert, AlertTitle, AlertDescription }; diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..d1c9aef --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +'use client'; + +import * as AvatarPrimitive from '@radix-ui/react-avatar'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Avatar({ + className, + ...props +}: React.ComponentProps<typeof AvatarPrimitive.Root>) { + return ( + <AvatarPrimitive.Root + data-slot='avatar' + className={cn( + 'relative flex size-8 shrink-0 overflow-hidden rounded-full', + className, + )} + {...props} + /> + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps<typeof AvatarPrimitive.Image>) { + return ( + <AvatarPrimitive.Image + data-slot='avatar-image' + className={cn('aspect-square size-full', className)} + {...props} + /> + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) { + return ( + <AvatarPrimitive.Fallback + data-slot='avatar-fallback' + className={cn( + 'flex size-full items-center justify-center rounded-full bg-muted', + className, + )} + {...props} + /> + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..ec19e56 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,59 @@ +import { Slot } from '@radix-ui/react-slot'; +import { type VariantProps, cva } from 'class-variance-authority'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', + destructive: + 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', + outline: + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-border dark:bg-input/30 dark:hover:bg-input/50', + secondary: + 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', + ghost: + 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<'button'> & + VariantProps<typeof buttonVariants> & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : 'button'; + + return ( + <Comp + data-slot='button' + className={cn(buttonVariants({ variant, size, className }))} + {...props} + /> + ); +} + +export { Button, buttonVariants }; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..c839cf9 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Card({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='card' + className={cn( + 'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm', + className, + )} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='card-header' + className={cn( + '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', + className, + )} + {...props} + /> + ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='card-title' + className={cn('font-semibold leading-none', className)} + {...props} + /> + ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='card-description' + className={cn('text-muted-foreground text-sm', className)} + {...props} + /> + ); +} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='card-action' + className={cn( + 'col-start-2 row-span-2 row-start-1 self-start justify-self-end', + className, + )} + {...props} + /> + ); +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='card-content' + className={cn('px-6', className)} + {...props} + /> + ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='card-footer' + className={cn('flex items-center px-6 [.border-t]:pt-6', className)} + {...props} + /> + ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..eb4601e --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +'use client'; + +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function DropdownMenu({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { + return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />; +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { + return ( + <DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} /> + ); +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { + return ( + <DropdownMenuPrimitive.Trigger + data-slot='dropdown-menu-trigger' + {...props} + /> + ); +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { + return ( + <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Content + data-slot='dropdown-menu-content' + sideOffset={sideOffset} + className={cn( + 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in', + className, + )} + {...props} + /> + </DropdownMenuPrimitive.Portal> + ); +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { + return ( + <DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} /> + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { + inset?: boolean; + variant?: 'default' | 'destructive'; +}) { + return ( + <DropdownMenuPrimitive.Item + data-slot='dropdown-menu-item' + data-inset={inset} + data-variant={variant} + className={cn( + "data-[variant=destructive]:*:[svg]:!text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20'size-'])]:size-4 relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden [&_svg:not([class*= focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0", + className, + )} + {...props} + /> + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) { + return ( + <DropdownMenuPrimitive.CheckboxItem + data-slot='dropdown-menu-checkbox-item' + className={cn( + "data-[disabled]:opacity-50'size-'])]:size-4 relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden [&_svg:not([class*= focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0", + className, + )} + checked={checked} + {...props} + > + <span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'> + <DropdownMenuPrimitive.ItemIndicator> + <CheckIcon className='size-4' /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.CheckboxItem> + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { + return ( + <DropdownMenuPrimitive.RadioGroup + data-slot='dropdown-menu-radio-group' + {...props} + /> + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) { + return ( + <DropdownMenuPrimitive.RadioItem + data-slot='dropdown-menu-radio-item' + className={cn( + "data-[disabled]:opacity-50'size-'])]:size-4 relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden [&_svg:not([class*= focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0", + className, + )} + {...props} + > + <span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'> + <DropdownMenuPrimitive.ItemIndicator> + <CircleIcon className='size-2 fill-current' /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.RadioItem> + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { + inset?: boolean; +}) { + return ( + <DropdownMenuPrimitive.Label + data-slot='dropdown-menu-label' + data-inset={inset} + className={cn( + 'px-2 py-1.5 font-medium text-sm data-[inset]:pl-8', + className, + )} + {...props} + /> + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { + return ( + <DropdownMenuPrimitive.Separator + data-slot='dropdown-menu-separator' + className={cn('-mx-1 my-1 h-px bg-border', className)} + {...props} + /> + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<'span'>) { + return ( + <span + data-slot='dropdown-menu-shortcut' + className={cn( + 'ml-auto text-muted-foreground text-xs tracking-widest', + className, + )} + {...props} + /> + ); +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { + return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { + inset?: boolean; +}) { + return ( + <DropdownMenuPrimitive.SubTrigger + data-slot='dropdown-menu-sub-trigger' + data-inset={inset} + className={cn( + 'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground', + className, + )} + {...props} + > + {children} + <ChevronRightIcon className='ml-auto size-4' /> + </DropdownMenuPrimitive.SubTrigger> + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) { + return ( + <DropdownMenuPrimitive.SubContent + data-slot='dropdown-menu-sub-content' + className={cn( + 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in', + className, + )} + {...props} + /> + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..5e54b0b --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,168 @@ +'use client'; + +import type * as LabelPrimitive from '@radix-ui/react-label'; +import { Slot } from '@radix-ui/react-slot'; +import * as React from 'react'; +import { + Controller, + type ControllerProps, + type FieldPath, + type FieldValues, + FormProvider, + useFormContext, + useFormState, +} from 'react-hook-form'; + +import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext<FormFieldContextValue>( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, +>({ + ...props +}: ControllerProps<TFieldValues, TName>) => { + return ( + <FormFieldContext.Provider value={{ name: props.name }}> + <Controller {...props} /> + </FormFieldContext.Provider> + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error('useFormField should be used within <FormField>'); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext<FormItemContextValue>( + {} as FormItemContextValue, +); + +function FormItem({ className, ...props }: React.ComponentProps<'div'>) { + const id = React.useId(); + + return ( + <FormItemContext.Provider value={{ id }}> + <div + data-slot='form-item' + className={cn('grid gap-2', className)} + {...props} + /> + </FormItemContext.Provider> + ); +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps<typeof LabelPrimitive.Root>) { + const { error, formItemId } = useFormField(); + + return ( + <Label + data-slot='form-label' + data-error={!!error} + className={cn('data-[error=true]:text-destructive', className)} + htmlFor={formItemId} + {...props} + /> + ); +} + +function FormControl({ ...props }: React.ComponentProps<typeof Slot>) { + const { error, formItemId, formDescriptionId, formMessageId } = + useFormField(); + + return ( + <Slot + data-slot='form-control' + id={formItemId} + aria-describedby={ + !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}` + } + aria-invalid={!!error} + {...props} + /> + ); +} + +function FormDescription({ className, ...props }: React.ComponentProps<'p'>) { + const { formDescriptionId } = useFormField(); + + return ( + <p + data-slot='form-description' + id={formDescriptionId} + className={cn('text-muted-foreground text-sm', className)} + {...props} + /> + ); +} + +function FormMessage({ className, ...props }: React.ComponentProps<'p'>) { + const { error, formMessageId } = useFormField(); + const body = error ? String(error?.message ?? '') : props.children; + + if (!body) { + return null; + } + + return ( + <p + data-slot='form-message' + id={formMessageId} + className={cn('text-destructive text-sm', className)} + {...props} + > + {body} + </p> + ); +} + +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +}; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..27bc18a --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + <input + type={type} + data-slot='input' + className={cn( + 'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30', + 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50', + 'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40', + className, + )} + {...props} + /> + ); +} + +export { Input }; diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..0553fbc --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +'use client'; + +import * as LabelPrimitive from '@radix-ui/react-label'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Label({ + className, + ...props +}: React.ComponentProps<typeof LabelPrimitive.Root>) { + return ( + <LabelPrimitive.Root + data-slot='label' + className={cn( + 'flex select-none items-center gap-2 font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50', + className, + )} + {...props} + /> + ); +} + +export { Label }; diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 0000000..e1bee31 --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,126 @@ +import { + ChevronLeftIcon, + ChevronRightIcon, + MoreHorizontalIcon, +} from 'lucide-react'; +import type * as React from 'react'; + +import { type Button, buttonVariants } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +function Pagination({ className, ...props }: React.ComponentProps<'nav'>) { + return ( + <nav + aria-label='pagination' + data-slot='pagination' + className={cn('mx-auto flex w-full justify-center', className)} + {...props} + /> + ); +} + +function PaginationContent({ + className, + ...props +}: React.ComponentProps<'ul'>) { + return ( + <ul + data-slot='pagination-content' + className={cn('flex flex-row items-center gap-1', className)} + {...props} + /> + ); +} + +function PaginationItem({ ...props }: React.ComponentProps<'li'>) { + return <li data-slot='pagination-item' {...props} />; +} + +type PaginationLinkProps = { + isActive?: boolean; +} & Pick<React.ComponentProps<typeof Button>, 'size'> & + React.ComponentProps<'a'>; + +function PaginationLink({ + className, + isActive, + size = 'icon', + ...props +}: PaginationLinkProps) { + return ( + <a + aria-current={isActive ? 'page' : undefined} + data-slot='pagination-link' + data-active={isActive} + className={cn( + buttonVariants({ + variant: isActive ? 'outline' : 'ghost', + size, + }), + className, + )} + {...props} + /> + ); +} + +function PaginationPrevious({ + className, + ...props +}: React.ComponentProps<typeof PaginationLink>) { + return ( + <PaginationLink + aria-label='Go to previous page' + size='default' + className={cn('gap-1 px-2.5 sm:pl-2.5', className)} + {...props} + > + <ChevronLeftIcon /> + <span className='hidden sm:block'>Previous</span> + </PaginationLink> + ); +} + +function PaginationNext({ + className, + ...props +}: React.ComponentProps<typeof PaginationLink>) { + return ( + <PaginationLink + aria-label='Go to next page' + size='default' + className={cn('gap-1 px-2.5 sm:pr-2.5', className)} + {...props} + > + <span className='hidden sm:block'>Next</span> + <ChevronRightIcon /> + </PaginationLink> + ); +} + +function PaginationEllipsis({ + className, + ...props +}: React.ComponentProps<'span'>) { + return ( + <span + aria-hidden + data-slot='pagination-ellipsis' + className={cn('flex size-9 items-center justify-center', className)} + {...props} + > + <MoreHorizontalIcon className='size-4' /> + <span className='sr-only'>More pages</span> + </span> + ); +} + +export { + Pagination, + PaginationContent, + PaginationLink, + PaginationItem, + PaginationPrevious, + PaginationNext, + PaginationEllipsis, +}; diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..24d6b2f --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from '@/lib/utils'; + +function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { + return ( + <div + data-slot='skeleton' + className={cn('animate-pulse rounded-md bg-accent', className)} + {...props} + /> + ); +} + +export { Skeleton }; diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..cde1bcb --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { useTheme } from 'next-themes'; +import { Toaster as Sonner, type ToasterProps } from 'sonner'; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = 'system' } = useTheme(); + + return ( + <Sonner + theme={theme as ToasterProps['theme']} + className='toaster group' + style={ + { + '--normal-bg': 'var(--color-fd-popover)', + '--normal-text': 'var(--color-fd-popover-foreground)', + '--normal-border': 'var(--color-fd-border)', + } as React.CSSProperties + } + {...props} + /> + ); +}; + +export { Toaster }; |
