diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md new file mode 100644 index 00000000..a4150f5e --- /dev/null +++ b/.claude/commands/commit.md @@ -0,0 +1,110 @@ +# Claude Command: Commit + +This command helps you create well-formatted commits with conventional commit messages. + +## Usage + +To create a commit, just type: + +``` +/commit +``` + +Or with options: + +``` +/commit --no-verify +``` + +## What This Command Does + +1. Checks which files are staged with `git status` +2. If 0 files are staged, automatically adds all modified and new files with `git add` +3. Performs a `git diff` to understand what changes are being committed +4. Analyzes the diff to determine if multiple distinct logical changes are present +5. If multiple distinct changes are detected, suggests breaking the commit into multiple smaller commits +6. For each commit (or the single commit if not split), creates a commit message using conventional commit format + +## Best Practices for Commits + +- **Verify before committing**: Ensure code is linted, builds correctly, and documentation is updated +- **Atomic commits**: Each commit should contain related changes that serve a single purpose +- **Split large changes**: If changes touch multiple concerns, split them into separate commits +- **Conventional commit format**: Use the format `: ` or `(scope): ` where type is one of: + - `feat`: A new feature + - `fix`: A bug fix + - `docs`: Documentation changes + - `style`: Code style changes (formatting, etc) + - `refactor`: Code changes that neither fix bugs nor add features + - `test`: Adding or fixing tests + - `chore`: Changes to the build process, tools, etc. + - `ci`: CI/CD related changes + - `build`: Build system or external dependency changes + - `revert`: Reverting a previous commit + - `ai`: AI agent related changes (rules, skills, etc.) +- **Present tense, imperative mood**: Write commit messages as commands (e.g., "add feature" not "added feature") +- **Concise first line**: Keep the first line under 72 characters +- **No emoji**: Do not use emoji in commit messages + +## Guidelines for Splitting Commits + +When analyzing the diff, consider splitting commits based on these criteria: + +1. **Different concerns**: Changes to unrelated parts of the codebase +2. **Different types of changes**: Mixing features, fixes, refactoring, etc. +3. **File patterns**: Changes to different types of files (e.g., source code vs documentation) +4. **Logical grouping**: Changes that would be easier to understand or review separately +5. **Size**: Very large changes that would be clearer if broken down + +## Examples + +Good commit messages (single line): + +- feat: add user authentication system +- fix: resolve memory leak in rendering process +- feat(auth): add OAuth2 login support +- fix(api): handle null response from server + +Good commit messages with body (use '-' bullet points): + +``` +refactor(design): separate corner radius extraction from stroke pipeline + +- Extract corner radius logic into dedicated CornerExtractor class +- Create CornerNormalizer for corner normalization +- Promote corner to top-level style property +- Fix paint-level alias to only apply to solid paints +``` + +- docs: update API documentation with new endpoints +- refactor: simplify error handling logic in parser +- chore: improve developer tooling setup process +- style: reorganize component structure for better readability +- ci: add GitHub Actions workflow for testing +- build: upgrade webpack to v5 +- revert: undo breaking change in user service +- ai: add claude command for commit automation + +Example of splitting commits: + +- First commit: feat: add new solc version type definitions +- Second commit: docs: update documentation for new solc versions +- Third commit: chore: update package.json dependencies +- Fourth commit: test: add unit tests for new solc version features +- Fifth commit: fix: update dependencies with security vulnerabilities + +## Command Options + +- `--no-verify`: Skip running the pre-commit checks (lint, build, generate:docs) + +## Important Notes + +- **lint-staged runs automatically**: When committing, lint-staged will run linting and formatting checks on staged files +- **If lint-staged fails, the commit will be rejected**: You cannot proceed with the commit until the issues are fixed +- **On failure, immediately explain the cause**: If the commit fails due to lint-staged, analyze the error output and explain what went wrong and how to fix it +- If specific files are already staged, the command will only commit those files +- If no files are staged, it will automatically stage all modified and new files +- The commit message will be constructed based on the changes detected +- Before committing, the command will review the diff to identify if multiple commits would be more appropriate +- If suggesting multiple commits, it will help you stage and commit the changes separately +- Always reviews the commit diff to ensure the message matches the changes diff --git a/CLAUDE.md b/CLAUDE.md index ee1db25e..a46460df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,24 @@ Uses conventional commits with custom types including "rule" for Cursor Rules ch - **Figma Integration**: MCP servers configured for Figma plugin and Playwright - **Build**: Uses Vercel for deployment +## 주의사항 + +### Radix UI Import Convention + +Use the unified `radix-ui` package instead of individual `@radix-ui/*` packages: + +```tsx +// ✅ Correct - Components +import { RadioGroup, Dialog, Form } from "radix-ui" + +// ✅ Correct - Internal hooks (use~ prefix) +import { useControllableState } from "radix-ui/internal" + +// ❌ Wrong - Legacy individual packages +import * as RadioGroup from "@radix-ui/react-radio-group" +import { useControllableState } from "@radix-ui/react-use-controllable-state" +``` + ## Additional Rules & Guidelines For comprehensive development guidelines and conventions, refer to the cursor rules in `.cursor/rules/`: diff --git a/commitlint.config.js b/commitlint.config.js index 61ff88d2..edb91d7a 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -15,7 +15,7 @@ module.exports = { "ci", "build", "revert", - "rule", // Cursor Rules 관련 변경사항 + "ai", // AI agent 관련 수정사항(rule,skill 등) ], ], }, diff --git a/refactor.md b/refactor.md new file mode 100644 index 00000000..2cb2ab98 --- /dev/null +++ b/refactor.md @@ -0,0 +1,247 @@ +# JECT-5-FE 성능 리팩토링 계획 + +**분석 대상**: `/Users/jh/JECT-5-FE/service/app` +**분석 기준**: Vercel React Best Practices Guide +**분석 일자**: 2026-01-17 + +--- + +## 📊 Executive Summary + +총 **7개 이슈** 발견 → **7개 적용 권장** + +### 예상 성능 개선 + +| 지표 | 현재 | 개선 후 | 개선율 | +|------|------|---------|--------| +| **Tree-shaking 효율** | 9개 파일 비최적 | 완전 최적화 | +100% | +| **Store 조회 복잡도** | O(n) | O(1) | -99% | +| **UX (Suspense)** | 로딩 중 빈 화면 | 스켈레톤 표시 | 향상 | +| **Bundle 중복** | radix-ui 패키지 혼용 | 통합 패키지 | 번들 감소 | +| **코드 품질** | verbose Dialog | 간결한 구조 | 유지보수성 향상 | + +--- + +## ✅ 적용 필요 (7개) + +### Priority: HIGH (즉시 적용) - 5개 + +| # | 이슈 | 파일 | Vercel Rule | 예상 개선 | 난이도 | +|---|------|------|-------------|-----------|--------| +| **1** | **Namespace imports 제거** | 9개 파일 | 2.1 tree-shaking | Bundle 최적화 | ⭐ 쉬움 | +| **2** | **findIndex → Map 구조** | Zustand stores | 7.2 lookup-optimization | O(n)→O(1) 조회 | ⭐⭐ 보통 | +| **5** | **Radix UI 패키지 통합** | design 컴포넌트들 | 2.1 tree-shaking | 번들 중복 제거 | ⭐ 쉬움 | +| **6** | **Dialog 코드 간소화** | `dialog/` | - | 코드량 감소 | ⭐⭐ 보통 | +| **7** | **useAuthGuard → AuthGuard** | 인증 필요 페이지들 | - | 선언적 패턴 | ⭐ 쉬움 | + +### Priority: MEDIUM (선택적) - 1개 + +| # | 이슈 | 파일 | Vercel Rule | 예상 개선 | 난이도 | +|---|------|------|-------------|-----------|--------| +| **3** | **Suspense fallback 개선** | `games/page.tsx` | 1.5 async-suspense-boundaries | UX 개선 | ⭐ 쉬움 | + +### Priority: 검증 필요 - 1개 + +| # | 이슈 | 파일 | Vercel Rule | 조건 | +|---|------|------|-------------|------| +| **4** | **React.cache() 추가** | `getGameDetail.ts` | 3.4 server-cache-react | 중복 요청 발생 시에만 | + +--- + +## 🎯 상세 이슈 + +## HIGH #1: Namespace imports 제거 📦 + +**파일 (9개)**: +- `service/app/src/app/create/components/questionInputForm.tsx` +- `service/app/src/app/create/components/createGameNavigation.tsx` +- `service/app/src/app/game/[gameId]/components/teamInputForm.tsx` +- `service/app/src/app/games/components/GamesNavigation.tsx` +- `service/app/src/app/dashboard/components/dashboardGameSection.tsx` +- `service/app/src/app/_components/GameSection/GameSectionClient.tsx` +- `service/app/src/app/games/components/gamesLibrarySection.tsx` +- `service/app/src/entities/game/ui/gameLibraryGrid.tsx` +- `service/app/src/entities/game/ui/gamePreview.tsx` + +### 문제점 +```tsx +// ❌ 전체 namespace 번들에 포함 +import * as TextField from "@ject-5-fe/design/components/textField" +import * as GameCard from "@/entities/game/ui/GameCard/gameCard" + + + 질문* + + +``` + +**왜 문제인가?** +- `import * as`는 tree-shaking 방해 +- 사용하지 않는 컴포넌트도 번들에 포함 +- 9개 파일에서 반복 사용으로 누적 영향 + +--- + +## HIGH #2: findIndex → Map 구조 🔄 + +**파일**: +- `service/app/src/app/create/store/useCreateGameStore.tsx` (6곳) +- `service/app/src/app/game/[gameId]/store/useGameStore.tsx` (2곳) + +### 문제점 +```tsx +// ❌ 매 호출마다 O(n) 검색 +const addQuestion = () => { + const selectedIndex = state.questions.findIndex((q) => q.id === selectedQuestionId) + // ... +} + +const moveQuestion = (id, direction) => { + const index = state.questions.findIndex((q) => q.id === id) + // ... +} + +// 질문 10개 추가 시: 1+2+3+...+10 = 55회 배열 스캔 +``` + +**왜 문제인가?** +- addQuestion, deleteQuestion, moveQuestion, updateQuestion 등에서 반복 호출 +- 빈번한 사용자 인터랙션마다 O(n) 연산 누적 + +--- + +## MEDIUM #3: Suspense fallback 개선 💡 + +**파일**: `service/app/src/app/games/page.tsx` + +### 문제점 +```tsx +// ❌ null fallback → 로딩 중 빈 화면 +export default function GamesPage() { + return ( + <> + + + + + + + + ) +} +``` + +**왜 문제인가?** +- 로딩 중 사용자에게 아무것도 보이지 않음 +- CLS (Cumulative Layout Shift) 발생 가능 +- UX 저하 + +--- + +## 검증 필요 #4: React.cache() 🔍 + +**파일**: `service/app/src/entities/game/api/getGameDetail.ts` + +### 문제점 +```tsx +// ❌ 동일 gameId로 여러 번 호출 시 중복 요청 가능성 +export const getGameDetail = async (gameId: UUID) => { + const response = await fetchClient.get(`games/${gameId}`) + return response.json() +} + +// generateMetadata와 컴포넌트에서 각각 호출 시 2번 요청 가능 +``` + +**왜 문제인가?** +- generateMetadata + Page 컴포넌트에서 동일 데이터 중복 fetch 가능성 +- 서버 사이드 요청 중복 제거 필요 + +--- + +## HIGH #5: Radix UI 패키지 중복 사용 📦 + +**파일**: +- `shared/design/src/components/checkBox/index.tsx` +- `shared/design/src/components/input/index.tsx` +- `shared/design/src/components/radioField/index.tsx` +- `shared/design/src/components/radio/RadioGroup.tsx` +- `shared/design/src/components/radio/RadioItem.tsx` + +### 문제점 +```tsx +// ❌ 개별 @radix-ui/* 패키지와 통합 radix-ui 패키지 혼용 +import { useControllableState } from "@radix-ui/react-use-controllable-state" +import * as RadixRadioGroup from "@radix-ui/react-radio-group" + +// 다른 파일에서는 +import { Dialog } from "radix-ui" +``` + +**왜 문제인가?** +- 동일 기능의 코드가 번들에 중복 포함 +- 패키지 버전 불일치 가능성 +- 의존성 관리 복잡성 증가 + +--- + +## HIGH #6: Dialog 코드가 verbose함 🗂️ + +**파일**: `shared/design/src/components/dialog/` + +### 문제점 +```tsx +// ❌ 불필요한 variant 분기와 CustomDialog 중복 +// - 77줄짜리 customDialog.tsx 별도 존재 +// - variant prop으로 복잡한 조건부 렌더링 +// - 동일 기능의 코드 중복 + +export function Dialog({ variant, ... }) { + if (variant === "title") return + if (variant === "onlyTitle") return + if (variant === "onlyBody") return + // ... +} +``` + +**왜 문제인가?** +- 불필요한 코드량 증가 (77줄 중복) +- variant별 분기로 유지보수 어려움 +- Compound Component 패턴 미활용 + +--- + +## HIGH #7: useAuthGuard() hook 사용 방식 🔐 + +**파일**: +- `service/app/src/app/create/page.tsx` +- `service/app/src/app/dashboard/page.tsx` +- 기타 인증 필요 페이지들 + +### 문제점 +```tsx +// ❌ Hook 기반 imperative 패턴 +export default function CreateGamePage() { + useAuthGuard() // 컴포넌트 렌더링 후 리다이렉트 + + return ( +
+ + {/* 인증 안 된 상태에서도 잠깐 렌더링됨 (깜빡임) */} +
+ ) +} +``` + +**왜 문제인가?** +- 컴포넌트가 먼저 렌더링된 후 리다이렉트 → UI 깜빡임 +- 인증 로직이 컴포넌트 내부에 숨어있어 가독성 저하 +- 선언적이지 않은 패턴 + +--- + +## 📚 참고 자료 + +- [Vercel React Best Practices Guide](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js) +- [React.cache() 공식 문서](https://react.dev/reference/react/cache) +- [Next.js Bundle Analyzer](https://www.npmjs.com/package/@next/bundle-analyzer) diff --git a/service/app/src/app/_components/GameSection/GameSectionClient.tsx b/service/app/src/app/_components/GameSection/GameSectionClient.tsx index e0c61024..fcee6afa 100644 --- a/service/app/src/app/_components/GameSection/GameSectionClient.tsx +++ b/service/app/src/app/_components/GameSection/GameSectionClient.tsx @@ -3,6 +3,7 @@ import type { GameListItem } from "@/entities/game" import { useGamePreview } from "@/entities/game/hooks/useGamePreview" import * as GameCard from "@/entities/game/ui/GameCard/gameCard" +import { DEFAULT_BLUR_DATA_URL } from "@/shared/constants/images" interface GameSectionClientProps { games: GameListItem[] @@ -30,7 +31,7 @@ export const GameSectionClient = ({ games }: GameSectionClientProps) => { alt={game.gameTitle} sizes="178px" placeholder="blur" - blurDataURL="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzI3IiBoZWlnaHQ9IjQ1OSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZTVlN2ViIi8+PC9zdmc+" + blurDataURL={DEFAULT_BLUR_DATA_URL} > {game.questionCount}문제 {game.isShared && ( diff --git a/service/app/src/app/create/page.tsx b/service/app/src/app/create/page.tsx index 95860f63..0f66de70 100644 --- a/service/app/src/app/create/page.tsx +++ b/service/app/src/app/create/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useAuthGuard } from "@/entities/auth/model/hooks/useAuthGuard" +import { AuthGuard } from "@/entities/auth/ui/authGuard" import { CreateGameNavigation } from "./components/createGameNavigation" import { FileUploadArea } from "./components/fileUploadArea" @@ -8,18 +8,18 @@ import { QuestionInputForm } from "./components/questionInputForm" import { QuestionList } from "./components/questionList" export default function CreateGamePage() { - useAuthGuard() - return ( -
- -
- -
- - -
+ +
+ +
+ +
+ + +
+
-
+ ) } diff --git a/service/app/src/app/dashboard/__tests__/dashboard-test-scenarios.md b/service/app/src/app/dashboard/__tests__/dashboard-test-scenarios.md index 4487576d..f080cdd5 100644 --- a/service/app/src/app/dashboard/__tests__/dashboard-test-scenarios.md +++ b/service/app/src/app/dashboard/__tests__/dashboard-test-scenarios.md @@ -11,31 +11,37 @@ ### 1. 네비게이션 기능 #### 1.1 홈 버튼 + - **동작**: 클릭 시 '102. 홈 화면 - 로그인'으로 이동 - **검증**: URL이 `/`로 변경되는지 확인 #### 1.2 게임 만들기 버튼 + - **동작**: 클릭 시 '400. 게임 만들기'로 이동 - **검증**: URL이 `/create`로 변경되는지 확인 ### 2. 게임 카드 표시 #### 2.1 게임 정보 표시 + - **총 문제 개수**: 게임별 문제 개수가 정확히 표시되는지 확인 - **게임명**: 게임 제목이 정확히 표시되는지 확인 - **최신 순 정렬**: 게임이 최신 순으로 나열되는지 확인 #### 2.2 게임 이미지 + - **대표 이미지**: 해당 게임의 첫 번째 이미지가 자동으로 사용되는지 확인 - **기본 이미지**: 이미지가 없는 경우 기본 이미지(로고)가 표시되는지 확인 ### 3. 게임 옵션 기능 #### 3.1 게임 옵션 아이콘 버튼 + - **동작**: 클릭 시 게임 옵션 팝업 표시 - **검증**: 팝업이 정상적으로 나타나는지 확인 #### 3.2 게임 옵션 팝업 메뉴 + - **게임 수정 버튼**: 클릭 시 '400. 게임 만들기'로 이동 - **게임 공유 버튼**: 클릭 시 '203. alert - 게임 공유' 표시 - **게임 삭제 버튼**: 클릭 시 '204. alert - 게임 삭제' 표시 @@ -43,34 +49,41 @@ ### 4. 공유 기능 #### 4.1 공유 완료 뱃지 + - **생성**: '203. alert - 게임 공유'에서 '네' 버튼 클릭 시 공유 완료 뱃지 생성 - **제거**: '205. alert - 게임 공유 취소'에서 '네' 버튼 클릭 시 공유 완료 뱃지 제거 #### 4.2 공유 상태 토글 + - **공유된 게임**: '공유 취소' 버튼이 표시되는지 확인 - **미공유 게임**: '게임 공유' 버튼이 표시되는지 확인 ### 5. 게임 미리보기 #### 5.1 게임 소개 카드 클릭 + - **동작**: 클릭 시 '202. 게임 미리 보기' 팝업 표시 - **검증**: 팝업이 정상적으로 나타나는지 확인 #### 5.2 게임 미리보기 팝업 내용 + - **게임명**: 게임 제목이 정확히 표시되는지 확인 - **게임 제작자명**: 제작자 정보가 정확히 표시되는지 확인 - **총 문제 개수**: 문제 개수가 정확히 표시되는지 확인 #### 5.3 게임 미리보기 팝업 닫기 + - **팝업 닫기 버튼**: 클릭 시 '201. 대시 보드'로 이동 - **팝업 닫기 영역**: 클릭 시 '201. 대시 보드'로 이동 #### 5.4 게임 시작 + - **게임 시작 버튼**: 클릭 시 '500. 게임 진행 화면'으로 이동 ### 6. Alert 팝업 기능 #### 6.1 Alert - 게임 공유 + - **아니요 버튼**: 클릭 시 '201. 대시 보드'로 이동 - **팝업 닫기 영역**: 클릭 시 '201. 대시 보드'로 이동 - **네 버튼**: 클릭 시 @@ -78,6 +91,7 @@ - '201. 대시 보드'에 공유 완료 뱃지가 생성됨 #### 6.2 Alert - 게임 공유 취소 + - **아니요 버튼**: 클릭 시 '201. 대시 보드'로 이동 - **팝업 닫기 영역**: 클릭 시 '201. 대시 보드'로 이동 - **네 버튼**: 클릭 시 @@ -85,6 +99,7 @@ - '201. 대시 보드'의 공유 완료 뱃지가 사라짐 #### 6.3 Alert - 게임 삭제 + - **아니요 버튼**: 클릭 시 '201. 대시 보드'로 이동 - **팝업 닫기 영역**: 클릭 시 '201. 대시 보드'로 이동 - **네 버튼**: 클릭 시 해당 게임이 '201. 대시보드'에서 삭제됨 @@ -92,26 +107,31 @@ ## 🧪 테스트 케이스 구조 ### 기본 UI 확인 + - 대시보드 페이지 진입 시 기본 UI 요소 표시 확인 - 네비게이션 버튼들 표시 확인 - 게임 카드 목록 표시 확인 ### 게임 카드 기능 + - 게임 카드 정보 표시 확인 - 게임 카드 클릭 시 미리보기 팝업 확인 - 게임 옵션 버튼 동작 확인 ### 게임 옵션 메뉴 + - 게임 수정 버튼 동작 확인 - 게임 공유/공유 취소 버튼 동작 확인 - 게임 삭제 버튼 동작 확인 ### Alert 팝업 + - 게임 공유 Alert 동작 확인 - 게임 공유 취소 Alert 동작 확인 - 게임 삭제 Alert 동작 확인 ### 게임 미리보기 + - 게임 미리보기 팝업 내용 확인 - 게임 미리보기 닫기 동작 확인 - 게임 시작 버튼 동작 확인 @@ -119,12 +139,14 @@ ## 📝 테스트 데이터 요구사항 ### 필수 테스트 데이터 + - 최소 2개 이상의 게임 데이터 - 공유된 게임과 미공유 게임 - 이미지가 있는 게임과 없는 게임 - 다양한 문제 개수를 가진 게임들 ### 게임 데이터 구조 + ```typescript interface GameData { gameId: string @@ -146,16 +168,19 @@ interface GameData { ## 🎯 검증 포인트 ### 접근성 + - 모든 버튼에 적절한 aria-label 설정 - 키보드 네비게이션 지원 - 스크린 리더 호환성 ### 사용자 경험 + - 로딩 상태 표시 - 에러 상태 처리 - 성공/실패 피드백 ### 성능 + - 게임 목록 로딩 속도 - 이미지 로딩 최적화 - 팝업 열기/닫기 반응성 diff --git a/service/app/src/app/dashboard/components/dashboardGameSection.tsx b/service/app/src/app/dashboard/components/dashboardGameSection.tsx index b290b423..c20d34f6 100644 --- a/service/app/src/app/dashboard/components/dashboardGameSection.tsx +++ b/service/app/src/app/dashboard/components/dashboardGameSection.tsx @@ -9,6 +9,7 @@ import * as GameCard from "@/entities/game/ui/GameCard/gameCard" import { GameCardOptions } from "@/entities/game/ui/gameCardOptions" import { GameCreate } from "@/entities/game/ui/gameCreate" import { GameLibrarySkeleton } from "@/entities/game/ui/gameLibrarySkeleton" +import { DEFAULT_BLUR_DATA_URL } from "@/shared/constants/images" import { useDashboardGameActions } from "../hooks/useDashboardGameActions" @@ -68,7 +69,7 @@ export const DashboardGameSection = () => { fill sizes="178px" placeholder="blur" - blurDataURL="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzI3IiBoZWlnaHQ9IjQ1OSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZTVlN2ViIi8+PC9zdmc+" + blurDataURL={DEFAULT_BLUR_DATA_URL} > {game.questionCount}문제 {game.isShared && ( diff --git a/service/app/src/app/dashboard/page.tsx b/service/app/src/app/dashboard/page.tsx index e254bd45..057774bd 100644 --- a/service/app/src/app/dashboard/page.tsx +++ b/service/app/src/app/dashboard/page.tsx @@ -1,17 +1,17 @@ "use client" -import { useAuthGuard } from "@/entities/auth/model/hooks/useAuthGuard" +import { AuthGuard } from "@/entities/auth/ui/authGuard" import { DashboardNavigation } from "@/widgets/DashboardNavigation" import { DashboardGameSection } from "./components/dashboardGameSection" export default function DashboardPage() { - useAuthGuard() - return ( -
- - -
+ +
+ + +
+
) } diff --git a/service/app/src/app/games/__tests__/library-e2e-scenarios.md b/service/app/src/app/games/__tests__/library-e2e-scenarios.md index a77cd2da..cb548e78 100644 --- a/service/app/src/app/games/__tests__/library-e2e-scenarios.md +++ b/service/app/src/app/games/__tests__/library-e2e-scenarios.md @@ -1,11 +1,13 @@ # 라이브러리 E2E 테스트 시나리오 ## 개요 + 라이브러리 기능의 End-to-End 테스트 시나리오를 정의합니다. 로그인/비로그인 상태에 따른 다양한 사용자 플로우를 검증합니다. ## 테스트 대상 페이지 + - 301. 라이브러리 - 로그인 -- 302. 라이브러리 - 비로그인 +- 302. 라이브러리 - 비로그인 - 303. 라이브러리 게임 미리 보기 - 로그인 - 304. 라이브러리 게임 미리 보기 - 비로그인 - 305. 게임 검색 - 로그인 @@ -16,35 +18,41 @@ ### 301. 라이브러리 - 로그인 #### 전제 조건 + - 사용자가 로그인된 상태 - 라이브러리 페이지에 접근 가능 #### 테스트 케이스 **TC-301-01: 홈 버튼 클릭** + - **Given**: 라이브러리 페이지에 로그인된 상태로 접근 - **When**: '홈' 버튼을 클릭 - **Then**: '102. 홈 화면 - 로그인'으로 이동 **TC-301-02: 검색바 클릭** + - **Given**: 라이브러리 페이지에 로그인된 상태로 접근 - **When**: 검색바를 클릭 - **Then**: '305. 게임 검색 - 로그인'으로 이동 **TC-301-03: 게임 카드 목록 표시** + - **Given**: 라이브러리 페이지에 로그인된 상태로 접근 - **When**: 페이지가 로드됨 -- **Then**: +- **Then**: - 게임 카드가 인기 + 최신 순으로 나열됨 - 각 게임 이름이 두 줄까지 표시됨 - 영역 초과 시 말줄임표(...) 처리됨 **TC-301-04: 게임 카드 클릭** + - **Given**: 라이브러리 페이지에 로그인된 상태로 접근 - **When**: 게임 카드를 클릭 - **Then**: '303. 라이브러리 게임 미리 보기 - 로그인'으로 이동 **TC-301-05: 게임 만들기 버튼 클릭** + - **Given**: 라이브러리 페이지에 로그인된 상태로 접근 - **When**: '게임 만들기' 버튼을 클릭 - **Then**: '400. 게임 만들기'로 이동 @@ -52,35 +60,41 @@ ### 302. 라이브러리 - 비로그인 #### 전제 조건 + - 사용자가 로그인되지 않은 상태 - 라이브러리 페이지에 접근 가능 #### 테스트 케이스 **TC-302-01: 홈 버튼 클릭** + - **Given**: 라이브러리 페이지에 비로그인 상태로 접근 - **When**: '홈' 버튼을 클릭 - **Then**: '101. 홈 화면 - 비로그인'으로 이동 **TC-302-02: 검색바 클릭** + - **Given**: 라이브러리 페이지에 비로그인 상태로 접근 - **When**: 검색바를 클릭 - **Then**: '306. 게임 검색 - 비로그인'으로 이동 **TC-302-03: 게임 카드 목록 표시** + - **Given**: 라이브러리 페이지에 비로그인 상태로 접근 - **When**: 페이지가 로드됨 -- **Then**: +- **Then**: - 게임 카드가 인기 + 최신 순으로 나열됨 - 각 게임 이름이 두 줄까지 표시됨 - 영역 초과 시 말줄임표(...) 처리됨 **TC-302-04: 게임 카드 클릭** + - **Given**: 라이브러리 페이지에 비로그인 상태로 접근 - **When**: 게임 카드를 클릭 - **Then**: '304. 라이브러리 게임 미리 보기 - 비로그인'으로 이동 **TC-302-05: 카톡 로그인 버튼 클릭** + - **Given**: 라이브러리 페이지에 비로그인 상태로 접근 - **When**: '카톡 로그인' 버튼을 클릭 - **Then**: 카톡 로그인 화면으로 이동 @@ -88,35 +102,41 @@ ### 303. 라이브러리 게임 미리 보기 - 로그인 #### 전제 조건 + - 사용자가 로그인된 상태 - 게임 미리 보기 팝업이 열린 상태 #### 테스트 케이스 **TC-303-01: 팝업 닫기 버튼 클릭** + - **Given**: 게임 미리 보기 팝업이 로그인 상태로 열림 - **When**: '팝업 닫기' 버튼을 클릭 - **Then**: '301. 라이브러리 - 로그인'으로 이동 **TC-303-02: 팝업 닫기 영역 클릭** + - **Given**: 게임 미리 보기 팝업이 로그인 상태로 열림 - **When**: '팝업 닫기' 영역을 클릭 - **Then**: '301. 라이브러리 - 로그인'으로 이동 **TC-303-03: 게임 정보 표시** + - **Given**: 게임 미리 보기 팝업이 로그인 상태로 열림 - **When**: 페이지가 로드됨 -- **Then**: +- **Then**: - 게임명이 표시됨 - 게임 제작자명이 표시됨 - 총 문제 개수가 표시됨 **TC-303-04: 이미지 캐러셀 동작** + - **Given**: 게임 미리 보기 팝업이 로그인 상태로 열림 - **When**: 이미지 캐러셀을 좌우로 스와이프 - **Then**: 이미지가 가로로 스크롤됨 **TC-303-05: 게임 시작 버튼 클릭** + - **Given**: 게임 미리 보기 팝업이 로그인 상태로 열림 - **When**: '게임 시작' 버튼을 클릭 - **Then**: '500. 게임 진행 화면'으로 이동 @@ -124,35 +144,41 @@ ### 304. 라이브러리 게임 미리 보기 - 비로그인 #### 전제 조건 + - 사용자가 로그인되지 않은 상태 - 게임 미리 보기 팝업이 열린 상태 #### 테스트 케이스 **TC-304-01: 팝업 닫기 버튼 클릭** + - **Given**: 게임 미리 보기 팝업이 비로그인 상태로 열림 - **When**: '팝업 닫기' 버튼을 클릭 - **Then**: '302. 라이브러리 - 비로그인'으로 이동 **TC-304-02: 팝업 닫기 영역 클릭** + - **Given**: 게임 미리 보기 팝업이 비로그인 상태로 열림 - **When**: '팝업 닫기' 영역을 클릭 - **Then**: '302. 라이브러리 - 비로그인'으로 이동 **TC-304-03: 게임 정보 표시** + - **Given**: 게임 미리 보기 팝업이 비로그인 상태로 열림 - **When**: 페이지가 로드됨 -- **Then**: +- **Then**: - 게임명이 표시됨 - 게임 제작자명이 표시됨 - 총 문제 개수가 표시됨 **TC-304-04: 이미지 캐러셀 동작** + - **Given**: 게임 미리 보기 팝업이 비로그인 상태로 열림 - **When**: 이미지 캐러셀을 좌우로 스와이프 - **Then**: 이미지가 가로로 스크롤됨 **TC-304-05: 게임 시작 버튼 클릭** + - **Given**: 게임 미리 보기 팝업이 비로그인 상태로 열림 - **When**: '게임 시작' 버튼을 클릭 - **Then**: '500. 게임 진행 화면'으로 이동 @@ -160,40 +186,47 @@ ### 305. 게임 검색 - 로그인 #### 전제 조건 + - 사용자가 로그인된 상태 - 게임 검색 페이지에 접근 가능 #### 테스트 케이스 **TC-305-01: 홈 버튼 클릭** + - **Given**: 게임 검색 페이지에 로그인된 상태로 접근 - **When**: '홈' 버튼을 클릭 - **Then**: '102. 홈 화면 - 로그인'으로 이동 **TC-305-02: 게임 만들기 버튼 클릭** + - **Given**: 게임 검색 페이지에 로그인된 상태로 접근 - **When**: '게임 만들기' 버튼을 클릭 - **Then**: '400. 게임 만들기'로 이동 **TC-305-03: 게임 카드 목록 표시** + - **Given**: 게임 검색 페이지에 로그인된 상태로 접근 - **When**: 페이지가 로드됨 -- **Then**: +- **Then**: - 게임 카드가 인기 + 최신 순으로 나열됨 - 각 게임 이름이 두 줄까지 표시됨 - 영역 초과 시 말줄임표(...) 처리됨 **TC-305-04: 게임 카드 클릭** + - **Given**: 게임 검색 페이지에 로그인된 상태로 접근 - **When**: 게임 카드를 클릭 - **Then**: '게임 미리 보기 팝업'이 표시됨 **TC-305-05: 검색어 입력 - 공백 포함** + - **Given**: 게임 검색 페이지에 로그인된 상태로 접근 - **When**: "한국 드라마"를 검색어로 입력 - **Then**: "한국드라마"가 포함된 결과도 함께 검색됨 **TC-305-06: 검색어 입력 - 공백 제거** + - **Given**: 게임 검색 페이지에 로그인된 상태로 접근 - **When**: 검색어 입력 시 공백이 제거됨 - **Then**: 유사 문자열도 함께 검색됨 @@ -201,40 +234,47 @@ ### 306. 게임 검색 - 비로그인 #### 전제 조건 + - 사용자가 로그인되지 않은 상태 - 게임 검색 페이지에 접근 가능 #### 테스트 케이스 **TC-306-01: 홈 버튼 클릭** + - **Given**: 게임 검색 페이지에 비로그인 상태로 접근 - **When**: '홈' 버튼을 클릭 - **Then**: '101. 홈 화면 - 비로그인'으로 이동 **TC-306-02: 카톡 로그인 버튼 클릭** + - **Given**: 게임 검색 페이지에 비로그인 상태로 접근 - **When**: '카톡 로그인' 버튼을 클릭 - **Then**: 카톡 로그인 화면으로 이동 **TC-306-03: 게임 카드 목록 표시** + - **Given**: 게임 검색 페이지에 비로그인 상태로 접근 - **When**: 페이지가 로드됨 -- **Then**: +- **Then**: - 게임 카드가 인기 + 최신 순으로 나열됨 - 각 게임 이름이 두 줄까지 표시됨 - 영역 초과 시 말줄임표(...) 처리됨 **TC-306-04: 게임 카드 클릭** + - **Given**: 게임 검색 페이지에 비로그인 상태로 접근 - **When**: 게임 카드를 클릭 - **Then**: '게임 미리 보기 팝업'이 표시됨 **TC-306-05: 검색어 입력 - 공백 포함** + - **Given**: 게임 검색 페이지에 비로그인 상태로 접근 - **When**: "한국 드라마"를 검색어로 입력 - **Then**: "한국드라마"가 포함된 결과도 함께 검색됨 **TC-306-06: 검색어 입력 - 공백 제거** + - **Given**: 게임 검색 페이지에 비로그인 상태로 접근 - **When**: 검색어 입력 시 공백이 제거됨 - **Then**: 유사 문자열도 함께 검색됨 @@ -242,12 +282,14 @@ ## 테스트 데이터 ### 게임 카드 데이터 + - 게임명: 다양한 길이의 게임명 (1줄, 2줄, 3줄 이상) - 제작자명: 다양한 사용자명 - 문제 개수: 1개 ~ 50개 - 썸네일 이미지: 다양한 크기와 비율 ### 검색 테스트 데이터 + - "한국 드라마" → "한국드라마" 포함 결과 - "영화 추천" → "영화추천" 포함 결과 - "게임 퀴즈" → "게임퀴즈" 포함 결과 @@ -255,10 +297,12 @@ ## 테스트 환경 설정 ### 로그인 상태 시뮬레이션 + - MSW를 통한 인증 상태 모킹 - 로그인/비로그인 상태별 API 응답 설정 ### 게임 데이터 모킹 + - 다양한 게임 카드 데이터 제공 - 이미지 캐러셀용 게임 이미지 데이터 - 검색 결과 데이터 @@ -266,12 +310,14 @@ ## 예상 결과 ### 성공 케이스 + - 모든 네비게이션 동작이 정상적으로 작동 - 게임 카드 목록이 올바른 순서로 표시 - 검색 기능이 정확한 결과를 반환 - 이미지 캐러셀이 부드럽게 동작 ### 실패 케이스 + - 네트워크 오류 시 적절한 에러 처리 - 로딩 상태 표시 - 빈 검색 결과 처리 diff --git a/service/app/src/app/games/components/gamesLibrarySection.tsx b/service/app/src/app/games/components/gamesLibrarySection.tsx index b3e9a502..c817eec7 100644 --- a/service/app/src/app/games/components/gamesLibrarySection.tsx +++ b/service/app/src/app/games/components/gamesLibrarySection.tsx @@ -8,6 +8,7 @@ import { useGamePreview } from "@/entities/game/hooks/useGamePreview" import { useInfiniteGameList } from "@/entities/game/model/useInfiniteGameList" import * as GameCard from "@/entities/game/ui/GameCard/gameCard" import { GameLibrarySkeleton } from "@/entities/game/ui/gameLibrarySkeleton" +import { DEFAULT_BLUR_DATA_URL } from "@/shared/constants/images" import { filterInput } from "../utils/filterInput" import { GameCardActions } from "./gameCardActions" @@ -61,7 +62,7 @@ export function GamesLibrarySection() { fill sizes="178px" placeholder="blur" - blurDataURL="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzI3IiBoZWlnaHQ9IjQ1OSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZTVlN2ViIi8+PC9zdmc+" + blurDataURL={DEFAULT_BLUR_DATA_URL} > {game.questionCount}문제 {game.isShared && ( diff --git a/service/app/src/app/games/components/reportGameDialog.tsx b/service/app/src/app/games/components/reportGameDialog.tsx index 70d16e3a..6bb685c7 100644 --- a/service/app/src/app/games/components/reportGameDialog.tsx +++ b/service/app/src/app/games/components/reportGameDialog.tsx @@ -11,7 +11,7 @@ import { DialogContent, DialogHeader, } from "@ject-5-fe/design/components/dialog" -import { RadioGroup, RadioItem } from "@ject-5-fe/design/components/radio" +import { createRadioGroup } from "@ject-5-fe/design/components/radio" import { Cross } from "@ject-5-fe/design/icons" import { overlay } from "overlay-kit" import { useState } from "react" @@ -30,6 +30,8 @@ export interface ReportGameDialogOptions { type ReportGameDialogStep = "select" | "success" | "error" +const RadioGroup = createRadioGroup() + function ReportGameDialogContent({ gameId, onReported, @@ -75,21 +77,19 @@ function ReportGameDialogContent({
- - setReasonCode(value as ReportReasonCode) - } + onValueChange={(value) => setReasonCode(value)} className="flex w-[259px] flex-col items-start gap-24" > {REPORT_REASON_OPTIONS.map((option) => ( - + {option.label} - + ))} - +
{ + if (authStatus === "unknown") return + if (authStatus !== "authenticated") { + if (unauthorizedMessage !== null) { + alert(unauthorizedMessage) + } + router.replace(redirectTo) + } + }, [authStatus, router, redirectTo, unauthorizedMessage]) + + if (authStatus === "unknown") { + return <>{fallback} + } + + if (authStatus !== "authenticated") { + return null + } + + return <>{children} +} diff --git a/service/app/src/entities/game/model/useGameShareActions.ts b/service/app/src/entities/game/model/useGameShareActions.ts index 583d50c0..b4dff3d2 100644 --- a/service/app/src/entities/game/model/useGameShareActions.ts +++ b/service/app/src/entities/game/model/useGameShareActions.ts @@ -32,7 +32,7 @@ export const useGameShareActions = (): UseGameShareActionsReturn => { throw error } }, - [queryClient] + [queryClient], ) const handleUnshareGame = useCallback( @@ -53,7 +53,7 @@ export const useGameShareActions = (): UseGameShareActionsReturn => { throw error } }, - [queryClient] + [queryClient], ) return { diff --git a/service/app/src/entities/game/ui/gameLibraryGrid.tsx b/service/app/src/entities/game/ui/gameLibraryGrid.tsx index 9ca9cdfa..86e5c961 100644 --- a/service/app/src/entities/game/ui/gameLibraryGrid.tsx +++ b/service/app/src/entities/game/ui/gameLibraryGrid.tsx @@ -9,6 +9,7 @@ import { useIntersectionObserver } from "react-simplikit" import type { GameListItem } from "@/entities/game/model" import * as GameCard from "@/entities/game/ui/GameCard/gameCard" import { GameCreate } from "@/entities/game/ui/gameCreate" +import { DEFAULT_BLUR_DATA_URL } from "@/shared/constants/images" import { useActions } from "../../../app/games/hooks/useGameCardActions" import { GameLibrarySkeleton } from "./gameLibrarySkeleton" @@ -86,7 +87,7 @@ export const GameLibraryGrid = ({ fill sizes="178px" placeholder="blur" - blurDataURL="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzI3IiBoZWlnaHQ9IjQ1OSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZTVlN2ViIi8+PC9zdmc+" + blurDataURL={DEFAULT_BLUR_DATA_URL} > {game.questionCount}문제 {game.isShared && ( diff --git a/service/app/src/entities/game/ui/gamePreview.tsx b/service/app/src/entities/game/ui/gamePreview.tsx index d04d111d..08a85e4a 100644 --- a/service/app/src/entities/game/ui/gamePreview.tsx +++ b/service/app/src/entities/game/ui/gamePreview.tsx @@ -13,6 +13,7 @@ import { import { Cross, Play } from "@ject-5-fe/design/icons" import * as GameCard from "@/entities/game/ui/GameCard/gameCard" +import { DEFAULT_BLUR_DATA_URL } from "@/shared/constants/images" interface GamePreviewProps { className?: string @@ -114,7 +115,7 @@ export const GamePreview = ({ src={question.imageUrl ?? "/thumbnail.svg"} alt={question.title} placeholder="blur" - blurDataURL="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzI3IiBoZWlnaHQ9IjQ1OSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZTVlN2ViIi8+PC9zdmc+" + blurDataURL={DEFAULT_BLUR_DATA_URL} /> {question.title} diff --git a/service/app/src/shared/constants/images.ts b/service/app/src/shared/constants/images.ts new file mode 100644 index 00000000..cd75f061 --- /dev/null +++ b/service/app/src/shared/constants/images.ts @@ -0,0 +1,6 @@ +/** + * Default blur placeholder for images + * Gray (#e5e7eb) SVG encoded as base64 + */ +export const DEFAULT_BLUR_DATA_URL = + "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzI3IiBoZWlnaHQ9IjQ1OSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZTVlN2ViIi8+PC9zdmc+" diff --git a/service/app/src/widgets/components/avatarButton.tsx b/service/app/src/widgets/components/avatarButton.tsx index 46549c50..76c114cc 100644 --- a/service/app/src/widgets/components/avatarButton.tsx +++ b/service/app/src/widgets/components/avatarButton.tsx @@ -7,6 +7,8 @@ import { } from "@ject-5-fe/design/components/menu" import Image from "next/image" +import { DEFAULT_BLUR_DATA_URL } from "@/shared/constants/images" + interface AvatarButtonProps { src?: string onClick: () => void @@ -27,7 +29,7 @@ export default function AvatarButton({ height={42} className="size-full rounded-full" placeholder="blur" - blurDataURL="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzI3IiBoZWlnaHQ9IjQ1OSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZTVlN2ViIi8+PC9zdmc+" + blurDataURL={DEFAULT_BLUR_DATA_URL} /> diff --git a/shared/design/.storybook/preview.ts b/shared/design/.storybook/preview.ts index b0ef056e..b68f5310 100644 --- a/shared/design/.storybook/preview.ts +++ b/shared/design/.storybook/preview.ts @@ -1,6 +1,8 @@ import "../index.css" import type { Preview } from "@storybook/react-vite" +import { OverlayProvider } from "overlay-kit" +import React from "react" const preview: Preview = { parameters: { @@ -18,6 +20,10 @@ const preview: Preview = { test: "todo", }, }, + decorators: [ + (Story) => + React.createElement(OverlayProvider, null, React.createElement(Story)), + ], } export default preview diff --git a/shared/design/package.json b/shared/design/package.json index dd37fb8d..2d7f459b 100644 --- a/shared/design/package.json +++ b/shared/design/package.json @@ -20,6 +20,9 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.3.1" }, + "peerDependencies": { + "overlay-kit": "^1.0.0" + }, "devDependencies": { "@chromatic-com/storybook": "^4", "@ject-5-fe/eslint": "workspace:*", @@ -38,6 +41,7 @@ "@vitest/coverage-v8": "^3.2.4", "autoprefixer": "^10.4.21", "eslint-plugin-storybook": "^9.0.9", + "overlay-kit": "^1.8.6", "playwright": "^1.53.1", "postcss": "^8.5.5", "storybook": "^9.0.9", diff --git a/shared/design/src/components/checkBox/index.tsx b/shared/design/src/components/checkBox/index.tsx index 7c5d3bb4..a991a0d6 100644 --- a/shared/design/src/components/checkBox/index.tsx +++ b/shared/design/src/components/checkBox/index.tsx @@ -1,6 +1,6 @@ "use client" -import { useControllableState } from "@radix-ui/react-use-controllable-state" +import { useControllableState } from "radix-ui/internal" import { type ComponentPropsWithoutRef, forwardRef, useId } from "react" import { Check } from "../../icons" diff --git a/shared/design/src/components/dialog/dialog.css b/shared/design/src/components/dialog/dialog.css index 83480c7c..5d0c32b4 100644 --- a/shared/design/src/components/dialog/dialog.css +++ b/shared/design/src/components/dialog/dialog.css @@ -1,11 +1,20 @@ -[data-slot="dialog-content"]:has([data-slot="dialog-header"]):has([data-slot="dialog-body"]) [data-slot="dialog-spacer"] { +[data-slot="dialog-content"]:has([data-slot="dialog-header"]):has( + [data-slot="dialog-body"] + ) + [data-slot="dialog-spacer"] { height: 28px; /* title variant */ } -[data-slot="dialog-content"]:has([data-slot="dialog-header"]):not(:has([data-slot="dialog-body"])) [data-slot="dialog-spacer"] { +[data-slot="dialog-content"]:has([data-slot="dialog-header"]):not( + :has([data-slot="dialog-body"]) + ) + [data-slot="dialog-spacer"] { height: 28px; /* onlyTitle variant */ } -[data-slot="dialog-content"]:not(:has([data-slot="dialog-header"])):has([data-slot="dialog-body"]) [data-slot="dialog-spacer"] { +[data-slot="dialog-content"]:not(:has([data-slot="dialog-header"])):has( + [data-slot="dialog-body"] + ) + [data-slot="dialog-spacer"] { height: 16px; /* onlyBody variant */ -} \ No newline at end of file +} diff --git a/shared/design/src/components/dialog/useDialog.tsx b/shared/design/src/components/dialog/useDialog.tsx new file mode 100644 index 00000000..73ab27cc --- /dev/null +++ b/shared/design/src/components/dialog/useDialog.tsx @@ -0,0 +1,103 @@ +"use client" + +import { overlay } from "overlay-kit" +import type { ReactNode } from "react" + +import { + Dialog, + DialogBody, + DialogButton, + DialogContent, + DialogFooter, + DialogHeader, +} from "../dialog" + +export interface ButtonConfig { + label: string + onClick?: () => void +} + +export type DialogConfig = { + title?: string + body?: ReactNode + role?: "dialog" | "alertdialog" + secondary?: ButtonConfig +} & ( + | { primary?: ButtonConfig; destructive?: never } + | { primary?: never; destructive?: ButtonConfig } +) + +export interface DialogActions { + open: (config: DialogConfig) => void + openAsync: (config: DialogConfig) => Promise +} + +function renderDialog( + config: DialogConfig, + { + isOpen, + close, + }: { + isOpen: boolean + close: (result: boolean) => void + }, +) { + const { title, body, role, primary, secondary, destructive } = config + + return ( + !open && close(false)}> + + {title && {title}} + {body && {body}} + + {secondary && ( + { + secondary.onClick?.() + close(false) + }} + > + {secondary.label} + + )} + {primary && ( + { + primary.onClick?.() + close(true) + }} + > + {primary.label} + + )} + {destructive && ( + { + destructive.onClick?.() + close(true) + }} + > + {destructive.label} + + )} + + + + ) +} + +export function useDialog(): DialogActions { + return { + open: (config) => { + overlay.open(({ isOpen, close }) => + renderDialog(config, { isOpen, close: () => close() }), + ) + }, + + openAsync: (config) => { + return overlay.openAsync(({ isOpen, close }) => + renderDialog(config, { isOpen, close }), + ) + }, + } +} diff --git a/shared/design/src/components/input/index.tsx b/shared/design/src/components/input/index.tsx index 9fd3e2bc..5a462699 100644 --- a/shared/design/src/components/input/index.tsx +++ b/shared/design/src/components/input/index.tsx @@ -1,8 +1,8 @@ "use client" -import { useControllableState } from "@radix-ui/react-use-controllable-state" import { cva } from "class-variance-authority" import { Form } from "radix-ui" +import { useControllableState } from "radix-ui/internal" import type { ComponentPropsWithoutRef, ReactNode } from "react" import type * as React from "react" import { createContext, forwardRef, useContext } from "react" diff --git a/shared/design/src/components/question/README.md b/shared/design/src/components/question/README.md index 1daf8245..2f624a21 100644 --- a/shared/design/src/components/question/README.md +++ b/shared/design/src/components/question/README.md @@ -56,6 +56,7 @@ export const DestructiveSolidIconButton = forwardRef< ``` **Question 컴포넌트에서 사용:** + ```tsx // Question.DeleteButton에서 사용 ` 요소를 사용하므로 안전 - **근거**: HTML 표준에서 button 요소의 `aria-label`은 공식 지원 속성 ### 4. **순서 기반 접근성의 장점** #### **동적 업데이트 지원** + ```typescript // QuestionList에서 질문 순서 변경 시 {state.questions.map((question, index) => ( - @@ -244,30 +256,32 @@ Question ``` **Before (순서 변경 전):** + - "1번째 문제" (첫 번째 질문) -- "2번째 문제" (두 번째 질문) +- "2번째 문제" (두 번째 질문) - "3번째 문제" (세 번째 질문) **After (두 번째 질문을 첫 번째로 이동):** + - "1번째 문제" (원래 두 번째였던 질문) ✅ 자동 업데이트 - "2번째 문제" (원래 첫 번째였던 질문) ✅ 자동 업데이트 - "3번째 문제" (세 번째 질문 그대로) #### **E2E 테스트에서의 활용** + ```typescript // 순서 기반으로 안정적인 테스트 작성 -test('질문 순서 변경', async ({ page }) => { +test("질문 순서 변경", async ({ page }) => { // 2번째 문제를 위로 이동 - await page.getByRole('button', { name: '2번째 문제 위로 이동' }).click() - + await page.getByRole("button", { name: "2번째 문제 위로 이동" }).click() + // 순서가 바뀐 후 첫 번째 질문 확인 (자동으로 라벨 업데이트됨) - await expect(page.getByRole('group', { name: '1번째 문제' })).toBeVisible() + await expect(page.getByRole("group", { name: "1번째 문제" })).toBeVisible() }) ``` 이러한 순서 기반 접근성 설계를 통해 Question 컴포넌트는 스크린 리더 사용자와 E2E 테스트 모두에서 안정적이고 예측 가능한 동작을 보장합니다. - ## 🔄 Figma 디자인 대비 리팩토링 변경사항 현재 Question 컴포넌트는 원본 Figma 디자인에서 크게 리팩토링되었습니다. 주요 변경사항과 그 이유를 설명합니다. @@ -279,7 +293,7 @@ Figma의 Question 컴포넌트 세트는 다음과 같이 구성되어 있었습 ``` COMPONENT_SET "question" ├── COMPONENT "state=selected, image=true" (350×118px) -├── COMPONENT "state=selected, image=false" (350×118px) +├── COMPONENT "state=selected, image=false" (350×118px) ├── COMPONENT "state=error, image=true" (350×118px) ├── COMPONENT "state=error, image=false" (350×118px) ├── COMPONENT "state=default, image=true" (350×118px) @@ -287,6 +301,7 @@ COMPONENT_SET "question" ``` **Figma 구조의 특징:** + - **고정된 배리언트**: `state` × `image` 조합으로 6개의 고정 배리언트 - **절대 위치 기반**: 각 요소가 절대 위치로 배치 (`x`, `y` 좌표) - **Figma 전용 속성**: `layoutMode="NONE"`, 절대 좌표 기반 레이아웃 @@ -312,6 +327,7 @@ COMPONENT_SET "question" #### 1. **Compound Component Pattern 도입** **변경 전 (Figma):** + ``` - 6개의 고정 배리언트 - state와 image 조합으로만 사용 가능 @@ -319,6 +335,7 @@ COMPONENT_SET "question" ``` **변경 후 (현재):** + ```typescript // 유연한 조합 가능 @@ -330,6 +347,7 @@ COMPONENT_SET "question" ``` **이유:** + - **확장성**: 새로운 요구사항에 맞춰 유연하게 조합 가능 - **재사용성**: 부분적으로 다른 구성이 필요한 경우 대응 가능 - **유지보수성**: 개별 하위 컴포넌트를 독립적으로 수정 가능 @@ -337,6 +355,7 @@ COMPONENT_SET "question" #### 2. **상태 관리 방식 변경** **변경 전 (Figma):** + ``` - state=default|selected|error (고정) - image=true|false (고정) @@ -344,16 +363,18 @@ COMPONENT_SET "question" ``` **변경 후 (현재):** + ```typescript interface QuestionRootProps { state: "default" | "selected" | "error" - index?: number // 순서 기반 접근성 (1부터 시작) + index?: number // 순서 기반 접근성 (1부터 시작) onClick?: () => void // 인터랙션 지원 // image (boolean) 속성 삭제 } ``` **이유:** + - **동적 상태**: 런타임에 상태 변경 가능, `state="error"`로 에러 처리 통합 - **순서 기반 접근성**: `index` prop으로 "n번째 문제" 라벨 자동 생성 - **동적 업데이트**: 질문 순서 변경 시 접근성 라벨도 자동 업데이트 @@ -363,11 +384,12 @@ interface QuestionRootProps { #### 3. **이미지 처리 방식 개선** **변경 후 (현재):** + ```typescript // shared/design 패키지에서는 일반 img 태그 사용 (Next.js 독립적) - 질문 관련 이미지 - 질문 관련 이미지 ``` **변경 후 (현재):** + ```typescript {/* index로 순서 기반 접근성 */} 사용자 정의 제목 @@ -425,8 +450,7 @@ interface QuestionRootProps { ``` **접근성 라벨 결과:** + - Question 카드: `aria-label="1번째 문제"` - 삭제 버튼: `aria-label="1번째 문제 삭제"` - 이동 버튼: `aria-label="1번째 문제 위로 이동"` - - diff --git a/shared/design/src/components/radio/RadioGroup.tsx b/shared/design/src/components/radio/RadioGroup.tsx index 1e04468d..c7fbb5b9 100644 --- a/shared/design/src/components/radio/RadioGroup.tsx +++ b/shared/design/src/components/radio/RadioGroup.tsx @@ -1,13 +1,19 @@ "use client" -import * as RadixRadioGroup from "@radix-ui/react-radio-group" +import { RadioGroup as RadixRadioGroup } from "radix-ui" import type { ComponentPropsWithoutRef } from "react" import { cn } from "../../utils/cn" -export type RadioGroupProps = ComponentPropsWithoutRef< - typeof RadixRadioGroup.Root -> +//기존의 value의 타입을 string -> T로 확장 +export type RadioGroupProps = Omit< + ComponentPropsWithoutRef, + "value" | "defaultValue" | "onValueChange" +> & { + defaultValue?: T + value?: T | null + onValueChange?: (value: T) => void +} export function RadioGroup({ className, ...props }: RadioGroupProps) { return ( diff --git a/shared/design/src/components/radio/RadioItem.tsx b/shared/design/src/components/radio/RadioItem.tsx index 792271e1..6cd1295b 100644 --- a/shared/design/src/components/radio/RadioItem.tsx +++ b/shared/design/src/components/radio/RadioItem.tsx @@ -1,15 +1,17 @@ "use client" -import * as RadixRadioGroup from "@radix-ui/react-radio-group" +import { RadioGroup as RadixRadioGroup } from "radix-ui" import { type ComponentPropsWithoutRef, forwardRef } from "react" import { cn } from "../../utils/cn" -export type RadioItemProps = Omit< +//기존의 value 관련 타입을 string -> T로 확장 +export type RadioItemProps = Omit< ComponentPropsWithoutRef, - "children" + "value" | "children" > & { children?: React.ReactNode + value: T } export const RadioItem = forwardRef( diff --git a/shared/design/src/components/radio/createRadioGroup.tsx b/shared/design/src/components/radio/createRadioGroup.tsx new file mode 100644 index 00000000..6cb78217 --- /dev/null +++ b/shared/design/src/components/radio/createRadioGroup.tsx @@ -0,0 +1,24 @@ +import { RadioGroup, type RadioGroupProps } from "./RadioGroup" +import { RadioItem, type RadioItemProps } from "./RadioItem" + +/** + * @remarks + * 반공변성으로 인해 props 타입을 T로 좁히기 위한 타입 단언 사용 + * + * @example + * ```tsx + * const ReportRadio = createRadioGroup<"SPAM" | "ABUSE">() + * + * { + * // v: "SPAM" | "ABUSE" (타입 캐스팅 불필요) + * }}> + * 스팸 + * 욕설 + * + * ``` + */ +export const createRadioGroup = () => + ({ Root: RadioGroup, Item: RadioItem }) as { + Root: (props: RadioGroupProps) => ReturnType + Item: (props: RadioItemProps) => ReturnType + } diff --git a/shared/design/src/components/radio/index.tsx b/shared/design/src/components/radio/index.tsx index ddca3fe7..90d6ecf3 100644 --- a/shared/design/src/components/radio/index.tsx +++ b/shared/design/src/components/radio/index.tsx @@ -1,2 +1,3 @@ +export { createRadioGroup } from "./createRadioGroup" export * from "./RadioGroup" export * from "./RadioItem" diff --git a/shared/design/src/components/radioField/index.tsx b/shared/design/src/components/radioField/index.tsx index 95acdc5d..3a60f438 100644 --- a/shared/design/src/components/radioField/index.tsx +++ b/shared/design/src/components/radioField/index.tsx @@ -1,6 +1,6 @@ "use client" -import { useControllableState } from "@radix-ui/react-use-controllable-state" +import { useControllableState } from "radix-ui/internal" import { type ComponentPropsWithoutRef, forwardRef, useId } from "react" import { cn } from "../../utils/cn" diff --git a/shared/design/src/scripts/applyIconsFromExport.js b/shared/design/src/scripts/applyIconsFromExport.js index 3dcd79d2..0aa6c8fb 100644 --- a/shared/design/src/scripts/applyIconsFromExport.js +++ b/shared/design/src/scripts/applyIconsFromExport.js @@ -2,7 +2,6 @@ import fs from "fs" import path from "path" function normalizeIconKey(figmaName) { - const placeholderMatch = /^iconPlaceholder\/(\d+px)$/i.exec(figmaName.trim()) if (placeholderMatch) return `iconplaceholder_${placeholderMatch[1]}` @@ -13,7 +12,8 @@ function normalizeIconKey(figmaName) { .trim() const parts = cleaned.split(" ").filter(Boolean) - if (parts.length === 0) throw new Error(`Cannot normalize empty name: "${figmaName}"`) + if (parts.length === 0) + throw new Error(`Cannot normalize empty name: "${figmaName}"`) const [first, ...rest] = parts return ( @@ -73,10 +73,14 @@ async function main() { fs.mkdirSync(path.dirname(absOut), { recursive: true }) fs.writeFileSync(absOut, JSON.stringify(iconJson, null, 2) + "\n", "utf-8") - process.stdout.write(`[design] wrote ${Object.keys(iconJson).length} icons -> ${absOut}\n`) + process.stdout.write( + `[design] wrote ${Object.keys(iconJson).length} icons -> ${absOut}\n`, + ) } main().catch((e) => { - process.stderr.write(`[design] apply-icons failed: ${e instanceof Error ? e.message : String(e)}\n`) + process.stderr.write( + `[design] apply-icons failed: ${e instanceof Error ? e.message : String(e)}\n`, + ) process.exit(1) -}) \ No newline at end of file +}) diff --git a/shared/design/src/stories/dialog.stories.tsx b/shared/design/src/stories/dialog.stories.tsx index ff060c61..b3f577aa 100644 --- a/shared/design/src/stories/dialog.stories.tsx +++ b/shared/design/src/stories/dialog.stories.tsx @@ -120,6 +120,64 @@ import { * - 제목만 필요한 경우: `DialogHeader`만 사용 * - 본문만 필요한 경우: `DialogBody`만 사용 * - 제목과 본문 모두 필요한 경우: `DialogHeader`와 `DialogBody` 모두 사용 + * + * --- + * + * ## useDialog Hook + * + * 반복되는 다이얼로그 보일러플레이트를 줄이기 위한 추상화 훅입니다. + * + * ### Before (30~50줄) + * ```tsx + * overlay.open(({ isOpen, close }) => ( + * + * + * 제목 + * 내용 + * + * close()}>취소 + * { onConfirm(); close() }}>확인 + * + * + * + * )) + * ``` + * + * ### After (5줄) + * ```tsx + * const dialog = useDialog() + * dialog.open({ + * title: "제목", + * body: "내용", + * primary: { label: "확인", onClick: onConfirm }, + * secondary: { label: "취소" }, + * }) + * ``` + * + * ### API + * + * | 메서드 | 설명 | + * |--------|------| + * | `dialog.open(config)` | 콜백 방식. 버튼의 `onClick`으로 액션 처리 | + * | `dialog.openAsync(config)` | Promise 반환. `primary` 또는 `destructive` 클릭 시 `true`, `secondary` 또는 배경 클릭 시 `false` | + * + * ### DialogConfig + * + * | 속성 | 타입 | 설명 | + * |------|------|------| + * | `title` | `string?` | 다이얼로그 제목 | + * | `body` | `ReactNode?` | 다이얼로그 본문 | + * | `role` | `"dialog" \| "alertdialog"?` | ARIA role | + * | `primary` | `ButtonConfig?` | Primary 버튼 (파란색) | + * | `secondary` | `ButtonConfig?` | Secondary 버튼 (회색) | + * | `destructive` | `ButtonConfig?` | Destructive 버튼 (빨간색) | + * + * **Note:** `primary`와 `destructive`는 동시에 사용할 수 없습니다. + * + * ### 제한 사항 + * + * 이 훅은 표준적인 다이얼로그 패턴(제목, 본문, 버튼)만 지원합니다. + * 커스텀 레이아웃이나 복잡한 UI가 필요한 경우, 기존 Dialog 프리미티브를 직접 조합하세요. */ const meta = { @@ -271,3 +329,82 @@ DialogHeader가 없는 경우, 스크린 리더 사용자를 위해 \`srTitle\` }, }, } + +import { useDialog } from "../components/dialog/useDialog" + +const UseDialogOpenExample = () => { + const dialog = useDialog() + + const handleClick = () => { + dialog.open({ + title: "게임을 삭제하시겠습니까?", + body: "삭제된 게임은 복구할 수 없습니다.", + destructive: { + label: "삭제", + onClick: () => console.log("삭제됨"), + }, + secondary: { label: "취소" }, + }) + } + + return ( + + ) +} + +export const UseDialogOpen: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + "`dialog.open()` - 콜백 방식. 버튼의 `onClick`에서 액션을 처리합니다.", + }, + }, + }, +} + +const UseDialogOpenAsyncExample = () => { + const dialog = useDialog() + + const handleClick = async () => { + const confirmed = await dialog.openAsync({ + title: "이 게임을 복제하시겠습니까?", + body: "선택한 게임이 복제되어, 곧바로 편집 화면으로 이동합니다.", + primary: { label: "네" }, + secondary: { label: "아니요" }, + }) + + if (confirmed) { + console.log("복제 진행") + } else { + console.log("취소됨") + } + } + + return ( + + ) +} + +export const UseDialogOpenAsync: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + "`dialog.openAsync()` - Promise 반환. `primary`/`destructive` 클릭 시 `true`, `secondary`/배경 클릭 시 `false`.", + }, + }, + }, +} diff --git a/shared/eslint/next.js b/shared/eslint/next.js index a531d224..3ce05d14 100644 --- a/shared/eslint/next.js +++ b/shared/eslint/next.js @@ -7,6 +7,14 @@ export default defineConfig([ nextPlugin.flatConfig.recommended, nextPlugin.flatConfig.coreWebVitals, { - ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "dist/**", "playwright-report/**", "test-results/**"], + ignores: [ + "node_modules/**", + ".next/**", + "out/**", + "build/**", + "dist/**", + "playwright-report/**", + "test-results/**", + ], }, ]) diff --git a/shared/figma-plugin/src/cli/exportIcons.ts b/shared/figma-plugin/src/cli/exportIcons.ts index cc158bd1..b0de2038 100644 --- a/shared/figma-plugin/src/cli/exportIcons.ts +++ b/shared/figma-plugin/src/cli/exportIcons.ts @@ -18,7 +18,11 @@ function randomId() { return `${Date.now()}-${Math.random().toString(16).slice(2)}` } -async function callSocketServer(port: number, command: string, params: unknown) { +async function callSocketServer( + port: number, + command: string, + params: unknown, +) { const id = randomId() const ws = new WebSocket(`ws://localhost:${port}`) @@ -73,10 +77,11 @@ async function main() { { pageName: "컴포넌트", frameName: "아이콘", ignoreFrameChildren: true }, ) - fs.mkdirSync(path.dirname(out), { recursive: true }) fs.writeFileSync(out, JSON.stringify(result.icons, null, 2) + "\n", "utf-8") - process.stdout.write(`[figma-plugin] exported ${result.icons.length} icons -> ${out}\n`) + process.stdout.write( + `[figma-plugin] exported ${result.icons.length} icons -> ${out}\n`, + ) } main().catch((e) => { @@ -84,4 +89,4 @@ main().catch((e) => { `[figma-plugin] export-icons failed: ${e instanceof Error ? e.message : String(e)}\n`, ) process.exit(1) -}) \ No newline at end of file +}) diff --git a/shared/figma-plugin/src/utils/sanitize.ts b/shared/figma-plugin/src/utils/sanitize.ts index 47b491c8..d1de64ae 100644 --- a/shared/figma-plugin/src/utils/sanitize.ts +++ b/shared/figma-plugin/src/utils/sanitize.ts @@ -1,34 +1,33 @@ export function sanitizeForPostMessage(value: unknown): unknown { - const isMixed = (v: unknown) => { - try { - - return typeof figma !== "undefined" && v === figma.mixed - } catch { - return false - } + const isMixed = (v: unknown) => { + try { + return typeof figma !== "undefined" && v === figma.mixed + } catch { + return false } - - if (isMixed(value)) return "[mixed]" - if (typeof value === "symbol") return value.toString() - if (typeof value === "bigint") return value.toString() - - if (value === null) return null - if (value === undefined) return undefined - - if (Array.isArray(value)) { - return value.map((v) => sanitizeForPostMessage(v)) + } + + if (isMixed(value)) return "[mixed]" + if (typeof value === "symbol") return value.toString() + if (typeof value === "bigint") return value.toString() + + if (value === null) return null + if (value === undefined) return undefined + + if (Array.isArray(value)) { + return value.map((v) => sanitizeForPostMessage(v)) + } + + if (typeof value === "object") { + if (value instanceof Date) return value.toISOString() + if (value instanceof RegExp) return value.toString() + + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + out[k] = sanitizeForPostMessage(v) } - - if (typeof value === "object") { - if (value instanceof Date) return value.toISOString() - if (value instanceof RegExp) return value.toString() - - const out: Record = {} - for (const [k, v] of Object.entries(value as Record)) { - out[k] = sanitizeForPostMessage(v) - } - return out - } - - return value - } \ No newline at end of file + return out + } + + return value +} diff --git a/yarn.lock b/yarn.lock index 5ae3efd2..0c108e61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1491,6 +1491,7 @@ __metadata: class-variance-authority: "npm:^0.7.1" clsx: "npm:^2.1.1" eslint-plugin-storybook: "npm:^9.0.9" + overlay-kit: "npm:^1.8.6" playwright: "npm:^1.53.1" postcss: "npm:^8.5.5" radix-ui: "npm:^1.4.2" @@ -1503,6 +1504,8 @@ __metadata: typescript: "npm:^5.8.3" vite: "npm:^6" vitest: "npm:^3.2.4" + peerDependencies: + overlay-kit: ^1.0.0 languageName: unknown linkType: soft @@ -9951,6 +9954,16 @@ __metadata: languageName: node linkType: hard +"overlay-kit@npm:^1.8.6": + version: 1.8.6 + resolution: "overlay-kit@npm:1.8.6" + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 + react-dom: ^16.8 || ^17 || ^18 || ^19 + checksum: 10c0/025d61133475500ccd7f07d07e346f65880feec7898ab2ad535a4f290c885dfb68d749518873ac5dd85b38b4caa4ef2b6464e249aef03707a26e0f1768fb2ef9 + languageName: node + linkType: hard + "own-keys@npm:^1.0.1": version: 1.0.1 resolution: "own-keys@npm:1.0.1"