피드백 등록 및 리스트업.
This commit is contained in:
60
TODO.md
60
TODO.md
@@ -1,45 +1,27 @@
|
||||
# Q&A 뷰어 프로젝트: 할 일 목록
|
||||
# Q&A 뷰어 프로젝트 TODO 리스트
|
||||
|
||||
## 🚀 1단계: 프로젝트 초기 설정
|
||||
## Phase 1: 기반 구축 및 핵심 기능 구현
|
||||
|
||||
- [x] Vite + React + TypeScript 프로젝트 생성 (완료 시간: 2025-07-29T12:00:00)
|
||||
- [x] pnpm 워크스페이스 설정 (완료 시간: 2025-07-29T12:01:00)
|
||||
- [x] Biome 포맷터/린터 설정 (완료 시간: 2025-07-29T12:02:00)
|
||||
- [x] Tailwind CSS 설정 (완료 시간: 2025-07-29T12:03:00)
|
||||
- [x] shadcn/ui 초기화 및 기본 컴포넌트 추가 (완료 시간: 2025-07-29T12:04:00)
|
||||
- [x] Zustand 상태 관리 라이브러리 설치 (완료 시간: 2025-07-29T12:05:00)
|
||||
- [x] pnpm 워크스페이스를 단일 프로젝트 구조로 변경 (viewer를 루트로 설정) (완료 시간: 2025-07-29T12:06:00)
|
||||
- [x] `GEMINI.md`에 프로젝트 개요 및 개발 계획 정리 (완료: 2025-07-30 17:53:48)
|
||||
- [x] `DynamicTable` 컴포넌트 구현 및 API 연동 (완료: 2025-07-30 17:53:48)
|
||||
- [x] `DynamicForm` 컴포넌트 구현 및 API 연동 (완료: 2025-07-30 17:53:48)
|
||||
- [x] 피드백 목록 페이지 (`FeedbackListPage`) 구현 (완료: 2025-07-30 17:53:48)
|
||||
- [x] 피드백 생성 페이지 (`FeedbackCreatePage`) 구현 (완료: 2025-07-30 17:53:48)
|
||||
- [x] 피드백 상세/수정 페이지 (`FeedbackDetailPage`) 구현 (완료: 2025-07-30 17:53:48)
|
||||
- [x] React Router를 이용한 전체 페이지 라우팅 설정 (완료: 2025-07-30 17:53:48)
|
||||
- [x] 테이블 UI/UX 개선 (행 클릭, 특정 필드 서식 지정 등) (완료: 2025-07-30 17:53:48)
|
||||
- [x] 폼 UI/UX 개선 (필드 숨김, 읽기 전용 처리 등) (완료: 2025-07-30 17:53:48)
|
||||
|
||||
## 🏗️ 2단계: 핵심 기능 개발
|
||||
## Phase 2: 기능 고도화 및 안정화
|
||||
|
||||
- [x] Vite 프록시를 이용한 동적 API 서비스 모듈 구현 (완료 시간: 2025-07-29T12:08:00)
|
||||
- [x] React Router를 이용한 동적 라우팅 설정 (완료 시간: 2025-07-29T12:09:00)
|
||||
- [x] 피드백 페이지 기능 구현 (완료 시간: 2025-07-30T00:15:00)
|
||||
- `feedback.ts` 서비<EC849C><EBB984><EFBFBD>: 피드백 생성/조회, 이슈 검색 API 연동
|
||||
- `FeedbackPage.tsx`: 피드백 생성 폼 및 목록 UI 구현
|
||||
- [ ] 스키마 기반 동적 테이블 컴포넌트 개발
|
||||
- [ ] 스키마 기반 동적 폼 컴포넌트 개발
|
||||
- [ ] 데이터 목록 조회 페이지 구현
|
||||
- [ ] 데이터 상세 조회/수정 페이지 구현
|
||||
- [ ] 동적 테이블에 페이지네이션, 정렬, 필터링 기능 추가
|
||||
- [ ] 동적 폼에 데이터 유효성 검사 기능 추가
|
||||
- [ ] 전역 상태 관리를 위한 Zustand 도입 검토
|
||||
- [ ] Biome을 이용한 코드 포맷팅 및 린트 규칙 적용 및 검사
|
||||
|
||||
## 🔐 3단계: 인증 기능 개발
|
||||
## Phase 3: 인증 및 배포
|
||||
|
||||
- [ ] OIDC 클라이언트 라이브러리 설치 및 설정
|
||||
- [ ] 로그인/로그아웃 기능 구현
|
||||
- [ ] 인증 상태에 따른 라우팅 처리 (Public/Private Routes)
|
||||
|
||||
## ✨ 4단계: 리팩토링 및 환경 개선
|
||||
|
||||
- [x] 빌드 및 스타일링 환경 안정화 (완료 시간: 2025-07-30T00:10:00)
|
||||
- Tailwind CSS v4 -> v3 다운그레이드
|
||||
- PostCSS 설정을 `vite.config.ts`에 통합하여 빌드 오류 해결
|
||||
- `shadcn/ui` 경로 별칭 문제 및 테마 설정 복구
|
||||
- [x] 설정 파일 TypeScript 마이그레이션 (완료 시간: 2025-07-30T00:05:00)
|
||||
- `tailwind.config.ts`, `postcss.config.ts`로 전환
|
||||
- [x] 피드백 페이지 UI 개선 (완료 시간: 2025-07-30T00:15:00)
|
||||
- `Card`, `Separator` 컴포넌트를 활용한 레이아웃 재구성
|
||||
- [x] 개발 환경 개선 (완료 시간: 2025-07-30T00:20:00)
|
||||
- `.env.example` 파일 추가
|
||||
- [ ] 전역 레이아웃 및 네비게이션 UI 개선
|
||||
- [ ] 예외 처리 및 로딩/에러 상태 UI 구현
|
||||
- [ ] 최종 코드 리뷰 및 리팩토링
|
||||
- [ ] OIDC 클라이언트 연동 및 인증 로직 구현
|
||||
- [ ] 로그인/로그아웃 및 인증 상태 관리
|
||||
- [ ] 인증이 필요한 라우트 보호 기능 적용
|
||||
- [ ] Docker를 이용한 배포 환경 구축
|
||||
10
viewer/.env
Normal file
10
viewer/.env
Normal file
@@ -0,0 +1,10 @@
|
||||
VITE_API_PROXY_TARGET=http://172.16.10.175:3030/_back
|
||||
|
||||
# API 요청 시 필요한 인증 키
|
||||
VITE_API_KEY=F5FE0363E37C012204F5
|
||||
|
||||
# 기본으로 사용할 프로젝트 ID
|
||||
VITE_DEFAULT_PROJECT_ID=1
|
||||
|
||||
# 기본으로 사용할 채널 ID
|
||||
VITE_DEFAULT_CHANNEL_ID=4
|
||||
@@ -1,11 +1,8 @@
|
||||
# API 서버의 기본 URL
|
||||
VITE_API_BASE_URL=API_BASE_URL
|
||||
# Vite 환경 변수 예시
|
||||
# 이 파일을 복사하여 .env 파일을 만들고, 실제 환경에 맞는 값으로 수정하여 사용하세요.
|
||||
|
||||
# API 요청 시 필요한 인증 키
|
||||
VITE_API_KEY=
|
||||
# API 프록시 서버 주소
|
||||
VITE_API_PROXY_TARGET=http://localhost:3030
|
||||
|
||||
# 기본으로 사용할 프로젝트 ID
|
||||
VITE_DEFAULT_PROJECT_ID=1
|
||||
|
||||
# 기본으로 사용할 채널 ID
|
||||
VITE_DEFAULT_CHANNEL_ID=4
|
||||
# API 키
|
||||
VITE_API_KEY=your_api_key_here
|
||||
@@ -7,11 +7,14 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"format": "biome format --write .",
|
||||
"lint": "biome lint --apply .",
|
||||
"lint": "biome lint --write .",
|
||||
"preview": "vite preview",
|
||||
"shadcn": "shadcn-ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -25,21 +28,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.1.2",
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
741
viewer/pnpm-lock.yaml
generated
741
viewer/pnpm-lock.yaml
generated
@@ -8,6 +8,15 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@radix-ui/react-icons':
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.2(react@19.1.1)
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.7
|
||||
version: 2.1.7(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.2.5
|
||||
version: 2.2.5(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-separator':
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.7(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
@@ -42,9 +51,6 @@ importers:
|
||||
'@biomejs/biome':
|
||||
specifier: ^2.1.2
|
||||
version: 2.1.2
|
||||
'@eslint/js':
|
||||
specifier: ^9.30.1
|
||||
version: 9.32.0
|
||||
'@types/react':
|
||||
specifier: ^19.1.8
|
||||
version: 19.1.9
|
||||
@@ -57,18 +63,6 @@ importers:
|
||||
autoprefixer:
|
||||
specifier: ^10.4.21
|
||||
version: 10.4.21(postcss@8.5.6)
|
||||
eslint:
|
||||
specifier: ^9.30.1
|
||||
version: 9.32.0(jiti@2.5.1)
|
||||
eslint-plugin-react-hooks:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(eslint@9.32.0(jiti@2.5.1))
|
||||
eslint-plugin-react-refresh:
|
||||
specifier: ^0.4.20
|
||||
version: 0.4.20(eslint@9.32.0(jiti@2.5.1))
|
||||
globals:
|
||||
specifier: ^16.3.0
|
||||
version: 16.3.0
|
||||
postcss:
|
||||
specifier: ^8.5.6
|
||||
version: 8.5.6
|
||||
@@ -84,9 +78,6 @@ importers:
|
||||
typescript:
|
||||
specifier: ~5.8.3
|
||||
version: 5.8.3
|
||||
typescript-eslint:
|
||||
specifier: ^8.35.1
|
||||
version: 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)
|
||||
vite:
|
||||
specifier: ^7.0.4
|
||||
version: 7.0.6(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0)
|
||||
@@ -507,6 +498,21 @@ packages:
|
||||
resolution: {integrity: sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@floating-ui/core@1.7.2':
|
||||
resolution: {integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==}
|
||||
|
||||
'@floating-ui/dom@1.7.2':
|
||||
resolution: {integrity: sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==}
|
||||
|
||||
'@floating-ui/react-dom@2.1.4':
|
||||
resolution: {integrity: sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -560,6 +566,38 @@ packages:
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@radix-ui/number@1.1.1':
|
||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||
|
||||
'@radix-ui/primitive@1.1.2':
|
||||
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7':
|
||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collection@1.1.7':
|
||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2':
|
||||
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
||||
peerDependencies:
|
||||
@@ -569,6 +607,112 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-context@1.1.2':
|
||||
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-direction@1.1.1':
|
||||
resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.10':
|
||||
resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.2':
|
||||
resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-focus-scope@1.1.7':
|
||||
resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-icons@1.3.2':
|
||||
resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==}
|
||||
peerDependencies:
|
||||
react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
'@radix-ui/react-id@1.1.1':
|
||||
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-label@2.1.7':
|
||||
resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popper@1.2.7':
|
||||
resolution: {integrity: sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-portal@1.1.9':
|
||||
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-primitive@2.1.3':
|
||||
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
||||
peerDependencies:
|
||||
@@ -582,6 +726,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-select@2.2.5':
|
||||
resolution: {integrity: sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-separator@1.1.7':
|
||||
resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==}
|
||||
peerDependencies:
|
||||
@@ -604,6 +761,94 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-controllable-state@1.2.2':
|
||||
resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.2':
|
||||
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-escape-keydown@1.1.1':
|
||||
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1':
|
||||
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-previous@1.1.1':
|
||||
resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-rect@1.1.1':
|
||||
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-size@1.1.1':
|
||||
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.3':
|
||||
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||
|
||||
@@ -840,6 +1085,10 @@ packages:
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
autoprefixer@10.4.21:
|
||||
resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -944,6 +1193,9 @@ packages:
|
||||
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
detect-node-es@1.1.0:
|
||||
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
||||
|
||||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
@@ -1090,6 +1342,10 @@ packages:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
get-nonce@1.0.1:
|
||||
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -1463,6 +1719,26 @@ packages:
|
||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
react-remove-scroll-bar@2.3.8:
|
||||
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-remove-scroll@2.7.1:
|
||||
resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-router-dom@7.7.1:
|
||||
resolution: {integrity: sha512-bavdk2BA5r3MYalGKZ01u8PGuDBloQmzpBZVhDLrOOv1N943Wq6dcM9GhB3x8b7AbqPMEezauv4PeGkAJfy7FQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -1480,6 +1756,16 @@ packages:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
react-style-singleton@2.2.3:
|
||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react@19.1.1:
|
||||
resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1616,6 +1902,9 @@ packages:
|
||||
ts-interface-checker@0.1.13:
|
||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tw-animate-css@1.3.6:
|
||||
resolution: {integrity: sha512-9dy0R9UsYEGmgf26L8UcHiLmSFTHa9+D7+dAt/G/sF5dCnPePZbfgDYinc7/UzAM7g/baVrmS6m9yEpU46d+LA==}
|
||||
|
||||
@@ -1644,6 +1933,26 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
use-callback-ref@1.3.3:
|
||||
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
use-sidecar@1.1.3:
|
||||
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
@@ -1968,11 +2277,6 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.25.8':
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.7.0(eslint@9.32.0(jiti@2.5.1))':
|
||||
dependencies:
|
||||
eslint: 9.32.0(jiti@2.5.1)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/eslint-utils@4.7.0(eslint@9.32.0)':
|
||||
dependencies:
|
||||
eslint: 9.32.0
|
||||
@@ -2017,6 +2321,23 @@ snapshots:
|
||||
'@eslint/core': 0.15.1
|
||||
levn: 0.4.1
|
||||
|
||||
'@floating-ui/core@1.7.2':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/dom@1.7.2':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.2
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/react-dom@2.1.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.2
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
'@humanfs/node@0.16.6':
|
||||
@@ -2068,12 +2389,127 @@ snapshots:
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.2': {}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
'@types/react-dom': 19.1.7(@types/react@19.1.9)
|
||||
|
||||
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.9)(react@19.1.1)
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
'@types/react-dom': 19.1.7(@types/react@19.1.9)
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.9)(react@19.1.1)':
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
'@radix-ui/react-context@1.1.2(@types/react@19.1.9)(react@19.1.1)':
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
'@radix-ui/react-direction@1.1.1(@types/react@19.1.9)(react@19.1.1)':
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.2
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
'@types/react-dom': 19.1.7(@types/react@19.1.9)
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.2(@types/react@19.1.9)(react@19.1.1)':
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
'@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
'@types/react-dom': 19.1.7(@types/react@19.1.9)
|
||||
|
||||
'@radix-ui/react-icons@1.3.2(react@19.1.1)':
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
|
||||
'@radix-ui/react-id@1.1.1(@types/react@19.1.9)(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
react: 19.1.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
'@radix-ui/react-label@2.1.7(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
'@types/react-dom': 19.1.7(@types/react@19.1.9)
|
||||
|
||||
'@radix-ui/react-popper@1.2.7(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/rect': 1.1.1
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
'@types/react-dom': 19.1.7(@types/react@19.1.9)
|
||||
|
||||
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
'@types/react-dom': 19.1.7(@types/react@19.1.9)
|
||||
|
||||
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.9)(react@19.1.1)
|
||||
@@ -2083,6 +2519,35 @@ snapshots:
|
||||
'@types/react': 19.1.9
|
||||
'@types/react-dom': 19.1.7(@types/react@19.1.9)
|
||||
|
||||
'@radix-ui/react-select@2.2.5(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
'@radix-ui/primitive': 1.1.2
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
react-remove-scroll: 2.7.1(@types/react@19.1.9)(react@19.1.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
'@types/react-dom': 19.1.7(@types/react@19.1.9)
|
||||
|
||||
'@radix-ui/react-separator@1.1.7(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
@@ -2099,6 +2564,71 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.9)(react@19.1.1)':
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.9)(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.9)(react@19.1.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
react: 19.1.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.9)(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
react: 19.1.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
'@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.1.9)(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
react: 19.1.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.9)(react@19.1.1)':
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
'@radix-ui/react-use-previous@1.1.1(@types/react@19.1.9)(react@19.1.1)':
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
'@radix-ui/react-use-rect@1.1.1(@types/react@19.1.9)(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/rect': 1.1.1
|
||||
react: 19.1.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
'@radix-ui/react-use-size@1.1.1(@types/react@19.1.9)(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.9)(react@19.1.1)
|
||||
react: 19.1.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
'@types/react-dom': 19.1.7(@types/react@19.1.9)
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.46.1':
|
||||
@@ -2194,23 +2724,6 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.1.3
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.1
|
||||
'@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)
|
||||
'@typescript-eslint/scope-manager': 8.38.0
|
||||
'@typescript-eslint/type-utils': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)
|
||||
'@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)
|
||||
'@typescript-eslint/visitor-keys': 8.38.0
|
||||
eslint: 9.32.0(jiti@2.5.1)
|
||||
graphemer: 1.4.0
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.1.0(typescript@5.8.3)
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.1
|
||||
@@ -2228,18 +2741,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.38.0
|
||||
'@typescript-eslint/types': 8.38.0
|
||||
'@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3)
|
||||
'@typescript-eslint/visitor-keys': 8.38.0
|
||||
debug: 4.4.1
|
||||
eslint: 9.32.0(jiti@2.5.1)
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.38.0
|
||||
@@ -2270,18 +2771,6 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.38.0
|
||||
'@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3)
|
||||
'@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)
|
||||
debug: 4.4.1
|
||||
eslint: 9.32.0(jiti@2.5.1)
|
||||
ts-api-utils: 2.1.0(typescript@5.8.3)
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/type-utils@8.38.0(eslint@9.32.0)(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.38.0
|
||||
@@ -2312,17 +2801,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0(jiti@2.5.1))
|
||||
'@typescript-eslint/scope-manager': 8.38.0
|
||||
'@typescript-eslint/types': 8.38.0
|
||||
'@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3)
|
||||
eslint: 9.32.0(jiti@2.5.1)
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.38.0(eslint@9.32.0)(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0)
|
||||
@@ -2397,6 +2875,10 @@ snapshots:
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
autoprefixer@10.4.21(postcss@8.5.6):
|
||||
dependencies:
|
||||
browserslist: 4.25.1
|
||||
@@ -2493,6 +2975,8 @@ snapshots:
|
||||
detect-libc@2.0.4:
|
||||
optional: true
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
dlv@1.1.3: {}
|
||||
@@ -2538,18 +3022,10 @@ snapshots:
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-plugin-react-hooks@5.2.0(eslint@9.32.0(jiti@2.5.1)):
|
||||
dependencies:
|
||||
eslint: 9.32.0(jiti@2.5.1)
|
||||
|
||||
eslint-plugin-react-hooks@5.2.0(eslint@9.32.0):
|
||||
dependencies:
|
||||
eslint: 9.32.0
|
||||
|
||||
eslint-plugin-react-refresh@0.4.20(eslint@9.32.0(jiti@2.5.1)):
|
||||
dependencies:
|
||||
eslint: 9.32.0(jiti@2.5.1)
|
||||
|
||||
eslint-plugin-react-refresh@0.4.20(eslint@9.32.0):
|
||||
dependencies:
|
||||
eslint: 9.32.0
|
||||
@@ -2603,48 +3079,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint@9.32.0(jiti@2.5.1):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0(jiti@2.5.1))
|
||||
'@eslint-community/regexpp': 4.12.1
|
||||
'@eslint/config-array': 0.21.0
|
||||
'@eslint/config-helpers': 0.3.0
|
||||
'@eslint/core': 0.15.1
|
||||
'@eslint/eslintrc': 3.3.1
|
||||
'@eslint/js': 9.32.0
|
||||
'@eslint/plugin-kit': 0.3.4
|
||||
'@humanfs/node': 0.16.6
|
||||
'@humanwhocodes/module-importer': 1.0.1
|
||||
'@humanwhocodes/retry': 0.4.3
|
||||
'@types/estree': 1.0.8
|
||||
'@types/json-schema': 7.0.15
|
||||
ajv: 6.12.6
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.1
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
espree: 10.4.0
|
||||
esquery: 1.6.0
|
||||
esutils: 2.0.3
|
||||
fast-deep-equal: 3.1.3
|
||||
file-entry-cache: 8.0.0
|
||||
find-up: 5.0.0
|
||||
glob-parent: 6.0.2
|
||||
ignore: 5.3.2
|
||||
imurmurhash: 0.1.4
|
||||
is-glob: 4.0.3
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 3.1.2
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
optionalDependencies:
|
||||
jiti: 2.5.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
espree@10.4.0:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
@@ -2719,6 +3153,8 @@ snapshots:
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-nonce@1.0.1: {}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
@@ -3014,6 +3450,25 @@ snapshots:
|
||||
|
||||
react-refresh@0.17.0: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.1.9)(react@19.1.1):
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
react-style-singleton: 2.2.3(@types/react@19.1.9)(react@19.1.1)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
react-remove-scroll@2.7.1(@types/react@19.1.9)(react@19.1.1):
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
react-remove-scroll-bar: 2.3.8(@types/react@19.1.9)(react@19.1.1)
|
||||
react-style-singleton: 2.2.3(@types/react@19.1.9)(react@19.1.1)
|
||||
tslib: 2.8.1
|
||||
use-callback-ref: 1.3.3(@types/react@19.1.9)(react@19.1.1)
|
||||
use-sidecar: 1.1.3(@types/react@19.1.9)(react@19.1.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
react-router-dom@7.7.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
@@ -3028,6 +3483,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@19.1.9)(react@19.1.1):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
react: 19.1.1
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
react@19.1.1: {}
|
||||
|
||||
read-cache@1.0.0:
|
||||
@@ -3196,23 +3659,14 @@ snapshots:
|
||||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tw-animate-css@1.3.6: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
typescript-eslint@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)
|
||||
'@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)
|
||||
'@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3)
|
||||
'@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)
|
||||
eslint: 9.32.0(jiti@2.5.1)
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
typescript-eslint@8.38.0(eslint@9.32.0)(typescript@5.8.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)(typescript@5.8.3)
|
||||
@@ -3236,6 +3690,21 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
use-callback-ref@1.3.3(@types/react@19.1.9)(react@19.1.1):
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
use-sidecar@1.1.3(@types/react@19.1.9)(react@19.1.1):
|
||||
dependencies:
|
||||
detect-node-es: 1.1.0
|
||||
react: 19.1.1
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
vite@7.0.6:
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
// src/App.tsx
|
||||
import {
|
||||
BrowserRouter,
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
} from 'react-router-dom';
|
||||
import { FeedbackPage } from '@/pages/FeedbackPage';
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
} from "react-router-dom";
|
||||
import { MainLayout } from "@/components/MainLayout";
|
||||
import { FeedbackCreatePage } from "@/pages/FeedbackCreatePage";
|
||||
import { FeedbackListPage } from "@/pages/FeedbackListPage";
|
||||
import { FeedbackDetailPage } from "@/pages/FeedbackDetailPage";
|
||||
import { IssueViewerPage } from "@/pages/IssueViewerPage";
|
||||
|
||||
function App() {
|
||||
const defaultProjectId = import.meta.env.VITE_DEFAULT_PROJECT_ID || 'default';
|
||||
const defaultChannelId = import.meta.env.VITE_DEFAULT_CHANNEL_ID || 'general';
|
||||
return (
|
||||
<Routes>
|
||||
{/* 기본 경로 리디렉션 */}
|
||||
<Route path="/" element={<Navigate to="/projects/1/channels/4/feedbacks" />} />
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/projects/:projectId/channels/:channelId"
|
||||
element={<FeedbackPage />}
|
||||
/>
|
||||
{/* .env.local에 설정된 기본 프로젝트/채널로 리디렉션합니다. */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Navigate
|
||||
to={`/projects/${defaultProjectId}/channels/${defaultChannelId}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
{/* 피드백 관련 페이지 (메인 레이아웃 사용) */}
|
||||
<Route
|
||||
path="/projects/:projectId/channels/:channelId/feedbacks"
|
||||
element={<MainLayout />}
|
||||
>
|
||||
<Route index element={<FeedbackListPage />} />
|
||||
<Route path="new" element={<FeedbackCreatePage />} />
|
||||
<Route path=":feedbackId" element={<FeedbackDetailPage />} />
|
||||
</Route>
|
||||
|
||||
{/* 독립적인 이슈 뷰어 페이지 */}
|
||||
<Route
|
||||
path="/issues/:issueId" // 이슈 ID만 받도록 단순화
|
||||
element={<IssueViewerPage />}
|
||||
/>
|
||||
|
||||
{/* 잘못된 접근을 위한 리디렉션 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
116
viewer/src/components/DynamicForm.tsx
Normal file
116
viewer/src/components/DynamicForm.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FeedbackField } from "@/services/feedback";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
// 컴포넌트 외부에 안정적인 참조를 가진 빈 객체 상수 선언
|
||||
const EMPTY_INITIAL_DATA = {};
|
||||
|
||||
interface DynamicFormProps {
|
||||
fields: FeedbackField[];
|
||||
onSubmit: (formData: Record<string, any>) => Promise<void>;
|
||||
initialData?: Record<string, any>;
|
||||
submitButtonText?: string;
|
||||
}
|
||||
|
||||
export function DynamicForm({
|
||||
fields,
|
||||
onSubmit,
|
||||
initialData = EMPTY_INITIAL_DATA, // 기본값으로 상수 사용
|
||||
submitButtonText = "제출",
|
||||
}: DynamicFormProps) {
|
||||
const [formData, setFormData] = useState<Record<string, any>>(initialData);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// initialData prop이 변경될 때만 폼 데이터를 동기화
|
||||
setFormData(initialData);
|
||||
}, [initialData]);
|
||||
|
||||
const handleFormChange = (fieldId: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldId]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
} catch (error) {
|
||||
console.error("Form submission error:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (field: FeedbackField) => {
|
||||
const commonProps = {
|
||||
id: field.id,
|
||||
value: formData[field.id] ?? "",
|
||||
disabled: field.readOnly,
|
||||
};
|
||||
|
||||
switch (field.type) {
|
||||
case "textarea":
|
||||
return (
|
||||
<Textarea
|
||||
{...commonProps}
|
||||
onChange={(e) => handleFormChange(field.id, e.target.value)}
|
||||
placeholder={field.readOnly ? "" : `${field.name}...`}
|
||||
rows={5}
|
||||
/>
|
||||
);
|
||||
case "text":
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type={field.type}
|
||||
onChange={(e) => handleFormChange(field.id, e.target.value)}
|
||||
placeholder={field.readOnly ? "" : field.name}
|
||||
/>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
value={commonProps.value}
|
||||
onValueChange={(value) => handleFormChange(field.id, value)}
|
||||
disabled={field.readOnly}
|
||||
>
|
||||
<SelectTrigger id={field.id}>
|
||||
<SelectValue placeholder={`-- ${field.name} 선택 --`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* options는 현재 API에서 제공되지 않으므로 비활성화 */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
default:
|
||||
return <p>지원하지 않는 필드 타입: {field.type}</p>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{fields.map((field) => (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Label htmlFor={field.id}>{field.name}</Label>
|
||||
{renderField(field)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "전송 중..." : submitButtonText}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
123
viewer/src/components/DynamicTable.tsx
Normal file
123
viewer/src/components/DynamicTable.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { Feedback, FeedbackField, Issue } from "@/services/feedback";
|
||||
|
||||
interface DynamicTableProps {
|
||||
columns: FeedbackField[];
|
||||
data: Feedback[];
|
||||
projectId: string;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export function DynamicTable({
|
||||
columns,
|
||||
data,
|
||||
projectId,
|
||||
channelId,
|
||||
}: DynamicTableProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleRowClick = (feedbackId: string) => {
|
||||
navigate(
|
||||
`/projects/${projectId}/channels/${channelId}/feedbacks/${feedbackId}`,
|
||||
);
|
||||
};
|
||||
|
||||
const renderCell = (item: Feedback, field: FeedbackField) => {
|
||||
const value = item[field.id];
|
||||
|
||||
// 필드 ID에 따라 렌더링 분기
|
||||
switch (field.id) {
|
||||
case "issues": {
|
||||
const issues = value as Issue[] | undefined;
|
||||
if (!issues || issues.length === 0) return "N/A";
|
||||
return (
|
||||
<div className="flex flex-col space-y-1">
|
||||
{issues.map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
to={`/issues/${issue.id}`}
|
||||
className="text-blue-600 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()} // 행 클릭 이벤트 전파 방지
|
||||
>
|
||||
{issue.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "title":
|
||||
return (
|
||||
<div className="whitespace-normal break-words w-60">{String(value ?? "N/A")}</div>
|
||||
);
|
||||
|
||||
case "contents": {
|
||||
const content = String(value ?? "N/A");
|
||||
const truncated =
|
||||
content.length > 60 ? `${content.substring(0, 60)}...` : content;
|
||||
return <div className="whitespace-normal break-words w-60">{truncated}</div>;
|
||||
}
|
||||
|
||||
case "createdAt":
|
||||
case "updatedAt":
|
||||
return String(value ?? "N/A").substring(0, 10); // YYYY-MM-DD
|
||||
|
||||
default:
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value ?? "N/A");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>피드백 목록</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((field) => (
|
||||
<TableHead key={field.id}>{field.name}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.length > 0 ? (
|
||||
data.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
onClick={() => handleRowClick(item.id.toString())}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
{columns.map((field) => (
|
||||
<TableCell key={field.id}>
|
||||
{renderCell(item, field)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="text-center">
|
||||
표시할 데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
97
viewer/src/components/ErrorDisplay.tsx
Normal file
97
viewer/src/components/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
// src/components/ErrorDisplay.tsx
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export const ErrorDisplay = ({ errorMessage }: ErrorDisplayProps) => {
|
||||
if (!errorMessage) return null;
|
||||
|
||||
const prettifyJson = (text: string) => {
|
||||
try {
|
||||
const obj = JSON.parse(text);
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch (e) {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
// For production or simple errors
|
||||
if (import.meta.env.PROD || !errorMessage.startsWith("[Dev]")) {
|
||||
return (
|
||||
<Card className="bg-destructive/10 border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">오류가 발생했습니다</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="text-sm text-destructive-foreground bg-destructive/20 p-4 rounded-md overflow-x-auto whitespace-pre-wrap">
|
||||
<code>{errorMessage}</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// For dev errors, parse and display detailed information
|
||||
const parts = errorMessage.split(" | ");
|
||||
const message = parts[0]?.replace("[Dev] ", "");
|
||||
const requestUrl = parts[1]?.replace("URL: ", "");
|
||||
const status = parts[2]?.replace("Status: ", "");
|
||||
const bodyRaw = parts.slice(3).join(" | ").replace("Body: ", "");
|
||||
const body = bodyRaw === "Empty" ? "Empty" : prettifyJson(bodyRaw);
|
||||
|
||||
// Reconstruct the final proxied URL based on environment variables
|
||||
let proxiedUrl = "프록시 URL을 계산할 수 없습니다.";
|
||||
if (requestUrl) {
|
||||
try {
|
||||
const url = new URL(requestUrl);
|
||||
const proxyTarget = import.meta.env.VITE_API_PROXY_TARGET;
|
||||
// This logic MUST match the proxy rewrite in `vite.config.ts`
|
||||
const finalPath = url.pathname.replace(/^\/api/, "/_api/api");
|
||||
proxiedUrl = `${proxyTarget}${finalPath}`;
|
||||
} catch (e) {
|
||||
// Ignore if URL parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-destructive/10 border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{message || "오류가 발생했습니다"}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-destructive/80">
|
||||
<div className="space-y-1 mt-2 text-xs">
|
||||
<p>
|
||||
<span className="font-semibold w-28 inline-block">요청 상태:</span>
|
||||
<span className="font-mono">{status}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold w-28 inline-block">Vite 서버 URL:</span>
|
||||
<span className="font-mono">{requestUrl}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold w-28 inline-block">최종 API URL:</span>
|
||||
<span className="font-mono">{proxiedUrl}</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<h4 className="font-bold mb-2 text-destructive-foreground/80">
|
||||
Response Body:
|
||||
</h4>
|
||||
<pre className="text-sm text-destructive-foreground bg-destructive/20 p-4 rounded-md overflow-x-auto">
|
||||
<code>{body}</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
18
viewer/src/components/MainLayout.tsx
Normal file
18
viewer/src/components/MainLayout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
// src/components/MainLayout.tsx
|
||||
import { Link, Outlet, useParams } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function MainLayout() {
|
||||
const { projectId, channelId } = useParams();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 md:p-8">
|
||||
<header className="mb-8 flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight">피드백 뷰어</h1>
|
||||
</header>
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import type * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react"
|
||||
import type * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react"
|
||||
import type * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
|
||||
24
viewer/src/components/ui/label.tsx
Normal file
24
viewer/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
156
viewer/src/components/ui/select.tsx
Normal file
156
viewer/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out 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 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react"
|
||||
import type * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
120
viewer/src/components/ui/table.tsx
Normal file
120
viewer/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react"
|
||||
import type * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
);
|
||||
106
viewer/src/pages/FeedbackCreatePage.tsx
Normal file
106
viewer/src/pages/FeedbackCreatePage.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { DynamicForm } from "@/components/DynamicForm";
|
||||
import { getFeedbackFields, createFeedback } from "@/services/feedback";
|
||||
import type { FeedbackField, CreateFeedbackRequest } from "@/services/feedback";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export function FeedbackCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const [fields, setFields] = useState<FeedbackField[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitMessage, setSubmitMessage] = useState<string | null>(null);
|
||||
|
||||
// TODO: projectId와 channelId는 URL 파라미터나 컨텍스트에서 가져와야 합니다.
|
||||
const projectId = "1";
|
||||
const channelId = "4";
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFields = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const fieldsData = await getFeedbackFields(projectId, channelId);
|
||||
|
||||
// 사용자에게 보여주지 않을 필드 목록
|
||||
const hiddenFields = ["id", "createdAt", "updatedAt", "issues"];
|
||||
|
||||
const processedFields = fieldsData
|
||||
.filter((field) => !hiddenFields.includes(field.id))
|
||||
.map((field) => {
|
||||
// 'contents' 필드를 항상 textarea로 처리
|
||||
if (field.id === "contents") {
|
||||
return { ...field, type: "textarea" as const };
|
||||
}
|
||||
return field;
|
||||
});
|
||||
|
||||
setFields(processedFields);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("알 수 없는 오류가 발생했습니다.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFields();
|
||||
}, [projectId, channelId]);
|
||||
|
||||
const handleSubmit = async (formData: Record<string, any>) => {
|
||||
try {
|
||||
setError(null);
|
||||
setSubmitMessage(null);
|
||||
|
||||
const requestData: CreateFeedbackRequest = {
|
||||
...formData,
|
||||
issueNames: [],
|
||||
};
|
||||
|
||||
await createFeedback(projectId, channelId, requestData);
|
||||
setSubmitMessage("피드백이 성공적으로 등록되었습니다! 곧 목록으로 돌아갑니다.");
|
||||
|
||||
// 2초 후 목록 페이지로 이동
|
||||
setTimeout(() => {
|
||||
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
|
||||
}, 2000);
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("피드백 등록 중 알 수 없는 오류가 발생했습니다.");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>폼을 불러오는 중...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="space-y-2 mb-6">
|
||||
<h1 className="text-2xl font-bold">피드백 작성</h1>
|
||||
<p className="text-muted-foreground">
|
||||
아래 폼을 작성하여 피드백을 제출해주세요.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="mt-6">
|
||||
<DynamicForm fields={fields} onSubmit={handleSubmit} />
|
||||
{error && <ErrorDisplay message={error} />}
|
||||
{submitMessage && (
|
||||
<div className="mt-4 p-3 bg-green-100 text-green-800 rounded-md">
|
||||
{submitMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
viewer/src/pages/FeedbackDetailPage.tsx
Normal file
148
viewer/src/pages/FeedbackDetailPage.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { DynamicForm } from "@/components/DynamicForm";
|
||||
import {
|
||||
getFeedbackFields,
|
||||
getFeedbackById,
|
||||
updateFeedback,
|
||||
} from "@/services/feedback";
|
||||
import type { Feedback, FeedbackField } from "@/services/feedback";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export function FeedbackDetailPage() {
|
||||
const { projectId, channelId, feedbackId } = useParams<{
|
||||
projectId: string;
|
||||
channelId: string;
|
||||
feedbackId: string;
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [fields, setFields] = useState<FeedbackField[]>([]);
|
||||
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const initialData = useMemo(() => feedback ?? {}, [feedback]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!projectId || !channelId || !feedbackId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const [fieldsData, feedbackData] = await Promise.all([
|
||||
getFeedbackFields(projectId, channelId),
|
||||
getFeedbackById(projectId, channelId, feedbackId),
|
||||
]);
|
||||
|
||||
// 폼에서 숨길 필드 목록
|
||||
const hiddenFields = ["id", "createdAt", "updatedAt", "issues", "screenshot"];
|
||||
|
||||
const processedFields = fieldsData
|
||||
.filter((field) => !hiddenFields.includes(field.id))
|
||||
.map((field) => {
|
||||
// 'contents' 필드는 항상 textarea로
|
||||
if (field.id === "contents") {
|
||||
return { ...field, type: "textarea" as const };
|
||||
}
|
||||
// 'customer' 필드는 읽기 전용으로
|
||||
if (field.id === "customer") {
|
||||
return { ...field, readOnly: true };
|
||||
}
|
||||
return field;
|
||||
});
|
||||
|
||||
setFields(processedFields);
|
||||
setFeedback(feedbackData);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("데이터를 불러오는 중 알 수 없는 오류가 발생했습니다.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [projectId, channelId, feedbackId]);
|
||||
|
||||
const handleSubmit = async (formData: Record<string, any>) => {
|
||||
if (!projectId || !channelId || !feedbackId) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
// API에 전송할 데이터 정제 (수정 가능한 필드만 포함)
|
||||
const dataToUpdate: Record<string, any> = {};
|
||||
fields.forEach(field => {
|
||||
if (!field.readOnly && formData[field.id] !== undefined) {
|
||||
dataToUpdate[field.id] = formData[field.id];
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Updating with data:", dataToUpdate); // [Debug]
|
||||
|
||||
await updateFeedback(projectId, channelId, feedbackId, dataToUpdate);
|
||||
setSuccessMessage("피드백이 성공적으로 수정되었습니다! 곧 목록으로 돌아갑니다.");
|
||||
|
||||
setTimeout(() => {
|
||||
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
|
||||
}, 2000);
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("피드백 수정 중 알 수 없는 오류가 발생했습니다.");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>로딩 중...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorDisplay message={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="space-y-2 mb-6">
|
||||
<h1 className="text-2xl font-bold">피드백 상세 및 수정</h1>
|
||||
<p className="text-muted-foreground">
|
||||
피드백 내용을 확인하고 수정할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="mt-6">
|
||||
<div className="flex justify-between items-center mb-4 p-3 bg-slate-50 rounded-md">
|
||||
<span className="text-sm font-medium text-slate-600">
|
||||
ID: {feedback?.id}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500">
|
||||
생성일: {feedback?.createdAt ? new Date(feedback.createdAt).toLocaleString("ko-KR") : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DynamicForm
|
||||
fields={fields}
|
||||
initialData={initialData}
|
||||
onSubmit={handleSubmit}
|
||||
submitButtonText="수정하기"
|
||||
/>
|
||||
{successMessage && (
|
||||
<div className="mt-4 p-3 bg-green-100 text-green-800 rounded-md">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
viewer/src/pages/FeedbackListPage.tsx
Normal file
68
viewer/src/pages/FeedbackListPage.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DynamicTable } from "@/components/DynamicTable";
|
||||
import { getFeedbacks, getFeedbackFields } from "@/services/feedback";
|
||||
import type { Feedback, FeedbackField } from "@/services/feedback";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function FeedbackListPage() {
|
||||
const [fields, setFields] = useState<FeedbackField[]>([]);
|
||||
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// TODO: projectId와 channelId는 URL 파라미터나 컨텍스트에서 가져와야 합니다.
|
||||
const projectId = "1";
|
||||
const channelId = "4";
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFieldsAndFeedbacks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const fieldsData = await getFeedbackFields(projectId, channelId);
|
||||
setFields(fieldsData);
|
||||
|
||||
try {
|
||||
const feedbacksData = await getFeedbacks(projectId, channelId);
|
||||
setFeedbacks(feedbacksData);
|
||||
} catch (feedbackError) {
|
||||
console.error("Failed to fetch feedbacks:", feedbackError);
|
||||
setError("피드백 목록을 불러오는 데 실패했습니다.");
|
||||
}
|
||||
} catch (fieldsError) {
|
||||
if (fieldsError instanceof Error) {
|
||||
setError(fieldsError.message);
|
||||
} else {
|
||||
setError("테이블 구조를 불러오는 데 실패했습니다.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFieldsAndFeedbacks();
|
||||
}, [projectId, channelId]);
|
||||
|
||||
if (loading) {
|
||||
return <div>로딩 중...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold">피드백 목록</h1>
|
||||
<Button asChild>
|
||||
<Link to="new">새 피드백 작성</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{error && <ErrorDisplay message={error} />}
|
||||
<DynamicTable
|
||||
columns={fields}
|
||||
data={feedbacks}
|
||||
projectId={projectId}
|
||||
channelId={channelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
// src/pages/FeedbackPage.tsx
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
getFeedbacks,
|
||||
createFeedback,
|
||||
searchIssues,
|
||||
type Feedback,
|
||||
type Issue,
|
||||
} from "@/services/feedback";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
// 디바운스 훅
|
||||
function useDebounce(value: string, delay: number) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export function FeedbackPage() {
|
||||
const { projectId, channelId } = useParams<{
|
||||
projectId: string;
|
||||
channelId: string;
|
||||
}>();
|
||||
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 피드백 생성 폼 상태
|
||||
const [message, setMessage] = useState("");
|
||||
const [issueSearch, setIssueSearch] = useState("");
|
||||
const [searchedIssues, setSearchedIssues] = useState<Issue[]>([]);
|
||||
const [selectedIssues, setSelectedIssues] = useState<Issue[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const debouncedSearchTerm = useDebounce(issueSearch, 500);
|
||||
|
||||
const fetchFeedbacks = useCallback(async () => {
|
||||
if (!projectId || !channelId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getFeedbacks(projectId, channelId);
|
||||
setFeedbacks(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError("피드백을 불러오지 못했습니다.");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, channelId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeedbacks();
|
||||
}, [fetchFeedbacks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm && projectId) {
|
||||
searchIssues(projectId, debouncedSearchTerm).then(setSearchedIssues);
|
||||
} else {
|
||||
setSearchedIssues([]);
|
||||
}
|
||||
}, [debouncedSearchTerm, projectId]);
|
||||
|
||||
const handleCreateFeedback = async () => {
|
||||
if (!projectId || !channelId || !message || selectedIssues.length === 0) {
|
||||
alert("메시지를 입력하고, 하나 이상의 이슈를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createFeedback(projectId, channelId, {
|
||||
message,
|
||||
issueNames: selectedIssues.map((issue) => issue.name),
|
||||
});
|
||||
setMessage("");
|
||||
setIssueSearch("");
|
||||
setSelectedIssues([]);
|
||||
setSearchedIssues([]);
|
||||
await fetchFeedbacks(); // 목록 새로고침
|
||||
} catch (err) {
|
||||
setError("피드백 생성에 실패했습니다.");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleIssueSelection = (issue: Issue) => {
|
||||
setSelectedIssues((prev) =>
|
||||
prev.some((i) => i.id === issue.id)
|
||||
? prev.filter((i) => i.id !== issue.id)
|
||||
: [...prev, issue],
|
||||
);
|
||||
setIssueSearch("");
|
||||
setSearchedIssues([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 md:p-8">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight">피드백</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
프로젝트: {projectId} / 채널: {channelId}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>새 피드백 작성</CardTitle>
|
||||
<CardDescription>
|
||||
새로운 피드백을 작성하고 관련 이슈를 연결하세요.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
placeholder="피드백 메시지를 입력하세요..."
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={5}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="연결할 이슈 이름 검색..."
|
||||
value={issueSearch}
|
||||
onChange={(e) => setIssueSearch(e.target.value)}
|
||||
/>
|
||||
{searchedIssues.length > 0 && (
|
||||
<Card className="absolute w-full mt-2 z-10">
|
||||
<CardContent className="p-0">
|
||||
<ul className="max-h-48 overflow-y-auto">
|
||||
{searchedIssues.map((issue, index) => (
|
||||
<li key={issue.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full text-left p-3 text-sm hover:bg-accent ${
|
||||
selectedIssues.some((i) => i.id === issue.id)
|
||||
? "bg-muted"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => toggleIssueSelection(issue)}
|
||||
>
|
||||
{issue.name}
|
||||
</button>
|
||||
{index < searchedIssues.length - 1 && (
|
||||
<Separator />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{selectedIssues.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">선택된 이슈:</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedIssues.map((issue) => (
|
||||
<span
|
||||
key={issue.id}
|
||||
className="bg-secondary text-secondary-foreground px-3 py-1 rounded-full text-sm"
|
||||
>
|
||||
{issue.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button onClick={handleCreateFeedback} disabled={isSubmitting}>
|
||||
{isSubmitting ? "제출 중..." : "피드백 제출"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>피드백 목록</CardTitle>
|
||||
<CardDescription>
|
||||
이 채널에 등록된 모든 피드백입니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && <p>로딩 중...</p>}
|
||||
{error && <p className="text-destructive">{error}</p>}
|
||||
{!loading && !error && (
|
||||
<ul className="space-y-4">
|
||||
{feedbacks.map((feedback, index) => (
|
||||
<li key={feedback.id}>
|
||||
<div className="p-4 border rounded-md bg-background">
|
||||
<p>{feedback.content}</p>
|
||||
{/* 피드백에 연결된 이슈 등 추가 정보 표시 가능 */}
|
||||
</div>
|
||||
{index < feedbacks.length - 1 && <Separator className="my-4" />}
|
||||
</li>
|
||||
))}
|
||||
{feedbacks.length === 0 && (
|
||||
<p className="text-muted-foreground text-center">
|
||||
표시할 피드백이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
viewer/src/pages/IssueViewerPage.tsx
Normal file
100
viewer/src/pages/IssueViewerPage.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
// src/pages/IssueViewerPage.tsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { getIssues, type Issue } from "@/services/issue";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
|
||||
// 테이블 헤더 정의
|
||||
const issueTableHeaders = [
|
||||
{ key: "title", label: "Title" },
|
||||
{ key: "feedbackCount", label: "Feedback Count" },
|
||||
{ key: "description", label: "Description" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "createdAt", label: "Created" },
|
||||
{ key: "updatedAt", label: "Updated" },
|
||||
{ key: "category", label: "Category" },
|
||||
];
|
||||
|
||||
export function IssueViewerPage() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [issues, setIssues] = useState<Issue[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getIssues(projectId)
|
||||
.then(setIssues)
|
||||
.catch((err) => setError((err as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 md:p-8">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight">이슈 뷰어</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
프로젝트: {projectId}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{error && <ErrorDisplay errorMessage={error} />}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>이슈 목록</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && <p className="text-center">로딩 중...</p>}
|
||||
{!loading && (
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{issueTableHeaders.map((header) => (
|
||||
<TableHead key={header.key}>{header.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{issues.length > 0 ? (
|
||||
issues.map((issue) => (
|
||||
<TableRow key={issue.id}>
|
||||
{issueTableHeaders.map((header) => (
|
||||
<TableCell key={`${issue.id}-${header.key}`}>
|
||||
{String(issue[header.key] ?? "")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={issueTableHeaders.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
표시할 이슈가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
viewer/src/services/error.ts
Normal file
18
viewer/src/services/error.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// src/services/error.ts
|
||||
|
||||
/**
|
||||
* API 요청 실패 시 공통으로 사용할 에러 처리 함수
|
||||
* @param message 프로덕션 환경에서 보여줄 기본 에러 메시지
|
||||
* @param response fetch API의 응답 객체
|
||||
*/
|
||||
export const handleApiError = async (message: string, response: Response) => {
|
||||
if (import.meta.env.DEV) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(
|
||||
`[Dev] ${message} | URL: ${response.url} | Status: ${
|
||||
response.status
|
||||
} ${response.statusText} | Body: ${errorBody || "Empty"}`,
|
||||
);
|
||||
}
|
||||
throw new Error(message);
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
// src/services/feedback.ts
|
||||
import { handleApiError } from "./error";
|
||||
|
||||
// --- 타입 정의 ---
|
||||
|
||||
// API 응답과 요청 본문에 대한 타입을 정의합니다.
|
||||
// 실제 API 명세에 따라 더 구체적으로 작성할 수 있습니다.
|
||||
export interface Feedback {
|
||||
id: string;
|
||||
content: string;
|
||||
// ... 다른 필드들
|
||||
[key: string]: any; // 동적 필드를 위해 인덱스 시그니처 사용
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
@@ -13,47 +14,91 @@ export interface Issue {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CreateFeedbackRequest {
|
||||
message: string;
|
||||
issueNames: string[];
|
||||
// 동적 폼 필드 스키마 타입
|
||||
export interface FeedbackField {
|
||||
id: string; // 예: "message", "rating"
|
||||
name: string; // 예: "피드백 내용", "평점"
|
||||
type: "text" | "textarea" | "number" | "select"; // 렌더링할 입력 타입
|
||||
readOnly?: boolean; // UI에서 읽기 전용으로 처리하기 위한 속성
|
||||
}
|
||||
|
||||
const getApiBaseUrl = (projectId: string, channelId:string) =>
|
||||
`/api/projects/${projectId}/channels/${channelId}/feedbacks`;
|
||||
// 피드백 생성 요청 타입 (동적 데이터 포함)
|
||||
export interface CreateFeedbackRequest {
|
||||
issueNames: string[];
|
||||
[key: string]: any; // 폼 데이터 필드 (예: { message: "...", rating: 5 })
|
||||
}
|
||||
|
||||
// --- API 함수 ---
|
||||
|
||||
const getFeedbacksSearchApiUrl = (projectId: string, channelId: string) =>
|
||||
`/api/v2/projects/${projectId}/channels/${channelId}/feedbacks/search`;
|
||||
|
||||
const getFeedbackFieldsApiUrl = (projectId: string, channelId: string) =>
|
||||
`/api/projects/${projectId}/channels/${channelId}/fields`;
|
||||
|
||||
const getIssuesApiUrl = (projectId: string) =>
|
||||
`/api/projects/${projectId}/issues/search`;
|
||||
|
||||
/**
|
||||
* 특정 채널의 피드백 목록을 조회합니다.
|
||||
* @param projectId 프로젝트 ID
|
||||
* @param channelId 채널 ID
|
||||
* @returns 피드백 목록 Promise
|
||||
*/
|
||||
export const getFeedbacks = async (
|
||||
projectId: string,
|
||||
channelId: string,
|
||||
): Promise<Feedback[]> => {
|
||||
const url = getApiBaseUrl(projectId, channelId);
|
||||
const response = await fetch(url);
|
||||
const url = getFeedbacksSearchApiUrl(projectId, channelId);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("피드백 목록을 불러오는 데 실패했습니다.");
|
||||
await handleApiError("피드백 목록을 불러오는 데 실패했습니다.", response);
|
||||
}
|
||||
const result = await response.json();
|
||||
return result.items || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 채널의 동적 폼 필드 스키마를 조회합니다.
|
||||
*/
|
||||
export const getFeedbackFields = async (
|
||||
projectId: string,
|
||||
channelId: string,
|
||||
): Promise<FeedbackField[]> => {
|
||||
const url = getFeedbackFieldsApiUrl(projectId, channelId);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
await handleApiError("피드백 필드 정보를 불러오는 데 실패했습니다.", response);
|
||||
}
|
||||
const apiFields = await response.json();
|
||||
|
||||
if (!Array.isArray(apiFields)) {
|
||||
console.error("Error: Fields API response is not an array.", apiFields);
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return apiFields
|
||||
.filter((field: any) => field.status === "ACTIVE")
|
||||
.map((field: any) => ({
|
||||
id: field.key,
|
||||
name: field.name,
|
||||
type: field.format,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 채널에 새로운 피드백을 생성합니다.
|
||||
* @param projectId 프로젝트 ID
|
||||
* @param channelId 채널 ID
|
||||
* @param feedbackData 생성할 피드백 데이터
|
||||
* @returns 생성된 피드백 Promise
|
||||
*/
|
||||
export const createFeedback = async (
|
||||
projectId: string,
|
||||
channelId: string,
|
||||
feedbackData: CreateFeedbackRequest,
|
||||
): Promise<Feedback> => {
|
||||
const url = getApiBaseUrl(projectId, channelId);
|
||||
const url = `/api/projects/${projectId}/channels/${channelId}/feedbacks`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -61,25 +106,20 @@ export const createFeedback = async (
|
||||
},
|
||||
body: JSON.stringify(feedbackData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("피드백 생성에 실패했습니다.");
|
||||
await handleApiError("피드백 생성에 실패했습니다.", response);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 프로젝트의 이슈를 검색합니다.
|
||||
* @param projectId 프로젝트 ID
|
||||
* @param query 검색어
|
||||
* @returns 이슈 목록 Promise
|
||||
*/
|
||||
export const searchIssues = async (
|
||||
projectId: string,
|
||||
query: string,
|
||||
): Promise<Issue[]> => {
|
||||
const url = `/api/projects/${projectId}/issues/search`;
|
||||
const url = getIssuesApiUrl(projectId);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -92,14 +132,48 @@ export const searchIssues = async (
|
||||
sort: { createdAt: "ASC" },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("이슈 검색에 실패했습니다.");
|
||||
await handleApiError("이슈 검색에 실패했습니다.", response);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
// API 응답이 { items: Issue[] } 형태일 경우를 가정
|
||||
return result.items || [];
|
||||
};
|
||||
|
||||
// 여기에 다른 API 함수들을 추가할 수 있습니다. (예: updateFeedback, deleteFeedback)
|
||||
/**
|
||||
* 특정 ID의 피드백 상세 정보를 조회합니다.
|
||||
*/
|
||||
export const getFeedbackById = async (
|
||||
projectId: string,
|
||||
channelId: string,
|
||||
feedbackId: string,
|
||||
): Promise<Feedback> => {
|
||||
const url = `/api/projects/${projectId}/channels/${channelId}/feedbacks/${feedbackId}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
await handleApiError("피드백 상세 정보를 불러오는 데 실패했습니다.", response);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 피드백을 수정합니다.
|
||||
*/
|
||||
export const updateFeedback = async (
|
||||
projectId: string,
|
||||
channelId: string,
|
||||
feedbackId: string,
|
||||
feedbackData: Partial<CreateFeedbackRequest>,
|
||||
): Promise<Feedback> => {
|
||||
const url = `/api/projects/${projectId}/channels/${channelId}/feedbacks/${feedbackId}`;
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(feedbackData),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await handleApiError("피드백 수정에 실패했습니다.", response);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
41
viewer/src/services/issue.ts
Normal file
41
viewer/src/services/issue.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// src/services/issue.ts
|
||||
import { handleApiError } from "./error";
|
||||
|
||||
// API 응답에 대한 타입을 정의합니다.
|
||||
// 실제 API 명세에 따라 더 구체적으로 작성해야 합니다.
|
||||
export interface Issue {
|
||||
id: string;
|
||||
title: string;
|
||||
feedbackCount: number;
|
||||
description: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
category: string;
|
||||
[key: string]: any; // 그 외 다른 필드들
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 프로젝트의 모든 이슈를 검색합니다.
|
||||
* @param projectId 프로젝트 ID
|
||||
* @returns 이슈 목록 Promise
|
||||
*/
|
||||
export const getIssues = async (projectId: string): Promise<Issue[]> => {
|
||||
const url = `/api/projects/${projectId}/issues/search`;
|
||||
// body를 비워서 보내면 모든 이슈를 가져오는 것으로 가정합니다.
|
||||
// 실제 API 명세에 따라 수정이 필요할 수 있습니다.
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await handleApiError("이슈 목록을 불러오는 데 실패했습니다.", response);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.items || [];
|
||||
};
|
||||
@@ -21,17 +21,18 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
// 나머지 /api 경로 처리
|
||||
"/api": {
|
||||
target: "https://feedback.hmac.kr",
|
||||
target: env.VITE_API_PROXY_TARGET,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, "/_api"),
|
||||
configure: (proxy, options) => {
|
||||
proxy.on("proxyReq", (proxyReq, req, res) => {
|
||||
proxyReq.setHeader("X_API_KEY", env.VITE_API_KEY);
|
||||
configure: (proxy, _options) => {
|
||||
proxy.on("proxyReq", (proxyReq, _req, _res) => {
|
||||
proxyReq.setHeader("X-Api-Key", env.VITE_API_KEY);
|
||||
proxyReq.removeHeader("cookie");
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user