326 lines
7.7 KiB
Markdown
326 lines
7.7 KiB
Markdown
# React 컴포넌트 패턴 (Frontend Component Patterns)
|
|
|
|
이 프로젝트의 React/Next.js 컴포넌트 패턴입니다.
|
|
|
|
## shadcn/ui 기반 컴포넌트
|
|
|
|
### 설치 및 초기화
|
|
```bash
|
|
# shadcn/ui 초기화
|
|
npx shadcn@latest init
|
|
|
|
# 컴포넌트 추가
|
|
npx shadcn@latest add button card dialog tabs table form
|
|
```
|
|
|
|
### Button 컴포넌트 (CVA 패턴)
|
|
```tsx
|
|
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)
|
|
```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 패턴
|
|
```tsx
|
|
// 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)
|
|
```tsx
|
|
import { clsx, type ClassValue } from "clsx"
|
|
import { twMerge } from "tailwind-merge"
|
|
|
|
export function cn(...inputs: ClassValue[]) {
|
|
return twMerge(clsx(inputs))
|
|
}
|
|
```
|
|
|
|
## 커스텀 훅 패턴
|
|
|
|
### 데이터 페칭 훅
|
|
```tsx
|
|
// 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 }
|
|
}
|
|
```
|
|
|
|
### 토글 훅
|
|
```tsx
|
|
// 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)
|
|
|
|
```tsx
|
|
"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>
|
|
)
|
|
}
|
|
```
|
|
|
|
## 다크모드 지원
|
|
|
|
### 테마 토글 버튼
|
|
```tsx
|
|
"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)
|
|
|
|
```tsx
|
|
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
|
|
```
|