Compare commits
16 Commits
e2cb482e5c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f5ff651ed | |||
| 27844ef237 | |||
| 9527b7d385 | |||
| c2fa1ec589 | |||
| 0e85144cdf | |||
| 84cc83d36b | |||
| cb60e7a43d | |||
| 13dad7272c | |||
|
|
466d719eef | ||
|
|
32506d22bb | ||
|
|
26fbb3f4c0 | ||
|
|
a53340e3c1 | ||
|
|
8db8ce668c | ||
| 211689e889 | |||
| 3ccb0c8f8a | |||
| b4e6a94fda |
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
||||
# Ignore node_modules
|
||||
node_modules
|
||||
viewer/node_modules
|
||||
|
||||
# Ignore build artifacts
|
||||
viewer/dist
|
||||
|
||||
# Ignore environment files to prevent them from being baked into the image
|
||||
*.env.local
|
||||
|
||||
# Ignore git directory
|
||||
.git
|
||||
|
||||
# Ignore Docker files
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
.env.local
|
||||
|
||||
47
Dockerfile
Normal file
47
Dockerfile
Normal file
@@ -0,0 +1,47 @@
|
||||
# Stage 1: Build the React application
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package manager files for the viewer app
|
||||
COPY viewer/package.json viewer/pnpm-lock.yaml ./viewer/
|
||||
|
||||
# Install pnpm and dependencies
|
||||
RUN npm install -g pnpm
|
||||
WORKDIR /app/viewer
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy the rest of the application source code
|
||||
COPY viewer/ ./
|
||||
|
||||
RUN unset VITE_API_PROXY_TARGET
|
||||
RUN unset VITE_API_PROXY_TARGET
|
||||
# Build the application. Vite will automatically use the .env file we just created.
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 2: Serve the application with Nginx
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
|
||||
# Remove the default Nginx configuration
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy the build output from the builder stage to the Nginx html directory
|
||||
COPY --from=builder /app/viewer/dist /usr/share/nginx/html
|
||||
|
||||
# Copy the Nginx configuration template and the entrypoint script
|
||||
COPY nginx.conf.template /etc/nginx/templates/
|
||||
COPY docker-entrypoint.sh /
|
||||
|
||||
# Make the entrypoint script executable
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
# Set the entrypoint
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start Nginx in the foreground
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
44
GEMINI.md
44
GEMINI.md
@@ -1,4 +1,10 @@
|
||||
# Q&A 뷰어 프로젝트
|
||||
## 0. 프젝트 전체 작업 진행 원칙
|
||||
- GEMINI cli로 하는 모든 작업에 대해 한글로 응답하며, 기능단위 결과를 TODO.md 에 남긴다.
|
||||
- 결과의 기록은 date 명령을 이용해 KST 기준으로 시분초까지 작성한다.
|
||||
- node_modules를 제외한 이 프로젝트에서 생성하는 파일은 ts 혹은 tsx 이며 js 생성시
|
||||
- 기능 단위 완성 후 사용자 승인을 받고 Biome를 이용해 lint 및 format 검사를 수행한다.
|
||||
- 최종적으로 사용자에게 git commit을 한 뒤 TODO.md에 기록한다.
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
@@ -13,7 +19,7 @@
|
||||
- OIDC 표준을 이용한 사용자 인증
|
||||
|
||||
## 3. 기술 스택
|
||||
|
||||
- **Language**: `typescript`
|
||||
- **Package Manager**: `pnpm`
|
||||
- **Framework**: `React`
|
||||
- **Build Tool**: `Vite`
|
||||
@@ -26,3 +32,39 @@
|
||||
|
||||
- **인증**: OIDC (OpenID Connect) 표준을 준수하여 인증을 구현합니다.
|
||||
- **데이터**: PoC(Proof of Concept) 레벨에서는 별도의 데이터베이스를 사용하지 않고, Mock 데이터를 활용하여 핵심 기능 개발에 집중합니다.
|
||||
|
||||
## 5. 개발 로드맵
|
||||
|
||||
### Phase 1: 기반 구축 및 핵심 기능 구현 (완료)
|
||||
|
||||
- **내용**: 프로젝트 초기 설정, 핵심 동적 UI 컴포넌트 개발 및 기본 페이지 구성을 완료했습니다.
|
||||
- **주요 산출물**:
|
||||
- `DynamicTable`, `DynamicForm` 컴포넌트
|
||||
- 피드백 CRUD 페이지
|
||||
- 기본 라우팅 설정
|
||||
|
||||
### Phase 2: 기능 고도화 및 안정화 (진행 중)
|
||||
|
||||
- **목표**: 사용자 경험을 개선하고 코드 품질을 향상시킵니다.
|
||||
- **완료된 작업**:
|
||||
- **동적 테이블 개선**: 페이지네이션, 열 정렬, 데이터 필터링, 날짜 범위 선택, 행 확장 등 고급 기능 추가
|
||||
- **상태 관리 도입**: Zustand를 활용하여 프로젝트 ID, 테마 등 전역 상태 관리 시스템 구축
|
||||
- **UI 개선**: 상단 헤더, 네비게이션 메뉴, Light/Dark/System 테마 전환 기능 구현
|
||||
- **진행할 작업**:
|
||||
- **테마 커스터마이징**: Dracula 테마를 다크 모드에 적용하고, 기본 테마를 라이트 모드로 설정
|
||||
- **동적 폼 개선**: Zod와 같은 라이브러리를 활용하여 스키마 기반의 동적 데이터 유효성 검사 구현
|
||||
|
||||
### Phase 3: 인증 및 배포 (예정)
|
||||
|
||||
- **목표**: OIDC 기반의 안정적인 인증 시스템을 구축하고, Docker를 통해 배포 환경을 마련합니다.
|
||||
- **주요 작업**:
|
||||
- **OIDC 연동**: OIDC 클라이언트 라이브러리를 설치하고, 로그인/로그아웃 및 토큰 관리 로직 구현
|
||||
- **인증 상태 관리**: 사용자의 로그인 상태를 전역으로 관리하고, 인증 상태에 따라 UI가 동적으로 변경되도록 설정
|
||||
- **라우트 보호**: 인증이 필요한 페이지에 접근 제어(Route Guard)를 적용하여 비인가 사용자의 접근 차단
|
||||
- **컨테이너화**: Dockerfile을 작성하고 Docker Compose를 설정하여 개발 및 프로덕션 환경을 컨테이너 기반으로 구축
|
||||
|
||||
## 6. 개발 원칙
|
||||
|
||||
- **코드 품질**: 모든 기능 구현 후, Biome 설정을 기반으로 코드를 정리하여 일관된 스타일과 높은 품질을 유지합니다.
|
||||
- **버전 관리**: 각 기능 단위의 개발이 완료되<EBA38C><EB9098> 정상 동작이 확인되면, 사용자에게 git commit 여부를 물어봅니다.
|
||||
|
||||
|
||||
42
Layout_fix.md
Normal file
42
Layout_fix.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 레이아웃 Y좌표 불일치 문제 해결 기록
|
||||
|
||||
## 1. 문제 상황
|
||||
|
||||
- `FeedbackListPage`, `IssueListPage`, `FeedbackDetailPage`, `FeedbackCreatePage` 등 여러 페이지의 제목과 메인 콘텐츠의 시작 위치(Y좌표)가 미세하게 달라 일관성이 없어 보임.
|
||||
|
||||
## 2. 시도 및 실패 원인 분석
|
||||
|
||||
### 시도 1: 모든 페이지에 `<Separator />` 추가
|
||||
- **내용**: 일부 페이지에만 있던 구분선을 모든 페이지에 추가하여 JSX 구조를 유사하게 만들었음.
|
||||
- **실패 원인**: 각 페이지의 최상위 `div`에 적용된 `space-y-4` 클래스가 문제였음. 이 클래스는 첫 번째 자식을 제외한 모든 자식에게 `margin-top`을 추가함. `Separator`를 추가하면서 마진이 적용되는 대상이 변경되었고, 이는 페이지마다 다른 결과를 낳아 여전히 불일치를 유발함.
|
||||
|
||||
### 시도 2: `space-y-4` 제거 및 제목 구조 통일
|
||||
- **내용**: 자동 여백을 제거하고, 모든 페이지의 제목 블록(`div` > `h1`+`p`) 구조를 동일하게 맞췄음.
|
||||
- **실패 원인**: 제목 블록 자체는 통일되었지만, 그 바로 다음에 오는 **콘텐츠 컴포넌트(`DynamicTable` vs `FeedbackFormCard`)가 달랐음.** 각 컴포넌트는 자신만의 기본 스타일과 최상위 태그(예: `Card`)를 가지고 있어, 제목 블록과의 상호작용에서 미세하게 다른 여백을 만들어냄.
|
||||
|
||||
### 시도 3: `items-start` 사용
|
||||
- **내용**: `flex` 컨테이너의 정렬을 `items-center`에서 `items-start`로 변경했음.
|
||||
- **실패 원인**: 이 방법은 제목 블록 *내부*의 요소들을 정렬하는 데는 유효했지만, 문제의 근본 원인인 **제목 블록과 그 아래 콘텐츠 사이의 간격**에는 아무런 영향을 주지 못했음. 완전히 잘못된 지점을 수정한 것임.
|
||||
|
||||
### 시도 4 & 5: `PageHeader/PageTitle` 및 `PageLayout` 컴포넌트 추상화
|
||||
- **내용**: 페이지의 상단부와 전체 레이아웃을 재사용 가능한 컴포넌트로 만들어 구조를 중앙화했음. 이는 소프트웨어 공학적으로 올바른 방향이었음.
|
||||
- **실패 원인**: 추상화는 올바랐지만, **`PageLayout`의 구현이 문제의 핵심을 해결하지 못했음.** `PageLayout`은 `PageTitle`과 그 아래의 `children`(메인 콘텐츠)을 그냥 연달아 렌더링했을 뿐, **두 요소 사이의 관계와 간격을 명시적으로 제어하지 않았음.** 결국, 각기 다른 `children` 컴포넌트가 `PageTitle`과 상호작용하며 발생하는 미세한 여백 차이를 막지 못함.
|
||||
|
||||
### 시도 6: `PageLayout` 내부 `div` 제거
|
||||
- **내용**: `PageLayout` 내부에서 `children`을 감싸던 불필요한 `div`를 제거하여 구조를 단순화했음.
|
||||
- **실패 원인**: 이 역시 문제의 진짜 원인이 아니었음. `PageLayout`의 구조는 이미 충분히 단순했음. 문제는 `PageLayout` *외부*에서, 즉 각 페이지에서 `children`으로 전달되는 컴포넌트들의 최상위 요소 스타일이 다르다는 점이었음.
|
||||
|
||||
---
|
||||
|
||||
## 3. 최종 해결 방안: `PageLayout`을 통한 명시적이고 일관된 콘텐츠 래핑
|
||||
|
||||
- **근본 원인 재정의**: `PageLayout`의 `children`으로 전달되는 `DynamicTable`과 `FeedbackFormCard`는 그 자체로 `Card` 컴포넌트를 최상위 요소로 가짐. 하지만 이 컴포넌트들이 렌더링될 때, React의 조건부 렌더링(`error && ...`, `schema && ...`)과 결합되면서 최종 DOM 구조에서 미세한 차이를 유발함.
|
||||
|
||||
- **해결책**: `PageLayout`이 `children`을 직접 렌더링하는 대신, **모든 `children`을 동일한 스타일의 `div`로 한번 감싸서 렌더링**하도록 `PageLayout` 자체를 수정한다. 이 `div`는 `PageTitle`의 `Separator`가 만드는 하단 여백(`mb-4`)을 받아, 그 아래에 위치하게 된다.
|
||||
|
||||
- **구현**:
|
||||
1. `PageLayout.tsx` 파일을 수정하여, `{children}`을 `<div className="page-content">{children}</div>`와 같이 명시적인 컨테이너로 감싼다. (클래스 이름은 설명을 위함이며, 실제로는 클래스가 필요 없을 수 있음)
|
||||
2. 이 컨테이너는 `PageTitle`의 `Separator` 바로 다음에 위치하게 되므로, 모든 페이지에서 동일한 Y좌표를 갖게 된다.
|
||||
3. 각 페이지에서는 `PageLayout`으로 감싸기만 하고, 추가적인 `div`나 여백 클래스를 사용하지 않는다.
|
||||
|
||||
이 방법은 `PageLayout`이 자신의 자식들을 어떻게 배치할지에 대한 **모든 제어권을 갖게** 하여, 외부(각 페이지)의 구조적 차이가 레이아웃에 영향을 미칠 가능성을 원천적으로 차단한다.
|
||||
60
TODO.md
60
TODO.md
@@ -1,27 +1,43 @@
|
||||
# Q&A 뷰어 프로젝트 TODO 리스트
|
||||
### 2025-08-05 13:56:51 KST
|
||||
- **Docker 환경 Nginx 프록시 오류 해결**: Docker 컨테이너 환경에서 Nginx가 API 서버로 요청을 올바르게 프록시하지 못하던 404 오류를 해결했습니다.
|
||||
- **원인 분석**: `proxy_pass`에 `http://`와 경로가 포함된 환경 변수가 그대로 사용되어 `upstream` 설정 오류가 발생하고, 경로가 잘못 조합되는 문제를 확인했습니다.
|
||||
- **해결**: `docker-entrypoint.sh` 스크립트에서 기존 `VITE_API_PROXY_TARGET` 변수를 `VITE_API_HOST` (호스트:포트)와 `VITE_API_DIR` (경로)로 분리하도록 수정했습니다.
|
||||
- **Nginx 설정 수정**: `nginx.conf.template`에서 `upstream` 블록은 `${VITE_API_HOST}`를 사용하고, `proxy_pass`에서는 `${VITE_API_DIR}`를 사용하여 최종 경로를 조합하도록 변경하여 문제를 근본적으로 해결했습니다.
|
||||
|
||||
## Phase 1: 기반 구축 및 핵심 기능 구현
|
||||
### 2025-08-05 11:27:58 KST
|
||||
- **빌드 오류 수정**: `pnpm build` 시 발생하던 12개의 타입스크립트 오류를 모두 해결하여 빌드 프로세스를 안정화했습니다.
|
||||
- `DynamicForm`: `value` prop의 타입 불일치 오류를 해결했습니다.
|
||||
- `DynamicTable`: 존재하지 않는 속성(`minSize`, `maxSize`) 접근 오류를 수정했습니다.
|
||||
- `Header`: 사용되지 않는 변수(`homePath`)를 제거했습니다.
|
||||
- `main.tsx`: `ThemeProvider`에 잘못 전달된 prop을 제거했습니다.
|
||||
- `FeedbackListPage`: 데이터 타입 불일치로 인해 발생하던 다양한 오류들을 해결했습니다.
|
||||
- **빌드 성능 최적화**: `React.lazy`와 `Suspense`를 사용하여 페이지 컴포넌트를 동적으로 가져오도록(Code Splitting) 구현했습니다. 이를 통해 초기 로딩 시 번들 크기를 줄여 성능을 개선하고, 빌드 시 발생하던 청크 크기 경고를 해결했습니다.
|
||||
- **개발 환경 개선**: `pnpm install` 또는 `biome` 실행 시 나타나던 `npm warn` 경고를 해결하기 위해 프로젝트 루트에 `.npmrc` 파일을 추가하여 전역 설정을 덮어쓰도록 조치했습니다.
|
||||
|
||||
- [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)
|
||||
2025-08-05 11:00:00 KST - Descope 연동 완료
|
||||
|
||||
## Phase 2: 기능 고도화 및 안정화
|
||||
---
|
||||
- **사용자 인증**: Descope SDK를 연동하여 로그인, 로그아웃, 사용자 정보 조회 기능 구현.
|
||||
- **UI 개선**:
|
||||
- 로그인 상태에 따라 사용자 프로필 아이콘(사진/이니셜/기본 아이콘) 동적 변경.
|
||||
- 테마(Light/Dark)에 따라 로고 이미지 자동 전환.
|
||||
- 로그아웃 시 홈페이지로 리디렉션.
|
||||
- **피드백 작성 연동**:
|
||||
- 새 피드백 작성 시, 로그인한 사용자 정보를 '작성자' 필드에 자동으로 채우고 수정 불가 처리.
|
||||
- 로그인하지 않은 경우 '새 피드백 작성' 버튼 숨김 처리.
|
||||
- **무한 렌더링 버그 수정**: `FeedbackCreatePage`에서 `useEffect` 의존성 문제로 발생하던 무한 렌더링 오류 해결.
|
||||
- **Favicon 설정**: 프로젝트 favicon을 올바르게 표시하도록 `index.html` 수정.
|
||||
|
||||
- [ ] 동적 테이블에 페이지네이션, 정렬, 필터링 기능 추가
|
||||
- [ ] 동적 폼에 데이터 유효성 검사 기능 추가
|
||||
- [ ] 전역 상태 관리를 위한 Zustand 도입 검토
|
||||
- [ ] Biome을 이용한 코드 포맷팅 및 린트 규칙 적용 및 검사
|
||||
2025-08-05 10:30:00 KST - 이슈 API 연동 및 UI 개선 완료
|
||||
|
||||
## Phase 3: 인증 및 배포
|
||||
|
||||
- [ ] OIDC 클라이언트 연동 및 인증 로직 구현
|
||||
- [ ] 로그인/로그아웃 및 인증 상태 관리
|
||||
- [ ] 인증이 필요한 라우트 보호 기능 적용
|
||||
- [ ] Docker를 이용한 배포 환경 구축
|
||||
---
|
||||
### 2025-08-04 20:00:27 KST
|
||||
- **레이아웃 안정성 및 반응형 개선 (완료)**
|
||||
- **페이지 레이아웃 고정**: 테이블 행 확장 시 페이지 전체가 밀리는 현상 수정. `PageTitle`을 상단에 고정하고 컨텐츠 영역만 스크롤되도록 `MainLayout` 및 `PageLayout` 구조 개선.
|
||||
- **일관된 페이지 너비 적용**: 목록 페이지와 상세 페이지의 너비가 다른 문제 해결. 모든 페이지가 최대 1400px 너비를 갖도록 `container` 사용법 통일.
|
||||
- **반응형 헤더 구현**: 모바일 화면 크기에서 햄버거 메뉴가 나타나도록 `Header` 컴포넌트 개선.
|
||||
- **컴포넌트 리팩터링**: `IssueDetailPage`의 UI를 재사용 가능한 `IssueDetailCard` 컴포넌트로 분리.
|
||||
- **UI/UX 개선**:
|
||||
- `DynamicTable`의 검색창과 카드 간 여백 조정.
|
||||
- `IssueDetailCard`의 제목, 레이블, 컨텐츠 스타일을 개선하여 가독성 향상.
|
||||
- **접근성(a11y) 수정**: `DynamicTable`의 컬럼 리사이저에 `slider` 역할을 부여하여 웹 접근성 lint 오류 해결.
|
||||
855
descope-react-sdk-readmd.md
Normal file
855
descope-react-sdk-readmd.md
Normal file
@@ -0,0 +1,855 @@
|
||||
# Descope SDK for React
|
||||
|
||||
The Descope SDK for React provides convenient access to the Descope for an application written on top of React. You can read more on the [Descope Website](https://descope.com).
|
||||
|
||||
## Requirements
|
||||
|
||||
- The SDK supports React version 16 and above.
|
||||
- A Descope `Project ID` is required for using the SDK. Find it on the [project page in the Descope Console](https://app.descope.com/settings/project).
|
||||
|
||||
## Installing the SDK
|
||||
|
||||
Install the package with:
|
||||
|
||||
```bash
|
||||
npm i --save @descope/react-sdk
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Wrap your app with Auth Provider
|
||||
|
||||
```js
|
||||
import { AuthProvider } from '@descope/react-sdk';
|
||||
|
||||
const AppRoot = () => {
|
||||
return (
|
||||
<AuthProvider
|
||||
projectId="my-project-id"
|
||||
// If the Descope project manages the token response in cookies, a custom domain
|
||||
// must be configured (e.g., https://auth.app.example.com)
|
||||
// and should be set as the baseUrl property.
|
||||
// baseUrl = "https://auth.app.example.com"
|
||||
>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Use Descope to render specific flow
|
||||
|
||||
You can use **default flows** or **provide flow id** directly to the Descope component
|
||||
|
||||
#### 1. Default flows
|
||||
|
||||
```js
|
||||
import { SignInFlow } from '@descope/react-sdk'
|
||||
// you can choose flow to run from the following
|
||||
// import { SignUpFlow } from '@descope/react-sdk'
|
||||
// import { SignUpOrInFlow } from '@descope/react-sdk'
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
{...}
|
||||
<SignInFlow
|
||||
onSuccess={(e) => console.log('Logged in!')}
|
||||
onError={(e) => console.log('Could not logged in!')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Provide flow id
|
||||
|
||||
```js
|
||||
import { Descope } from '@descope/react-sdk'
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
{...}
|
||||
<Descope
|
||||
flowId="my-flow-id"
|
||||
onSuccess={(e) => console.log('Logged in!')}
|
||||
onError={(e) => console.log('Could not logged in')}
|
||||
// onReady={() => {
|
||||
// This event is triggered when the flow is ready to be displayed
|
||||
// Its useful for showing a loading indication before the page ready
|
||||
// console.log('Flow is ready');
|
||||
// }}
|
||||
|
||||
// theme can be "light", "dark" or "os", which auto select a theme based on the OS theme. Default is "light"
|
||||
// theme="dark"
|
||||
|
||||
// locale can be any supported locale which the flow's screen translated to, if not provided, the locale is taken from the browser's locale.
|
||||
// locale="en"
|
||||
|
||||
// debug can be set to true to enable debug mode
|
||||
// debug={true}
|
||||
|
||||
// tenant ID for SSO (SAML) login. If not provided, Descope will use the domain of available email to choose the tenant
|
||||
// tenant=<tenantId>
|
||||
|
||||
// Redirect URL for OAuth and SSO (will be used when redirecting back from the OAuth provider / IdP), or for "Magic Link" and "Enchanted Link" (will be used as a link in the message sent to the the user)
|
||||
// redirectUrl=<redirectUrl>
|
||||
|
||||
// autoFocus can be true, false or "skipFirstScreen". Default is true.
|
||||
// - true: automatically focus on the first input of each screen
|
||||
// - false: do not automatically focus on screen's inputs
|
||||
// - "skipFirstScreen": automatically focus on the first input of each screen, except first screen
|
||||
// autoFocus="skipFirstScreen"
|
||||
|
||||
// validateOnBlur: set it to true will show input validation errors on blur, in addition to on submit
|
||||
|
||||
// restartOnError: if set to true, in case of flow version mismatch, will restart the flow if the components version was not changed. Default is false
|
||||
|
||||
// errorTransformer is a function that receives an error object and returns a string. The returned string will be displayed to the user.
|
||||
// NOTE: errorTransformer is not required. If not provided, the error object will be displayed as is.
|
||||
// Example:
|
||||
// const errorTransformer = useCallback(
|
||||
// (error: { text: string; type: string }) => {
|
||||
// const translationMap = {
|
||||
// SAMLStartFailed: 'Failed to start SAML flow'
|
||||
// };
|
||||
// return translationMap[error.type] || error.text;
|
||||
// },
|
||||
// []
|
||||
// );
|
||||
// ...
|
||||
// errorTransformer={errorTransformer}
|
||||
// ...
|
||||
|
||||
|
||||
// form is an object the initial form context that is used in screens inputs in the flow execution.
|
||||
// Used to inject predefined input values on flow start such as custom inputs, custom attributes and other inputs.
|
||||
// Keys passed can be accessed in flows actions, conditions and screens prefixed with "form.".
|
||||
// NOTE: form is not required. If not provided, 'form' context key will be empty before user input.
|
||||
// Example:
|
||||
// ...
|
||||
// form={{ email: "predefinedname@domain.com", firstName: "test", "customAttribute.test": "aaaa", "myCustomInput": 12 }}
|
||||
// ...
|
||||
|
||||
|
||||
// client is an object the initial client context in the flow execution.
|
||||
// Keys passed can be accessed in flows actions and conditions prefixed with "client.".
|
||||
// NOTE: client is not required. If not provided, context key will be empty.
|
||||
// Example:
|
||||
// ...
|
||||
// client={{ version: "1.2.0" }}
|
||||
// ...
|
||||
|
||||
|
||||
// logger is an object describing how to log info, warn and errors.
|
||||
// NOTE: logger is not required. If not provided, the logs will be printed to the console.
|
||||
// Example:
|
||||
// const logger = {
|
||||
// info: (title: string, description: string, state: any) => {
|
||||
// console.log(title, description, JSON.stringify(state));
|
||||
// },
|
||||
// warn: (title: string, description: string) => {
|
||||
// console.warn(title);
|
||||
// },
|
||||
// error: (title: string, description: string) => {
|
||||
// console.error('OH NOO');
|
||||
// },
|
||||
// }
|
||||
// ...
|
||||
// logger={logger}
|
||||
// ...
|
||||
|
||||
|
||||
// Use a custom style name or keep empty to use the default style.
|
||||
// styleId="my-awesome-style"
|
||||
// Set a CSP nonce that will be used for style and script tags
|
||||
//nonce="rAnd0m"
|
||||
|
||||
// Clear screen error message on user input
|
||||
//dismissScreenErrorOnInput={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### `onScreenUpdate`
|
||||
|
||||
A function that is called whenever there is a new screen state and after every next call. It receives the following parameters:
|
||||
|
||||
- `screenName`: The name of the screen that is about to be rendered
|
||||
- `context`: An object containing the upcoming screen state
|
||||
- `next`: A function that, when called, continues the flow execution
|
||||
- `ref`: A reference to the descope-wc node
|
||||
|
||||
The function can be sync or async, and should return a boolean indicating whether a custom screen should be rendered:
|
||||
|
||||
- `true`: Render a custom screen
|
||||
- `false`: Render the default flow screen
|
||||
|
||||
This function allows rendering custom screens instead of the default flow screens.
|
||||
It can be useful for highly customized UIs or specific logic not covered by the default screens
|
||||
|
||||
To render a custom screen, its elements should be appended as children of the `Descope` component
|
||||
|
||||
Usage example:
|
||||
|
||||
```javascript
|
||||
const CustomScreen = ({onClick, setForm}) => {
|
||||
const onChange = (e) => setForm({ email: e.target.value })
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
onChange={onChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
const Login = () => {
|
||||
const [state, setState] = useState();
|
||||
const [form, setForm] = useState();
|
||||
|
||||
const onScreenUpdate = (screenName, context, next) => {
|
||||
setState({screenName, context, next})
|
||||
|
||||
if (screenName === 'My Custom Screen') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return <Descope
|
||||
...
|
||||
onScreenUpdate={onScreenUpdate}
|
||||
>{state.screenName === 'My Custom Screen' && <CustomScreen
|
||||
onClick={() => {
|
||||
// replace with the button interaction id
|
||||
state.next('interactionId', form)
|
||||
}}
|
||||
setForm={setForm}/>}
|
||||
</Descope>
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### Use the `useDescope`, `useSession` and `useUser` hooks in your components in order to get authentication state, user details and utilities
|
||||
|
||||
This can be helpful to implement application-specific logic. Examples:
|
||||
|
||||
- Render different components if current session is authenticated
|
||||
- Render user's content
|
||||
- Logout button
|
||||
|
||||
```js
|
||||
import { useDescope, useSession, useUser } from '@descope/react-sdk';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
const App = () => {
|
||||
// NOTE - `useDescope`, `useSession`, `useUser` should be used inside `AuthProvider` context,
|
||||
// and will throw an exception if this requirement is not met
|
||||
// useSession retrieves authentication state, session loading status, and session token
|
||||
// If the session token is managed in cookies in project settings, sessionToken will be empty.
|
||||
const { isAuthenticated, isSessionLoading, sessionToken } = useSession();
|
||||
// useUser retrieves the logged in user information
|
||||
const { user, isUserLoading } = useUser();
|
||||
// useDescope retrieves Descope SDK for further operations related to authentication
|
||||
// such as logout
|
||||
const sdk = useDescope();
|
||||
|
||||
if (isSessionLoading || isUserLoading) {
|
||||
return <p>Loading...</p>;
|
||||
}
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
sdk.logout();
|
||||
}, [sdk]);
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
<p>Hello {user.name}</p>
|
||||
<button onClick={handleLogout}>Logout</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <p>You are not logged in</p>;
|
||||
};
|
||||
```
|
||||
|
||||
Note: `useSession` triggers a single request to the Descope backend to attempt to refresh the session. If you **don't** `useSession` on your app, the session will not be refreshed automatically. If your app does not require `useSession`, you can trigger the refresh manually by calling `refresh` from `useDescope` hook. Example:
|
||||
|
||||
```js
|
||||
const { refresh } = useDescope();
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
```
|
||||
|
||||
|
||||
### Auto refresh session token
|
||||
Descope SDK automatically refreshes the session token when it is about to expire. This is done in the background using the refresh token, without any additional configuration.
|
||||
If you want to disable this behavior, you can pass `autoRefresh={false}` to the `AuthProvider` component. This will prevent the SDK from automatically refreshing the session token.
|
||||
|
||||
**For more SDK usage examples refer to [docs](https://docs.descope.com/build/guides/client_sdks/)**
|
||||
|
||||
### Session token server validation (pass session token to server API)
|
||||
|
||||
When developing a full-stack application, it is common to have private server API which requires a valid session token:
|
||||
|
||||

|
||||
|
||||
Note: Descope also provides server-side SDKs in various languages (NodeJS, Go, Python, etc). Descope's server SDKs have out-of-the-box session validation API that supports the options described bellow. To read more about session validation, Read [this section](https://docs.descope.com/build/guides/gettingstarted/#session-validation) in Descope documentation.
|
||||
|
||||
There are 2 ways to achieve that:
|
||||
|
||||
1. Using `getSessionToken` to get the token, and pass it on the `Authorization` Header (Recommended)
|
||||
2. Passing `sessionTokenViaCookie` boolean prop to the `AuthProvider` component (Use cautiously, session token may grow, especially in cases of using authorization, or adding custom claim)
|
||||
|
||||
#### 1. Using `getSessionToken` to get the token
|
||||
|
||||
An example for api function, and passing the token on the `Authorization` header:
|
||||
|
||||
```js
|
||||
import { getSessionToken } from '@descope/react-sdk';
|
||||
|
||||
// fetch data using back
|
||||
// Note: Descope backend SDKs support extracting session token from the Authorization header
|
||||
export const fetchData = async () => {
|
||||
const sessionToken = getSessionToken();
|
||||
const res = await fetch('/path/to/server/api', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${sessionToken}`,
|
||||
},
|
||||
});
|
||||
// ... use res
|
||||
};
|
||||
```
|
||||
|
||||
An example for component that uses `fetchData` function from above
|
||||
|
||||
```js
|
||||
// Component code
|
||||
import { fetchData } from 'path/to/api/file'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
const Component = () => {
|
||||
const onClick = useCallback(() => {
|
||||
fetchData()
|
||||
},[])
|
||||
return (
|
||||
{...}
|
||||
{
|
||||
// button that triggers an API that may use session token
|
||||
<button onClick={onClick}>Click Me</button>
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Note that ff Descope project settings are configured to manage session token in cookies, the `getSessionToken` function will return an empty string.
|
||||
|
||||
#### 2. Passing `sessionTokenViaCookie` boolean prop to the `AuthProvider`
|
||||
|
||||
Passing `sessionTokenViaCookie` prop to `AuthProvider` component. Descope SDK will automatically store session token on the `DS` cookie.
|
||||
|
||||
Note: Use this option if session token will stay small (less than 1k). Session token can grow, especially in cases of using authorization, or adding custom claims
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
import { AuthProvider } from '@descope/react-sdk';
|
||||
|
||||
const AppRoot = () => {
|
||||
return (
|
||||
<AuthProvider projectId="my-project-id" sessionTokenViaCookie>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Now, whenever you call `fetch`, the cookie will automatically be sent with the request. Descope backend SDKs also support extracting the token from the `DS` cookie.
|
||||
|
||||
Note:
|
||||
The session token cookie is set as a [`Secure`](https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.5) cookie. It will be sent only over HTTPS connections.
|
||||
In addition, some browsers (e.g. Safari) may not store `Secure` cookie if the hosted page is running on an HTTP protocol.
|
||||
|
||||
The session token cookie is set to [`SameSite=Strict; Secure;`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) by default.
|
||||
If you need to customize this, you can set `sessionTokenViaCookie={sameSite: 'Lax', secure: false}` (if you pass only `sameSite`, `secure` will be set to `true` by default).
|
||||
|
||||
#### 3. Configure Descope project to manage session token in cookies
|
||||
|
||||
If project settings are configured to manage session token in cookies, Descope services will automatically set the session token in the `DS` cookie as a `Secure` and `HttpOnly` cookie. In this case, the session token will not be stored in the browser's and will not be accessible to the client-side code using `useSession` or `getSessionToken`.
|
||||
|
||||
````js
|
||||
### Helper Functions
|
||||
|
||||
You can also use the following functions to assist with various actions managing your JWT.
|
||||
|
||||
`getSessionToken()` - Get current session token.
|
||||
`getRefreshToken()` - Get current refresh token. Note: Relevant only if the refresh token is stored in local storage. If the refresh token is stored in an `httpOnly` cookie, it will return an empty string.
|
||||
`refresh(token = getRefreshToken())` - Force a refresh on current session token using an existing valid refresh token.
|
||||
`isSessionTokenExpired(token = getSessionToken())` - Check whether the current session token is expired. Provide a session token if is not persisted (see [token persistence](#token-persistence)).
|
||||
`isRefreshTokenExpired(token = getRefreshToken())` - Check whether the current refresh token is expired. Provide a refresh token if is not persisted (see [token persistence](#token-persistence)).
|
||||
`getJwtRoles(token = getSessionToken(), tenant = '')` - Get current roles from an existing session token. Provide tenant id for specific tenant roles.
|
||||
`getJwtPermissions(token = getSessionToken(), tenant = '')` - Fet current permissions from an existing session token. Provide tenant id for specific tenant permissions.
|
||||
`getCurrentTenant(token = getSessionToken())` - Get current tenant id from an existing session token (from the `dct` claim).
|
||||
|
||||
### Refresh token lifecycle
|
||||
|
||||
Descope SDK is automatically refreshes the session token when it is about to expire. This is done in the background using the refresh token, without any additional configuration.
|
||||
|
||||
If the Descope project settings are configured to manage tokens in cookies.
|
||||
you must also configure a custom domain, and set it as the `baseUrl` prop in the `AuthProvider` component. See the above [`AuthProvider` usage](#wrap-your-app-with-auth-provider) for usage example.
|
||||
|
||||
### Token Persistence
|
||||
|
||||
Descope stores two tokens: the session token and the refresh token.
|
||||
|
||||
- The refresh token is either stored in local storage or an `httpOnly` cookie. This is configurable in the Descope console.
|
||||
- The session token is stored in either local storage or a JS cookie. This behavior is configurable via the `sessionTokenViaCookie` prop in the `AuthProvider` component.
|
||||
|
||||
However, for security reasons, you may choose not to store tokens in the browser. In this case, you can pass `persistTokens={false}` to the `AuthProvider` component. This prevents the SDK from storing the tokens in the browser.
|
||||
|
||||
Notes:
|
||||
|
||||
- You must configure the refresh token to be stored in an `httpOnly` cookie in the Descope console. Otherwise, the refresh token will not be stored, and when the page is refreshed, the user will be logged out.
|
||||
- You can still retrieve the session token using the `useSession` hook.
|
||||
|
||||
### Custom Refresh Cookie Name
|
||||
|
||||
When managing multiple Descope projects on the same domain, you can avoid refresh cookie conflicts by assigning a custom cookie name to your refresh token during the login process (for example, using Descope Flows). However, you must also configure the SDK to recognize this unique name by passing the `refreshCookieName` prop to the `AuthProvider` component.
|
||||
|
||||
This will signal Descope API to use the custom cookie name as the refresh token.
|
||||
|
||||
Note that this option is only available when the refresh token managed on cookies.
|
||||
|
||||
```js
|
||||
import { AuthProvider } from '@descope/react-sdk';
|
||||
|
||||
const AppRoot = () => {
|
||||
// pass the custom cookie name to the AuthProvider
|
||||
return (
|
||||
<AuthProvider projectId="my-project-id" refreshCookieName="MY_DSR">
|
||||
<App />
|
||||
</AuthProvider>
|
||||
);
|
||||
};
|
||||
````
|
||||
|
||||
### Last User Persistence
|
||||
|
||||
Descope stores the last user information in local storage. If you wish to disable this feature, you can pass `storeLastAuthenticatedUser={false}` to the `AuthProvider` component. Please note that some features related to the last authenticated user may not function as expected if this behavior is disabled. Local storage is being cleared when the user logs out, if you want the avoid clearing the local storage, you can pass `keepLastAuthenticatedUserAfterLogout={true}` to the `AuthProvider` component.
|
||||
|
||||
### Seamless Session Migration
|
||||
|
||||
If you are migrating from an external authentication provider to Descope, you can use the `getExternalToken` prop in the `AuthProvider` component. This function should return a valid token from the external provider. The SDK will then use this token to authenticate the user with Descope.
|
||||
|
||||
```js
|
||||
import { AuthProvider } from '@descope/react-sdk';
|
||||
|
||||
const AppRoot = () => {
|
||||
return (
|
||||
<AuthProvider
|
||||
projectId="my-project-id"
|
||||
getExternalToken={async () => {
|
||||
// Bring token from external provider (e.g. get access token from another auth provider)
|
||||
return 'my-external-token';
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Widgets
|
||||
|
||||
Widgets are components that allow you to expose management features for tenant-based implementation. In certain scenarios, your customers may require the capability to perform managerial actions independently, alleviating the necessity to contact you. Widgets serve as a feature enabling you to delegate these capabilities to your customers in a modular manner.
|
||||
|
||||
Important Note:
|
||||
|
||||
- For the user to be able to use the widget, they need to be assigned the `Tenant Admin` Role.
|
||||
|
||||
#### User Management
|
||||
|
||||
The `UserManagement` widget lets you embed a user table in your site to view and take action.
|
||||
|
||||
The widget lets you:
|
||||
|
||||
- Create a new user
|
||||
- Edit an existing user
|
||||
- Activate / disable an existing user
|
||||
- Reset an existing user's password
|
||||
- Remove an existing user's passkey
|
||||
- Delete an existing user
|
||||
|
||||
Note:
|
||||
|
||||
- Custom fields also appear in the table.
|
||||
|
||||
###### Usage
|
||||
|
||||
```js
|
||||
import { UserManagement } from '@descope/react-sdk';
|
||||
...
|
||||
<UserManagement
|
||||
widgetId="user-management-widget"
|
||||
tenant="tenant-id"
|
||||
/>
|
||||
```
|
||||
|
||||
Example:
|
||||
[Manage Users](./examples/app/ManageUsers.tsx)
|
||||
|
||||
#### Role Management
|
||||
|
||||
The `RoleManagement` widget lets you embed a role table in your site to view and take action.
|
||||
|
||||
The widget lets you:
|
||||
|
||||
- Create a new role
|
||||
- Change an existing role's fields
|
||||
- Delete an existing role
|
||||
|
||||
Note:
|
||||
|
||||
- The `Editable` field is determined by the user's access to the role - meaning that project-level roles are not editable by tenant level users.
|
||||
- You need to pre-define the permissions that the user can use, which are not editable in the widget.
|
||||
|
||||
###### Usage
|
||||
|
||||
```js
|
||||
import { RoleManagement } from '@descope/react-sdk';
|
||||
...
|
||||
<RoleManagement
|
||||
widgetId="role-management-widget"
|
||||
tenant="tenant-id"
|
||||
/>
|
||||
```
|
||||
|
||||
Example:
|
||||
[Manage Roles](./examples/app/ManageRoles.tsx)
|
||||
|
||||
#### Access Key Management
|
||||
|
||||
The `AccessKeyManagement` widget lets you embed an access key table in your site to view and take action.
|
||||
|
||||
The widget lets you:
|
||||
|
||||
- Create a new access key
|
||||
- Activate / deactivate an existing access key
|
||||
- Delete an exising access key
|
||||
|
||||
###### Usage
|
||||
|
||||
```js
|
||||
import { AccessKeyManagement } from '@descope/react-sdk';
|
||||
...
|
||||
{
|
||||
/* admin view: manage all tenant users' access keys */
|
||||
}
|
||||
<AccessKeyManagement
|
||||
widgetId="access-key-management-widget"
|
||||
tenant="tenant-id"
|
||||
/>
|
||||
|
||||
{
|
||||
/* user view: mange access key for the logged-in tenant's user */
|
||||
}
|
||||
<AccessKeyManagement
|
||||
widgetId="user-access-key-management-widget"
|
||||
tenant="tenant-id"
|
||||
/>
|
||||
```
|
||||
|
||||
Example:
|
||||
[Manage Access Keys](./examples/app/ManageAccessKeys.tsx)
|
||||
|
||||
#### Audit Management
|
||||
|
||||
The `AuditManagement` widget lets you embed an audit table in your site.
|
||||
|
||||
###### Usage
|
||||
|
||||
```js
|
||||
import { AuditManagement } from '@descope/react-sdk';
|
||||
...
|
||||
<AuditManagement
|
||||
widgetId="audit-management-widget"
|
||||
tenant="tenant-id"
|
||||
/>
|
||||
```
|
||||
|
||||
Example:
|
||||
[Manage Audit](./examples/app/ManageAudit.tsx)
|
||||
|
||||
#### User Profile
|
||||
|
||||
The `UserProfile` widget lets you embed a user profile component in your app and let the logged in user update his profile.
|
||||
|
||||
The widget lets you:
|
||||
|
||||
- Update user profile picture
|
||||
- Update user personal information
|
||||
- Update authentication methods
|
||||
- Logout
|
||||
|
||||
###### Usage
|
||||
|
||||
```js
|
||||
import { UserProfile } from '@descope/react-sdk';
|
||||
...
|
||||
<UserProfile
|
||||
widgetId="user-profile-widget"
|
||||
onLogout={() => {
|
||||
// add here you own logout callback
|
||||
window.location.href = '/login';
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
Example:
|
||||
[User Profile](./examples/app/MyUserProfile.tsx)
|
||||
|
||||
#### Applications Portal
|
||||
|
||||
The `ApplicationsPortal` lets you embed an applications portal component in your app and allows the logged-in user to open applications they are assigned to.
|
||||
|
||||
###### Usage
|
||||
|
||||
```js
|
||||
import { ApplicationsPortal } from '@descope/react-sdk';
|
||||
...
|
||||
<ApplicationsPortal
|
||||
widgetId="applications-portal-widget"
|
||||
/>
|
||||
```
|
||||
|
||||
Example:
|
||||
[Applications Portal](./examples/app/MyApplicationsPortal.tsx)
|
||||
|
||||
## Code Example
|
||||
|
||||
You can find an example react app in the [examples folder](./examples).
|
||||
|
||||
### Setup
|
||||
|
||||
To run the examples, set your `Project ID` by setting the `DESCOPE_PROJECT_ID` env var or directly
|
||||
in the sample code.
|
||||
Find your Project ID in the [Descope console](https://app.descope.com/settings/project).
|
||||
|
||||
```bash
|
||||
export DESCOPE_PROJECT_ID=<Project-ID>
|
||||
```
|
||||
|
||||
Alternatively, put the environment variable in `.env` file in the project root directory.
|
||||
See bellow for an `.env` file template with more information.
|
||||
|
||||
### Run Example
|
||||
|
||||
Note: Due to an issue with react-sdk tsconfig, you need to remove `"examples"` from the `exclude` field in the `tsconfig.json` file in the root of the project before running the example.
|
||||
|
||||
Run the following command in the root of the project to build and run the example:
|
||||
|
||||
```bash
|
||||
npm i && npm start
|
||||
```
|
||||
|
||||
### Example Optional Env Variables
|
||||
|
||||
See the following table for customization environment variables for the example app:
|
||||
|
||||
| Env Variable | Description | Default value |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------- |
|
||||
| DESCOPE_FLOW_ID | Which flow ID to use in the login page | **sign-up-or-in** |
|
||||
| DESCOPE_BASE_URL | Custom Descope base URL | None |
|
||||
| DESCOPE_BASE_STATIC_URL | Allows to override the base URL that is used to fetch static files | https://static.descope.com/pages |
|
||||
| DESCOPE_THEME | Flow theme | None |
|
||||
| DESCOPE_LOCALE | Flow locale | Browser's locale |
|
||||
| DESCOPE_REDIRECT_URL | Flow redirect URL for OAuth/SSO/Magic Link/Enchanted Link | None |
|
||||
| DESCOPE_TENANT_ID | Flow tenant ID for SSO/SAML | None |
|
||||
| DESCOPE_DEBUG_MODE | **"true"** - Enable debugger</br>**"false"** - Disable flow debugger | None |
|
||||
| DESCOPE_STEP_UP_FLOW_ID | Step up flow ID to show to logged in user (via button). e.g. "step-up". Button will be hidden if not provided | None |
|
||||
| DESCOPE_TELEMETRY_KEY | **String** - Telemetry public key provided by Descope Inc | None |
|
||||
| | | |
|
||||
| DESCOPE_OIDC_ENABLED | **"true"** - Use OIDC login | None |
|
||||
| DESCOPE_OIDC_APPLICATION_ID | Descope OIDC Application ID, In case OIDC login is used | None |
|
||||
|
||||
Example for `.env` file template:
|
||||
|
||||
```
|
||||
# Your project ID
|
||||
DESCOPE_PROJECT_ID="<Project-ID>"
|
||||
# Login flow ID
|
||||
DESCOPE_FLOW_ID=""
|
||||
# Descope base URL
|
||||
DESCOPE_BASE_URL=""
|
||||
# Descope base static URL
|
||||
DESCOPE_BASE_STATIC_URL=""
|
||||
# Set flow theme to dark
|
||||
DESCOPE_THEME=dark
|
||||
# Set flow locale, default is browser's locale
|
||||
DESCOPE_LOCALE=""
|
||||
# Flow Redirect URL
|
||||
DESCOPE_REDIRECT_URL=""
|
||||
# Tenant ID
|
||||
DESCOPE_TENANT_ID=""
|
||||
# Enable debugger
|
||||
DESCOPE_DEBUG_MODE=true
|
||||
# Show step-up flow for logged in user
|
||||
DESCOPE_STEP_UP_FLOW_ID=step-up
|
||||
# Telemetry key
|
||||
DESCOPE_TELEMETRY_KEY=""
|
||||
```
|
||||
|
||||
## Performance / Bundle Size
|
||||
|
||||
To improve modularity and reduce bundle size, all flow-related utilities are available also under `@descope/react-sdk/flows` subpath. Example:
|
||||
|
||||
```
|
||||
import { Descope, useSession, ... } from '@descope/react-sdk/flows';
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
### I updated the user in my backend, but the user / session token are not updated in the frontend
|
||||
|
||||
The Descope SDK caches the user and session token in the frontend. If you update the user in your backend (using Descope Management SDK/API for example), you can call `me` / `refresh` from `useDescope` hook to refresh the user and session token. Example:
|
||||
|
||||
```js
|
||||
const sdk = useDescope();
|
||||
|
||||
const handleUpdateUser = useCallback(() => {
|
||||
myBackendUpdateUser().then(() => {
|
||||
sdk.me();
|
||||
// or
|
||||
sdk.refresh();
|
||||
});
|
||||
}, [sdk]);
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more please see the [Descope Documentation and API reference page](https://docs.descope.com/).
|
||||
|
||||
## OIDC Login
|
||||
|
||||
Descope also supports OIDC login. To enable OIDC login, pass `oidcConfig` prop to the `AuthProvider` component. Example:
|
||||
|
||||
### AuthProvider setup with OIDC
|
||||
|
||||
```js
|
||||
import { AuthProvider } from '@descope/react-sdk';
|
||||
|
||||
const AppRoot = () => {
|
||||
return (
|
||||
<AuthProvider
|
||||
projectId="my-project-id" // also serves as the client ID
|
||||
oidcConfig={true}
|
||||
|
||||
/* alternatively, you can pass the oidcConfig object
|
||||
oidcConfig={{
|
||||
applicationId: 'my-application-id', // optional, if not provided, the default OIDC application will be used
|
||||
|
||||
redirectUri: 'https://my-app.com/redirect', // optional, if not provided, the default redirect URI will be used
|
||||
|
||||
|
||||
scope: 'openid profile email', // optional, if not provided, default is openid email offline_access roles descope.custom_claims
|
||||
}}
|
||||
*/
|
||||
>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Login
|
||||
|
||||
Use the `oidc.loginWithRedirect` method from the `useDescope` hook to trigger the OIDC login. Example:
|
||||
|
||||
```js
|
||||
const MyComponent = () => {
|
||||
const sdk = useDescope();
|
||||
|
||||
return (
|
||||
// ...
|
||||
<button
|
||||
onClick={() => {
|
||||
sdk.oidc.loginWithRedirect({
|
||||
// By default, the login will redirect the user to the current URL
|
||||
// If you want to redirect the user to a different URL, you can specify it here
|
||||
redirect_uri: window.location.origin,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Login with OIDC
|
||||
</button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Redirect back from OIDC provider
|
||||
|
||||
The `AuthProvider` will automatically handle the redirect back from the OIDC provider. The user will be redirected to the `redirect_uri` specified in the `oidc.login` method.
|
||||
|
||||
### Logout
|
||||
|
||||
You can call `sdk.logout` to logout the user. Example:
|
||||
|
||||
```js
|
||||
const MyComponent = () => {
|
||||
const sdk = useDescope();
|
||||
|
||||
return (
|
||||
// ...
|
||||
<button
|
||||
onClick={() => {
|
||||
sdk.logout();
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
If you want to redirect the user to a different URL after logout, you can use `oidc.logout` method. Example:
|
||||
|
||||
```js
|
||||
const MyComponent = () => {
|
||||
const sdk = useDescope();
|
||||
|
||||
return (
|
||||
// ...
|
||||
<button
|
||||
onClick={() => {
|
||||
sdk.oidc.logout({
|
||||
// by default, the logout will redirect the user to the current URL
|
||||
// if you want to redirect the user to a different URL, you can specify it here
|
||||
post_logout_redirect_uri: window.location.origin + '/after-logout',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Contact Us
|
||||
|
||||
If you need help you can email [Descope Support](mailto:support@descope.com)
|
||||
|
||||
## License
|
||||
|
||||
The Descope SDK for React is licensed for use under the terms and conditions of the [MIT license Agreement](./LICENSE).
|
||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
qna-viewer:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
# Also load the .env file for the runtime container environment (for the entrypoint script).
|
||||
- viewer/.env
|
||||
ports:
|
||||
# Map port on the host to port 80 in the container
|
||||
- "8073:80"
|
||||
restart: unless-stopped
|
||||
container_name: qna-viewer-react
|
||||
36
docker-entrypoint.sh
Normal file
36
docker-entrypoint.sh
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Exit immediately if a command exits with a non-zero status.
|
||||
set -e
|
||||
|
||||
echo "--- Docker Entrypoint Script Started ---"
|
||||
echo "Listing all environment variables:"
|
||||
printenv
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Check if the required environment variables are set. Exit with an error if they are not.
|
||||
: "${VITE_API_PROXY_TARGET:?Error: VITE_API_PROXY_TARGET is not set. Please check your .env file and docker-compose.yml}"
|
||||
: "${VITE_API_KEY:?Error: VITE_API_KEY is not set. Please check your .env file and docker-compose.yml}"
|
||||
|
||||
# Extract host and directory from VITE_API_PROXY_TARGET
|
||||
export VITE_API_HOST=$(echo $VITE_API_PROXY_TARGET | sed -e 's,http://\([^/]*\).*$,\1,g')
|
||||
export VITE_API_DIR=$(echo $VITE_API_PROXY_TARGET | sed -e 's,http://[^/]*\(/.*\)$,\1,g' -e 's,/$,,')
|
||||
|
||||
echo "Extracted VITE_API_HOST: ${VITE_API_HOST}"
|
||||
echo "Extracted VITE_API_DIR: ${VITE_API_DIR}"
|
||||
|
||||
# Define the template and output file paths
|
||||
TEMPLATE_FILE="/etc/nginx/templates/nginx.conf.template"
|
||||
OUTPUT_FILE="/etc/nginx/conf.d/default.conf"
|
||||
|
||||
# Substitute environment variables in the template file.
|
||||
envsubst '${VITE_API_HOST} ${VITE_API_DIR} ${VITE_API_KEY}' < "$TEMPLATE_FILE" > "$OUTPUT_FILE"
|
||||
|
||||
|
||||
echo "Nginx configuration generated successfully. Content:"
|
||||
echo "----------------------------------------"
|
||||
cat "$OUTPUT_FILE"
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Execute the command passed to this script (e.g., "nginx -g 'daemon off;'")
|
||||
exec "$@"
|
||||
44
nginx.conf.template
Normal file
44
nginx.conf.template
Normal file
@@ -0,0 +1,44 @@
|
||||
upstream api_back {
|
||||
server ${VITE_API_HOST};
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Root directory for static files
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Proxy API requests
|
||||
location /api/ {
|
||||
# IMPORTANT: The resolver is necessary for Nginx to resolve DNS inside a Docker container
|
||||
# when using variables in proxy_pass. 127.0.0.11 is Docker's internal DNS server.
|
||||
# resolver 127.0.0.11;
|
||||
|
||||
# Set headers for the proxied request
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Inject the custom API key header
|
||||
proxy_set_header X-API-KEY '${VITE_API_KEY}';
|
||||
|
||||
# Use environment variables for the proxy target and API key.
|
||||
# These will be substituted by envsubst in the entrypoint script.
|
||||
proxy_pass http://api_back${VITE_API_DIR}/api/;
|
||||
}
|
||||
|
||||
# Serve static files directly
|
||||
location / {
|
||||
# Fallback to index.html for Single Page Application (SPA) routing
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Optional: Add error pages for better user experience
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
6
package.json
Normal file
6
package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"viewer"
|
||||
]
|
||||
}
|
||||
9
pnpm-lock.yaml
generated
Normal file
9
pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.: {}
|
||||
27
viewer/.env
27
viewer/.env
@@ -1,3 +1,4 @@
|
||||
# VITE_API_PROXY_TARGET=https://feedback.hmac.kr/_back
|
||||
VITE_API_PROXY_TARGET=http://172.16.10.175:3030/_back
|
||||
|
||||
# API 요청 시 필요한 인증 키
|
||||
@@ -7,4 +8,28 @@ VITE_API_KEY=F5FE0363E37C012204F5
|
||||
VITE_DEFAULT_PROJECT_ID=1
|
||||
|
||||
# 기본으로 사용할 채널 ID
|
||||
VITE_DEFAULT_CHANNEL_ID=4
|
||||
VITE_DEFAULT_CHANNEL_ID=4
|
||||
|
||||
# Your project ID
|
||||
VITE_DESCOPE_PROJECT_ID=P2wON5fy1K6kyia269VpeIzYP8oP
|
||||
# Login flow ID
|
||||
VITE_DESCOPE_FLOW_ID=sign-up-with-password-standard
|
||||
|
||||
VITE_DESCOPE_USER_PROFILE_WIDGET_ID=user-profile-widget-standard
|
||||
# Descope base URL
|
||||
DESCOPE_BASE_URL=""
|
||||
# Descope base static URL
|
||||
DESCOPE_BASE_STATIC_URL=""
|
||||
# Set flow locale, default is browser's locale
|
||||
DESCOPE_LOCALE=""
|
||||
# Flow Redirect URL
|
||||
DESCOPE_REDIRECT_URL=""
|
||||
# Tenant ID
|
||||
DESCOPE_TENANT_ID=""
|
||||
# Enable debugger
|
||||
DESCOPE_DEBUG_MODE=true
|
||||
# Show step-up flow for logged in user
|
||||
DESCOPE_STEP_UP_FLOW_ID=step-up
|
||||
# Telemetry key
|
||||
DESCOPE_TELEMETRY_KEY=""
|
||||
|
||||
|
||||
@@ -5,4 +5,7 @@
|
||||
VITE_API_PROXY_TARGET=http://localhost:3030
|
||||
|
||||
# API 키
|
||||
VITE_API_KEY=your_api_key_here
|
||||
VITE_API_KEY=your_api_key_here
|
||||
|
||||
# 기본 채널 ID
|
||||
VITE_DEFAULT_CHANNEL_ID=4
|
||||
@@ -1,34 +1,18 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.1.2/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": false
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"$schema": "https://biomejs.dev/schemas/2.1.3/schema.json",
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
|
||||
16
viewer/components/LanguageSelectBox.js
Normal file
16
viewer/components/LanguageSelectBox.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { Languages } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
export function LanguageSelectBox() {
|
||||
return _jsxs(Button, {
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
children: [
|
||||
_jsx(Languages, {}),
|
||||
_jsx("span", {
|
||||
className: "sr-only",
|
||||
children: "\uC5B8\uC5B4 \uBCC0\uACBD",
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<title>Baron 컨설턴트 제품 피드백</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -5,38 +5,47 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"format": "biome format --write .",
|
||||
"lint": "biome lint --write .",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"format": "npx biome format --write .",
|
||||
"lint": "npx biome lint --write .",
|
||||
"preview": "vite preview",
|
||||
"shadcn": "shadcn-ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@descope/react-sdk": "^2.16.4",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@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-dom": "^19.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-day-picker": "^9.8.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.7.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zustand": "^5.0.6"
|
||||
"zustand": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.1.2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@biomejs/biome": "^2.1.3",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
"vite": "^7.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
1930
viewer/pnpm-lock.yaml
generated
1930
viewer/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
viewer/public/favicon.ico
Normal file
BIN
viewer/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
2
viewer/src/App.d.ts
vendored
Normal file
2
viewer/src/App.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare function App(): import("react/jsx-runtime").JSX.Element;
|
||||
export default App;
|
||||
@@ -1,40 +1,86 @@
|
||||
// src/App.tsx
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
} from "react-router-dom";
|
||||
import { Suspense, lazy } 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";
|
||||
import { FeedbackDetailPage } from "@/pages/FeedbackDetailPage";
|
||||
import { IssueViewerPage } from "@/pages/IssueViewerPage";
|
||||
|
||||
// 페이지 컴포넌트를 동적으로 import
|
||||
const FeedbackListPage = lazy(() =>
|
||||
import("@/pages/FeedbackListPage").then((module) => ({
|
||||
default: module.FeedbackListPage,
|
||||
})),
|
||||
);
|
||||
const FeedbackCreatePage = lazy(() =>
|
||||
import("@/pages/FeedbackCreatePage").then((module) => ({
|
||||
default: module.FeedbackCreatePage,
|
||||
})),
|
||||
);
|
||||
const FeedbackDetailPage = lazy(() =>
|
||||
import("@/pages/FeedbackDetailPage").then((module) => ({
|
||||
default: module.FeedbackDetailPage,
|
||||
})),
|
||||
);
|
||||
const IssueListPage = lazy(() =>
|
||||
import("@/pages/IssueListPage").then((module) => ({
|
||||
default: module.IssueListPage,
|
||||
})),
|
||||
);
|
||||
const IssueDetailPage = lazy(() =>
|
||||
import("@/pages/IssueDetailPage").then((module) => ({
|
||||
default: module.IssueDetailPage,
|
||||
})),
|
||||
);
|
||||
const ProfilePage = lazy(() =>
|
||||
import("@/pages/ProfilePage").then((module) => ({
|
||||
default: module.ProfilePage,
|
||||
})),
|
||||
);
|
||||
|
||||
function App() {
|
||||
const defaultProjectId = import.meta.env.VITE_DEFAULT_PROJECT_ID || "1";
|
||||
const defaultChannelId = import.meta.env.VITE_DEFAULT_CHANNEL_ID || "4";
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* 기본 경로 리디렉션 */}
|
||||
<Route path="/" element={<Navigate to="/projects/1/channels/4/feedbacks" />} />
|
||||
<Suspense fallback={<div className="text-center p-8">로딩 중...</div>}>
|
||||
<Routes>
|
||||
{/* 기본 경로 리디렉션 */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Navigate
|
||||
to={`/projects/${defaultProjectId}/channels/${defaultChannelId}/feedbacks`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 피드백 관련 페이지 (메인 레이아웃 사용) */}
|
||||
<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="/projects/:projectId" element={<MainLayout />}>
|
||||
<Route
|
||||
path="channels/:channelId/feedbacks"
|
||||
element={<FeedbackListPage />}
|
||||
/>
|
||||
<Route
|
||||
path="channels/:channelId/feedbacks/new"
|
||||
element={<FeedbackCreatePage />}
|
||||
/>
|
||||
<Route
|
||||
path="channels/:channelId/feedbacks/:feedbackId"
|
||||
element={<FeedbackDetailPage />}
|
||||
/>
|
||||
|
||||
{/* 독립적인 이슈 뷰어 페이지 */}
|
||||
<Route
|
||||
path="/issues/:issueId" // 이슈 ID만 받도록 단순화
|
||||
element={<IssueViewerPage />}
|
||||
/>
|
||||
{/* 채널 비종속 페이지 */}
|
||||
<Route path="issues" element={<IssueListPage />} />
|
||||
<Route path="issues/:issueId" element={<IssueDetailPage />} />
|
||||
</Route>
|
||||
|
||||
{/* 잘못된 접근을 위한 리디렉션 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
{/* 전체 레이아웃 */}
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
|
||||
{/* 잘못된 접근을 위한 리디렉션 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
BIN
viewer/src/assets/logo_dark.png
Normal file
BIN
viewer/src/assets/logo_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
viewer/src/assets/logo_light.png
Normal file
BIN
viewer/src/assets/logo_light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
13
viewer/src/components/DynamicForm.d.ts
vendored
Normal file
13
viewer/src/components/DynamicForm.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { FeedbackField } from "@/services/feedback";
|
||||
interface DynamicFormProps {
|
||||
fields: FeedbackField[];
|
||||
onSubmit: (formData: Record<string, unknown>) => Promise<void>;
|
||||
initialData?: Record<string, unknown>;
|
||||
submitButtonText?: string;
|
||||
}
|
||||
export declare function DynamicForm({
|
||||
fields,
|
||||
onSubmit,
|
||||
initialData, // 기본값으로 상수 사용
|
||||
submitButtonText,
|
||||
}: DynamicFormProps): import("react/jsx-runtime").JSX.Element;
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import type { FeedbackField } from "@/services/feedback";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -7,38 +6,36 @@ 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>;
|
||||
formData: Record<string, unknown>;
|
||||
setFormData: (formData: Record<string, unknown>) => void;
|
||||
onSubmit: (formData: Record<string, unknown>) => Promise<void>;
|
||||
submitButtonText?: string;
|
||||
onCancel?: () => void;
|
||||
cancelButtonText?: string;
|
||||
hideButtons?: boolean;
|
||||
}
|
||||
|
||||
export function DynamicForm({
|
||||
fields,
|
||||
formData,
|
||||
setFormData,
|
||||
onSubmit,
|
||||
initialData = EMPTY_INITIAL_DATA, // 기본값으로 상수 사용
|
||||
submitButtonText = "제출",
|
||||
onCancel,
|
||||
cancelButtonText = "취소",
|
||||
hideButtons = false,
|
||||
}: 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 handleFormChange = (fieldId: string, value: unknown) => {
|
||||
setFormData({ ...formData, [fieldId]: value });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -65,6 +62,7 @@ export function DynamicForm({
|
||||
return (
|
||||
<Textarea
|
||||
{...commonProps}
|
||||
value={String(commonProps.value)}
|
||||
onChange={(e) => handleFormChange(field.id, e.target.value)}
|
||||
placeholder={field.readOnly ? "" : `${field.name}...`}
|
||||
rows={5}
|
||||
@@ -75,6 +73,7 @@ export function DynamicForm({
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
value={String(commonProps.value)}
|
||||
type={field.type}
|
||||
onChange={(e) => handleFormChange(field.id, e.target.value)}
|
||||
placeholder={field.readOnly ? "" : field.name}
|
||||
@@ -83,7 +82,7 @@ export function DynamicForm({
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
value={commonProps.value}
|
||||
value={String(commonProps.value)}
|
||||
onValueChange={(value) => handleFormChange(field.id, value)}
|
||||
disabled={field.readOnly}
|
||||
>
|
||||
@@ -108,9 +107,18 @@ export function DynamicForm({
|
||||
{renderField(field)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "전송 중..." : submitButtonText}
|
||||
</Button>
|
||||
{!hideButtons && (
|
||||
<div className="flex justify-between">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "전송 중..." : submitButtonText}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{cancelButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
14
viewer/src/components/DynamicTable.d.ts
vendored
Normal file
14
viewer/src/components/DynamicTable.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Feedback, FeedbackField } from "@/services/feedback";
|
||||
interface DynamicTableProps {
|
||||
columns: FeedbackField[];
|
||||
data: Feedback[];
|
||||
projectId: string;
|
||||
channelId: string;
|
||||
}
|
||||
export declare function DynamicTable({
|
||||
columns: rawColumns,
|
||||
data,
|
||||
projectId,
|
||||
channelId,
|
||||
}: DynamicTableProps): import("react/jsx-runtime").JSX.Element;
|
||||
export default DynamicTable;
|
||||
@@ -1,4 +1,55 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type ColumnSizingState,
|
||||
type ExpandedState,
|
||||
type Row,
|
||||
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, Fragment } from "react";
|
||||
import type { DateRange } from "react-day-picker";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Card, CardContent } 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,117 +58,479 @@ 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[];
|
||||
data: Feedback[];
|
||||
projectId: string;
|
||||
channelId: string;
|
||||
// --- 공용 타입 정의 ---
|
||||
interface BaseData {
|
||||
id: string | number;
|
||||
updatedAt: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function DynamicTable({
|
||||
columns,
|
||||
interface FieldSchema {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface DynamicTableProps<TData extends BaseData> {
|
||||
columns: FieldSchema[];
|
||||
data: TData[];
|
||||
onRowClick: (row: TData) => void;
|
||||
renderExpandedRow?: (row: Row<TData>) => React.ReactNode;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export function DynamicTable<TData extends BaseData>({
|
||||
columns: rawColumns,
|
||||
data,
|
||||
onRowClick,
|
||||
renderExpandedRow,
|
||||
projectId,
|
||||
channelId,
|
||||
}: DynamicTableProps) {
|
||||
const navigate = useNavigate();
|
||||
}: DynamicTableProps<TData>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
screenshot: false,
|
||||
createdAt: false,
|
||||
});
|
||||
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({});
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [date, setDate] = useState<DateRange | undefined>();
|
||||
|
||||
const handleRowClick = (feedbackId: string) => {
|
||||
navigate(
|
||||
`/projects/${projectId}/channels/${channelId}/feedbacks/${feedbackId}`,
|
||||
const columnNameMap = useMemo(() => {
|
||||
return new Map(rawColumns.map((col) => [col.id, col.name]));
|
||||
}, [rawColumns]);
|
||||
|
||||
const columns = useMemo<ColumnDef<TData>[]>(() => {
|
||||
// 컬럼 순서 고정: 'id', 'title'/'name'을 항상 앞으로
|
||||
const fixedOrder = ["id", "title", "name"];
|
||||
const sortedRawColumns = [...rawColumns].sort((a, b) => {
|
||||
// 'description' 또는 'contents'를 항상 맨 뒤로 보냄
|
||||
const isADesc = a.id === "description" || a.id === "contents";
|
||||
const isBDesc = b.id === "description" || b.id === "contents";
|
||||
if (isADesc) return 1;
|
||||
if (isBDesc) return -1;
|
||||
|
||||
const aIndex = fixedOrder.indexOf(a.id);
|
||||
const bIndex = fixedOrder.indexOf(b.id);
|
||||
if (aIndex === -1 && bIndex === -1) return 0; // 둘 다 고정 순서에 없으면 순서 유지
|
||||
if (aIndex === -1) return 1; // a만 없으면 뒤로
|
||||
if (bIndex === -1) return -1; // b만 없으면 앞으로
|
||||
return aIndex - bIndex; // 둘 다 있으면 순서대로
|
||||
});
|
||||
|
||||
const generatedColumns: ColumnDef<TData>[] = sortedRawColumns.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 { id: string; name: string }[] | undefined) || [];
|
||||
if (issues.length === 0) return "N/A";
|
||||
return (
|
||||
<div className="flex flex-col space-y-1">
|
||||
{issues.map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
to={`/projects/${projectId}/issues/${issue.id}`}
|
||||
className="text-blue-600 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{issue.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "name":
|
||||
case "title": {
|
||||
const content = String(value ?? "N/A");
|
||||
const truncated =
|
||||
content.length > 50
|
||||
? `${content.substring(0, 50)}...`
|
||||
: content;
|
||||
return (
|
||||
<div className="whitespace-normal break-all overflow-hidden">
|
||||
{truncated}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "contents":
|
||||
case "description": {
|
||||
const content = String(value ?? "N/A");
|
||||
const truncated =
|
||||
content.length > 50
|
||||
? `${content.substring(0, 50)}...`
|
||||
: content;
|
||||
return (
|
||||
<div className="whitespace-normal break-all overflow-hidden">
|
||||
{truncated}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "createdAt":
|
||||
case "updatedAt":
|
||||
return String(value ?? "N/A").substring(0, 10);
|
||||
case "customer": {
|
||||
const content = String(value ?? "N/A");
|
||||
const truncated =
|
||||
content.length > 20
|
||||
? `${content.substring(0, 20)}...`
|
||||
: content;
|
||||
return (
|
||||
<div title={content} className="whitespace-nowrap">
|
||||
{truncated}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value ?? "N/A");
|
||||
}
|
||||
},
|
||||
size:
|
||||
field.id === "id"
|
||||
? 50
|
||||
: field.id === "name" || field.id === "title"
|
||||
? 300
|
||||
: field.id === "description" || field.id === "contents"
|
||||
? 500
|
||||
: field.id === "createdAt" || field.id === "updatedAt"
|
||||
? 120 // 10글자 너비
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
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()} // 행 클릭 이벤트 전파 방지
|
||||
if (renderExpandedRow) {
|
||||
return [
|
||||
{
|
||||
id: "expander",
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
row.toggleExpanded();
|
||||
}}
|
||||
>
|
||||
{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");
|
||||
{row.getIsExpanded() ? "▼" : "▶"}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
size: 25, // Expander 컬럼 너비 증가 (10 -> 25)
|
||||
},
|
||||
...generatedColumns,
|
||||
];
|
||||
}
|
||||
};
|
||||
return generatedColumns;
|
||||
}, [rawColumns, renderExpandedRow, projectId]);
|
||||
|
||||
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,
|
||||
columnResizeMode: "onChange",
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
onExpandedChange: setExpanded,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 20,
|
||||
},
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
expanded,
|
||||
globalFilter,
|
||||
},
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>피드백 목록</CardTitle>
|
||||
</CardHeader>
|
||||
<Card className="mt-6">
|
||||
<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"
|
||||
<div className="flex items-center justify-between pb-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)
|
||||
}
|
||||
>
|
||||
{columnNameMap.get(column.id) ?? column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full overflow-auto rounded-md border">
|
||||
<Table
|
||||
className="w-full"
|
||||
style={{
|
||||
width: table.getCenterTotalSize(),
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className="relative"
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
<div
|
||||
role="slider"
|
||||
aria-label={`컬럼 너비 조절: ${header.id}`}
|
||||
aria-valuemin={header.column.columnDef.minSize}
|
||||
aria-valuemax={header.column.columnDef.maxSize}
|
||||
aria-valuenow={header.column.getSize()}
|
||||
tabIndex={0}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
onDoubleClick={() => header.column.resetSize()}
|
||||
className={`resizer ${
|
||||
header.column.getIsResizing() ? "isResizing" : ""
|
||||
}`}
|
||||
/>
|
||||
</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) => (
|
||||
<Fragment key={row.id}>
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
onClick={() => onRowClick(row.original)}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{row.getIsExpanded() && renderExpandedRow && (
|
||||
<TableRow key={`${row.id}-expanded`}>
|
||||
{/* 들여쓰기를 위한 빈 셀 */}
|
||||
<TableCell />
|
||||
<TableCell colSpan={columns.length}>
|
||||
{renderExpandedRow(row)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length + 2}
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
6
viewer/src/components/ErrorDisplay.d.ts
vendored
Normal file
6
viewer/src/components/ErrorDisplay.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
interface ErrorDisplayProps {
|
||||
message: string;
|
||||
}
|
||||
export declare function ErrorDisplay({
|
||||
message,
|
||||
}: ErrorDisplayProps): import("react/jsx-runtime").JSX.Element;
|
||||
@@ -1,97 +1,15 @@
|
||||
// src/components/ErrorDisplay.tsx
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
errorMessage: string | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export function ErrorDisplay({ message }: ErrorDisplayProps) {
|
||||
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>
|
||||
<div
|
||||
className="bg-destructive/15 text-destructive p-4 rounded-md text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="font-semibold">오류 발생</p>
|
||||
<p className="text-sm">{message}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
83
viewer/src/components/FeedbackFormCard.tsx
Normal file
83
viewer/src/components/FeedbackFormCard.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
// src/components/FeedbackFormCard.tsx
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DynamicForm } from "@/components/DynamicForm";
|
||||
import type { FeedbackField } from "@/services/feedback";
|
||||
import { ErrorDisplay } from "./ErrorDisplay";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface FeedbackFormCardProps {
|
||||
title: string;
|
||||
fields: FeedbackField[];
|
||||
formData: Record<string, unknown>;
|
||||
setFormData: (formData: Record<string, unknown>) => void;
|
||||
onSubmit: (formData: Record<string, unknown>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
submitButtonText: string;
|
||||
cancelButtonText?: string;
|
||||
successMessage: string | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
isEditing: boolean;
|
||||
onEditClick: () => void;
|
||||
}
|
||||
|
||||
export function FeedbackFormCard({
|
||||
title,
|
||||
fields,
|
||||
formData,
|
||||
setFormData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitButtonText,
|
||||
cancelButtonText = "취소",
|
||||
successMessage,
|
||||
error,
|
||||
loading,
|
||||
isEditing,
|
||||
onEditClick,
|
||||
}: FeedbackFormCardProps) {
|
||||
if (loading) {
|
||||
return <div>폼 로딩 중...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorDisplay message={error} />;
|
||||
}
|
||||
|
||||
const readOnlyFields = fields.map((field) => ({ ...field, readOnly: true }));
|
||||
|
||||
return (
|
||||
<Card className="w-full mt-6 max-w-3xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DynamicForm
|
||||
fields={isEditing ? fields : readOnlyFields}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
submitButtonText={submitButtonText}
|
||||
cancelButtonText={cancelButtonText}
|
||||
hideButtons={!isEditing}
|
||||
/>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button onClick={onEditClick}>수정</Button>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{cancelButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<div className="mt-4 p-3 bg-green-100 text-green-800 rounded-md">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
1
viewer/src/components/Header.d.ts
vendored
Normal file
1
viewer/src/components/Header.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function Header(): import("react/jsx-runtime").JSX.Element;
|
||||
126
viewer/src/components/Header.tsx
Normal file
126
viewer/src/components/Header.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Menu } from "lucide-react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
|
||||
import LogoLight from "@/assets/logo_light.png";
|
||||
import LogoDark from "@/assets/logo_dark.png";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||
import { LanguageSelectBox } from "./LanguageSelectBox";
|
||||
import { ProjectSelectBox } from "./ProjectSelectBox";
|
||||
import { ThemeSelectBox } from "./ThemeSelectBox";
|
||||
import { UserProfileBox } from "./UserProfileBox";
|
||||
|
||||
const menuItems = [
|
||||
{ name: "Feedback", path: "/feedbacks", type: "feedback" },
|
||||
{ name: "Issue", path: "/issues", type: "issue" },
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
const { projectId, channelId, theme } = useSettingsStore();
|
||||
const [currentLogo, setCurrentLogo] = useState(LogoLight);
|
||||
|
||||
useEffect(() => {
|
||||
const getSystemTheme = () =>
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
const resolvedTheme = theme === "system" ? getSystemTheme() : theme;
|
||||
setCurrentLogo(resolvedTheme === "dark" ? LogoDark : LogoLight);
|
||||
|
||||
if (theme === "system") {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = () => {
|
||||
setCurrentLogo(mediaQuery.matches ? LogoDark : LogoLight);
|
||||
};
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const getPath = (type: string, basePath: string) => {
|
||||
if (type === "issue") {
|
||||
return `/projects/${projectId}${basePath}`;
|
||||
}
|
||||
return `/projects/${projectId}/channels/${channelId}${basePath}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto flex h-16 items-center">
|
||||
{/* Left Section */}
|
||||
<div className="flex items-center gap-6">
|
||||
<NavLink to="/" className="flex items-center gap-2">
|
||||
<img src={currentLogo} alt="Logo" className="h-8 w-auto" />
|
||||
</NavLink>
|
||||
<ProjectSelectBox />
|
||||
</div>
|
||||
|
||||
{/* Middle Navigation (Desktop) */}
|
||||
<nav className="mx-8 hidden items-center space-x-4 md:flex lg:space-x-6">
|
||||
{menuItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.name}
|
||||
to={getPath(item.type, item.path)}
|
||||
className={({ isActive }) =>
|
||||
`text-base transition-colors hover:text-primary ${
|
||||
isActive
|
||||
? "font-bold text-primary"
|
||||
: "font-medium text-muted-foreground"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.name}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Right Section (Desktop) */}
|
||||
<div className="ml-auto hidden items-center gap-4 md:flex">
|
||||
<ThemeSelectBox />
|
||||
<LanguageSelectBox />
|
||||
<UserProfileBox />
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<div className="ml-auto md:hidden">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Menu className="h-6 w-6" />
|
||||
<span className="sr-only">메뉴 열기</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{menuItems.map((item) => (
|
||||
<DropdownMenuItem key={item.name} asChild>
|
||||
<NavLink to={getPath(item.type, item.path)}>
|
||||
{item.name}
|
||||
</NavLink>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<div className="border-t my-2" />
|
||||
<div className="px-2 py-1.5 text-sm">
|
||||
<ThemeSelectBox />
|
||||
</div>
|
||||
<div className="px-2 py-1.5 text-sm">
|
||||
<LanguageSelectBox />
|
||||
</div>
|
||||
<div className="border-t my-2" />
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<UserProfileBox />
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
45
viewer/src/components/IssueDetailCard.tsx
Normal file
45
viewer/src/components/IssueDetailCard.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
// src/components/IssueDetailCard.tsx
|
||||
import type { Issue } from "@/services/issue";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface IssueDetailCardProps {
|
||||
issue: Issue;
|
||||
}
|
||||
|
||||
export function IssueDetailCard({ issue }: IssueDetailCardProps) {
|
||||
return (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">{issue.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm text-muted-foreground">설명</h3>
|
||||
<p className="font-medium whitespace-pre-wrap">{issue.description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-sm text-muted-foreground">상태</h3>
|
||||
<p className="font-medium">{issue.status}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm text-muted-foreground">우선순위</h3>
|
||||
<p className="font-medium">{issue.priority}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm text-muted-foreground">생성일</h3>
|
||||
<p className="font-medium">
|
||||
{new Date(issue.createdAt).toLocaleString("ko-KR")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm text-muted-foreground">수정일</h3>
|
||||
<p className="font-medium">
|
||||
{new Date(issue.updatedAt).toLocaleString("ko-KR")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
1
viewer/src/components/LanguageSelectBox.d.ts
vendored
Normal file
1
viewer/src/components/LanguageSelectBox.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function LanguageSelectBox(): import("react/jsx-runtime").JSX.Element;
|
||||
11
viewer/src/components/LanguageSelectBox.tsx
Normal file
11
viewer/src/components/LanguageSelectBox.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Languages } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export function LanguageSelectBox() {
|
||||
return (
|
||||
<Button variant="ghost" size="icon">
|
||||
<Languages />
|
||||
<span className="sr-only">언어 변경</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
37
viewer/src/components/LoginModal.tsx
Normal file
37
viewer/src/components/LoginModal.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
// src/components/LoginModal.tsx
|
||||
import { Descope } from "@descope/react-sdk";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface LoginModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const flowId = import.meta.env.VITE_DESCOPE_FLOW_ID || "sign-up-or-in";
|
||||
|
||||
export function LoginModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: LoginModalProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>로그인 또는 회원가입</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Descope
|
||||
flowId={flowId}
|
||||
onSuccess={onSuccess}
|
||||
onError={(e) => console.error("로그인 실패:", e)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1
viewer/src/components/MainLayout.d.ts
vendored
Normal file
1
viewer/src/components/MainLayout.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function MainLayout(): import("react/jsx-runtime").JSX.Element;
|
||||
@@ -1,16 +1,12 @@
|
||||
// src/components/MainLayout.tsx
|
||||
import { Link, Outlet, useParams } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Header } from "./Header";
|
||||
|
||||
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>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-y-scroll bg-muted/40">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
32
viewer/src/components/PageLayout.tsx
Normal file
32
viewer/src/components/PageLayout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
// src/components/PageLayout.tsx
|
||||
import { PageTitle } from "./PageTitle";
|
||||
|
||||
interface PageLayoutProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
size?: "default" | "narrow";
|
||||
}
|
||||
|
||||
export function PageLayout({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
size = "default",
|
||||
}: PageLayoutProps) {
|
||||
const containerClass =
|
||||
size === "narrow" ? "max-w-3xl mx-auto" : "max-w-7xl mx-auto";
|
||||
|
||||
return (
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className={containerClass}>
|
||||
<PageTitle title={title} description={description}>
|
||||
{actions}
|
||||
</PageTitle>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
viewer/src/components/PageTitle.tsx
Normal file
25
viewer/src/components/PageTitle.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// src/components/PageHeader.tsx
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PageTitle({ title, description, children }: PageHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
viewer/src/components/ProjectSelectBox.d.ts
vendored
Normal file
1
viewer/src/components/ProjectSelectBox.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function ProjectSelectBox(): import("react/jsx-runtime").JSX.Element;
|
||||
49
viewer/src/components/ProjectSelectBox.tsx
Normal file
49
viewer/src/components/ProjectSelectBox.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getProjects, type Project } from "@/services/project";
|
||||
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||
|
||||
export function ProjectSelectBox() {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { projectId, setProjectId } = useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
getProjects().then((loadedProjects) => {
|
||||
setProjects(loadedProjects);
|
||||
// 로드된 프로젝트 목록에 현재 ID가 없으면, 첫 번째 프로젝트로 ID를 설정
|
||||
if (
|
||||
loadedProjects.length > 0 &&
|
||||
!loadedProjects.find((p) => p.id === projectId)
|
||||
) {
|
||||
setProjectId(loadedProjects[0].id);
|
||||
}
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [projectId, setProjectId]); // 마운트 시 한 번만 실행
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="w-[180px] h-10 bg-muted rounded-md animate-pulse" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select value={projectId ?? ""} onValueChange={setProjectId}>
|
||||
<SelectTrigger className="w-[180px] border-none shadow-none focus:ring-0 bg-muted">
|
||||
<SelectValue placeholder="프로젝트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-muted">
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
1
viewer/src/components/ThemeSelectBox.d.ts
vendored
Normal file
1
viewer/src/components/ThemeSelectBox.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function ThemeSelectBox(): import("react/jsx-runtime").JSX.Element;
|
||||
39
viewer/src/components/ThemeSelectBox.tsx
Normal file
39
viewer/src/components/ThemeSelectBox.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Moon, Sun, Laptop } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||
|
||||
export function ThemeSelectBox() {
|
||||
const { setTheme } = useSettingsStore();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">테마 변경</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Laptop className="mr-2 h-4 w-4" />
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
1
viewer/src/components/UserProfileBox.d.ts
vendored
Normal file
1
viewer/src/components/UserProfileBox.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function UserProfileBox(): import("react/jsx-runtime").JSX.Element;
|
||||
98
viewer/src/components/UserProfileBox.tsx
Normal file
98
viewer/src/components/UserProfileBox.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
// src/components/UserProfileBox.tsx
|
||||
import { useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useDescope, useSession, useUser } from "@descope/react-sdk";
|
||||
import { User } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "./ui/button";
|
||||
import { LoginModal } from "./LoginModal";
|
||||
|
||||
export function UserProfileBox() {
|
||||
const { isAuthenticated, isSessionLoading } = useSession();
|
||||
const { user, isUserLoading } = useUser();
|
||||
const sdk = useDescope();
|
||||
const navigate = useNavigate();
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
sdk.logout();
|
||||
navigate("/");
|
||||
}, [sdk, navigate]);
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
setIsLoginModalOpen(false);
|
||||
};
|
||||
|
||||
if (isSessionLoading || isUserLoading) {
|
||||
return (
|
||||
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center animate-pulse" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||
<Avatar className="h-8 w-8">
|
||||
{isAuthenticated && user?.picture && (
|
||||
<AvatarImage src={user.picture} alt={user.name ?? ""} />
|
||||
)}
|
||||
<AvatarFallback>
|
||||
{isAuthenticated && user?.name ? (
|
||||
user.name
|
||||
.split(" ")
|
||||
.map((chunk) => chunk[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
) : (
|
||||
<User className="h-5 w-5" />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{user?.name}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate("/profile")}>
|
||||
내 프로필
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
로그아웃
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => setIsLoginModalOpen(true)}>
|
||||
로그인
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<LoginModal
|
||||
isOpen={isLoginModalOpen}
|
||||
onOpenChange={setIsLoginModalOpen}
|
||||
onSuccess={handleLoginSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
viewer/src/components/providers/ThemeProvider.d.ts
vendored
Normal file
9
viewer/src/components/providers/ThemeProvider.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export declare function ThemeProvider({
|
||||
children,
|
||||
}: ThemeProviderProps): React.ReactElement;
|
||||
28
viewer/src/components/providers/ThemeProvider.tsx
Normal file
28
viewer/src/components/providers/ThemeProvider.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||
const theme = useSettingsStore((state) => state.theme);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
50
viewer/src/components/ui/avatar.tsx
Normal file
50
viewer/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
29
viewer/src/components/ui/button.d.ts
vendored
Normal file
29
viewer/src/components/ui/button.d.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
import type * as React from "react";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
declare const buttonVariants: (
|
||||
props?:
|
||||
| ({
|
||||
variant?:
|
||||
| "link"
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| null
|
||||
| undefined;
|
||||
size?: "default" | "sm" | "lg" | "icon" | null | undefined;
|
||||
} & import("class-variance-authority/types").ClassProp)
|
||||
| undefined,
|
||||
) => string;
|
||||
declare function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}): import("react/jsx-runtime").JSX.Element;
|
||||
export { Button, buttonVariants };
|
||||
24
viewer/src/components/ui/calendar.d.ts
vendored
Normal file
24
viewer/src/components/ui/calendar.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
import { DayButton, DayPicker } from "react-day-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
declare function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays,
|
||||
captionLayout,
|
||||
buttonVariant,
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||
}): import("react/jsx-runtime").JSX.Element;
|
||||
declare function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<
|
||||
typeof DayButton
|
||||
>): import("react/jsx-runtime").JSX.Element;
|
||||
export { Calendar, CalendarDayButton };
|
||||
215
viewer/src/components/ui/calendar.tsx
Normal file
215
viewer/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type 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 };
|
||||
38
viewer/src/components/ui/card.d.ts
vendored
Normal file
38
viewer/src/components/ui/card.d.ts
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
import type * as React from "react";
|
||||
declare function Card({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">): import("react/jsx-runtime").JSX.Element;
|
||||
declare function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">): import("react/jsx-runtime").JSX.Element;
|
||||
declare function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">): import("react/jsx-runtime").JSX.Element;
|
||||
declare function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">): import("react/jsx-runtime").JSX.Element;
|
||||
declare function CardAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">): import("react/jsx-runtime").JSX.Element;
|
||||
declare function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">): import("react/jsx-runtime").JSX.Element;
|
||||
declare function CardFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">): import("react/jsx-runtime").JSX.Element;
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
@@ -1,92 +1,92 @@
|
||||
import type * as React from "react"
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
|
||||
119
viewer/src/components/ui/dialog.tsx
Normal file
119
viewer/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
108
viewer/src/components/ui/dropdown-menu.d.ts
vendored
Normal file
108
viewer/src/components/ui/dropdown-menu.d.ts
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
declare const DropdownMenu: React.FC<DropdownMenuPrimitive.DropdownMenuProps>;
|
||||
declare const DropdownMenuTrigger: React.ForwardRefExoticComponent<
|
||||
DropdownMenuPrimitive.DropdownMenuTriggerProps &
|
||||
React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
declare const DropdownMenuGroup: React.ForwardRefExoticComponent<
|
||||
DropdownMenuPrimitive.DropdownMenuGroupProps &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const DropdownMenuPortal: React.FC<DropdownMenuPrimitive.DropdownMenuPortalProps>;
|
||||
declare const DropdownMenuSub: React.FC<DropdownMenuPrimitive.DropdownMenuSubProps>;
|
||||
declare const DropdownMenuRadioGroup: React.ForwardRefExoticComponent<
|
||||
DropdownMenuPrimitive.DropdownMenuRadioGroupProps &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const DropdownMenuSubTrigger: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
DropdownMenuPrimitive.DropdownMenuSubTriggerProps &
|
||||
React.RefAttributes<HTMLDivElement>,
|
||||
"ref"
|
||||
> & {
|
||||
inset?: boolean;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const DropdownMenuSubContent: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
DropdownMenuPrimitive.DropdownMenuSubContentProps &
|
||||
React.RefAttributes<HTMLDivElement>,
|
||||
"ref"
|
||||
> &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const DropdownMenuContent: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
DropdownMenuPrimitive.DropdownMenuContentProps &
|
||||
React.RefAttributes<HTMLDivElement>,
|
||||
"ref"
|
||||
> &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const DropdownMenuItem: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
DropdownMenuPrimitive.DropdownMenuItemProps &
|
||||
React.RefAttributes<HTMLDivElement>,
|
||||
"ref"
|
||||
> & {
|
||||
inset?: boolean;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const DropdownMenuCheckboxItem: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
DropdownMenuPrimitive.DropdownMenuCheckboxItemProps &
|
||||
React.RefAttributes<HTMLDivElement>,
|
||||
"ref"
|
||||
> &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const DropdownMenuRadioItem: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
DropdownMenuPrimitive.DropdownMenuRadioItemProps &
|
||||
React.RefAttributes<HTMLDivElement>,
|
||||
"ref"
|
||||
> &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const DropdownMenuLabel: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
DropdownMenuPrimitive.DropdownMenuLabelProps &
|
||||
React.RefAttributes<HTMLDivElement>,
|
||||
"ref"
|
||||
> & {
|
||||
inset?: boolean;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const DropdownMenuSeparator: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
DropdownMenuPrimitive.DropdownMenuSeparatorProps &
|
||||
React.RefAttributes<HTMLDivElement>,
|
||||
"ref"
|
||||
> &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const DropdownMenuShortcut: {
|
||||
({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>): import("react/jsx-runtime").JSX.Element;
|
||||
displayName: string;
|
||||
};
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
202
viewer/src/components/ui/dropdown-menu.tsx
Normal file
202
viewer/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
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,
|
||||
};
|
||||
7
viewer/src/components/ui/input.d.ts
vendored
Normal file
7
viewer/src/components/ui/input.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import type * as React from "react";
|
||||
declare function Input({
|
||||
className,
|
||||
type,
|
||||
...props
|
||||
}: React.ComponentProps<"input">): import("react/jsx-runtime").JSX.Element;
|
||||
export { Input };
|
||||
@@ -1,21 +1,21 @@
|
||||
import type * as React from "react"
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
16
viewer/src/components/ui/label.d.ts
vendored
Normal file
16
viewer/src/components/ui/label.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
declare const Label: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
LabelPrimitive.LabelProps & React.RefAttributes<HTMLLabelElement>,
|
||||
"ref"
|
||||
> &
|
||||
VariantProps<
|
||||
(
|
||||
props?: import("class-variance-authority/types").ClassProp | undefined,
|
||||
) => string
|
||||
> &
|
||||
React.RefAttributes<HTMLLabelElement>
|
||||
>;
|
||||
export { Label };
|
||||
@@ -1,24 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
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"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
"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>
|
||||
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
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
17
viewer/src/components/ui/popover.d.ts
vendored
Normal file
17
viewer/src/components/ui/popover.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
declare const Popover: React.FC<PopoverPrimitive.PopoverProps>;
|
||||
declare const PopoverTrigger: React.ForwardRefExoticComponent<
|
||||
PopoverPrimitive.PopoverTriggerProps & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
declare const PopoverAnchor: React.ForwardRefExoticComponent<
|
||||
PopoverPrimitive.PopoverAnchorProps & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const PopoverContent: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
PopoverPrimitive.PopoverContentProps & React.RefAttributes<HTMLDivElement>,
|
||||
"ref"
|
||||
> &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
33
viewer/src/components/ui/popover.tsx
Normal file
33
viewer/src/components/ui/popover.tsx
Normal 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 };
|
||||
72
viewer/src/components/ui/select.d.ts
vendored
Normal file
72
viewer/src/components/ui/select.d.ts
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
declare const Select: React.FC<SelectPrimitive.SelectProps>;
|
||||
declare const SelectGroup: React.ForwardRefExoticComponent<
|
||||
SelectPrimitive.SelectGroupProps & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const SelectValue: React.ForwardRefExoticComponent<
|
||||
SelectPrimitive.SelectValueProps & React.RefAttributes<HTMLSpanElement>
|
||||
>;
|
||||
declare const SelectTrigger: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
SelectPrimitive.SelectTriggerProps & React.RefAttributes<HTMLButtonElement>,
|
||||
"ref"
|
||||
> &
|
||||
React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
declare const SelectScrollUpButton: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
SelectPrimitive.SelectScrollUpButtonProps &
|
||||
React.RefAttributes<HTMLDivElement>,
|
||||
"ref"
|
||||
> &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const SelectScrollDownButton: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
SelectPrimitive.SelectScrollDownButtonProps &
|
||||
React.RefAttributes<HTMLDivElement>,
|
||||
"ref"
|
||||
> &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const SelectContent: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
SelectPrimitive.SelectContentProps & React.RefAttributes<HTMLDivElement>,
|
||||
"ref"
|
||||
> &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const SelectLabel: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
SelectPrimitive.SelectLabelProps & React.RefAttributes<HTMLDivElement>,
|
||||
"ref"
|
||||
> &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const SelectItem: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
SelectPrimitive.SelectItemProps & React.RefAttributes<HTMLDivElement>,
|
||||
"ref"
|
||||
> &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
declare const SelectSeparator: React.ForwardRefExoticComponent<
|
||||
Omit<
|
||||
SelectPrimitive.SelectSeparatorProps & React.RefAttributes<HTMLDivElement>,
|
||||
"ref"
|
||||
> &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -1,156 +1,160 @@
|
||||
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"
|
||||
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 Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
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
|
||||
<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>
|
||||
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
|
||||
<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>
|
||||
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>
|
||||
))
|
||||
<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
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
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
|
||||
<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>
|
||||
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
|
||||
<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>
|
||||
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
|
||||
<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>
|
||||
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
|
||||
<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,
|
||||
}
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
|
||||
11
viewer/src/components/ui/separator.d.ts
vendored
Normal file
11
viewer/src/components/ui/separator.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import type * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
declare function Separator({
|
||||
className,
|
||||
orientation,
|
||||
decorative,
|
||||
...props
|
||||
}: React.ComponentProps<
|
||||
typeof SeparatorPrimitive.Root
|
||||
>): import("react/jsx-runtime").JSX.Element;
|
||||
export { Separator };
|
||||
@@ -1,26 +1,26 @@
|
||||
import type * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import type * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
||||
42
viewer/src/components/ui/table.d.ts
vendored
Normal file
42
viewer/src/components/ui/table.d.ts
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
declare const Table: React.ForwardRefExoticComponent<
|
||||
React.HTMLAttributes<HTMLTableElement> & React.RefAttributes<HTMLTableElement>
|
||||
>;
|
||||
declare const TableHeader: React.ForwardRefExoticComponent<
|
||||
React.HTMLAttributes<HTMLTableSectionElement> &
|
||||
React.RefAttributes<HTMLTableSectionElement>
|
||||
>;
|
||||
declare const TableBody: React.ForwardRefExoticComponent<
|
||||
React.HTMLAttributes<HTMLTableSectionElement> &
|
||||
React.RefAttributes<HTMLTableSectionElement>
|
||||
>;
|
||||
declare const TableFooter: React.ForwardRefExoticComponent<
|
||||
React.HTMLAttributes<HTMLTableSectionElement> &
|
||||
React.RefAttributes<HTMLTableSectionElement>
|
||||
>;
|
||||
declare const TableRow: React.ForwardRefExoticComponent<
|
||||
React.HTMLAttributes<HTMLTableRowElement> &
|
||||
React.RefAttributes<HTMLTableRowElement>
|
||||
>;
|
||||
declare const TableHead: React.ForwardRefExoticComponent<
|
||||
React.ThHTMLAttributes<HTMLTableCellElement> &
|
||||
React.RefAttributes<HTMLTableCellElement>
|
||||
>;
|
||||
declare const TableCell: React.ForwardRefExoticComponent<
|
||||
React.TdHTMLAttributes<HTMLTableCellElement> &
|
||||
React.RefAttributes<HTMLTableCellElement>
|
||||
>;
|
||||
declare const TableCaption: React.ForwardRefExoticComponent<
|
||||
React.HTMLAttributes<HTMLTableCaptionElement> &
|
||||
React.RefAttributes<HTMLTableCaptionElement>
|
||||
>;
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
@@ -1,120 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
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"
|
||||
<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>
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
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"
|
||||
<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>
|
||||
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"
|
||||
<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>
|
||||
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"
|
||||
<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>
|
||||
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"
|
||||
<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>
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
<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,
|
||||
}
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
|
||||
6
viewer/src/components/ui/textarea.d.ts
vendored
Normal file
6
viewer/src/components/ui/textarea.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import type * as React from "react";
|
||||
declare function Textarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">): import("react/jsx-runtime").JSX.Element;
|
||||
export { Textarea };
|
||||
@@ -1,18 +1,18 @@
|
||||
import type * as React from "react"
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
export { Textarea };
|
||||
|
||||
22
viewer/src/hooks/useSyncChannelId.ts
Normal file
22
viewer/src/hooks/useSyncChannelId.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// src/hooks/useSyncChannelId.ts
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||
|
||||
/**
|
||||
* URL의 `channelId` 파라미터를 감지하여 전역 상태와 동기화하는 커스텀 훅입니다.
|
||||
* 이 훅은 `channelId`가 URL에 존재하는 페이지 컴포넌트에서 사용해야 합니다.
|
||||
*/
|
||||
export function useSyncChannelId() {
|
||||
const { channelId } = useParams<{ channelId: string }>();
|
||||
const setChannelId = useSettingsStore.getState().setChannelId;
|
||||
|
||||
useEffect(() => {
|
||||
// URL 파라미터에 channelId가 존재하고 유효한 경우에만 전역 상태를 업데이트합니다.
|
||||
// channelId가 없는 페이지(ex: /issues)에서는 이 조건이 false가 되어
|
||||
// 기존의 유효한 channelId 값을 덮어쓰지 않습니다.
|
||||
if (channelId) {
|
||||
setChannelId(channelId);
|
||||
}
|
||||
}, [channelId, setChannelId]);
|
||||
}
|
||||
@@ -1,123 +1,86 @@
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
@layer base {
|
||||
:root {
|
||||
/* Light theme based on user's request */
|
||||
--background: 207 100% 98%; /* #f6fbff */
|
||||
--foreground: 240 10% 3.9%; /* Near black */
|
||||
--card: 0 0% 100%; /* White */
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 197 100% 36%; /* #0082b5 */
|
||||
--primary-foreground: 0 0% 98%; /* Light text for primary */
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 210 40% 96.1%; /* #f0f2f5 */
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%; /* #e4e4e7 */
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 197 100% 36%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.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);
|
||||
.dark {
|
||||
/* Descope Theme */
|
||||
--background: 240 0% 40%; /* #666 */
|
||||
--foreground: 0 0% 100%; /* #fff */
|
||||
--card: 0 0% 10%; /* #4F442C */
|
||||
--card-foreground: 0 0% 100%; /* #fff */
|
||||
--popover: 0 0% 0%; /* #000 */
|
||||
--popover-foreground: 0 0% 100%; /* #fff */
|
||||
--primary: 217 100% 48%; /* #006af5 */
|
||||
--primary-foreground: 0 0% 100%; /* #fff */
|
||||
--secondary: 0 0% 100%; /* #fff */
|
||||
--secondary-foreground: 0 0% 0%; /* #000 */
|
||||
--muted: 240 0% 45%; /* #737373 - Slightly lighter than background */
|
||||
--muted-foreground: 0 0% 60%; /* #999 */
|
||||
--accent: 217 100% 48%; /* #006af5 */
|
||||
--accent-foreground: 0 0% 100%; /* #fff */
|
||||
--destructive: 348 100% 49%; /* #fb3c00 */
|
||||
--destructive-foreground: 0 0% 100%; /* #fff */
|
||||
--border: 0 0% 60%; /* #999 */
|
||||
--input: 0 0% 60%; /* #999 */
|
||||
--ring: 217 100% 48%; /* #006af5 */
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@apply border-border;
|
||||
}
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
.resizer {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 5px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.resizer.isResizing {
|
||||
background: blue;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
th:hover .resizer {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
2
viewer/src/lib/utils.d.ts
vendored
Normal file
2
viewer/src/lib/utils.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import { type ClassValue } from "clsx";
|
||||
export declare function cn(...inputs: ClassValue[]): string;
|
||||
1
viewer/src/main.d.ts
vendored
Normal file
1
viewer/src/main.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import "./index.css";
|
||||
@@ -1,13 +1,24 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { AuthProvider } from "@descope/react-sdk";
|
||||
import App from "./App.tsx";
|
||||
import { ThemeProvider } from "./components/providers/ThemeProvider.tsx";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
const projectId = import.meta.env.VITE_DESCOPE_PROJECT_ID;
|
||||
if (!projectId) {
|
||||
throw new Error("VITE_DESCOPE_PROJECT_ID is not set in .env");
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<AuthProvider projectId={projectId}>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
1
viewer/src/pages/FeedbackCreatePage.d.ts
vendored
Normal file
1
viewer/src/pages/FeedbackCreatePage.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function FeedbackCreatePage(): import("react/jsx-runtime").JSX.Element;
|
||||
@@ -1,106 +1,137 @@
|
||||
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";
|
||||
import { useUser } from "@descope/react-sdk";
|
||||
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||
import { useSyncChannelId } from "@/hooks/useSyncChannelId";
|
||||
import {
|
||||
getFeedbackFields,
|
||||
createFeedback,
|
||||
type FeedbackField,
|
||||
type CreateFeedbackRequest,
|
||||
} from "@/services/feedback";
|
||||
import { FeedbackFormCard } from "@/components/FeedbackFormCard";
|
||||
import { PageLayout } from "@/components/PageLayout";
|
||||
|
||||
export function FeedbackCreatePage() {
|
||||
useSyncChannelId();
|
||||
const navigate = useNavigate();
|
||||
const { projectId, channelId } = useSettingsStore();
|
||||
const { user } = useUser();
|
||||
|
||||
const [fields, setFields] = useState<FeedbackField[]>([]);
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
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";
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFields = async () => {
|
||||
if (!projectId || !channelId) return;
|
||||
|
||||
const fetchAndProcessSchema = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const fieldsData = await getFeedbackFields(projectId, channelId);
|
||||
const schemaData = await getFeedbackFields(projectId, channelId);
|
||||
|
||||
// 사용자에게 보여주지 않을 필드 목록
|
||||
const hiddenFields = ["id", "createdAt", "updatedAt", "issues"];
|
||||
let processedFields = schemaData
|
||||
.filter(
|
||||
(field) =>
|
||||
!["id", "createdAt", "updatedAt", "issues"].includes(field.id),
|
||||
)
|
||||
.map((field) => ({
|
||||
...field,
|
||||
type: field.id === "contents" ? "textarea" : field.type,
|
||||
}));
|
||||
|
||||
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;
|
||||
});
|
||||
const initialData: Record<string, unknown> = {};
|
||||
|
||||
if (user) {
|
||||
const authorField = processedFields.find((f) =>
|
||||
["customer", "author", "writer"].includes(f.id),
|
||||
);
|
||||
|
||||
if (authorField) {
|
||||
const { name, email, customAttributes } = user;
|
||||
const company =
|
||||
customAttributes?.familyCompany ||
|
||||
customAttributes?.company ||
|
||||
customAttributes?.customerCompany ||
|
||||
"";
|
||||
const team = customAttributes?.team || "";
|
||||
const companyInfo = [company, team].filter(Boolean).join(", ");
|
||||
const authorString = `${name} <${email}>${
|
||||
companyInfo ? ` at ${companyInfo}` : ""
|
||||
}`;
|
||||
|
||||
initialData[authorField.id] = authorString;
|
||||
|
||||
processedFields = processedFields.map((field) =>
|
||||
field.id === authorField.id
|
||||
? { ...field, readOnly: true }
|
||||
: field,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setFields(processedFields);
|
||||
setFormData(initialData);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("알 수 없는 오류가 발생했습니다.");
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "폼 로딩 중 오류 발생");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFields();
|
||||
}, [projectId, channelId]);
|
||||
fetchAndProcessSchema();
|
||||
}, [projectId, channelId, user]);
|
||||
|
||||
const handleSubmit = async (formData: Record<string, any>) => {
|
||||
const handleSubmit = async (submittedData: Record<string, unknown>) => {
|
||||
if (!projectId || !channelId) return;
|
||||
try {
|
||||
setError(null);
|
||||
setSubmitMessage(null);
|
||||
|
||||
setSuccessMessage(null);
|
||||
const requestData: CreateFeedbackRequest = {
|
||||
...formData,
|
||||
...submittedData,
|
||||
issueNames: [],
|
||||
};
|
||||
|
||||
await createFeedback(projectId, channelId, requestData);
|
||||
setSubmitMessage("피드백이 성공적으로 등록되었습니다! 곧 목록으로 돌아갑니다.");
|
||||
|
||||
// 2초 후 목록 페이지로 이동
|
||||
setTimeout(() => {
|
||||
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
|
||||
}, 2000);
|
||||
|
||||
setSuccessMessage(
|
||||
"피드백이 성공적으로 등록되었습니다! 곧 목록으로 돌아갑니다.",
|
||||
);
|
||||
setTimeout(
|
||||
() =>
|
||||
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`),
|
||||
2000,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("피드백 등록 중 알 수 없는 오류가 발생했습니다.");
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "피드백 등록 중 오류 발생");
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>폼을 불러오는 중...</div>;
|
||||
}
|
||||
const handleCancel = () => {
|
||||
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
|
||||
};
|
||||
|
||||
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>
|
||||
<PageLayout
|
||||
title="새 피드백 작성"
|
||||
description="아래 폼을 작성하여 피드백을 제출해주세요."
|
||||
size="narrow"
|
||||
>
|
||||
<FeedbackFormCard
|
||||
title="새 피드백"
|
||||
fields={fields}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
submitButtonText="제출하기"
|
||||
successMessage={successMessage}
|
||||
error={error}
|
||||
loading={loading}
|
||||
isEditing={true}
|
||||
onEditClick={() => {}} // 사용되지 않음
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1
viewer/src/pages/FeedbackDetailPage.d.ts
vendored
Normal file
1
viewer/src/pages/FeedbackDetailPage.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function FeedbackDetailPage(): import("react/jsx-runtime").JSX.Element;
|
||||
@@ -1,35 +1,42 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { DynamicForm } from "@/components/DynamicForm";
|
||||
import { useUser } from "@descope/react-sdk";
|
||||
import { useSyncChannelId } from "@/hooks/useSyncChannelId";
|
||||
import {
|
||||
getFeedbackFields,
|
||||
getFeedbackById,
|
||||
updateFeedback,
|
||||
getFeedbackFields,
|
||||
type Feedback,
|
||||
type FeedbackField,
|
||||
} from "@/services/feedback";
|
||||
import type { Feedback, FeedbackField } from "@/services/feedback";
|
||||
import { FeedbackFormCard } from "@/components/FeedbackFormCard";
|
||||
import { PageLayout } from "@/components/PageLayout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export function FeedbackDetailPage() {
|
||||
useSyncChannelId();
|
||||
const { projectId, channelId, feedbackId } = useParams<{
|
||||
projectId: string;
|
||||
channelId: string;
|
||||
feedbackId: string;
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useUser();
|
||||
|
||||
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]);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!projectId || !channelId || !feedbackId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const [fieldsData, feedbackData] = await Promise.all([
|
||||
@@ -37,75 +44,140 @@ export function FeedbackDetailPage() {
|
||||
getFeedbackById(projectId, channelId, feedbackId),
|
||||
]);
|
||||
|
||||
// 폼에서 숨길 필드 목록
|
||||
const hiddenFields = ["id", "createdAt", "updatedAt", "issues", "screenshot"];
|
||||
|
||||
const hiddenFields = [
|
||||
"id",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"issues",
|
||||
"screenshot",
|
||||
"customer",
|
||||
];
|
||||
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;
|
||||
});
|
||||
.map((field) => ({
|
||||
...field,
|
||||
type: field.id === "contents" ? "textarea" : field.type,
|
||||
}));
|
||||
|
||||
setFields(processedFields);
|
||||
setFeedback(feedbackData);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("데이터를 불러오는 중 알 수 없는 오류가 발생했습니다.");
|
||||
}
|
||||
setError(
|
||||
err instanceof Error ? err.message : "데이터 로딩 중 오류 발생",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [projectId, channelId, feedbackId]);
|
||||
|
||||
const handleSubmit = async (formData: Record<string, any>) => {
|
||||
if (!projectId || !channelId || !feedbackId) return;
|
||||
const handleEditClick = () => {
|
||||
setFormData(feedback ?? {});
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (submittedData: Record<string, unknown>) => {
|
||||
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);
|
||||
|
||||
const dataToUpdate = Object.fromEntries(
|
||||
Object.entries(submittedData).filter(([key]) =>
|
||||
fields.some((f) => f.id === key && !f.readOnly),
|
||||
),
|
||||
);
|
||||
const updatedFeedback = await updateFeedback(
|
||||
projectId,
|
||||
channelId,
|
||||
feedbackId,
|
||||
dataToUpdate,
|
||||
);
|
||||
setFeedback((prev) => ({ ...prev, ...updatedFeedback }));
|
||||
setSuccessMessage("피드백이 성공적으로 수정되었습니다!");
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("피드백 수정 중 알 수 없는 오류가 발생했습니다.");
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "피드백 수정 중 오류 발생");
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (isEditing) {
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
|
||||
}
|
||||
};
|
||||
|
||||
const ReadOnlyDisplay = ({ onEditClick }: { onEditClick: () => void }) => {
|
||||
if (!feedback) return null;
|
||||
|
||||
const getEmailFromCustomer = (customer: unknown): string | null => {
|
||||
if (typeof customer !== "string") return null;
|
||||
const match = customer.match(/<([^>]+)>/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const authorEmail = getEmailFromCustomer(feedback.customer);
|
||||
const isOwner =
|
||||
!!user?.email && !!authorEmail && user.email === authorEmail;
|
||||
|
||||
return (
|
||||
<Card className="w-full mt-6 max-w-3xl mx-auto">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle>피드백 정보 (ID: {feedback.id})</CardTitle>
|
||||
{!!feedback.customer && (
|
||||
<span
|
||||
className="text-sm text-muted-foreground whitespace-nowrap"
|
||||
title={String(feedback.customer)}
|
||||
>
|
||||
{String(feedback.customer).length > 45
|
||||
? `${String(feedback.customer).substring(0, 45)}...`
|
||||
: String(feedback.customer)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{fields.map((field) => (
|
||||
<div key={field.id}>
|
||||
<Label htmlFor={field.id} className="font-semibold">
|
||||
{field.name}
|
||||
</Label>
|
||||
<div
|
||||
id={field.id}
|
||||
className={`mt-1 p-2 border rounded-md bg-muted min-h-[40px] whitespace-pre-wrap ${
|
||||
field.id === "contents" ? "min-h-[120px]" : ""
|
||||
}`}
|
||||
>
|
||||
{String(feedback[field.id] ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
{isOwner && <Button onClick={onEditClick}>수정</Button>}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/projects/${projectId}/channels/${channelId}/feedbacks`,
|
||||
)
|
||||
}
|
||||
>
|
||||
목록으로
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>로딩 중...</div>;
|
||||
return <div>페이지 로딩 중...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -113,36 +185,30 @@ export function FeedbackDetailPage() {
|
||||
}
|
||||
|
||||
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
|
||||
<PageLayout
|
||||
title="개별 피드백"
|
||||
description="피드백 내용을 확인하고 수정할 수 있습니다."
|
||||
size="narrow"
|
||||
>
|
||||
{isEditing ? (
|
||||
<FeedbackFormCard
|
||||
title={`피드백 수정 (ID: ${feedback?.id})`}
|
||||
fields={fields}
|
||||
initialData={initialData}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
onSubmit={handleSubmit}
|
||||
submitButtonText="수정하기"
|
||||
onCancel={handleCancel}
|
||||
submitButtonText="완료"
|
||||
cancelButtonText="취소"
|
||||
successMessage={successMessage}
|
||||
error={error}
|
||||
loading={loading}
|
||||
isEditing={isEditing}
|
||||
onEditClick={handleEditClick}
|
||||
/>
|
||||
{successMessage && (
|
||||
<div className="mt-4 p-3 bg-green-100 text-green-800 rounded-md">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ReadOnlyDisplay onEditClick={handleEditClick} />
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1
viewer/src/pages/FeedbackListPage.d.ts
vendored
Normal file
1
viewer/src/pages/FeedbackListPage.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function FeedbackListPage(): import("react/jsx-runtime").JSX.Element;
|
||||
@@ -1,68 +1,99 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useSession } from "@descope/react-sdk";
|
||||
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||
import { useSyncChannelId } from "@/hooks/useSyncChannelId";
|
||||
import { DynamicTable } from "@/components/DynamicTable";
|
||||
import { getFeedbacks, getFeedbackFields } from "@/services/feedback";
|
||||
import type { Feedback, FeedbackField } from "@/services/feedback";
|
||||
import {
|
||||
getFeedbacks,
|
||||
getFeedbackFields,
|
||||
type Feedback,
|
||||
type FeedbackField,
|
||||
} from "@/services/feedback";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageLayout } from "@/components/PageLayout";
|
||||
import type { Row } from "@tanstack/react-table";
|
||||
|
||||
export function FeedbackListPage() {
|
||||
const [fields, setFields] = useState<FeedbackField[]>([]);
|
||||
useSyncChannelId();
|
||||
const { projectId, channelId } = useSettingsStore();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated } = useSession();
|
||||
|
||||
const [schema, setSchema] = useState<FeedbackField[] | null>(null);
|
||||
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 () => {
|
||||
if (!projectId || !channelId) return;
|
||||
|
||||
const fetchSchemaAndFeedbacks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const fieldsData = await getFeedbackFields(projectId, channelId);
|
||||
setFields(fieldsData);
|
||||
setError(null);
|
||||
|
||||
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("테이블 구조를 불러오는 데 실패했습니다.");
|
||||
}
|
||||
const schemaData = await getFeedbackFields(projectId, channelId);
|
||||
setSchema(schemaData);
|
||||
|
||||
const feedbacksData = await getFeedbacks(projectId, channelId);
|
||||
setFeedbacks(feedbacksData);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "데이터 로딩에 실패했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFieldsAndFeedbacks();
|
||||
fetchSchemaAndFeedbacks();
|
||||
}, [projectId, channelId]);
|
||||
|
||||
const handleRowClick = (row: Feedback) => {
|
||||
navigate(
|
||||
`/projects/${projectId}/channels/${channelId}/feedbacks/${row.id}`,
|
||||
);
|
||||
};
|
||||
|
||||
const renderExpandedRow = (row: Row<Feedback>) => (
|
||||
<div className="p-4 bg-muted rounded-md">
|
||||
<h4 className="font-bold text-lg mb-2">
|
||||
{String(row.original.title ?? "")}
|
||||
</h4>
|
||||
<p className="whitespace-pre-wrap">
|
||||
{String(row.original.contents ?? "")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <div>로딩 중...</div>;
|
||||
return <div className="text-center py-10">로딩 중...</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>
|
||||
<PageLayout
|
||||
title="피드백 목록"
|
||||
description="프로젝트의 피드백 목록입니다."
|
||||
actions={
|
||||
isAuthenticated ? (
|
||||
<Button asChild>
|
||||
<Link to="new">새 피드백 작성</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{error && <ErrorDisplay message={error} />}
|
||||
<DynamicTable
|
||||
columns={fields}
|
||||
data={feedbacks}
|
||||
projectId={projectId}
|
||||
channelId={channelId}
|
||||
/>
|
||||
</div>
|
||||
{schema && (
|
||||
<DynamicTable
|
||||
columns={schema}
|
||||
data={feedbacks}
|
||||
onRowClick={handleRowClick}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
65
viewer/src/pages/IssueDetailPage.tsx
Normal file
65
viewer/src/pages/IssueDetailPage.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
// src/pages/IssueDetailPage.tsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { getIssueById, type Issue } from "@/services/issue";
|
||||
import { PageLayout } from "@/components/PageLayout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import { IssueDetailCard } from "@/components/IssueDetailCard";
|
||||
|
||||
export function IssueDetailPage() {
|
||||
const { projectId, issueId } = useParams<{
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [issue, setIssue] = useState<Issue | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchIssue = async () => {
|
||||
if (!projectId || !issueId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const issueData = await getIssueById(projectId, issueId);
|
||||
setIssue(issueData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "이슈 로딩 중 오류 발생");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchIssue();
|
||||
}, [projectId, issueId]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-10">로딩 중...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorDisplay message={error} />;
|
||||
}
|
||||
|
||||
if (!issue) {
|
||||
return <ErrorDisplay message="이슈를 찾을 수 없습니다." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="이슈 상세 정보"
|
||||
description={`이슈 ID: ${issue.id}`}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/projects/${projectId}/issues`)}
|
||||
>
|
||||
목록으로
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<IssueDetailCard issue={issue} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
77
viewer/src/pages/IssueListPage.tsx
Normal file
77
viewer/src/pages/IssueListPage.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||
import { DynamicTable } from "@/components/DynamicTable";
|
||||
import {
|
||||
getIssues,
|
||||
getIssueFields,
|
||||
type Issue,
|
||||
type IssueField,
|
||||
} from "@/services/issue";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import { PageLayout } from "@/components/PageLayout";
|
||||
import type { Row } from "@tanstack/react-table";
|
||||
|
||||
export function IssueListPage() {
|
||||
const { projectId } = useSettingsStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [schema, setSchema] = useState<IssueField[] | null>(null);
|
||||
const [issues, setIssues] = useState<Issue[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
const fetchSchemaAndIssues = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const schemaData = await getIssueFields();
|
||||
setSchema(schemaData);
|
||||
|
||||
const issuesData = await getIssues(projectId);
|
||||
setIssues(issuesData);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "데이터 로딩에 실패했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSchemaAndIssues();
|
||||
}, [projectId]);
|
||||
|
||||
const handleRowClick = (row: Issue) => {
|
||||
navigate(`/projects/${projectId}/issues/${row.id}`);
|
||||
};
|
||||
|
||||
const renderExpandedRow = (row: Row<Issue>) => (
|
||||
<div className="p-4 bg-muted rounded-md">
|
||||
<h4 className="font-bold text-lg mb-2">{row.original.name}</h4>
|
||||
<p className="whitespace-pre-wrap">{row.original.description}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-10">로딩 중...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout title="이슈 목록" description="프로젝트의 이슈 목록입니다.">
|
||||
{error && <ErrorDisplay message={error} />}
|
||||
{schema && (
|
||||
<DynamicTable
|
||||
columns={schema}
|
||||
data={issues}
|
||||
onRowClick={handleRowClick}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
34
viewer/src/pages/ProfilePage.tsx
Normal file
34
viewer/src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
// src/pages/ProfilePage.tsx
|
||||
import { UserProfile } from "@descope/react-sdk";
|
||||
import { PageLayout } from "@/components/PageLayout";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const widgetId = import.meta.env.VITE_DESCOPE_USER_PROFILE_WIDGET_ID;
|
||||
|
||||
export function ProfilePage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!widgetId) {
|
||||
return (
|
||||
<PageLayout title="오류">
|
||||
<p>프로필 위젯 ID가 설정되지 않았습니다.</p>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="내 프로필"
|
||||
description="프로필 정보를 수정할 수 있습니다."
|
||||
>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<UserProfile
|
||||
widgetId={widgetId}
|
||||
onLogout={() => {
|
||||
navigate("/");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
9
viewer/src/services/error.d.ts
vendored
Normal file
9
viewer/src/services/error.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* API 요청 실패 시 공통으로 사용할 에러 처리 함수
|
||||
* @param message 프로덕션 환경에서 보여줄 기본 에러 메시지
|
||||
* @param response fetch API의 응답 객체
|
||||
*/
|
||||
export declare const handleApiError: (
|
||||
message: string,
|
||||
response: Response,
|
||||
) => Promise<never>;
|
||||
65
viewer/src/services/feedback.d.ts
vendored
Normal file
65
viewer/src/services/feedback.d.ts
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
export interface Feedback {
|
||||
id: string;
|
||||
content: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface Issue {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
export interface FeedbackField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "text" | "textarea" | "number" | "select";
|
||||
readOnly?: boolean;
|
||||
}
|
||||
export interface CreateFeedbackRequest {
|
||||
issueNames: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
/**
|
||||
* 특정 채널의 피드백 목록을 조회합니다.
|
||||
*/
|
||||
export declare const getFeedbacks: (
|
||||
projectId: string,
|
||||
channelId: string,
|
||||
) => Promise<Feedback[]>;
|
||||
/**
|
||||
* 특정 채널의 동적 폼 필드 스키마를 조회합니다.
|
||||
*/
|
||||
export declare const getFeedbackFields: (
|
||||
projectId: string,
|
||||
channelId: string,
|
||||
) => Promise<FeedbackField[]>;
|
||||
/**
|
||||
* 특정 채널에 새로운 피드백을 생성합니다.
|
||||
*/
|
||||
export declare const createFeedback: (
|
||||
projectId: string,
|
||||
channelId: string,
|
||||
feedbackData: CreateFeedbackRequest,
|
||||
) => Promise<Feedback>;
|
||||
/**
|
||||
* 프로젝트의 이슈를 검색합니다.
|
||||
*/
|
||||
export declare const searchIssues: (
|
||||
projectId: string,
|
||||
query: string,
|
||||
) => Promise<Issue[]>;
|
||||
/**
|
||||
* 특정 ID의 피드백 상세 정보를 조회합니다.
|
||||
*/
|
||||
export declare const getFeedbackById: (
|
||||
projectId: string,
|
||||
channelId: string,
|
||||
feedbackId: string,
|
||||
) => Promise<Feedback>;
|
||||
/**
|
||||
* 특정 피드백을 수정합니다.
|
||||
*/
|
||||
export declare const updateFeedback: (
|
||||
projectId: string,
|
||||
channelId: string,
|
||||
feedbackId: string,
|
||||
feedbackData: Partial<CreateFeedbackRequest>,
|
||||
) => Promise<Feedback>;
|
||||
@@ -3,10 +3,18 @@ import { handleApiError } from "./error";
|
||||
|
||||
// --- 타입 정의 ---
|
||||
|
||||
interface ApiField {
|
||||
key: string;
|
||||
name: string;
|
||||
format: "text" | "textarea" | "number" | "select";
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Feedback {
|
||||
id: string;
|
||||
content: string;
|
||||
[key: string]: any; // 동적 필드를 위해 인덱스 시그니처 사용
|
||||
updatedAt: string;
|
||||
[key: string]: unknown; // 동적 필드를 위해 인덱스 시그니처 사용
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
@@ -25,7 +33,7 @@ export interface FeedbackField {
|
||||
// 피드백 생성 요청 타입 (동적 데이터 포함)
|
||||
export interface CreateFeedbackRequest {
|
||||
issueNames: string[];
|
||||
[key: string]: any; // 폼 데이터 필드 (예: { message: "...", rating: 5 })
|
||||
[key: string]: unknown; // 폼 데이터 필드 (예: { message: "...", rating: 5 })
|
||||
}
|
||||
|
||||
// --- API 함수 ---
|
||||
@@ -72,7 +80,10 @@ export const getFeedbackFields = async (
|
||||
const url = getFeedbackFieldsApiUrl(projectId, channelId);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
await handleApiError("피드백 필드 정보를 불러오는 데 실패했습니다.", response);
|
||||
await handleApiError(
|
||||
"피드백 필드 정보를 불러오는 데 실패했습니다.",
|
||||
response,
|
||||
);
|
||||
}
|
||||
const apiFields = await response.json();
|
||||
|
||||
@@ -81,11 +92,23 @@ export const getFeedbackFields = async (
|
||||
return [];
|
||||
}
|
||||
|
||||
const nameMapping: { [key: string]: string } = {
|
||||
id: "ID",
|
||||
title: "제목",
|
||||
contents: "내용",
|
||||
customer: "작성자",
|
||||
status: "상태",
|
||||
priority: "우선순위",
|
||||
createdAt: "생성일",
|
||||
updatedAt: "수정일",
|
||||
issues: "관련 이슈",
|
||||
};
|
||||
|
||||
return apiFields
|
||||
.filter((field: any) => field.status === "ACTIVE")
|
||||
.map((field: any) => ({
|
||||
.filter((field: ApiField) => field.status === "ACTIVE")
|
||||
.map((field: ApiField) => ({
|
||||
id: field.key,
|
||||
name: field.name,
|
||||
name: nameMapping[field.key] || field.name,
|
||||
type: field.format,
|
||||
}));
|
||||
};
|
||||
@@ -150,7 +173,10 @@ export const getFeedbackById = async (
|
||||
const url = `/api/projects/${projectId}/channels/${channelId}/feedbacks/${feedbackId}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
await handleApiError("피드백 상세 정보를 불러오는 데 실패했습니다.", response);
|
||||
await handleApiError(
|
||||
"피드백 상세 정보를 불러오는 데 실패했습니다.",
|
||||
response,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
@@ -175,5 +201,11 @@ export const updateFeedback = async (
|
||||
if (!response.ok) {
|
||||
await handleApiError("피드백 수정에 실패했습니다.", response);
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get("Content-Length");
|
||||
if (contentLength === "0" || !contentLength) {
|
||||
return {} as Feedback; // 혹은 적절한 기본 객체
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
17
viewer/src/services/issue.d.ts
vendored
Normal file
17
viewer/src/services/issue.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface Issue {
|
||||
id: string;
|
||||
title: string;
|
||||
feedbackCount: number;
|
||||
description: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
category: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
/**
|
||||
* 특정 프로젝트의 모든 이슈를 검색합니다.
|
||||
* @param projectId 프로젝트 ID
|
||||
* @returns 이슈 목록 Promise
|
||||
*/
|
||||
export declare const getIssues: (projectId: string) => Promise<Issue[]>;
|
||||
@@ -1,29 +1,28 @@
|
||||
// src/services/issue.ts
|
||||
import { handleApiError } from "./error";
|
||||
|
||||
// API 응답에 대한 타입을 정의합니다.
|
||||
// 실제 API 명세에 따라 더 구체적으로 작성해야 합니다.
|
||||
export interface Issue {
|
||||
id: string;
|
||||
title: string;
|
||||
feedbackCount: number;
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority?: string;
|
||||
category?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
category: string;
|
||||
[key: string]: any; // 그 외 다른 필드들
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 프로젝트의 모든 이슈를 검색합니다.
|
||||
* @param projectId 프로젝트 ID
|
||||
* @returns 이슈 목록 Promise
|
||||
*/
|
||||
export const getIssues = async (projectId: string): Promise<Issue[]> => {
|
||||
export interface IssueField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "text" | "textarea" | "number" | "select" | "date";
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export async function getIssues(projectId: string): Promise<Issue[]> {
|
||||
console.log(`Fetching issues for project: ${projectId}`);
|
||||
const url = `/api/projects/${projectId}/issues/search`;
|
||||
// body를 비워서 보내면 모든 이슈를 가져오는 것으로 가정합니다.
|
||||
// 실제 API 명세에 따라 수정이 필요할 수 있습니다.
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -31,11 +30,39 @@ export const getIssues = async (projectId: string): Promise<Issue[]> => {
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await handleApiError("이슈 목록을 불러오는 데 실패했습니다.", response);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.items;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.items || [];
|
||||
};
|
||||
export async function getIssueById(
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
): Promise<Issue> {
|
||||
const url = `/api/projects/${projectId}/issues/${issueId}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
await handleApiError(
|
||||
"이슈 상세 정보를 불러오는 데 실패했습니다.",
|
||||
response,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getIssueFields(): Promise<IssueField[]> {
|
||||
// This is mock data, but it's used by other components.
|
||||
return [
|
||||
{ id: "id", name: "ID", type: "text" },
|
||||
{ id: "name", name: "이름", type: "text" },
|
||||
{ id: "description", name: "설명", type: "text" },
|
||||
{ id: "status", name: "상태", type: "text" },
|
||||
{ id: "priority", name: "우선순위", type: "text" },
|
||||
{ id: "createdAt", name: "생성일", type: "date" },
|
||||
{ id: "updatedAt", name: "수정일", type: "date" },
|
||||
];
|
||||
}
|
||||
|
||||
24
viewer/src/services/project.d.ts
vendored
Normal file
24
viewer/src/services/project.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
timezone: {
|
||||
countryCode: string;
|
||||
name: string;
|
||||
offset: string;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
/**
|
||||
* 모든 접근 가능한 프로젝트 목록을 가져옵니다.
|
||||
* 현재는 ID가 1인 프로젝트 하나만 가져오도록 구현되어 있습니다.
|
||||
*/
|
||||
export declare const getProjects: () => Promise<Project[]>;
|
||||
/**
|
||||
* 특정 ID를 가진 프로젝트의 상세 정보를 가져옵니다.
|
||||
* @param id - 조회할 프로젝트의 ID
|
||||
*/
|
||||
export declare const getProjectById: (
|
||||
id: string,
|
||||
) => Promise<Project | undefined>;
|
||||
68
viewer/src/services/project.ts
Normal file
68
viewer/src/services/project.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export interface Project {
|
||||
id: string; // 애플리케이션 내에서는 string 타입으로 일관성 유지
|
||||
name: string;
|
||||
description: string;
|
||||
timezone: {
|
||||
countryCode: string;
|
||||
name: string;
|
||||
offset: string;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// API 응답의 id는 number 타입이므로, 파싱을 위한 별도 인터페이스 정의
|
||||
interface ProjectApiResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
timezone: {
|
||||
countryCode: string;
|
||||
name: string;
|
||||
offset: string;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const API_BASE_URL = "/api";
|
||||
|
||||
/**
|
||||
* 모든 접근 가능한 프로젝트 목록을 가져옵니다.
|
||||
* 현재는 ID가 1인 프로젝트 하나만 가져오도록 구현되어 있습니다.
|
||||
*/
|
||||
export const getProjects = async (): Promise<Project[]> => {
|
||||
try {
|
||||
const project = await getProjectById("1");
|
||||
return project ? [project] : [];
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch projects:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 ID를 가진 프로젝트의 상세 정보를 가져옵니다.
|
||||
* @param id - 조회할 프로젝트의 ID
|
||||
*/
|
||||
export const getProjectById = async (
|
||||
id: string,
|
||||
): Promise<Project | undefined> => {
|
||||
try {
|
||||
// 'project' -> 'projects'로 수정
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API call failed with status: ${response.status}`);
|
||||
}
|
||||
const data: ProjectApiResponse = await response.json();
|
||||
|
||||
// API 응답(id: number)을 내부 모델(id: string)로 변환
|
||||
return {
|
||||
...data,
|
||||
id: data.id.toString(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch project with id ${id}:`, error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
32
viewer/src/store/useSettingsStore.d.ts
vendored
Normal file
32
viewer/src/store/useSettingsStore.d.ts
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
type Theme = "light" | "dark" | "system";
|
||||
interface SettingsState {
|
||||
projectId: string | null;
|
||||
theme: Theme;
|
||||
setProjectId: (projectId: string) => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
export declare const useSettingsStore: import("zustand").UseBoundStore<
|
||||
Omit<import("zustand").StoreApi<SettingsState>, "persist"> & {
|
||||
persist: {
|
||||
setOptions: (
|
||||
options: Partial<
|
||||
import("zustand/middleware").PersistOptions<
|
||||
SettingsState,
|
||||
SettingsState
|
||||
>
|
||||
>,
|
||||
) => void;
|
||||
clearStorage: () => void;
|
||||
rehydrate: () => Promise<void> | void;
|
||||
hasHydrated: () => boolean;
|
||||
onHydrate: (fn: (state: SettingsState) => void) => () => void;
|
||||
onFinishHydration: (fn: (state: SettingsState) => void) => () => void;
|
||||
getOptions: () => Partial<
|
||||
import("zustand/middleware").PersistOptions<
|
||||
SettingsState,
|
||||
SettingsState
|
||||
>
|
||||
>;
|
||||
};
|
||||
}
|
||||
>;
|
||||
27
viewer/src/store/useSettingsStore.ts
Normal file
27
viewer/src/store/useSettingsStore.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
interface SettingsState {
|
||||
projectId: string;
|
||||
channelId: string;
|
||||
theme: "light" | "dark" | "system";
|
||||
setProjectId: (projectId: string) => void;
|
||||
setChannelId: (channelId: string) => void;
|
||||
setTheme: (theme: "light" | "dark" | "system") => void;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
projectId: "1", // 기본 프로젝트 ID
|
||||
channelId: "4", // 기본 채널 ID
|
||||
theme: "system",
|
||||
setProjectId: (projectId) => set({ projectId }),
|
||||
setChannelId: (channelId) => set({ channelId }),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
}),
|
||||
{
|
||||
name: "settings-storage", // localStorage에 저장될 이름
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -77,4 +77,4 @@ const config: Config = {
|
||||
plugins: [tailwindcssAnimate],
|
||||
};
|
||||
|
||||
export default config;
|
||||
export default config;
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowJs": false,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"effort_js/src/services/error.js",
|
||||
"effort_js/src/services/feedback.js",
|
||||
"effort_js/src/services/issue.js",
|
||||
"effort_js/src/services/project.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user