Compare commits

...

16 Commits

Author SHA1 Message Date
8f5ff651ed 레이아웃 수정, 글쓰기/작성한글 수정 간단 권한 2025-08-05 14:44:41 +09:00
27844ef237 dockerizing for production 2025-08-05 14:00:07 +09:00
9527b7d385 빌드 깨짐 수정 2025-08-05 11:28:44 +09:00
c2fa1ec589 Descope 연동 2025-08-04 21:07:30 +09:00
0e85144cdf 이슈 상세페이지 가독성 개선. 2025-08-04 20:01:51 +09:00
84cc83d36b 반응성 적용 2025-08-04 19:44:42 +09:00
cb60e7a43d 높이 문제 해결 2025-08-04 19:30:12 +09:00
13dad7272c feat: 이슈 API 연동 및 UI 개선
- 이슈 목록/상세 API 연동

- 테이블 컬럼 너비 조정

- Biome 린터 설정 수정
2025-08-04 18:08:28 +09:00
Lectom C Han
466d719eef 1 - feat(dynamic-table): 컬럼 너비 조절 및 고정 기능 추가
3 - 사용자가 직접 컬럼의 너비를 조절할 수 있도록 리사이즈 핸들러를 추가
   4 - '생성일'과 '수정일' 컬럼의 너비를 120px로 고정하여 가독성을 높임   5 - 리사이즈 핸들러가 올바르게 표시되도록 관련 CSS 스타일을 추가했습니다.
2025-08-04 00:40:14 +09:00
Lectom C Han
32506d22bb 중간 수정. 피드백 디테일보기 해결 2025-08-03 18:30:02 +09:00
Lectom C Han
26fbb3f4c0 Issue도 Dynamic Table 사용하도록 공유 2025-08-03 01:23:20 +09:00
Lectom C Han
a53340e3c1 js 의존성 제거 2025-08-03 00:40:25 +09:00
Lectom C Han
8db8ce668c - Biome 설정 파일 마이그레이션 및 규칙 적용 │
│    - 전체 파일 대상 포맷팅 및 린트 오류 수정                                             │
 │    -  타입을 으로 변경하여 타입 안정성 강화                                │
 │    - 불필요한 import 제거 및 useEffect 의존성 배열 수정                                  │
 │    -  파일을 /로 마이그레이션하여 타입스크립트 일관성 확보               │
 │    - 에 개발 원칙 추가
2025-08-02 18:51:22 +09:00
211689e889 내비게이션, 테마 적용 2025-07-31 19:06:58 +09:00
3ccb0c8f8a feedback list 기능추가. 상단바 추가. 테마 적용 안됨. 2025-07-31 17:22:13 +09:00
b4e6a94fda feedback list 기능 정리. 2025-07-31 16:32:42 +09:00
103 changed files with 5618 additions and 2285 deletions

17
.dockerignore Normal file
View 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
View File

@@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env
.env.local

47
Dockerfile Normal file
View 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;"]

View File

@@ -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
View 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
View File

@@ -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
View 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:
![session-token-validation-diagram](https://docs.descope.com/static/SessionValidation-cf7b2d5d26594f96421d894273a713d8.png)
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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
{
"private": true,
"workspaces": [
"viewer"
]
}

9
pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,9 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}

View File

@@ -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=""

View File

@@ -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

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}

View 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",
}),
],
});
}

View File

@@ -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>

View File

@@ -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

File diff suppressed because it is too large Load Diff

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
View File

@@ -0,0 +1,2 @@
declare function App(): import("react/jsx-runtime").JSX.Element;
export default App;

View File

@@ -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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

13
viewer/src/components/DynamicForm.d.ts vendored Normal file
View 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;

View File

@@ -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
View 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;

View File

@@ -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>
);
}
}

View File

@@ -0,0 +1,6 @@
interface ErrorDisplayProps {
message: string;
}
export declare function ErrorDisplay({
message,
}: ErrorDisplayProps): import("react/jsx-runtime").JSX.Element;

View File

@@ -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>
);
};
}

View 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
View File

@@ -0,0 +1 @@
export declare function Header(): import("react/jsx-runtime").JSX.Element;

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1 @@
export declare function LanguageSelectBox(): import("react/jsx-runtime").JSX.Element;

View 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>
);
}

View 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
View File

@@ -0,0 +1 @@
export declare function MainLayout(): import("react/jsx-runtime").JSX.Element;

View File

@@ -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>

View 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>
);
}

View 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 />
</>
);
}

View File

@@ -0,0 +1 @@
export declare function ProjectSelectBox(): import("react/jsx-runtime").JSX.Element;

View 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>
);
}

View File

@@ -0,0 +1 @@
export declare function ThemeSelectBox(): import("react/jsx-runtime").JSX.Element;

View 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>
);
}

View File

@@ -0,0 +1 @@
export declare function UserProfileBox(): import("react/jsx-runtime").JSX.Element;

View 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}
/>
</>
);
}

View File

@@ -0,0 +1,9 @@
import React from "react";
interface ThemeProviderProps {
children: React.ReactNode;
}
export declare function ThemeProvider({
children,
}: ThemeProviderProps): React.ReactElement;

View 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}</>;
}

View 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
View 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
View 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 };

View 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
View 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,
};

View File

@@ -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,
};

View 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,
};

View 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,
};

View 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
View 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 };

View File

@@ -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
View 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 };

View File

@@ -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
View 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 };

View File

@@ -0,0 +1,33 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

72
viewer/src/components/ui/select.d.ts vendored Normal file
View 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,
};

View File

@@ -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
View 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 };

View File

@@ -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
View 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,
};

View File

@@ -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,
};

View 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 };

View File

@@ -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 };

View 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]);
}

View File

@@ -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
View 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
View File

@@ -0,0 +1 @@
import "./index.css";

View File

@@ -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>,
);

View File

@@ -0,0 +1 @@
export declare function FeedbackCreatePage(): import("react/jsx-runtime").JSX.Element;

View File

@@ -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>
);
}
}

View File

@@ -0,0 +1 @@
export declare function FeedbackDetailPage(): import("react/jsx-runtime").JSX.Element;

View File

@@ -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>
);
}
}

View File

@@ -0,0 +1 @@
export declare function FeedbackListPage(): import("react/jsx-runtime").JSX.Element;

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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
View 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
View 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>;

View File

@@ -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
View 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[]>;

View File

@@ -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
View 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>;

View 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
View 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
>
>;
};
}
>;

View 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에 저장될 이름
},
),
);

View File

@@ -77,4 +77,4 @@ const config: Config = {
plugins: [tailwindcssAnimate],
};
export default config;
export default config;

View File

@@ -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"
]
}

View File

@@ -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