Files
web-inspector/.claude/skills/frontend-component-patterns.md
jungwoo choi c37cda5b13 Initial commit: 프로젝트 초기 구성
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 16:10:57 +09:00

7.7 KiB

React 컴포넌트 패턴 (Frontend Component Patterns)

이 프로젝트의 React/Next.js 컴포넌트 패턴입니다.

shadcn/ui 기반 컴포넌트

설치 및 초기화

# shadcn/ui 초기화
npx shadcn@latest init

# 컴포넌트 추가
npx shadcn@latest add button card dialog tabs table form

Button 컴포넌트 (CVA 패턴)

import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-white hover:bg-destructive/90",
        outline: "border bg-background shadow-xs hover:bg-accent",
        secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4 py-2",
        sm: "h-8 rounded-md px-3",
        lg: "h-10 rounded-md px-6",
        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
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  )
}

export { Button, buttonVariants }

Next.js App Router 구조

레이아웃 (layout.tsx)

import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/providers";
import { Toaster } from "@/components/ui/sonner";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "News Engine Admin",
  description: "Admin dashboard for News Pipeline management",
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body className={`${geistSans.variable} antialiased`}>
        <Providers>
          {children}
          <Toaster />
        </Providers>
      </body>
    </html>
  );
}

Provider 패턴

// components/providers.tsx
"use client"

import { ThemeProvider } from "next-themes"

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider
      attribute="class"
      defaultTheme="system"
      enableSystem
      disableTransitionOnChange
    >
      {children}
    </ThemeProvider>
  )
}

유틸리티 함수

cn() 함수 (lib/utils.ts)

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

커스텀 훅 패턴

데이터 페칭 훅

// hooks/use-articles.ts
import { useState, useEffect } from "react"

interface Article {
  id: string
  title: string
  summary: string
}

export function useArticles() {
  const [articles, setArticles] = useState<Article[]>([])
  const [isLoading, setIsLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    async function fetchArticles() {
      try {
        const res = await fetch("/api/articles")
        if (!res.ok) throw new Error("Failed to fetch")
        const data = await res.json()
        setArticles(data.items)
      } catch (e) {
        setError(e as Error)
      } finally {
        setIsLoading(false)
      }
    }

    fetchArticles()
  }, [])

  return { articles, isLoading, error }
}

토글 훅

// hooks/use-toggle.ts
import { useState, useCallback } from "react"

export function useToggle(initialState = false) {
  const [state, setState] = useState(initialState)

  const toggle = useCallback(() => setState((s) => !s), [])
  const setTrue = useCallback(() => setState(true), [])
  const setFalse = useCallback(() => setState(false), [])

  return { state, toggle, setTrue, setFalse }
}

폼 패턴 (react-hook-form + zod)

"use client"

import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"

const formSchema = z.object({
  title: z.string().min(1, "제목을 입력하세요"),
  content: z.string().min(10, "내용은 10자 이상이어야 합니다"),
})

type FormValues = z.infer<typeof formSchema>

export function ArticleForm() {
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      title: "",
      content: "",
    },
  })

  async function onSubmit(values: FormValues) {
    try {
      const res = await fetch("/api/articles", {
        method: "POST",
        body: JSON.stringify(values),
      })
      if (!res.ok) throw new Error("Failed to create")
      // 성공 처리
    } catch (error) {
      console.error(error)
    }
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="title"
          render={({ field }) => (
            <FormItem>
              <FormLabel>제목</FormLabel>
              <FormControl>
                <Input {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">저장</Button>
      </form>
    </Form>
  )
}

다크모드 지원

테마 토글 버튼

"use client"

import { useTheme } from "next-themes"
import { Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button"

export function ThemeToggle() {
  const { theme, setTheme } = useTheme()

  return (
    <Button
      variant="ghost"
      size="icon"
      onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
    >
      <Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
      <Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
    </Button>
  )
}

토스트 알림 (sonner)

import { toast } from "sonner"

// 성공 알림
toast.success("저장되었습니다")

// 에러 알림
toast.error("오류가 발생했습니다")

// 로딩 알림
const toastId = toast.loading("처리 중...")
// 완료 후
toast.success("완료!", { id: toastId })

파일 구조

src/
├── app/                    # Next.js App Router
│   ├── layout.tsx          # 루트 레이아웃
│   ├── page.tsx            # 메인 페이지
│   └── dashboard/
│       └── page.tsx
├── components/
│   ├── ui/                 # shadcn/ui 기본 컴포넌트
│   │   ├── button.tsx
│   │   ├── card.tsx
│   │   └── ...
│   ├── providers.tsx       # Context Providers
│   └── app-sidebar.tsx     # 앱 전용 컴포넌트
├── hooks/                  # 커스텀 훅
│   └── use-articles.ts
├── lib/                    # 유틸리티
│   └── utils.ts
└── types/                  # TypeScript 타입
    └── index.ts