feedback list 기능 정리.

This commit is contained in:
2025-07-31 16:32:42 +09:00
parent e2cb482e5c
commit b4e6a94fda
10 changed files with 1158 additions and 123 deletions

View File

@@ -26,3 +26,31 @@
- **인증**: OIDC (OpenID Connect) 표준을 준수하여 인증을 구현합니다.
- **데이터**: PoC(Proof of Concept) 레벨에서는 별도의 데이터베이스를 사용하지 않고, Mock 데이터를 활용하여 핵심 기능 개발에 집중합니다.
## 5. 개발 로드맵
### Phase 1: 기반 구축 및 핵심 기능 구현 (완료)
- **내용**: 프로젝트 초기 설정, 핵심 동적 UI 컴포넌트 개발 및 기본 페이지 구성을 완료했습니다.
- **주요 산출물**:
- `DynamicTable`, `DynamicForm` 컴포넌트
- 피드백 CRUD 페이지
- 기본 라우팅 설정
### Phase 2: 기능 고도화 및 안정화
- **목표**: 사용자 경험을 개선하고 코드 품질을 향상시킵니다.
- **주요 작업**:
- **동적 테이블 개선**: 페이지네이션, 열 정렬, 데이터 필터링 기능을 추가하여 대량의 데이터를 효율적으로 탐색할 수 있도록 합니다.
- **동적 폼 개선**: Zod와 같은 라이브러리를 활용하여 스키마 기반의 동적 데이터 유효성 검사를 구현합니다.
- **상태 관리 도입**: 컴포넌트 간의 복잡한 상태 공유 및 비동기 데이터 관리를 위해 Zustand를 도입하여 전역 상태 관리를 구현합니다.
- **코드 품질 관리**: Biome을 프로젝트에 통합하여 코드 포맷팅과 린트 검사를 자동화하고, 일관된 코드 스타일을 유지합니다.
### Phase 3: 인증 및 배포
- **목표**: OIDC 기반의 안정적인 인증 시스템을 구축하고, Docker를 통해 배포 환경을 마련합니다.
- **주요 작업**:
- **OIDC 연동**: OIDC 클라이언트 라이브러리를 설치하고, 로그인/로그아웃 및 토큰 관리 로직을 구현합니다.
- **인증 상태 관리**: 사용자의 로그인 상태를 전역으로 관리하고, 인증 상태에 따라 UI가 동적으로 변경되도록 합니다.
- **라우트 보호**: 인증이 필요한 페이지에 접근 제어(Route Guard)를 적용하여 비인가 사용자의 접근을 차단합니다.
- **컨테이너화**: Dockerfile을 작성하고 Docker Compose를 설정하여 개발 및 프로덕션 환경을 컨테이너 기반으로 구축합니다.

View File

@@ -12,15 +12,20 @@
"shadcn": "shadcn-ui"
},
"dependencies": {
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.533.0",
"react": "^19.1.0",
"react-day-picker": "^9.8.1",
"react-dom": "^19.1.0",
"react-router-dom": "^7.7.1",
"tailwind-merge": "^3.3.1",

218
viewer/pnpm-lock.yaml generated
View File

@@ -8,12 +8,18 @@ importers:
.:
dependencies:
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.15
version: 2.1.15(@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-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-popover':
specifier: ^1.1.14
version: 1.1.14(@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)
@@ -23,18 +29,27 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.2.3
version: 1.2.3(@types/react@19.1.9)(react@19.1.1)
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
date-fns:
specifier: ^4.1.0
version: 4.1.0
lucide-react:
specifier: ^0.533.0
version: 0.533.0(react@19.1.1)
react:
specifier: ^19.1.0
version: 19.1.1
react-day-picker:
specifier: ^9.8.1
version: 9.8.1(react@19.1.1)
react-dom:
specifier: ^19.1.0
version: 19.1.1(react@19.1.1)
@@ -304,6 +319,9 @@ packages:
cpu: [x64]
os: [win32]
'@date-fns/tz@1.2.0':
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
'@esbuild/aix-ppc64@0.25.8':
resolution: {integrity: sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==}
engines: {node: '>=18'}
@@ -638,6 +656,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-dropdown-menu@2.1.15':
resolution: {integrity: sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==}
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:
@@ -687,6 +718,32 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-menu@2.1.15':
resolution: {integrity: sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==}
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-popover@1.1.14':
resolution: {integrity: sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==}
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:
@@ -713,6 +770,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.4':
resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==}
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:
@@ -726,6 +796,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.10':
resolution: {integrity: sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==}
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-select@2.2.5':
resolution: {integrity: sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==}
peerDependencies:
@@ -952,6 +1035,17 @@ packages:
cpu: [x64]
os: [win32]
'@tanstack/react-table@8.21.3':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -1177,6 +1271,12 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'}
@@ -1710,6 +1810,12 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
react-day-picker@9.8.1:
resolution: {integrity: sha512-kMcLrp3PfN/asVJayVv82IjF3iLOOxuH5TNFWezX6lS/T8iVRFPTETpHl3TUSTH99IDMZLubdNPJr++rQctkEw==}
engines: {node: '>=18'}
peerDependencies:
react: '>=16.8.0'
react-dom@19.1.1:
resolution: {integrity: sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==}
peerDependencies:
@@ -2199,6 +2305,8 @@ snapshots:
'@biomejs/cli-win32-x64@2.1.2':
optional: true
'@date-fns/tz@1.2.0': {}
'@esbuild/aix-ppc64@0.25.8':
optional: true
@@ -2445,6 +2553,21 @@ snapshots:
'@types/react': 19.1.9
'@types/react-dom': 19.1.7(@types/react@19.1.9)
'@radix-ui/react-dropdown-menu@2.1.15(@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-context': 1.1.2(@types/react@19.1.9)(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.9)(react@19.1.1)
'@radix-ui/react-menu': 2.1.15(@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-use-controllable-state': 1.2.2(@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
@@ -2482,6 +2605,55 @@ snapshots:
'@types/react': 19.1.9
'@types/react-dom': 19.1.7(@types/react@19.1.9)
'@radix-ui/react-menu@2.1.15(@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-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-presence': 1.1.4(@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-roving-focus': 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-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)
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-popover@1.1.14(@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-context': 1.1.2(@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-presence': 1.1.4(@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-controllable-state': 1.2.2(@types/react@19.1.9)(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-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)
@@ -2510,6 +2682,16 @@ snapshots:
'@types/react': 19.1.9
'@types/react-dom': 19.1.7(@types/react@19.1.9)
'@radix-ui/react-presence@1.1.4(@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-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)
@@ -2519,6 +2701,23 @@ snapshots:
'@types/react': 19.1.9
'@types/react-dom': 19.1.7(@types/react@19.1.9)
'@radix-ui/react-roving-focus@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-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-id': 1.1.1(@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-controllable-state': 1.2.2(@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-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
@@ -2691,6 +2890,14 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.46.1':
optional: true
'@tanstack/react-table@8.21.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@tanstack/table-core': 8.21.3
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
'@tanstack/table-core@8.21.3': {}
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.28.0
@@ -2966,6 +3173,10 @@ snapshots:
csstype@3.1.3: {}
date-fns-jalali@4.1.0-0: {}
date-fns@4.1.0: {}
debug@4.4.1:
dependencies:
ms: 2.1.3
@@ -3443,6 +3654,13 @@ snapshots:
queue-microtask@1.2.3: {}
react-day-picker@9.8.1(react@19.1.1):
dependencies:
'@date-fns/tz': 1.2.0
date-fns: 4.1.0
date-fns-jalali: 4.1.0-0
react: 19.1.1
react-dom@19.1.1(react@19.1.1):
dependencies:
react: 19.1.1

View File

@@ -1,9 +1,6 @@
// src/App.tsx
import {
Routes,
Route,
Navigate,
} from "react-router-dom";
import { useEffect } from "react";
import { Routes, Route, Navigate } from "react-router-dom";
import { MainLayout } from "@/components/MainLayout";
import { FeedbackCreatePage } from "@/pages/FeedbackCreatePage";
import { FeedbackListPage } from "@/pages/FeedbackListPage";
@@ -11,10 +8,18 @@ import { FeedbackDetailPage } from "@/pages/FeedbackDetailPage";
import { IssueViewerPage } from "@/pages/IssueViewerPage";
function App() {
useEffect(() => {
const root = window.document.documentElement;
root.classList.add("dark");
}, []);
return (
<Routes>
{/* 기본 경로 리디렉션 */}
<Route path="/" element={<Navigate to="/projects/1/channels/4/feedbacks" />} />
<Route
path="/"
element={<Navigate to="/projects/1/channels/4/feedbacks" />}
/>
{/* 피드백 관련 페이지 (메인 레이아웃 사용) */}
<Route

View File

@@ -1,4 +1,54 @@
import {
flexRender,
getCoreRowModel,
getExpandedRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type ColumnFiltersState,
type ExpandedState,
type SortingState,
type VisibilityState,
} from "@tanstack/react-table";
import { addDays, format } from "date-fns";
import {
ArrowUpDown,
Calendar as CalendarIcon,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import { useMemo, useState } from "react";
import { DateRange } from "react-day-picker";
import { Link, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -7,8 +57,8 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Feedback, FeedbackField, Issue } from "@/services/feedback";
import { cn } from "@/lib/utils";
interface DynamicTableProps {
columns: FeedbackField[];
@@ -17,13 +67,172 @@ interface DynamicTableProps {
channelId: string;
}
const DEFAULT_COLUMN_ORDER = [
"id",
"title",
"contents",
"issues",
"customer",
"updatedAt",
];
export function DynamicTable({
columns,
columns: rawColumns,
data,
projectId,
channelId,
}: DynamicTableProps) {
const navigate = useNavigate();
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [expanded, setExpanded] = useState<ExpandedState>({});
const [globalFilter, setGlobalFilter] = useState("");
const [date, setDate] = useState<DateRange | undefined>();
const columns = useMemo<ColumnDef<Feedback>[]>(() => {
const orderedRawColumns = [...rawColumns].sort((a, b) => {
const indexA = DEFAULT_COLUMN_ORDER.indexOf(a.id);
const indexB = DEFAULT_COLUMN_ORDER.indexOf(b.id);
if (indexA === -1 && indexB === -1) return 0;
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
const generatedColumns: ColumnDef<Feedback>[] = orderedRawColumns.map(
(field) => ({
accessorKey: field.id,
header: ({ column }) => {
if (field.id === "issues") {
return <div>{field.name}</div>;
}
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{field.name}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.original[field.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-48">
{String(value ?? "N/A")}
</div>
);
case "contents": {
const content = String(value ?? "N/A");
const truncated =
content.length > 50
? `${content.substring(0, 50)}...`
: content;
return (
<div className="whitespace-normal break-words w-60">
{truncated}
</div>
);
}
case "createdAt":
case "updatedAt":
return String(value ?? "N/A").substring(0, 10);
default:
if (typeof value === "object" && value !== null) {
return JSON.stringify(value);
}
return String(value ?? "N/A");
}
},
}),
);
return [
{
id: "expander",
header: () => null,
cell: ({ row }) => {
return (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
row.toggleExpanded();
}}
>
{row.getIsExpanded() ? "▼" : "▶"}
</Button>
);
},
},
...generatedColumns,
];
}, [rawColumns]);
const filteredData = useMemo(() => {
if (!date?.from) {
return data;
}
const fromDate = date.from;
const toDate = date.to ? addDays(date.to, 1) : addDays(fromDate, 1);
return data.filter((item) => {
const itemDate = new Date(item.updatedAt);
return itemDate >= fromDate && itemDate < toDate;
});
}, [data, date]);
const table = useReactTable({
data: filteredData,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onExpandedChange: setExpanded,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getExpandedRowModel: getExpandedRowModel(),
initialState: {
pagination: {
pageSize: 20,
},
},
state: {
sorting,
columnFilters,
columnVisibility,
expanded,
globalFilter,
},
onGlobalFilterChange: setGlobalFilter,
});
const handleRowClick = (feedbackId: string) => {
navigate(
@@ -31,93 +240,221 @@ export function DynamicTable({
);
};
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"
<CardContent className="pt-6">
<div className="flex items-center justify-between py-4">
<Input
placeholder="전체 데이터에서 검색..."
value={globalFilter}
onChange={(event) => setGlobalFilter(event.target.value)}
className="max-w-sm"
/>
<div className="flex items-center space-x-2">
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={"outline"}
className={cn(
"w-[300px] justify-start text-left font-normal",
!date && "text-muted-foreground",
)}
>
{columns.map((field) => (
<TableCell key={field.id}>
{renderCell(item, field)}
</TableCell>
<CalendarIcon className="mr-2 h-4 w-4" />
{date?.from ? (
date.to ? (
<>
{format(date.from, "LLL dd, y")} -{" "}
{format(date.to, "LLL dd, y")}
</>
) : (
format(date.from, "LLL dd, y")
)
) : (
<span> </span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
initialFocus
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={setDate}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto">
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} style={{ width: header.getSize() }}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="text-center">
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<>
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={() => handleRowClick(row.original.id.toString())}
className="cursor-pointer hover:bg-muted/50"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
{row.getIsExpanded() && (
<TableRow>
<TableCell colSpan={columns.length + 1}>
<div className="p-4 bg-muted rounded-md">
<h4 className="font-bold text-lg">
{row.original.title}
</h4>
<p className="mt-2 whitespace-pre-wrap">
{row.original.contents}
</p>
</div>
</TableCell>
</TableRow>
)}
</>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length + 1}
className="h-24 text-center"
>
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length}
</div>
<div className="flex items-center space-x-6">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium"> </p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[20, 30, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
{table.getPageCount()} {" "}
{table.getState().pagination.pageIndex + 1}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only"> </span>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only"> </span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only"> </span>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only"> </span>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
}
export default DynamicTable;

View File

@@ -0,0 +1,211 @@
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[--cell-size] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon, DotFilledIcon } from "@radix-ui/react-icons"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 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-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -80,37 +80,37 @@
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--background: oklch(0.22 0.03 265); /* Dracula Background */
--foreground: oklch(0.97 0.01 265); /* Dracula Foreground */
--card: oklch(0.31 0.04 265); /* Dracula Current Line */
--card-foreground: oklch(0.97 0.01 265); /* Dracula Foreground */
--popover: oklch(0.31 0.04 265); /* Dracula Current Line - Opaque */
--popover-foreground: oklch(0.97 0.01 265); /* Dracula Foreground */
--primary: oklch(0.7 0.15 290); /* Dracula Purple */
--primary-foreground: oklch(0.22 0.03 265); /* Dracula Background for contrast */
--secondary: oklch(0.45 0.1 265); /* Dracula Comment */
--secondary-foreground: oklch(0.97 0.01 265); /* Dracula Foreground */
--muted: oklch(0.31 0.04 265);
--muted-foreground: oklch(0.65 0.05 265);
--accent: oklch(0.8 0.15 320); /* Dracula Pink */
--accent-foreground: oklch(0.22 0.03 265);
--destructive: oklch(0.65 0.2 15); /* Dracula Red */
--border: oklch(0.45 0.1 265 / 0.5); /* Dracula Comment with transparency */
--input: oklch(0.45 0.1 265 / 0.5); /* Dracula Comment with transparency */
--ring: oklch(0.7 0.15 290); /* Dracula Purple */
--chart-1: oklch(0.85 0.15 180); /* Dracula Cyan */
--chart-2: oklch(0.8 0.2 130); /* Dracula Green */
--chart-3: oklch(0.8 0.15 80); /* Dracula Orange */
--chart-4: oklch(0.8 0.15 320); /* Dracula Pink */
--chart-5: oklch(0.9 0.1 90); /* Dracula Yellow */
--sidebar: oklch(0.22 0.03 265);
--sidebar-foreground: oklch(0.97 0.01 265);
--sidebar-primary: oklch(0.7 0.15 290);
--sidebar-primary-foreground: oklch(0.22 0.03 265);
--sidebar-accent: oklch(0.31 0.04 265);
--sidebar-accent-foreground: oklch(0.97 0.01 265);
--sidebar-border: oklch(0.45 0.1 265 / 0.5);
--sidebar-ring: oklch(0.7 0.15 290);
}
@layer base {

View File

@@ -45,8 +45,8 @@ const config: Config = {
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
DEFAULT: "oklch(var(--popover))",
foreground: "oklch(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",