summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorBertrand Yuan <bert.yuan@outlook.com>2025-12-15 23:48:10 +0800
committerBertrand Yuan <bert.yuan@outlook.com>2025-12-15 23:48:10 +0800
commit5b7ccf0b671e2999b62befc729a3e517a0433728 (patch)
tree8bf476dc7c75914c221042546840dc76267366df /src/components
initial commit -- the front-end prototype
The initial code is base on Anirudh's work. More to see at: https://github.com/techwithanirudh/shadcn-blog Therefore, the code in this commit is under MIT license.
Diffstat (limited to 'src/components')
-rw-r--r--src/components/active-link.tsx44
-rw-r--r--src/components/analytics.tsx20
-rw-r--r--src/components/auth/user-avatar.tsx56
-rw-r--r--src/components/auth/user-button.tsx127
-rw-r--r--src/components/blur-image.tsx43
-rw-r--r--src/components/docs.tsx64
-rw-r--r--src/components/icons/animated/check.tsx111
-rw-r--r--src/components/icons/animated/upload.tsx102
-rw-r--r--src/components/icons/icons.tsx131
-rw-r--r--src/components/inline-link.tsx28
-rw-r--r--src/components/json-ld.tsx114
-rw-r--r--src/components/mdx-layout.tsx51
-rw-r--r--src/components/newsletter-form.tsx96
-rw-r--r--src/components/numbered-pagination.tsx131
-rw-r--r--src/components/posts/post-card.tsx76
-rw-r--r--src/components/section.tsx44
-rw-r--r--src/components/sections/footer.tsx112
-rw-r--r--src/components/sections/header/index.tsx174
-rw-r--r--src/components/sections/header/menu.tsx120
-rw-r--r--src/components/sections/header/navbar.tsx55
-rw-r--r--src/components/separator.tsx10
-rw-r--r--src/components/tags/tag-card.tsx35
-rw-r--r--src/components/tailwind-indicator.tsx14
-rw-r--r--src/components/theme-provider.tsx11
-rw-r--r--src/components/theme-toggle.tsx126
-rw-r--r--src/components/ui/accordion.tsx66
-rw-r--r--src/components/ui/alert.tsx66
-rw-r--r--src/components/ui/avatar.tsx53
-rw-r--r--src/components/ui/button.tsx59
-rw-r--r--src/components/ui/card.tsx92
-rw-r--r--src/components/ui/dropdown-menu.tsx257
-rw-r--r--src/components/ui/form.tsx168
-rw-r--r--src/components/ui/input.tsx21
-rw-r--r--src/components/ui/label.tsx24
-rw-r--r--src/components/ui/pagination.tsx126
-rw-r--r--src/components/ui/skeleton.tsx13
-rw-r--r--src/components/ui/sonner.tsx25
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 };