feat: Drama Studio 프로젝트 초기 구조 설정
- FastAPI 백엔드 (audio-studio-api) - Next.js 프론트엔드 (audio-studio-ui) - Qwen3-TTS 엔진 (audio-studio-tts) - MusicGen 서비스 (audio-studio-musicgen) - Docker Compose 개발/운영 환경 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
325
.claude/skills/frontend-component-patterns.md
Normal file
325
.claude/skills/frontend-component-patterns.md
Normal file
@ -0,0 +1,325 @@
|
||||
# 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
|
||||
```
|
||||
Reference in New Issue
Block a user