summaryrefslogtreecommitdiff
path: root/src/components/ui
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/ui
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/ui')
-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
12 files changed, 970 insertions, 0 deletions
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 };