Compare commits
3 Commits
e2cb482e5c
...
211689e889
| Author | SHA1 | Date | |
|---|---|---|---|
| 211689e889 | |||
| 3ccb0c8f8a | |||
| b4e6a94fda |
31
GEMINI.md
31
GEMINI.md
@@ -26,3 +26,34 @@
|
|||||||
|
|
||||||
- **인증**: OIDC (OpenID Connect) 표준을 준수하여 인증을 구현합니다.
|
- **인증**: OIDC (OpenID Connect) 표준을 준수하여 인증을 구현합니다.
|
||||||
- **데이터**: PoC(Proof of Concept) 레벨에서는 별도의 데이터베이스를 사용하지 않고, Mock 데이터를 활용하여 핵심 기능 개발에 집중합니다.
|
- **데이터**: PoC(Proof of Concept) 레벨에서는 별도의 데이터베이스를 사용하지 않고, Mock 데이터를 활용하여 핵심 기능 개발에 집중합니다.
|
||||||
|
|
||||||
|
## 5. 개발 로드맵
|
||||||
|
|
||||||
|
### Phase 1: 기반 구축 및 핵심 기능 구현 (완료)
|
||||||
|
|
||||||
|
- **내용**: 프로젝트 초기 설정, 핵심 동적 UI 컴포넌트 개발 및 기본 페이지 구성을 완료했습니다.
|
||||||
|
- **주요 산출물**:
|
||||||
|
- `DynamicTable`, `DynamicForm` 컴포넌트
|
||||||
|
- 피드백 CRUD 페이지
|
||||||
|
- 기본 라우팅 설정
|
||||||
|
|
||||||
|
### Phase 2: 기능 고도화 및 안정화 (진행 중)
|
||||||
|
|
||||||
|
- **목표**: 사용자 경험을 개선하고 코드 품질을 향상시킵니다.
|
||||||
|
- **완료된 작업**:
|
||||||
|
- **동적 테이블 개선**: 페이지네이션, 열 정렬, 데이터 필터링, 날짜 범위 선택, 행 확장 등 고급 기능 추가
|
||||||
|
- **상태 관리 도입**: Zustand를 활용하여 프로젝트 ID, 테마 등 전역 상태 관리 시스템 구축
|
||||||
|
- **UI 개선**: 상단 헤더, 네비게이션 메뉴, Light/Dark/System 테마 전환 기능 구현
|
||||||
|
- **진행할 작업**:
|
||||||
|
- **테마 커스터마이징**: Dracula 테마를 다크 모드에 적용하고, 기본 테마를 라이트 모드로 설정
|
||||||
|
- **동적 폼 개선**: Zod와 같은 라이브러리를 활용하여 스키마 기반의 동적 데이터 유효성 검사 구현
|
||||||
|
- **코드 품질 관리**: Biome을 프로젝트에 통합하여 코드 포맷팅과 린트 검사를 자동화하고, 일관된 코드 스타일 유지
|
||||||
|
|
||||||
|
### Phase 3: 인증 및 배포 (예정)
|
||||||
|
|
||||||
|
- **목표**: OIDC 기반의 안정적인 인증 시스템을 구축하고, Docker를 통해 배포 환경을 마련합니다.
|
||||||
|
- **주요 작업**:
|
||||||
|
- **OIDC 연동**: OIDC 클라이언트 라이브러리를 설치하고, 로그인/로그아웃 및 토큰 관리 로직 구현
|
||||||
|
- **인증 상태 관리**: 사용자의 로그인 상태를 전역으로 관리하고, 인증 상태에 따라 UI가 동적으로 변경되도록 설정
|
||||||
|
- **라우트 보호**: 인증이 필요한 페이지에 접근 제어(Route Guard)를 적용하여 비인가 사용자의 접근 차단
|
||||||
|
- **컨테이너화**: Dockerfile을 작성하고 Docker Compose를 설정하여 개발 및 프로덕션 환경을 컨테이너 기반으로 구축
|
||||||
|
|||||||
9
TODO.md
9
TODO.md
@@ -14,9 +14,12 @@
|
|||||||
|
|
||||||
## Phase 2: 기능 고도화 및 안정화
|
## Phase 2: 기능 고도화 및 안정화
|
||||||
|
|
||||||
- [ ] 동적 테이블에 페이지네이션, 정렬, 필터링 기능 추가
|
- [x] 동적 테이블 기능 고도화 (페이지네이션, 정렬, 필터링, 날짜범위, 행 확장 등) (완료: 2025-07-31 17:20:41)
|
||||||
|
- [x] 전역 상태 관리를 위한 Zustand 도입 (프로젝트 ID, 테마) (완료: 2025-07-31 17:20:41)
|
||||||
|
- [x] 상단 헤더 및 네비게이션 구현 (완료: 2025-07-31 17:20:41)
|
||||||
|
- [x] Light/Dark/System 테마 기능 구현 및 커스텀 테마 적용 (완료: 2025-07-31 17:20:41)
|
||||||
|
- [x] TypeScript 및 빌드 오류 디버깅 및 해결 (완료: 2025-07-31 17:20:41)
|
||||||
- [ ] 동적 폼에 데이터 유효성 검사 기능 추가
|
- [ ] 동적 폼에 데이터 유효성 검사 기능 추가
|
||||||
- [ ] 전역 상태 관리를 위한 Zustand 도입 검토
|
|
||||||
- [ ] Biome을 이용한 코드 포맷팅 및 린트 규칙 적용 및 검사
|
- [ ] Biome을 이용한 코드 포맷팅 및 린트 규칙 적용 및 검사
|
||||||
|
|
||||||
## Phase 3: 인증 및 배포
|
## Phase 3: 인증 및 배포
|
||||||
@@ -24,4 +27,4 @@
|
|||||||
- [ ] OIDC 클라이언트 연동 및 인증 로직 구현
|
- [ ] OIDC 클라이언트 연동 및 인증 로직 구현
|
||||||
- [ ] 로그인/로그아웃 및 인증 상태 관리
|
- [ ] 로그인/로그아웃 및 인증 상태 관리
|
||||||
- [ ] 인증이 필요한 라우트 보호 기능 적용
|
- [ ] 인증이 필요한 라우트 보호 기능 적용
|
||||||
- [ ] Docker를 이용한 배포 환경 구축
|
- [ ] Docker를 이용한 배포 환경 구축
|
||||||
|
|||||||
@@ -5,4 +5,7 @@
|
|||||||
VITE_API_PROXY_TARGET=http://localhost:3030
|
VITE_API_PROXY_TARGET=http://localhost:3030
|
||||||
|
|
||||||
# API 키
|
# API 키
|
||||||
VITE_API_KEY=your_api_key_here
|
VITE_API_KEY=your_api_key_here
|
||||||
|
|
||||||
|
# 기본 채널 ID
|
||||||
|
VITE_DEFAULT_CHANNEL_ID=4
|
||||||
@@ -5,38 +5,44 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"lint": "biome lint --write .",
|
"lint": "biome lint --write .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"shadcn": "shadcn-ui"
|
"shadcn": "shadcn-ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.533.0",
|
"lucide-react": "^0.533.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-day-picker": "^9.8.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.7.1",
|
"react-router-dom": "^7.7.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zustand": "^5.0.6"
|
"zustand": "^5.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.1.2",
|
"@biomejs/biome": "^2.1.3",
|
||||||
"@types/react": "^19.1.8",
|
"@types/node": "^24.1.0",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react": "^19.1.9",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@types/react-dom": "^19.1.7",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tw-animate-css": "^1.3.6",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1538
viewer/pnpm-lock.yaml
generated
1538
viewer/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
viewer/src/App.d.ts
vendored
Normal file
2
viewer/src/App.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare function App(): import("react/jsx-runtime").JSX.Element;
|
||||||
|
export default App;
|
||||||
14
viewer/src/App.js
Normal file
14
viewer/src/App.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
// src/App.tsx
|
||||||
|
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 { ThemeProvider } from "./components/providers/ThemeProvider";
|
||||||
|
function App() {
|
||||||
|
return (_jsx(ThemeProvider, { children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(Navigate, { to: "/projects/1/channels/4/feedbacks" }) }), _jsxs(Route, { path: "/projects/:projectId/channels/:channelId/feedbacks", element: _jsx(MainLayout, {}), children: [_jsx(Route, { index: true, element: _jsx(FeedbackListPage, {}) }), _jsx(Route, { path: "new", element: _jsx(FeedbackCreatePage, {}) }), _jsx(Route, { path: ":feedbackId", element: _jsx(FeedbackDetailPage, {}) })] }), _jsx(Route, { path: "/issues/:issueId" // 이슈 ID만 받도록 단순화
|
||||||
|
, element: _jsx(IssueViewerPage, {}) }), _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/" }) })] }) }));
|
||||||
|
}
|
||||||
|
export default App;
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
// src/App.tsx
|
// src/App.tsx
|
||||||
import {
|
import { Routes, Route, Navigate, Outlet } from "react-router-dom";
|
||||||
Routes,
|
|
||||||
Route,
|
|
||||||
Navigate,
|
|
||||||
} from "react-router-dom";
|
|
||||||
import { MainLayout } from "@/components/MainLayout";
|
import { MainLayout } from "@/components/MainLayout";
|
||||||
import { FeedbackCreatePage } from "@/pages/FeedbackCreatePage";
|
import { FeedbackCreatePage } from "@/pages/FeedbackCreatePage";
|
||||||
import { FeedbackListPage } from "@/pages/FeedbackListPage";
|
import { FeedbackListPage } from "@/pages/FeedbackListPage";
|
||||||
@@ -11,27 +7,41 @@ import { FeedbackDetailPage } from "@/pages/FeedbackDetailPage";
|
|||||||
import { IssueViewerPage } from "@/pages/IssueViewerPage";
|
import { IssueViewerPage } from "@/pages/IssueViewerPage";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const defaultProjectId = import.meta.env.VITE_DEFAULT_PROJECT_ID || "1";
|
||||||
|
const defaultChannelId = import.meta.env.VITE_DEFAULT_CHANNEL_ID || "4";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* 기본 경로 리디렉션 */}
|
{/* 기본 경로 리디렉션 */}
|
||||||
<Route path="/" element={<Navigate to="/projects/1/channels/4/feedbacks" />} />
|
|
||||||
|
|
||||||
{/* 피드백 관련 페이지 (메인 레이아웃 사용) */}
|
|
||||||
<Route
|
<Route
|
||||||
path="/projects/:projectId/channels/:channelId/feedbacks"
|
path="/"
|
||||||
element={<MainLayout />}
|
element={
|
||||||
>
|
<Navigate
|
||||||
<Route index element={<FeedbackListPage />} />
|
to={`/projects/${defaultProjectId}/channels/${defaultChannelId}/feedbacks`}
|
||||||
<Route path="new" element={<FeedbackCreatePage />} />
|
/>
|
||||||
<Route path=":feedbackId" element={<FeedbackDetailPage />} />
|
}
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* 독립적인 이슈 뷰어 페이지 */}
|
|
||||||
<Route
|
|
||||||
path="/issues/:issueId" // 이슈 ID만 받도록 단순화
|
|
||||||
element={<IssueViewerPage />}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 프로젝트 기반 레이아웃 */}
|
||||||
|
<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" element={<IssueViewerPage />} />
|
||||||
|
{/* 여기에 이슈 상세 페이지 라우트 추가 예정 */}
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* 잘못된 접근을 위한 리디렉션 */}
|
{/* 잘못된 접근을 위한 리디렉션 */}
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
10
viewer/src/components/DynamicForm.d.ts
vendored
Normal file
10
viewer/src/components/DynamicForm.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { FeedbackField } from "@/services/feedback";
|
||||||
|
interface DynamicFormProps {
|
||||||
|
fields: FeedbackField[];
|
||||||
|
onSubmit: (formData: Record<string, any>) => Promise<void>;
|
||||||
|
initialData?: Record<string, any>;
|
||||||
|
submitButtonText?: string;
|
||||||
|
}
|
||||||
|
export declare function DynamicForm({ fields, onSubmit, initialData, // 기본값으로 상수 사용
|
||||||
|
submitButtonText, }: DynamicFormProps): import("react/jsx-runtime").JSX.Element;
|
||||||
|
export {};
|
||||||
53
viewer/src/components/DynamicForm.js
Normal file
53
viewer/src/components/DynamicForm.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
// 컴포넌트 외부에 안정적인 참조를 가진 빈 객체 상수 선언
|
||||||
|
const EMPTY_INITIAL_DATA = {};
|
||||||
|
export function DynamicForm({ fields, onSubmit, initialData = EMPTY_INITIAL_DATA, // 기본값으로 상수 사용
|
||||||
|
submitButtonText = "제출", }) {
|
||||||
|
const [formData, setFormData] = useState(initialData);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
// initialData prop이 변경될 때만 폼 데이터를 동기화
|
||||||
|
setFormData(initialData);
|
||||||
|
}, [initialData]);
|
||||||
|
const handleFormChange = (fieldId, value) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [fieldId]: value }));
|
||||||
|
};
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit(formData);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Form submission error:", error);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const renderField = (field) => {
|
||||||
|
const commonProps = {
|
||||||
|
id: field.id,
|
||||||
|
value: formData[field.id] ?? "",
|
||||||
|
disabled: field.readOnly,
|
||||||
|
};
|
||||||
|
switch (field.type) {
|
||||||
|
case "textarea":
|
||||||
|
return (_jsx(Textarea, { ...commonProps, onChange: (e) => handleFormChange(field.id, e.target.value), placeholder: field.readOnly ? "" : `${field.name}...`, rows: 5 }));
|
||||||
|
case "text":
|
||||||
|
case "number":
|
||||||
|
return (_jsx(Input, { ...commonProps, type: field.type, onChange: (e) => handleFormChange(field.id, e.target.value), placeholder: field.readOnly ? "" : field.name }));
|
||||||
|
case "select":
|
||||||
|
return (_jsxs(Select, { value: commonProps.value, onValueChange: (value) => handleFormChange(field.id, value), disabled: field.readOnly, children: [_jsx(SelectTrigger, { id: field.id, children: _jsx(SelectValue, { placeholder: `-- ${field.name} 선택 --` }) }), _jsx(SelectContent, {})] }));
|
||||||
|
default:
|
||||||
|
return _jsxs("p", { children: ["\uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 \uD544\uB4DC \uD0C0\uC785: ", field.type] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (_jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [fields.map((field) => (_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: field.id, children: field.name }), renderField(field)] }, field.id))), _jsx(Button, { type: "submit", disabled: isSubmitting, children: isSubmitting ? "전송 중..." : submitButtonText })] }));
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import { Label } from "@/components/ui/label";
|
|||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
|||||||
9
viewer/src/components/DynamicTable.d.ts
vendored
Normal file
9
viewer/src/components/DynamicTable.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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;
|
||||||
146
viewer/src/components/DynamicTable.js
Normal file
146
viewer/src/components/DynamicTable.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||||
|
import { flexRender, getCoreRowModel, getExpandedRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } 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 { Link, useNavigate } 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, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
const DEFAULT_COLUMN_ORDER = [
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"contents",
|
||||||
|
"issues",
|
||||||
|
"customer",
|
||||||
|
"updatedAt",
|
||||||
|
];
|
||||||
|
export function DynamicTable({ columns: rawColumns, data, projectId, channelId, }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [sorting, setSorting] = useState([]);
|
||||||
|
const [columnFilters, setColumnFilters] = useState([]);
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState({});
|
||||||
|
const [expanded, setExpanded] = useState({});
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("");
|
||||||
|
const [date, setDate] = useState();
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
const orderedRawColumns = [...rawColumns].sort((a, b) => {
|
||||||
|
const indexA = DEFAULT_COLUMN_ORDER.indexOf(a.id);
|
||||||
|
const indexB = DEFAULT_COLUMN_ORDER.indexOf(b.id);
|
||||||
|
if (indexA === -1 && indexB === -1)
|
||||||
|
return 0;
|
||||||
|
if (indexA === -1)
|
||||||
|
return 1;
|
||||||
|
if (indexB === -1)
|
||||||
|
return -1;
|
||||||
|
return indexA - indexB;
|
||||||
|
});
|
||||||
|
const generatedColumns = orderedRawColumns.map((field) => ({
|
||||||
|
accessorKey: field.id,
|
||||||
|
header: ({ column }) => {
|
||||||
|
if (field.id === "issues") {
|
||||||
|
return _jsx("div", { children: field.name });
|
||||||
|
}
|
||||||
|
return (_jsxs(Button, { variant: "ghost", onClick: () => column.toggleSorting(column.getIsSorted() === "asc"), children: [field.name, _jsx(ArrowUpDown, { className: "ml-2 h-4 w-4" })] }));
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const value = row.original[field.id];
|
||||||
|
switch (field.id) {
|
||||||
|
case "issues": {
|
||||||
|
const issues = value;
|
||||||
|
if (!issues || issues.length === 0)
|
||||||
|
return "N/A";
|
||||||
|
return (_jsx("div", { className: "flex flex-col space-y-1", children: issues.map((issue) => (_jsx(Link, { to: `/issues/${issue.id}`, className: "text-blue-600 hover:underline", onClick: (e) => e.stopPropagation(), children: issue.name }, issue.id))) }));
|
||||||
|
}
|
||||||
|
case "title":
|
||||||
|
return (_jsx("div", { className: "whitespace-normal break-words w-48", children: String(value ?? "N/A") }));
|
||||||
|
case "contents": {
|
||||||
|
const content = String(value ?? "N/A");
|
||||||
|
const truncated = content.length > 50
|
||||||
|
? `${content.substring(0, 50)}...`
|
||||||
|
: content;
|
||||||
|
return (_jsx("div", { className: "whitespace-normal break-words w-60", children: truncated }));
|
||||||
|
}
|
||||||
|
case "createdAt":
|
||||||
|
case "updatedAt":
|
||||||
|
return String(value ?? "N/A").substring(0, 10);
|
||||||
|
default:
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
return String(value ?? "N/A");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "expander",
|
||||||
|
header: () => null,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (_jsx(Button, { variant: "ghost", size: "sm", onClick: (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
row.toggleExpanded();
|
||||||
|
}, children: row.getIsExpanded() ? "▼" : "▶" }));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...generatedColumns,
|
||||||
|
];
|
||||||
|
}, [rawColumns]);
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (!date?.from) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
const fromDate = date.from;
|
||||||
|
const toDate = date.to ? addDays(date.to, 1) : addDays(fromDate, 1);
|
||||||
|
return data.filter((item) => {
|
||||||
|
const itemDate = new Date(item.updatedAt);
|
||||||
|
return itemDate >= fromDate && itemDate < toDate;
|
||||||
|
});
|
||||||
|
}, [data, date]);
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filteredData,
|
||||||
|
columns,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onExpandedChange: setExpanded,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
getExpandedRowModel: getExpandedRowModel(),
|
||||||
|
initialState: {
|
||||||
|
pagination: {
|
||||||
|
pageSize: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
|
expanded,
|
||||||
|
globalFilter,
|
||||||
|
},
|
||||||
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
|
});
|
||||||
|
const handleRowClick = (feedbackId) => {
|
||||||
|
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks/${feedbackId}`);
|
||||||
|
};
|
||||||
|
return (_jsx(Card, { children: _jsxs(CardContent, { children: [_jsxs("div", { className: "flex items-center justify-between py-4", children: [_jsx(Input, { placeholder: "\uC804\uCCB4 \uB370\uC774\uD130\uC5D0\uC11C \uAC80\uC0C9...", value: globalFilter, onChange: (event) => setGlobalFilter(event.target.value), className: "max-w-sm" }), _jsxs("div", { className: "flex items-center space-x-2", children: [_jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { id: "date", variant: "outline", className: cn("w-[300px] justify-start text-left font-normal", !date && "text-muted-foreground"), children: [_jsx(CalendarIcon, { className: "mr-2 h-4 w-4" }), date?.from ? (date.to ? (_jsxs(_Fragment, { children: [format(date.from, "LLL dd, y"), " -", " ", format(date.to, "LLL dd, y")] })) : (format(date.from, "LLL dd, y"))) : (_jsx("span", { children: "\uAE30\uAC04 \uC120\uD0DD" }))] }) }), _jsx(PopoverContent, { className: "w-auto p-0", align: "end", children: _jsx(Calendar, { initialFocus: true, mode: "range", defaultMonth: date?.from, selected: date, onSelect: setDate, numberOfMonths: 2 }) })] }), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", className: "ml-auto", children: ["\uCEEC\uB7FC \uD45C\uC2DC ", _jsx(ChevronDown, { className: "ml-2 h-4 w-4" })] }) }), _jsx(DropdownMenuContent, { align: "end", children: table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => column.getCanHide())
|
||||||
|
.map((column) => {
|
||||||
|
return (_jsx(DropdownMenuCheckboxItem, { className: "capitalize", checked: column.getIsVisible(), onCheckedChange: (value) => column.toggleVisibility(!!value), children: column.id }, column.id));
|
||||||
|
}) })] })] })] }), _jsx("div", { className: "rounded-md border", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { children: headerGroup.headers.map((header) => (_jsx(TableHead, { style: { width: header.getSize() }, children: header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext()) }, header.id))) }, headerGroup.id))) }), _jsx(TableBody, { children: table.getRowModel().rows?.length ? (table.getRowModel().rows.map((row) => (_jsxs(Fragment, { children: [_jsx(TableRow, { "data-state": row.getIsSelected() && "selected", onClick: () => handleRowClick(row.original.id.toString()), className: "cursor-pointer hover:bg-muted/50", children: row.getVisibleCells().map((cell) => (_jsx(TableCell, { children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id))) }), row.getIsExpanded() && (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: columns.length + 1, children: _jsxs("div", { className: "p-4 bg-muted rounded-md", children: [_jsx("h4", { className: "font-bold text-lg", children: row.original.title }), _jsx("p", { className: "mt-2 whitespace-pre-wrap", children: row.original.contents })] }) }) }, `${row.id}-expanded`))] }, row.id)))) : (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: columns.length + 1, className: "h-24 text-center", children: "\uD45C\uC2DC\uD560 \uB370\uC774\uD130\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." }) })) })] }) }), _jsxs("div", { className: "flex items-center justify-between py-4", children: [_jsxs("div", { className: "flex-1 text-sm text-muted-foreground", children: ["\uCD1D ", table.getFilteredRowModel().rows.length, "\uAC1C"] }), _jsxs("div", { className: "flex items-center space-x-6", children: [_jsxs("div", { className: "flex items-center space-x-2", children: [_jsx("p", { className: "text-sm font-medium", children: "\uD398\uC774\uC9C0 \uB2F9 \uD589 \uC218" }), _jsxs(Select, { value: `${table.getState().pagination.pageSize}`, onValueChange: (value) => {
|
||||||
|
table.setPageSize(Number(value));
|
||||||
|
}, children: [_jsx(SelectTrigger, { className: "h-8 w-[70px]", children: _jsx(SelectValue, { placeholder: table.getState().pagination.pageSize }) }), _jsx(SelectContent, { side: "top", children: [20, 30, 50].map((pageSize) => (_jsx(SelectItem, { value: `${pageSize}`, children: pageSize }, pageSize))) })] })] }), _jsxs("div", { className: "flex w-[100px] items-center justify-center text-sm font-medium", children: [table.getPageCount(), " \uD398\uC774\uC9C0 \uC911", " ", table.getState().pagination.pageIndex + 1] }), _jsxs("div", { className: "flex items-center space-x-2", children: [_jsxs(Button, { variant: "outline", className: "hidden h-8 w-8 p-0 lg:flex", onClick: () => table.setPageIndex(0), disabled: !table.getCanPreviousPage(), children: [_jsx("span", { className: "sr-only", children: "\uCCAB \uD398\uC774\uC9C0\uB85C" }), _jsx(ChevronsLeft, { className: "h-4 w-4" })] }), _jsxs(Button, { variant: "outline", className: "h-8 w-8 p-0", onClick: () => table.previousPage(), disabled: !table.getCanPreviousPage(), children: [_jsx("span", { className: "sr-only", children: "\uC774\uC804 \uD398\uC774\uC9C0\uB85C" }), _jsx(ChevronLeft, { className: "h-4 w-4" })] }), _jsxs(Button, { variant: "outline", className: "h-8 w-8 p-0", onClick: () => table.nextPage(), disabled: !table.getCanNextPage(), children: [_jsx("span", { className: "sr-only", children: "\uB2E4\uC74C \uD398\uC774\uC9C0\uB85C" }), _jsx(ChevronRight, { className: "h-4 w-4" })] }), _jsxs(Button, { variant: "outline", className: "hidden h-8 w-8 p-0 lg:flex", onClick: () => table.setPageIndex(table.getPageCount() - 1), disabled: !table.getCanNextPage(), children: [_jsx("span", { className: "sr-only", children: "\uB9C8\uC9C0\uB9C9 \uD398\uC774\uC9C0\uB85C" }), _jsx(ChevronsRight, { className: "h-4 w-4" })] })] })] })] })] }) }));
|
||||||
|
}
|
||||||
|
export default DynamicTable;
|
||||||
@@ -1,4 +1,54 @@
|
|||||||
|
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getExpandedRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
type ColumnFiltersState,
|
||||||
|
type ExpandedState,
|
||||||
|
type SortingState,
|
||||||
|
type VisibilityState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { addDays, format } from "date-fns";
|
||||||
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
Calendar as CalendarIcon,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState, Fragment } from "react";
|
||||||
|
import type { DateRange } from "react-day-picker";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } 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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -7,8 +57,8 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import type { Feedback, FeedbackField, Issue } from "@/services/feedback";
|
import type { Feedback, FeedbackField, Issue } from "@/services/feedback";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface DynamicTableProps {
|
interface DynamicTableProps {
|
||||||
columns: FeedbackField[];
|
columns: FeedbackField[];
|
||||||
@@ -17,13 +67,172 @@ interface DynamicTableProps {
|
|||||||
channelId: string;
|
channelId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_COLUMN_ORDER = [
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"contents",
|
||||||
|
"issues",
|
||||||
|
"customer",
|
||||||
|
"updatedAt",
|
||||||
|
];
|
||||||
|
|
||||||
export function DynamicTable({
|
export function DynamicTable({
|
||||||
columns,
|
columns: rawColumns,
|
||||||
data,
|
data,
|
||||||
projectId,
|
projectId,
|
||||||
channelId,
|
channelId,
|
||||||
}: DynamicTableProps) {
|
}: DynamicTableProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||||
|
const [expanded, setExpanded] = useState<ExpandedState>({});
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("");
|
||||||
|
const [date, setDate] = useState<DateRange | undefined>();
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<Feedback>[]>(() => {
|
||||||
|
const orderedRawColumns = [...rawColumns].sort((a, b) => {
|
||||||
|
const indexA = DEFAULT_COLUMN_ORDER.indexOf(a.id);
|
||||||
|
const indexB = DEFAULT_COLUMN_ORDER.indexOf(b.id);
|
||||||
|
if (indexA === -1 && indexB === -1) return 0;
|
||||||
|
if (indexA === -1) return 1;
|
||||||
|
if (indexB === -1) return -1;
|
||||||
|
return indexA - indexB;
|
||||||
|
});
|
||||||
|
|
||||||
|
const generatedColumns: ColumnDef<Feedback>[] = orderedRawColumns.map(
|
||||||
|
(field) => ({
|
||||||
|
accessorKey: field.id,
|
||||||
|
header: ({ column }) => {
|
||||||
|
if (field.id === "issues") {
|
||||||
|
return <div>{field.name}</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{field.name}
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const value = row.original[field.id];
|
||||||
|
switch (field.id) {
|
||||||
|
case "issues": {
|
||||||
|
const issues = value as Issue[] | undefined;
|
||||||
|
if (!issues || issues.length === 0) return "N/A";
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
{issues.map((issue) => (
|
||||||
|
<Link
|
||||||
|
key={issue.id}
|
||||||
|
to={`/issues/${issue.id}`}
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{issue.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "title":
|
||||||
|
return (
|
||||||
|
<div className="whitespace-normal break-words w-48">
|
||||||
|
{String(value ?? "N/A")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "contents": {
|
||||||
|
const content = String(value ?? "N/A");
|
||||||
|
const truncated =
|
||||||
|
content.length > 50
|
||||||
|
? `${content.substring(0, 50)}...`
|
||||||
|
: content;
|
||||||
|
return (
|
||||||
|
<div className="whitespace-normal break-words w-60">
|
||||||
|
{truncated}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "createdAt":
|
||||||
|
case "updatedAt":
|
||||||
|
return String(value ?? "N/A").substring(0, 10);
|
||||||
|
default:
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
return String(value ?? "N/A");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "expander",
|
||||||
|
header: () => null,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
row.toggleExpanded();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.getIsExpanded() ? "▼" : "▶"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...generatedColumns,
|
||||||
|
];
|
||||||
|
}, [rawColumns]);
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (!date?.from) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
const fromDate = date.from;
|
||||||
|
const toDate = date.to ? addDays(date.to, 1) : addDays(fromDate, 1);
|
||||||
|
|
||||||
|
return data.filter((item) => {
|
||||||
|
const itemDate = new Date(item.updatedAt);
|
||||||
|
return itemDate >= fromDate && itemDate < toDate;
|
||||||
|
});
|
||||||
|
}, [data, date]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filteredData,
|
||||||
|
columns,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onExpandedChange: setExpanded,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
getExpandedRowModel: getExpandedRowModel(),
|
||||||
|
initialState: {
|
||||||
|
pagination: {
|
||||||
|
pageSize: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
|
expanded,
|
||||||
|
globalFilter,
|
||||||
|
},
|
||||||
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
|
});
|
||||||
|
|
||||||
const handleRowClick = (feedbackId: string) => {
|
const handleRowClick = (feedbackId: string) => {
|
||||||
navigate(
|
navigate(
|
||||||
@@ -31,93 +240,220 @@ export function DynamicTable({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCell = (item: Feedback, field: FeedbackField) => {
|
|
||||||
const value = item[field.id];
|
|
||||||
|
|
||||||
// 필드 ID에 따라 렌더링 분기
|
|
||||||
switch (field.id) {
|
|
||||||
case "issues": {
|
|
||||||
const issues = value as Issue[] | undefined;
|
|
||||||
if (!issues || issues.length === 0) return "N/A";
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col space-y-1">
|
|
||||||
{issues.map((issue) => (
|
|
||||||
<Link
|
|
||||||
key={issue.id}
|
|
||||||
to={`/issues/${issue.id}`}
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
onClick={(e) => e.stopPropagation()} // 행 클릭 이벤트 전파 방지
|
|
||||||
>
|
|
||||||
{issue.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
case "title":
|
|
||||||
return (
|
|
||||||
<div className="whitespace-normal break-words w-60">{String(value ?? "N/A")}</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "contents": {
|
|
||||||
const content = String(value ?? "N/A");
|
|
||||||
const truncated =
|
|
||||||
content.length > 60 ? `${content.substring(0, 60)}...` : content;
|
|
||||||
return <div className="whitespace-normal break-words w-60">{truncated}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "createdAt":
|
|
||||||
case "updatedAt":
|
|
||||||
return String(value ?? "N/A").substring(0, 10); // YYYY-MM-DD
|
|
||||||
|
|
||||||
default:
|
|
||||||
if (typeof value === "object" && value !== null) {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
return String(value ?? "N/A");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>피드백 목록</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
<div className="flex items-center justify-between py-4">
|
||||||
<TableHeader>
|
<Input
|
||||||
<TableRow>
|
placeholder="전체 데이터에서 검색..."
|
||||||
{columns.map((field) => (
|
value={globalFilter}
|
||||||
<TableHead key={field.id}>{field.name}</TableHead>
|
onChange={(event) => setGlobalFilter(event.target.value)}
|
||||||
))}
|
className="max-w-sm"
|
||||||
</TableRow>
|
/>
|
||||||
</TableHeader>
|
<div className="flex items-center space-x-2">
|
||||||
<TableBody>
|
<Popover>
|
||||||
{data.length > 0 ? (
|
<PopoverTrigger asChild>
|
||||||
data.map((item) => (
|
<Button
|
||||||
<TableRow
|
id="date"
|
||||||
key={item.id}
|
variant={"outline"}
|
||||||
onClick={() => handleRowClick(item.id.toString())}
|
className={cn(
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
"w-[300px] justify-start text-left font-normal",
|
||||||
|
!date && "text-muted-foreground",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{columns.map((field) => (
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
<TableCell key={field.id}>
|
{date?.from ? (
|
||||||
{renderCell(item, field)}
|
date.to ? (
|
||||||
</TableCell>
|
<>
|
||||||
|
{format(date.from, "LLL dd, y")} -{" "}
|
||||||
|
{format(date.to, "LLL dd, y")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
format(date.from, "LLL dd, y")
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span>기간 선택</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="end">
|
||||||
|
<Calendar
|
||||||
|
initialFocus
|
||||||
|
mode="range"
|
||||||
|
defaultMonth={date?.from}
|
||||||
|
selected={date}
|
||||||
|
onSelect={setDate}
|
||||||
|
numberOfMonths={2}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" className="ml-auto">
|
||||||
|
컬럼 표시 <ChevronDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => column.getCanHide())
|
||||||
|
.map((column) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.id}
|
||||||
|
className="capitalize"
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
column.toggleVisibility(!!value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{column.id}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id} style={{ width: header.getSize() }}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))}
|
||||||
) : (
|
</TableHeader>
|
||||||
<TableRow>
|
<TableBody>
|
||||||
<TableCell colSpan={columns.length} className="text-center">
|
{table.getRowModel().rows?.length ? (
|
||||||
표시할 데이터가 없습니다.
|
table.getRowModel().rows.map((row) => (
|
||||||
</TableCell>
|
<Fragment key={row.id}>
|
||||||
</TableRow>
|
<TableRow
|
||||||
)}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
</TableBody>
|
onClick={() => handleRowClick(row.original.id.toString())}
|
||||||
</Table>
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
{row.getIsExpanded() && (
|
||||||
|
<TableRow key={`${row.id}-expanded`}>
|
||||||
|
<TableCell colSpan={columns.length + 1}>
|
||||||
|
<div className="p-4 bg-muted rounded-md">
|
||||||
|
<h4 className="font-bold text-lg">
|
||||||
|
{row.original.title}
|
||||||
|
</h4>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap">
|
||||||
|
{row.original.contents}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length + 1}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
표시할 데이터가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between py-4">
|
||||||
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
|
총 {table.getFilteredRowModel().rows.length}개
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-6">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-sm font-medium">페이지 당 행 수</p>
|
||||||
|
<Select
|
||||||
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
table.setPageSize(Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={table.getState().pagination.pageSize}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{[20, 30, 50].map((pageSize) => (
|
||||||
|
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||||
|
{pageSize}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||||
|
{table.getPageCount()} 페이지 중{" "}
|
||||||
|
{table.getState().pagination.pageIndex + 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
|
onClick={() => table.setPageIndex(0)}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">첫 페이지로</span>
|
||||||
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">이전 페이지로</span>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">다음 페이지로</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">마지막 페이지로</span>
|
||||||
|
<ChevronsRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default DynamicTable;
|
||||||
|
|||||||
5
viewer/src/components/ErrorDisplay.d.ts
vendored
Normal file
5
viewer/src/components/ErrorDisplay.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
interface ErrorDisplayProps {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
export declare function ErrorDisplay({ message }: ErrorDisplayProps): import("react/jsx-runtime").JSX.Element;
|
||||||
|
export {};
|
||||||
4
viewer/src/components/ErrorDisplay.js
Normal file
4
viewer/src/components/ErrorDisplay.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
export function ErrorDisplay({ message }) {
|
||||||
|
return (_jsxs("div", { className: "bg-destructive/15 text-destructive p-4 rounded-md text-center", role: "alert", children: [_jsx("p", { className: "font-semibold", children: "\uC624\uB958 \uBC1C\uC0DD" }), _jsx("p", { className: "text-sm", children: message })] }));
|
||||||
|
}
|
||||||
@@ -1,97 +1,15 @@
|
|||||||
// src/components/ErrorDisplay.tsx
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
|
|
||||||
interface ErrorDisplayProps {
|
interface ErrorDisplayProps {
|
||||||
errorMessage: string | null;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ErrorDisplay = ({ errorMessage }: ErrorDisplayProps) => {
|
export function ErrorDisplay({ message }: ErrorDisplayProps) {
|
||||||
if (!errorMessage) return null;
|
|
||||||
|
|
||||||
const prettifyJson = (text: string) => {
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(text);
|
|
||||||
return JSON.stringify(obj, null, 2);
|
|
||||||
} catch (e) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// For production or simple errors
|
|
||||||
if (import.meta.env.PROD || !errorMessage.startsWith("[Dev]")) {
|
|
||||||
return (
|
|
||||||
<Card className="bg-destructive/10 border-destructive/50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-destructive">오류가 발생했습니다</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<pre className="text-sm text-destructive-foreground bg-destructive/20 p-4 rounded-md overflow-x-auto whitespace-pre-wrap">
|
|
||||||
<code>{errorMessage}</code>
|
|
||||||
</pre>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For dev errors, parse and display detailed information
|
|
||||||
const parts = errorMessage.split(" | ");
|
|
||||||
const message = parts[0]?.replace("[Dev] ", "");
|
|
||||||
const requestUrl = parts[1]?.replace("URL: ", "");
|
|
||||||
const status = parts[2]?.replace("Status: ", "");
|
|
||||||
const bodyRaw = parts.slice(3).join(" | ").replace("Body: ", "");
|
|
||||||
const body = bodyRaw === "Empty" ? "Empty" : prettifyJson(bodyRaw);
|
|
||||||
|
|
||||||
// Reconstruct the final proxied URL based on environment variables
|
|
||||||
let proxiedUrl = "프록시 URL을 계산할 수 없습니다.";
|
|
||||||
if (requestUrl) {
|
|
||||||
try {
|
|
||||||
const url = new URL(requestUrl);
|
|
||||||
const proxyTarget = import.meta.env.VITE_API_PROXY_TARGET;
|
|
||||||
// This logic MUST match the proxy rewrite in `vite.config.ts`
|
|
||||||
const finalPath = url.pathname.replace(/^\/api/, "/_api/api");
|
|
||||||
proxiedUrl = `${proxyTarget}${finalPath}`;
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore if URL parsing fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-destructive/10 border-destructive/50">
|
<div
|
||||||
<CardHeader>
|
className="bg-destructive/15 text-destructive p-4 rounded-md text-center"
|
||||||
<CardTitle className="text-destructive">
|
role="alert"
|
||||||
{message || "오류가 발생했습니다"}
|
>
|
||||||
</CardTitle>
|
<p className="font-semibold">오류 발생</p>
|
||||||
<CardDescription className="text-destructive/80">
|
<p className="text-sm">{message}</p>
|
||||||
<div className="space-y-1 mt-2 text-xs">
|
</div>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
1
viewer/src/components/Header.d.ts
vendored
Normal file
1
viewer/src/components/Header.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export declare function Header(): import("react/jsx-runtime").JSX.Element;
|
||||||
30
viewer/src/components/Header.js
Normal file
30
viewer/src/components/Header.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import { ProjectSelectBox } from "./ProjectSelectBox";
|
||||||
|
import { Separator } from "./ui/separator";
|
||||||
|
import { ThemeSelectBox } from "./ThemeSelectBox";
|
||||||
|
import { LanguageSelectBox } from "./LanguageSelectBox";
|
||||||
|
import { UserProfileBox } from "./UserProfileBox";
|
||||||
|
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ name: "Home", path: "/" },
|
||||||
|
{ name: "Feedback", path: "/feedbacks" },
|
||||||
|
{ name: "Issue", path: "/issues" },
|
||||||
|
];
|
||||||
|
export function Header() {
|
||||||
|
const projectId = useSettingsStore((state) => state.projectId);
|
||||||
|
const channelId = useSettingsStore((state) => state.channelId);
|
||||||
|
const getFullPath = (path) => {
|
||||||
|
if (path === "/")
|
||||||
|
return `/`; // Landing 페이지 경로 예시
|
||||||
|
if (path.startsWith("/feedbacks")) {
|
||||||
|
return `/projects/${projectId}/channels/${channelId}${path}`;
|
||||||
|
}
|
||||||
|
if (path.startsWith("/issues")){
|
||||||
|
return `/projects/${projectId}${path}`;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
return (_jsxs("header", { className: "flex h-16 items-center border-b px-4", children: [_jsx(ProjectSelectBox, { className: "mr-4"}), _jsx("nav", { className: "flex items-center space-x-4 lg:space-x-6 flex-1 ml-8", children: menuItems.map((item) => (_jsx(NavLink, { to: getFullPath(item.path), className: ({ isActive }) => `text-sm font-medium transition-colors hover:text-primary ${!isActive ? "text-muted-foreground" : ""}`, children: item.name }, item.name))) }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(ThemeSelectBox, {}), _jsx(LanguageSelectBox, {}), _jsx(UserProfileBox, {})] })] }));
|
||||||
|
}
|
||||||
65
viewer/src/components/Header.tsx
Normal file
65
viewer/src/components/Header.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import { ProjectSelectBox } from "./ProjectSelectBox";
|
||||||
|
import { ThemeSelectBox } from "./ThemeSelectBox";
|
||||||
|
import { LanguageSelectBox } from "./LanguageSelectBox";
|
||||||
|
import { UserProfileBox } from "./UserProfileBox";
|
||||||
|
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ name: "Feedback", path: "/feedbacks", type: "feedback" },
|
||||||
|
{ name: "Issue", path: "/issues", type: "issue" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const { projectId, channelId } = useSettingsStore();
|
||||||
|
|
||||||
|
const getPath = (type: string, basePath: string) => {
|
||||||
|
if (type === "issue") {
|
||||||
|
return `/projects/${projectId}${basePath}`;
|
||||||
|
}
|
||||||
|
return `/projects/${projectId}/channels/${channelId}${basePath}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const homePath = `/projects/${projectId}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-16 items-center justify-between border-b px-6">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<NavLink
|
||||||
|
to={homePath}
|
||||||
|
end
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`text-lg font-bold transition-colors hover:text-primary ${
|
||||||
|
isActive ? "text-primary" : "text-muted-foreground"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</NavLink>
|
||||||
|
<ProjectSelectBox />
|
||||||
|
<nav className="ml-8 flex items-center space-x-4 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>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<ThemeSelectBox />
|
||||||
|
<LanguageSelectBox />
|
||||||
|
<UserProfileBox />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
viewer/src/components/LanguageSelectBox.d.ts
vendored
Normal file
1
viewer/src/components/LanguageSelectBox.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export declare function LanguageSelectBox(): import("react/jsx-runtime").JSX.Element;
|
||||||
6
viewer/src/components/LanguageSelectBox.js
Normal file
6
viewer/src/components/LanguageSelectBox.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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" })] }));
|
||||||
|
}
|
||||||
11
viewer/src/components/LanguageSelectBox.tsx
Normal file
11
viewer/src/components/LanguageSelectBox.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Languages } from "lucide-react";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
|
export function LanguageSelectBox() {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Languages />
|
||||||
|
<span className="sr-only">언어 변경</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
viewer/src/components/MainLayout.d.ts
vendored
Normal file
1
viewer/src/components/MainLayout.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export declare function MainLayout(): import("react/jsx-runtime").JSX.Element;
|
||||||
7
viewer/src/components/MainLayout.js
Normal file
7
viewer/src/components/MainLayout.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
// src/components/MainLayout.tsx
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { Header } from "./Header";
|
||||||
|
export function MainLayout() {
|
||||||
|
return (_jsxs("div", { className: "flex flex-col min-h-screen", children: [_jsx(Header, {}), _jsx("main", { className: "flex-1 p-6", children: _jsx(Outlet, {}) })] }));
|
||||||
|
}
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
// src/components/MainLayout.tsx
|
// src/components/MainLayout.tsx
|
||||||
import { Link, Outlet, useParams } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Header } from "./Header";
|
||||||
|
|
||||||
export function MainLayout() {
|
export function MainLayout() {
|
||||||
const { projectId, channelId } = useParams();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-4 md:p-8">
|
<div className="flex flex-col min-h-screen">
|
||||||
<header className="mb-8 flex justify-between items-center">
|
<Header />
|
||||||
<h1 className="text-3xl font-bold tracking-tight">피드백 뷰어</h1>
|
<main className="flex-1 p-6">
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1
viewer/src/components/ProjectSelectBox.d.ts
vendored
Normal file
1
viewer/src/components/ProjectSelectBox.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export declare function ProjectSelectBox(): import("react/jsx-runtime").JSX.Element;
|
||||||
25
viewer/src/components/ProjectSelectBox.js
Normal file
25
viewer/src/components/ProjectSelectBox.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||||
|
import { getProjects } from "@/services/project";
|
||||||
|
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||||
|
export function ProjectSelectBox() {
|
||||||
|
const [projects, setProjects] = useState([]);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}, []); // 마운트 시 한 번만 실행
|
||||||
|
if (isLoading) {
|
||||||
|
return (_jsx("div", { className: "w-[180px] h-10 bg-muted rounded-md animate-pulse" }));
|
||||||
|
}
|
||||||
|
return (_jsxs(Select, { value: projectId ?? "", onValueChange: setProjectId, children: [_jsx(SelectTrigger, { className: "w-[180px]", children: _jsx(SelectValue, { placeholder: "\uD504\uB85C\uC81D\uD2B8 \uC120\uD0DD" }) }), _jsx(SelectContent, { children: projects.map((p) => (_jsx(SelectItem, { value: p.id, children: p.name }, p.id))) })] }));
|
||||||
|
}
|
||||||
51
viewer/src/components/ProjectSelectBox.tsx
Normal file
51
viewer/src/components/ProjectSelectBox.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
}, []); // 마운트 시 한 번만 실행
|
||||||
|
|
||||||
|
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">
|
||||||
|
<SelectValue placeholder="프로젝트 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
viewer/src/components/ThemeSelectBox.d.ts
vendored
Normal file
1
viewer/src/components/ThemeSelectBox.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export declare function ThemeSelectBox(): import("react/jsx-runtime").JSX.Element;
|
||||||
9
viewer/src/components/ThemeSelectBox.js
Normal file
9
viewer/src/components/ThemeSelectBox.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
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 (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "icon", children: [_jsx(Sun, { className: "h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" }), _jsx(Moon, { className: "absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" }), _jsx("span", { className: "sr-only", children: "\uD14C\uB9C8 \uBCC0\uACBD" })] }) }), _jsxs(DropdownMenuContent, { align: "end", children: [_jsxs(DropdownMenuItem, { onClick: () => setTheme("light"), children: [_jsx(Sun, { className: "mr-2 h-4 w-4" }), "Light"] }), _jsxs(DropdownMenuItem, { onClick: () => setTheme("dark"), children: [_jsx(Moon, { className: "mr-2 h-4 w-4" }), "Dark"] }), _jsxs(DropdownMenuItem, { onClick: () => setTheme("system"), children: [_jsx(Laptop, { className: "mr-2 h-4 w-4" }), "System"] })] })] }));
|
||||||
|
}
|
||||||
39
viewer/src/components/ThemeSelectBox.tsx
Normal file
39
viewer/src/components/ThemeSelectBox.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Moon, Sun, Laptop } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||||
|
|
||||||
|
export function ThemeSelectBox() {
|
||||||
|
const { setTheme } = useSettingsStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">테마 변경</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||||
|
<Sun className="mr-2 h-4 w-4" />
|
||||||
|
Light
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||||
|
<Moon className="mr-2 h-4 w-4" />
|
||||||
|
Dark
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||||
|
<Laptop className="mr-2 h-4 w-4" />
|
||||||
|
System
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
viewer/src/components/UserProfileBox.d.ts
vendored
Normal file
1
viewer/src/components/UserProfileBox.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export declare function UserProfileBox(): import("react/jsx-runtime").JSX.Element;
|
||||||
6
viewer/src/components/UserProfileBox.js
Normal file
6
viewer/src/components/UserProfileBox.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { CircleUser } from "lucide-react";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
export function UserProfileBox() {
|
||||||
|
return (_jsxs(Button, { variant: "ghost", size: "icon", children: [_jsx(CircleUser, {}), _jsx("span", { className: "sr-only", children: "\uC0AC\uC6A9\uC790 \uD504\uB85C\uD544" })] }));
|
||||||
|
}
|
||||||
11
viewer/src/components/UserProfileBox.tsx
Normal file
11
viewer/src/components/UserProfileBox.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { CircleUser } from "lucide-react";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
|
export function UserProfileBox() {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<CircleUser />
|
||||||
|
<span className="sr-only">사용자 프로필</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
viewer/src/components/providers/ThemeProvider.d.ts
vendored
Normal file
5
viewer/src/components/providers/ThemeProvider.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
interface ThemeProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
export declare function ThemeProvider({ children }: ThemeProviderProps): import("react/jsx-runtime").JSX.Element;
|
||||||
|
export {};
|
||||||
20
viewer/src/components/providers/ThemeProvider.js
Normal file
20
viewer/src/components/providers/ThemeProvider.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||||
|
export function ThemeProvider({ children }) {
|
||||||
|
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 _jsx(_Fragment, { children: children });
|
||||||
|
}
|
||||||
28
viewer/src/components/providers/ThemeProvider.tsx
Normal file
28
viewer/src/components/providers/ThemeProvider.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||||
|
|
||||||
|
interface ThemeProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||||
|
const theme = useSettingsStore((state) => state.theme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
root.classList.remove("light", "dark");
|
||||||
|
|
||||||
|
if (theme === "system") {
|
||||||
|
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
.matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
root.classList.add(systemTheme);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.classList.add(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
10
viewer/src/components/ui/button.d.ts
vendored
Normal file
10
viewer/src/components/ui/button.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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 };
|
||||||
31
viewer/src/components/ui/button.js
Normal file
31
viewer/src/components/ui/button.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
const buttonVariants = cva("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none 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", {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
function Button({ className, variant, size, asChild = false, ...props }) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (_jsx(Comp, { "data-slot": "button", className: cn(buttonVariants({ variant, size, className })), ...props }));
|
||||||
|
}
|
||||||
|
export { Button, buttonVariants };
|
||||||
8
viewer/src/components/ui/calendar.d.ts
vendored
Normal file
8
viewer/src/components/ui/calendar.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
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 };
|
||||||
73
viewer/src/components/ui/calendar.js
Normal file
73
viewer/src/components/ui/calendar.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import * as React from "react";
|
||||||
|
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, } from "lucide-react";
|
||||||
|
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
function Calendar({ className, classNames, showOutsideDays = true, captionLayout = "label", buttonVariant = "ghost", formatters, components, ...props }) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
return (_jsx(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 (_jsx("div", { "data-slot": "calendar", ref: rootRef, className: cn(className), ...props }));
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (_jsx(ChevronLeftIcon, { className: cn("size-4", className), ...props }));
|
||||||
|
}
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (_jsx(ChevronRightIcon, { className: cn("size-4", className), ...props }));
|
||||||
|
}
|
||||||
|
return (_jsx(ChevronDownIcon, { className: cn("size-4", className), ...props }));
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (_jsx("td", { ...props, children: _jsx("div", { className: "flex size-[--cell-size] items-center justify-center text-center", children: children }) }));
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}, ...props }));
|
||||||
|
}
|
||||||
|
function CalendarDayButton({ className, day, modifiers, ...props }) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
const ref = React.useRef(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused)
|
||||||
|
ref.current?.focus();
|
||||||
|
}, [modifiers.focused]);
|
||||||
|
return (_jsx(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 };
|
||||||
211
viewer/src/components/ui/calendar.tsx
Normal file
211
viewer/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString("default", { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"relative flex flex-col gap-4 md:flex-row",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"bg-popover absolute inset-0 opacity-0",
|
||||||
|
defaultClassNames.dropdown
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"select-none font-medium",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"w-[--cell-size] select-none",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-muted-foreground select-none text-[0.8rem]",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"bg-accent rounded-l-md",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-[--cell-size] items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus()
|
||||||
|
}, [modifiers.focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton }
|
||||||
9
viewer/src/components/ui/card.d.ts
vendored
Normal file
9
viewer/src/components/ui/card.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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, };
|
||||||
24
viewer/src/components/ui/card.js
Normal file
24
viewer/src/components/ui/card.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
function Card({ className, ...props }) {
|
||||||
|
return (_jsx("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 }) {
|
||||||
|
return (_jsx("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 }) {
|
||||||
|
return (_jsx("div", { "data-slot": "card-title", className: cn("leading-none font-semibold", className), ...props }));
|
||||||
|
}
|
||||||
|
function CardDescription({ className, ...props }) {
|
||||||
|
return (_jsx("div", { "data-slot": "card-description", className: cn("text-muted-foreground text-sm", className), ...props }));
|
||||||
|
}
|
||||||
|
function CardAction({ className, ...props }) {
|
||||||
|
return (_jsx("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 }) {
|
||||||
|
return (_jsx("div", { "data-slot": "card-content", className: cn("px-6", className), ...props }));
|
||||||
|
}
|
||||||
|
function CardFooter({ className, ...props }) {
|
||||||
|
return (_jsx("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, };
|
||||||
27
viewer/src/components/ui/dropdown-menu.d.ts
vendored
Normal file
27
viewer/src/components/ui/dropdown-menu.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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, };
|
||||||
35
viewer/src/components/ui/dropdown-menu.js
Normal file
35
viewer/src/components/ui/dropdown-menu.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
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(({ className, inset, children, ...props }, ref) => (_jsxs(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: [children, _jsx(ChevronRightIcon, { className: "ml-auto" })] })));
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (_jsx(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(({ className, sideOffset = 4, ...props }, ref) => (_jsx(DropdownMenuPrimitive.Portal, { children: _jsx(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 }) })));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (_jsx(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(({ className, children, checked, ...props }, ref) => (_jsxs(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, children: [_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(DropdownMenuPrimitive.ItemIndicator, { children: _jsx(CheckIcon, { className: "h-4 w-4" }) }) }), children] })));
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs(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, children: [_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(DropdownMenuPrimitive.ItemIndicator, { children: _jsx(DotFilledIcon, { className: "h-2 w-2 fill-current" }) }) }), children] })));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (_jsx(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(({ className, ...props }, ref) => (_jsx(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 }) => {
|
||||||
|
return (_jsx("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, };
|
||||||
198
viewer/src/components/ui/dropdown-menu.tsx
Normal file
198
viewer/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CheckIcon, ChevronRightIcon, DotFilledIcon } from "@radix-ui/react-icons"
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<DotFilledIcon className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
||||||
3
viewer/src/components/ui/input.d.ts
vendored
Normal file
3
viewer/src/components/ui/input.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type * as React from "react";
|
||||||
|
declare function Input({ className, type, ...props }: React.ComponentProps<"input">): import("react/jsx-runtime").JSX.Element;
|
||||||
|
export { Input };
|
||||||
6
viewer/src/components/ui/input.js
Normal file
6
viewer/src/components/ui/input.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
function Input({ className, type, ...props }) {
|
||||||
|
return (_jsx("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 };
|
||||||
5
viewer/src/components/ui/label.d.ts
vendored
Normal file
5
viewer/src/components/ui/label.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
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 };
|
||||||
9
viewer/src/components/ui/label.js
Normal file
9
viewer/src/components/ui/label.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
|
||||||
|
const Label = React.forwardRef(({ className, ...props }, ref) => (_jsx(LabelPrimitive.Root, { ref: ref, className: cn(labelVariants(), className), ...props })));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
export { Label };
|
||||||
7
viewer/src/components/ui/popover.d.ts
vendored
Normal file
7
viewer/src/components/ui/popover.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
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 };
|
||||||
11
viewer/src/components/ui/popover.js
Normal file
11
viewer/src/components/ui/popover.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
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(({ className, align = "center", sideOffset = 4, ...props }, ref) => (_jsx(PopoverPrimitive.Portal, { children: _jsx(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 }) })));
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
33
viewer/src/components/ui/popover.tsx
Normal file
33
viewer/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
13
viewer/src/components/ui/select.d.ts
vendored
Normal file
13
viewer/src/components/ui/select.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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, };
|
||||||
26
viewer/src/components/ui/select.js
Normal file
26
viewer/src/components/ui/select.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons";
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs(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: [children, _jsx(SelectPrimitive.Icon, { asChild: true, children: _jsx(ChevronDownIcon, { className: "h-4 w-4 opacity-50" }) })] })));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (_jsx(SelectPrimitive.ScrollUpButton, { ref: ref, className: cn("flex cursor-default items-center justify-center py-1", className), ...props, children: _jsx(ChevronUpIcon, { className: "h-4 w-4" }) })));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (_jsx(SelectPrimitive.ScrollDownButton, { ref: ref, className: cn("flex cursor-default items-center justify-center py-1", className), ...props, children: _jsx(ChevronDownIcon, { className: "h-4 w-4" }) })));
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (_jsx(SelectPrimitive.Portal, { children: _jsxs(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, children: [_jsx(SelectScrollUpButton, {}), _jsx(SelectPrimitive.Viewport, { className: cn("p-1", position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"), children: children }), _jsx(SelectScrollDownButton, {})] }) })));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (_jsx(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(({ className, children, ...props }, ref) => (_jsxs(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, children: [_jsx("span", { className: "absolute right-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(SelectPrimitive.ItemIndicator, { children: _jsx(CheckIcon, { className: "h-4 w-4" }) }) }), _jsx(SelectPrimitive.ItemText, { children: children })] })));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (_jsx(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, };
|
||||||
4
viewer/src/components/ui/separator.d.ts
vendored
Normal file
4
viewer/src/components/ui/separator.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
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 };
|
||||||
7
viewer/src/components/ui/separator.js
Normal file
7
viewer/src/components/ui/separator.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
function Separator({ className, orientation = "horizontal", decorative = true, ...props }) {
|
||||||
|
return (_jsx(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 };
|
||||||
10
viewer/src/components/ui/table.d.ts
vendored
Normal file
10
viewer/src/components/ui/table.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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, };
|
||||||
20
viewer/src/components/ui/table.js
Normal file
20
viewer/src/components/ui/table.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
const Table = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { className: "relative w-full overflow-auto", children: _jsx("table", { ref: ref, className: cn("w-full caption-bottom text-sm", className), ...props }) })));
|
||||||
|
Table.displayName = "Table";
|
||||||
|
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (_jsx("thead", { ref: ref, className: cn("[&_tr]:border-b", className), ...props })));
|
||||||
|
TableHeader.displayName = "TableHeader";
|
||||||
|
const TableBody = React.forwardRef(({ className, ...props }, ref) => (_jsx("tbody", { ref: ref, className: cn("[&_tr:last-child]:border-0", className), ...props })));
|
||||||
|
TableBody.displayName = "TableBody";
|
||||||
|
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (_jsx("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(({ className, ...props }, ref) => (_jsx("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(({ className, ...props }, ref) => (_jsx("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(({ className, ...props }, ref) => (_jsx("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(({ className, ...props }, ref) => (_jsx("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, };
|
||||||
3
viewer/src/components/ui/textarea.d.ts
vendored
Normal file
3
viewer/src/components/ui/textarea.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type * as React from "react";
|
||||||
|
declare function Textarea({ className, ...props }: React.ComponentProps<"textarea">): import("react/jsx-runtime").JSX.Element;
|
||||||
|
export { Textarea };
|
||||||
6
viewer/src/components/ui/textarea.js
Normal file
6
viewer/src/components/ui/textarea.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
function Textarea({ className, ...props }) {
|
||||||
|
return (_jsx("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 };
|
||||||
22
viewer/src/hooks/useSyncChannelId.ts
Normal file
22
viewer/src/hooks/useSyncChannelId.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// src/hooks/useSyncChannelId.ts
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL의 `channelId` 파라미터를 감지하여 전역 상태와 동기화하는 커스텀 훅입니다.
|
||||||
|
* 이 훅은 `channelId`가 URL에 존재하는 페이지 컴포넌트에서 사용해야 합니다.
|
||||||
|
*/
|
||||||
|
export function useSyncChannelId() {
|
||||||
|
const { channelId } = useParams<{ channelId: string }>();
|
||||||
|
const setChannelId = useSettingsStore.getState().setChannelId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// URL 파라미터에 channelId가 존재하고 유효한 경우에만 전역 상태를 업데이트합니다.
|
||||||
|
// channelId가 없는 페이지(ex: /issues)에서는 이 조건이 false가 되어
|
||||||
|
// 기존의 유효한 channelId 값을 덮어쓰지 않습니다.
|
||||||
|
if (channelId) {
|
||||||
|
setChannelId(channelId);
|
||||||
|
}
|
||||||
|
}, [channelId]);
|
||||||
|
}
|
||||||
@@ -1,123 +1,182 @@
|
|||||||
@import "tw-animate-css";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@theme inline {
|
@layer base {
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
:root {
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
/* Light theme based on user's request */
|
||||||
--radius-lg: var(--radius);
|
--background: 207 100% 98%; /* #f6fbff */
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--foreground: 240 10% 3.9%; /* Near black */
|
||||||
--color-background: var(--background);
|
--card: 0 0% 100%; /* White */
|
||||||
--color-foreground: var(--foreground);
|
--card-foreground: 240 10% 3.9%;
|
||||||
--color-card: var(--card);
|
--popover: 0 0% 100%;
|
||||||
--color-card-foreground: var(--card-foreground);
|
--popover-foreground: 240 10% 3.9%;
|
||||||
--color-popover: var(--popover);
|
--primary: 197 100% 36%; /* #0082b5 */
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--primary-foreground: 0 0% 98%; /* Light text for primary */
|
||||||
--color-primary: var(--primary);
|
--secondary: 240 4.8% 95.9%;
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
--color-secondary: var(--secondary);
|
--muted: 210 40% 96.1%; /* #f0f2f5 */
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
--color-muted: var(--muted);
|
--accent: 240 4.8% 95.9%;
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--accent-foreground: 240 5.9% 10%;
|
||||||
--color-accent: var(--accent);
|
--destructive: 0 84.2% 60.2%;
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--destructive-foreground: 0 0% 98%;
|
||||||
--color-destructive: var(--destructive);
|
--border: 240 5.9% 90%; /* #e4e4e7 */
|
||||||
--color-border: var(--border);
|
--input: 240 5.9% 90%;
|
||||||
--color-input: var(--input);
|
--ring: 197 100% 36%;
|
||||||
--color-ring: var(--ring);
|
--radius: 0.5rem;
|
||||||
--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);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
.dark {
|
||||||
--radius: 0.625rem;
|
/* Descope Theme */
|
||||||
--background: oklch(1 0 0);
|
--background: 240 0% 40%; /* #666 */
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: 0 0% 100%; /* #fff */
|
||||||
--card: oklch(1 0 0);
|
--card: 0 0% 0%; /* #000 */
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: 0 0% 100%; /* #fff */
|
||||||
--popover: oklch(1 0 0);
|
--popover: 0 0% 0%; /* #000 */
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: 0 0% 100%; /* #fff */
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: 217 100% 48%; /* #006af5 */
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: 0 0% 100%; /* #fff */
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: 0 0% 100%; /* #fff */
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: 0 0% 0%; /* #000 */
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: 240 0% 40%; /* #666 */
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: 0 0% 60%; /* #999 */
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: 217 100% 48%; /* #006af5 */
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: 0 0% 100%; /* #fff */
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: 348 100% 49%; /* #fb3c00 */
|
||||||
--border: oklch(0.922 0 0);
|
--destructive-foreground: 0 0% 100%; /* #fff */
|
||||||
--input: oklch(0.922 0 0);
|
--border: 0 0% 60%; /* #999 */
|
||||||
--ring: oklch(0.708 0 0);
|
--input: 0 0% 60%; /* #999 */
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--ring: 217 100% 48%; /* #006af5 */
|
||||||
--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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* User's custom styles */
|
||||||
|
#root,
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
.app {
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.app,
|
||||||
|
.app-content {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
.app-content {
|
||||||
|
margin-top: 150px;
|
||||||
|
}
|
||||||
|
.descope-base-container {
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 1px 50px 0 #b2b2b280;
|
||||||
|
}
|
||||||
|
.descope-login-container {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
.descope-wide-container {
|
||||||
|
margin: 20px auto;
|
||||||
|
max-height: 90vh;
|
||||||
|
max-width: 800px;
|
||||||
|
overflow-y: auto;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.welcome-title {
|
||||||
|
color: #0082b5;
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 128%;
|
||||||
|
}
|
||||||
|
.example-title,
|
||||||
|
.welcome-title {
|
||||||
|
font-family: JetBrains Mono;
|
||||||
|
font-style: normal;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.example-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.example {
|
||||||
|
align-items: center;
|
||||||
|
background-color: #f6fbff;
|
||||||
|
border: 2px solid #0082b5;
|
||||||
|
border-radius: 100px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 350px;
|
||||||
|
padding: 16px 32px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.copy-icon {
|
||||||
|
height: 100%;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.text-body {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 128%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
display: flex;
|
||||||
|
font-family: Barlow;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
line-height: 160%;
|
||||||
|
margin-top: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.app-content {
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
.descope-container {
|
||||||
|
margin-left: 16px;
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
.example {
|
||||||
|
min-width: fit-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media only screen and (min-width: 600px) {
|
||||||
|
.app-content {
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
.example {
|
||||||
|
min-width: fit-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media only screen and (min-width: 768px) {
|
||||||
|
.app-content {
|
||||||
|
width: 55%;
|
||||||
|
}
|
||||||
|
.example {
|
||||||
|
min-width: 350px;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
viewer/src/lib/utils.d.ts
vendored
Normal file
2
viewer/src/lib/utils.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { type ClassValue } from "clsx";
|
||||||
|
export declare function cn(...inputs: ClassValue[]): string;
|
||||||
5
viewer/src/lib/utils.js
Normal file
5
viewer/src/lib/utils.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
1
viewer/src/main.d.ts
vendored
Normal file
1
viewer/src/main.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "./index.css";
|
||||||
7
viewer/src/main.js
Normal file
7
viewer/src/main.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App.tsx";
|
||||||
|
import "./index.css";
|
||||||
|
createRoot(document.getElementById("root")).render(_jsx(StrictMode, { children: _jsx(BrowserRouter, { children: _jsx(App, {}) }) }));
|
||||||
@@ -2,12 +2,15 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
|
import { ThemeProvider } from "./components/providers/ThemeProvider.tsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
1
viewer/src/pages/FeedbackCreatePage.d.ts
vendored
Normal file
1
viewer/src/pages/FeedbackCreatePage.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export declare function FeedbackCreatePage(): import("react/jsx-runtime").JSX.Element;
|
||||||
78
viewer/src/pages/FeedbackCreatePage.js
Normal file
78
viewer/src/pages/FeedbackCreatePage.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { DynamicForm } from "@/components/DynamicForm";
|
||||||
|
import { getFeedbackFields, createFeedback } from "@/services/feedback";
|
||||||
|
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
export function FeedbackCreatePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [fields, setFields] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [submitMessage, setSubmitMessage] = useState(null);
|
||||||
|
// TODO: projectId와 channelId는 URL 파라미터나 컨텍스트에서 가져와야 합니다.
|
||||||
|
const projectId = "1";
|
||||||
|
const channelId = "4";
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFields = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const fieldsData = await getFeedbackFields(projectId, channelId);
|
||||||
|
// 사용자에게 보여주지 않을 필드 목록
|
||||||
|
const hiddenFields = ["id", "createdAt", "updatedAt", "issues"];
|
||||||
|
const processedFields = fieldsData
|
||||||
|
.filter((field) => !hiddenFields.includes(field.id))
|
||||||
|
.map((field) => {
|
||||||
|
// 'contents' 필드를 항상 textarea로 처리
|
||||||
|
if (field.id === "contents") {
|
||||||
|
return { ...field, type: "textarea" };
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
});
|
||||||
|
setFields(processedFields);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setError("알 수 없는 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchFields();
|
||||||
|
}, [projectId, channelId]);
|
||||||
|
const handleSubmit = async (formData) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setSubmitMessage(null);
|
||||||
|
const requestData = {
|
||||||
|
...formData,
|
||||||
|
issueNames: [],
|
||||||
|
};
|
||||||
|
await createFeedback(projectId, channelId, requestData);
|
||||||
|
setSubmitMessage("피드백이 성공적으로 등록되었습니다! 곧 목록으로 돌아갑니다.");
|
||||||
|
// 2초 후 목록 페이지로 이동
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setError("피드백 등록 중 알 수 없는 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (loading) {
|
||||||
|
return _jsx("div", { children: "\uD3FC\uC744 \uBD88\uB7EC\uC624\uB294 \uC911..." });
|
||||||
|
}
|
||||||
|
return (_jsxs("div", { className: "container mx-auto p-4", children: [_jsxs("div", { className: "space-y-2 mb-6", children: [_jsx("h1", { className: "text-2xl font-bold", children: "\uD53C\uB4DC\uBC31 \uC791\uC131" }), _jsx("p", { className: "text-muted-foreground", children: "\uC544\uB798 \uD3FC\uC744 \uC791\uC131\uD558\uC5EC \uD53C\uB4DC\uBC31\uC744 \uC81C\uCD9C\uD574\uC8FC\uC138\uC694." })] }), _jsx(Separator, {}), _jsxs("div", { className: "mt-6", children: [_jsx(DynamicForm, { fields: fields, onSubmit: handleSubmit }), error && _jsx(ErrorDisplay, { message: error }), submitMessage && (_jsx("div", { className: "mt-4 p-3 bg-green-100 text-green-800 rounded-md", children: submitMessage }))] })] }));
|
||||||
|
}
|
||||||
@@ -1,106 +1,98 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||||
|
import { useSyncChannelId } from "@/hooks/useSyncChannelId";
|
||||||
import { DynamicForm } from "@/components/DynamicForm";
|
import { DynamicForm } from "@/components/DynamicForm";
|
||||||
import { getFeedbackFields, createFeedback } from "@/services/feedback";
|
import {
|
||||||
import type { FeedbackField, CreateFeedbackRequest } from "@/services/feedback";
|
getFeedbackSchema,
|
||||||
|
createFeedback,
|
||||||
|
type FeedbackSchema,
|
||||||
|
type CreateFeedbackRequest,
|
||||||
|
} from "@/services/feedback";
|
||||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
export function FeedbackCreatePage() {
|
export function FeedbackCreatePage() {
|
||||||
|
useSyncChannelId();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [fields, setFields] = useState<FeedbackField[]>([]);
|
const { projectId, channelId } = useSettingsStore();
|
||||||
|
|
||||||
|
const [schema, setSchema] = useState<FeedbackSchema | null>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [submitMessage, setSubmitMessage] = useState<string | null>(null);
|
const [submitMessage, setSubmitMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// TODO: projectId와 channelId는 URL 파라미터나 컨텍스트에서 가져와야 합니다.
|
|
||||||
const projectId = "1";
|
|
||||||
const channelId = "4";
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchFields = async () => {
|
if (!projectId || !channelId) return;
|
||||||
|
|
||||||
|
const fetchSchema = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const fieldsData = await getFeedbackFields(projectId, channelId);
|
const schemaData = await getFeedbackSchema(projectId, channelId);
|
||||||
|
setSchema(schemaData);
|
||||||
// 사용자에게 보여주지 않을 필드 목록
|
|
||||||
const hiddenFields = ["id", "createdAt", "updatedAt", "issues"];
|
|
||||||
|
|
||||||
const processedFields = fieldsData
|
|
||||||
.filter((field) => !hiddenFields.includes(field.id))
|
|
||||||
.map((field) => {
|
|
||||||
// 'contents' 필드를 항상 textarea로 처리
|
|
||||||
if (field.id === "contents") {
|
|
||||||
return { ...field, type: "textarea" as const };
|
|
||||||
}
|
|
||||||
return field;
|
|
||||||
});
|
|
||||||
|
|
||||||
setFields(processedFields);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error) {
|
setError(err instanceof Error ? err.message : "폼을 불러오는 데 실패했습니다.");
|
||||||
setError(err.message);
|
|
||||||
} else {
|
|
||||||
setError("알 수 없는 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchFields();
|
fetchSchema();
|
||||||
}, [projectId, channelId]);
|
}, [projectId, channelId]);
|
||||||
|
|
||||||
const handleSubmit = async (formData: Record<string, any>) => {
|
const handleSubmit = async (formData: Record<string, any>) => {
|
||||||
|
if (!projectId || !channelId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
setSubmitMessage(null);
|
setSubmitMessage(null);
|
||||||
|
|
||||||
const requestData: CreateFeedbackRequest = {
|
const requestData: CreateFeedbackRequest = {
|
||||||
...formData,
|
...formData,
|
||||||
issueNames: [],
|
issueNames: [], // 이슈 이름은 현재 UI에서 받지 않으므로 빈 배열로 설정
|
||||||
};
|
};
|
||||||
|
|
||||||
await createFeedback(projectId, channelId, requestData);
|
await createFeedback(projectId, channelId, requestData);
|
||||||
setSubmitMessage("피드백이 성공적으로 등록되었습니다! 곧 목록으로 돌아갑니다.");
|
setSubmitMessage("피드백이 성공적으로 등록되었습니다! 곧 목록으로 돌아갑니다.");
|
||||||
|
|
||||||
// 2초 후 목록 페이지로 이동
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
|
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error) {
|
setError(err instanceof Error ? err.message : "피드백 등록 중 오류가 발생했습니다.");
|
||||||
setError(err.message);
|
throw err; // DynamicForm이 오류 상태를 인지하도록 re-throw
|
||||||
} else {
|
|
||||||
setError("피드백 등록 중 알 수 없는 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div>폼을 불러오는 중...</div>;
|
return <div className="text-center py-10">폼을 불러오는 중...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ErrorDisplay message={error} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2 mb-6">
|
<div className="space-y-2">
|
||||||
<h1 className="text-2xl font-bold">피드백 작성</h1>
|
<h1 className="text-2xl font-bold">피드백 작성</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
아래 폼을 작성하여 피드백을 제출해주세요.
|
아래 폼을 작성하여 피드백을 제출해주세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="mt-6">
|
{schema && (
|
||||||
<DynamicForm fields={fields} onSubmit={handleSubmit} />
|
<DynamicForm
|
||||||
{error && <ErrorDisplay message={error} />}
|
schema={schema}
|
||||||
{submitMessage && (
|
onSubmit={handleSubmit}
|
||||||
<div className="mt-4 p-3 bg-green-100 text-green-800 rounded-md">
|
submitButtonText="제출하기"
|
||||||
{submitMessage}
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
{submitMessage && (
|
||||||
</div>
|
<div className="mt-4 p-3 bg-green-100 text-green-800 rounded-md">
|
||||||
|
{submitMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
1
viewer/src/pages/FeedbackDetailPage.d.ts
vendored
Normal file
1
viewer/src/pages/FeedbackDetailPage.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export declare function FeedbackDetailPage(): import("react/jsx-runtime").JSX.Element;
|
||||||
96
viewer/src/pages/FeedbackDetailPage.js
Normal file
96
viewer/src/pages/FeedbackDetailPage.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { DynamicForm } from "@/components/DynamicForm";
|
||||||
|
import { getFeedbackFields, getFeedbackById, updateFeedback, } from "@/services/feedback";
|
||||||
|
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
export function FeedbackDetailPage() {
|
||||||
|
const { projectId, channelId, feedbackId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [fields, setFields] = useState([]);
|
||||||
|
const [feedback, setFeedback] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState(null);
|
||||||
|
const initialData = useMemo(() => feedback ?? {}, [feedback]);
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
if (!projectId || !channelId || !feedbackId)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [fieldsData, feedbackData] = await Promise.all([
|
||||||
|
getFeedbackFields(projectId, channelId),
|
||||||
|
getFeedbackById(projectId, channelId, feedbackId),
|
||||||
|
]);
|
||||||
|
// 폼에서 숨길 필드 목록
|
||||||
|
const hiddenFields = ["id", "createdAt", "updatedAt", "issues", "screenshot"];
|
||||||
|
const processedFields = fieldsData
|
||||||
|
.filter((field) => !hiddenFields.includes(field.id))
|
||||||
|
.map((field) => {
|
||||||
|
// 'contents' 필드는 항상 textarea로
|
||||||
|
if (field.id === "contents") {
|
||||||
|
return { ...field, type: "textarea" };
|
||||||
|
}
|
||||||
|
// 'customer' 필드는 읽기 전용으로
|
||||||
|
if (field.id === "customer") {
|
||||||
|
return { ...field, readOnly: true };
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
});
|
||||||
|
setFields(processedFields);
|
||||||
|
setFeedback(feedbackData);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setError("데이터를 불러오는 중 알 수 없는 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [projectId, channelId, feedbackId]);
|
||||||
|
const handleSubmit = async (formData) => {
|
||||||
|
if (!projectId || !channelId || !feedbackId)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
// API에 전송할 데이터 정제 (수정 가능한 필드만 포함)
|
||||||
|
const dataToUpdate = {};
|
||||||
|
fields.forEach(field => {
|
||||||
|
if (!field.readOnly && formData[field.id] !== undefined) {
|
||||||
|
dataToUpdate[field.id] = formData[field.id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log("Updating with data:", dataToUpdate); // [Debug]
|
||||||
|
await updateFeedback(projectId, channelId, feedbackId, dataToUpdate);
|
||||||
|
setSuccessMessage("피드백이 성공적으로 수정되었습니다! 곧 목록으로 돌아갑니다.");
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setError("피드백 수정 중 알 수 없는 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (loading) {
|
||||||
|
return _jsx("div", { children: "\uB85C\uB529 \uC911..." });
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return _jsx(ErrorDisplay, { message: error });
|
||||||
|
}
|
||||||
|
return (_jsxs("div", { className: "container mx-auto p-4", children: [_jsxs("div", { className: "space-y-2 mb-6", children: [_jsx("h1", { className: "text-2xl font-bold", children: "\uD53C\uB4DC\uBC31 \uC0C1\uC138 \uBC0F \uC218\uC815" }), _jsx("p", { className: "text-muted-foreground", children: "\uD53C\uB4DC\uBC31 \uB0B4\uC6A9\uC744 \uD655\uC778\uD558\uACE0 \uC218\uC815\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4." })] }), _jsx(Separator, {}), _jsxs("div", { className: "mt-6", children: [_jsxs("div", { className: "flex justify-between items-center mb-4 p-3 bg-slate-50 rounded-md", children: [_jsxs("span", { className: "text-sm font-medium text-slate-600", children: ["ID: ", feedback?.id] }), _jsxs("span", { className: "text-sm text-slate-500", children: ["\uC0DD\uC131\uC77C: ", feedback?.createdAt ? new Date(feedback.createdAt).toLocaleString("ko-KR") : 'N/A'] })] }), _jsx(DynamicForm, { fields: fields, initialData: initialData, onSubmit: handleSubmit, submitButtonText: "\uC218\uC815\uD558\uAE30" }), successMessage && (_jsx("div", { className: "mt-4 p-3 bg-green-100 text-green-800 rounded-md", children: successMessage }))] })] }));
|
||||||
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { DynamicForm } from "@/components/DynamicForm";
|
import { DynamicForm } from "@/components/DynamicForm";
|
||||||
|
import { useSyncChannelId } from "@/hooks/useSyncChannelId";
|
||||||
import {
|
import {
|
||||||
getFeedbackFields,
|
getFeedbackSchema,
|
||||||
getFeedbackById,
|
getFeedbackById,
|
||||||
updateFeedback,
|
updateFeedback,
|
||||||
|
type Feedback,
|
||||||
|
type FeedbackSchema,
|
||||||
} from "@/services/feedback";
|
} from "@/services/feedback";
|
||||||
import type { Feedback, FeedbackField } from "@/services/feedback";
|
|
||||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
export function FeedbackDetailPage() {
|
export function FeedbackDetailPage() {
|
||||||
|
useSyncChannelId();
|
||||||
const { projectId, channelId, feedbackId } = useParams<{
|
const { projectId, channelId, feedbackId } = useParams<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
|
|||||||
1
viewer/src/pages/FeedbackListPage.d.ts
vendored
Normal file
1
viewer/src/pages/FeedbackListPage.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export declare function FeedbackListPage(): import("react/jsx-runtime").JSX.Element;
|
||||||
49
viewer/src/pages/FeedbackListPage.js
Normal file
49
viewer/src/pages/FeedbackListPage.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { DynamicTable } from "@/components/DynamicTable";
|
||||||
|
import { getFeedbacks, getFeedbackFields } from "@/services/feedback";
|
||||||
|
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
export function FeedbackListPage() {
|
||||||
|
const [fields, setFields] = useState([]);
|
||||||
|
const [feedbacks, setFeedbacks] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
// TODO: projectId와 channelId는 URL 파라미터나 컨텍스트에서 가져와야 합니다.
|
||||||
|
const projectId = "1";
|
||||||
|
const channelId = "4";
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFieldsAndFeedbacks = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const fieldsData = await getFeedbackFields(projectId, channelId);
|
||||||
|
setFields(fieldsData);
|
||||||
|
try {
|
||||||
|
const feedbacksData = await getFeedbacks(projectId, channelId);
|
||||||
|
setFeedbacks(feedbacksData);
|
||||||
|
}
|
||||||
|
catch (feedbackError) {
|
||||||
|
console.error("Failed to fetch feedbacks:", feedbackError);
|
||||||
|
setError("피드백 목록을 불러오는 데 실패했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (fieldsError) {
|
||||||
|
if (fieldsError instanceof Error) {
|
||||||
|
setError(fieldsError.message);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setError("테이블 구조를 불러오는 데 실패했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchFieldsAndFeedbacks();
|
||||||
|
}, [projectId, channelId]);
|
||||||
|
if (loading) {
|
||||||
|
return _jsx("div", { children: "\uB85C\uB529 \uC911..." });
|
||||||
|
}
|
||||||
|
return (_jsxs("div", { className: "container mx-auto p-4", children: [_jsxs("div", { className: "flex justify-between items-center mb-4", children: [_jsx("h1", { className: "text-2xl font-bold", children: "\uD53C\uB4DC\uBC31 \uBAA9\uB85D" }), _jsx(Button, { asChild: true, children: _jsx(Link, { to: "new", children: "\uC0C8 \uD53C\uB4DC\uBC31 \uC791\uC131" }) })] }), error && _jsx(ErrorDisplay, { message: error }), _jsx(DynamicTable, { columns: fields, data: feedbacks, projectId: projectId, channelId: channelId })] }));
|
||||||
|
}
|
||||||
@@ -1,68 +1,74 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||||
|
import { useSyncChannelId } from "@/hooks/useSyncChannelId";
|
||||||
import { DynamicTable } from "@/components/DynamicTable";
|
import { DynamicTable } from "@/components/DynamicTable";
|
||||||
import { getFeedbacks, getFeedbackFields } from "@/services/feedback";
|
import {
|
||||||
import type { Feedback, FeedbackField } from "@/services/feedback";
|
getFeedbacks,
|
||||||
|
getFeedbackSchema,
|
||||||
|
type Feedback,
|
||||||
|
type FeedbackSchema,
|
||||||
|
} from "@/services/feedback";
|
||||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export function FeedbackListPage() {
|
export function FeedbackListPage() {
|
||||||
const [fields, setFields] = useState<FeedbackField[]>([]);
|
useSyncChannelId(); // URL의 channelId를 전역 상태와 동기화
|
||||||
|
const { projectId, channelId } = useSettingsStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [schema, setSchema] = useState<FeedbackSchema | null>(null);
|
||||||
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
|
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// TODO: projectId와 channelId는 URL 파라미터나 컨텍스트에서 가져와야 합니다.
|
|
||||||
const projectId = "1";
|
|
||||||
const channelId = "4";
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchFieldsAndFeedbacks = async () => {
|
if (!projectId || !channelId) return;
|
||||||
|
|
||||||
|
const fetchSchemaAndFeedbacks = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const fieldsData = await getFeedbackFields(projectId, channelId);
|
setError(null);
|
||||||
setFields(fieldsData);
|
|
||||||
|
|
||||||
try {
|
const schemaData = await getFeedbackSchema(projectId, channelId);
|
||||||
const feedbacksData = await getFeedbacks(projectId, channelId);
|
setSchema(schemaData);
|
||||||
setFeedbacks(feedbacksData);
|
|
||||||
} catch (feedbackError) {
|
const feedbacksData = await getFeedbacks(projectId, channelId);
|
||||||
console.error("Failed to fetch feedbacks:", feedbackError);
|
setFeedbacks(feedbacksData);
|
||||||
setError("피드백 목록을 불러오는 데 실패했습니다.");
|
} catch (err) {
|
||||||
}
|
setError(err instanceof Error ? err.message : "데이터 로딩에 실패했습니다.");
|
||||||
} catch (fieldsError) {
|
|
||||||
if (fieldsError instanceof Error) {
|
|
||||||
setError(fieldsError.message);
|
|
||||||
} else {
|
|
||||||
setError("테이블 구조를 불러오는 데 실패했습니다.");
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchFieldsAndFeedbacks();
|
fetchSchemaAndFeedbacks();
|
||||||
}, [projectId, channelId]);
|
}, [projectId, channelId]);
|
||||||
|
|
||||||
|
const handleRowClick = (row: Feedback) => {
|
||||||
|
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks/${row.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div>로딩 중...</div>;
|
return <div className="text-center py-10">로딩 중...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center">
|
||||||
<h1 className="text-2xl font-bold">피드백 목록</h1>
|
<h1 className="text-2xl font-bold">피드백 목록</h1>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to="new">새 피드백 작성</Link>
|
<Link to="new">새 피드백 작성</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{error && <ErrorDisplay message={error} />}
|
{error && <ErrorDisplay message={error} />}
|
||||||
<DynamicTable
|
{schema && (
|
||||||
columns={fields}
|
<DynamicTable
|
||||||
data={feedbacks}
|
schema={schema}
|
||||||
projectId={projectId}
|
data={feedbacks}
|
||||||
channelId={channelId}
|
onRowClick={handleRowClick}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1
viewer/src/pages/IssueViewerPage.d.ts
vendored
Normal file
1
viewer/src/pages/IssueViewerPage.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export declare function IssueViewerPage(): import("react/jsx-runtime").JSX.Element;
|
||||||
35
viewer/src/pages/IssueViewerPage.js
Normal file
35
viewer/src/pages/IssueViewerPage.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
// src/pages/IssueViewerPage.tsx
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { getIssues } 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();
|
||||||
|
const [issues, setIssues] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId)
|
||||||
|
return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
getIssues(projectId)
|
||||||
|
.then(setIssues)
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [projectId]);
|
||||||
|
return (_jsxs("div", { className: "container mx-auto p-4 md:p-8", children: [_jsxs("header", { className: "mb-8", children: [_jsx("h1", { className: "text-3xl font-bold tracking-tight", children: "\uC774\uC288 \uBDF0\uC5B4" }), _jsxs("p", { className: "text-muted-foreground mt-1", children: ["\uD504\uB85C\uC81D\uD2B8: ", projectId] })] }), error && _jsx(ErrorDisplay, { message: error }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "\uC774\uC288 \uBAA9\uB85D" }) }), _jsxs(CardContent, { children: [loading && _jsx("p", { className: "text-center", children: "\uB85C\uB529 \uC911..." }), !loading && (_jsx("div", { className: "border rounded-md", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsx(TableRow, { children: issueTableHeaders.map((header) => (_jsx(TableHead, { children: header.label }, header.key))) }) }), _jsx(TableBody, { children: issues.length > 0 ? (issues.map((issue) => (_jsx(TableRow, { children: issueTableHeaders.map((header) => (_jsx(TableCell, { children: String(issue[header.key] ?? "") }, `${issue.id}-${header.key}`))) }, issue.id)))) : (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: issueTableHeaders.length, className: "h-24 text-center", children: "\uD45C\uC2DC\uD560 \uC774\uC288\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." }) })) })] }) }))] })] })] }));
|
||||||
|
}
|
||||||
@@ -41,60 +41,53 @@ export function IssueViewerPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ErrorDisplay message={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-4 md:p-8">
|
<Card>
|
||||||
<header className="mb-8">
|
<CardHeader>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">이슈 뷰어</h1>
|
<CardTitle>이슈 목록</CardTitle>
|
||||||
<p className="text-muted-foreground mt-1">
|
</CardHeader>
|
||||||
프로젝트: {projectId}
|
<CardContent>
|
||||||
</p>
|
{loading && <p className="text-center">로딩 중...</p>}
|
||||||
</header>
|
{!loading && (
|
||||||
|
<div className="border rounded-md">
|
||||||
{error && <ErrorDisplay errorMessage={error} />}
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
<Card>
|
<TableRow>
|
||||||
<CardHeader>
|
{issueTableHeaders.map((header) => (
|
||||||
<CardTitle>이슈 목록</CardTitle>
|
<TableHead key={header.key}>{header.label}</TableHead>
|
||||||
</CardHeader>
|
))}
|
||||||
<CardContent>
|
</TableRow>
|
||||||
{loading && <p className="text-center">로딩 중...</p>}
|
</TableHeader>
|
||||||
{!loading && (
|
<TableBody>
|
||||||
<div className="border rounded-md">
|
{issues.length > 0 ? (
|
||||||
<Table>
|
issues.map((issue) => (
|
||||||
<TableHeader>
|
<TableRow key={issue.id}>
|
||||||
<TableRow>
|
{issueTableHeaders.map((header) => (
|
||||||
{issueTableHeaders.map((header) => (
|
<TableCell key={`${issue.id}-${header.key}`}>
|
||||||
<TableHead key={header.key}>{header.label}</TableHead>
|
{String(issue[header.key] ?? "")}
|
||||||
))}
|
</TableCell>
|
||||||
</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>
|
</TableRow>
|
||||||
)}
|
))
|
||||||
</TableBody>
|
) : (
|
||||||
</Table>
|
<TableRow>
|
||||||
</div>
|
<TableCell
|
||||||
)}
|
colSpan={issueTableHeaders.length}
|
||||||
</CardContent>
|
className="h-24 text-center"
|
||||||
</Card>
|
>
|
||||||
</div>
|
표시할 이슈가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
6
viewer/src/services/error.d.ts
vendored
Normal file
6
viewer/src/services/error.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* API 요청 실패 시 공통으로 사용할 에러 처리 함수
|
||||||
|
* @param message 프로덕션 환경에서 보여줄 기본 에러 메시지
|
||||||
|
* @param response fetch API의 응답 객체
|
||||||
|
*/
|
||||||
|
export declare const handleApiError: (message: string, response: Response) => Promise<never>;
|
||||||
13
viewer/src/services/error.js
Normal file
13
viewer/src/services/error.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// src/services/error.ts
|
||||||
|
/**
|
||||||
|
* API 요청 실패 시 공통으로 사용할 에러 처리 함수
|
||||||
|
* @param message 프로덕션 환경에서 보여줄 기본 에러 메시지
|
||||||
|
* @param response fetch API의 응답 객체
|
||||||
|
*/
|
||||||
|
export const handleApiError = async (message, response) => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
const errorBody = await response.text();
|
||||||
|
throw new Error(`[Dev] ${message} | URL: ${response.url} | Status: ${response.status} ${response.statusText} | Body: ${errorBody || "Empty"}`);
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
};
|
||||||
43
viewer/src/services/feedback.d.ts
vendored
Normal file
43
viewer/src/services/feedback.d.ts
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export interface Feedback {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
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]: any;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 특정 채널의 피드백 목록을 조회합니다.
|
||||||
|
*/
|
||||||
|
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>;
|
||||||
114
viewer/src/services/feedback.js
Normal file
114
viewer/src/services/feedback.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// src/services/feedback.ts
|
||||||
|
import { handleApiError } from "./error";
|
||||||
|
// --- API 함수 ---
|
||||||
|
const getFeedbacksSearchApiUrl = (projectId, channelId) => `/api/v2/projects/${projectId}/channels/${channelId}/feedbacks/search`;
|
||||||
|
const getFeedbackFieldsApiUrl = (projectId, channelId) => `/api/projects/${projectId}/channels/${channelId}/fields`;
|
||||||
|
const getIssuesApiUrl = (projectId) => `/api/projects/${projectId}/issues/search`;
|
||||||
|
/**
|
||||||
|
* 특정 채널의 피드백 목록을 조회합니다.
|
||||||
|
*/
|
||||||
|
export const getFeedbacks = async (projectId, channelId) => {
|
||||||
|
const url = getFeedbacksSearchApiUrl(projectId, channelId);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
await handleApiError("피드백 목록을 불러오는 데 실패했습니다.", response);
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
return result.items || [];
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 특정 채널의 동적 폼 필드 스키마를 조회합니다.
|
||||||
|
*/
|
||||||
|
export const getFeedbackFields = async (projectId, channelId) => {
|
||||||
|
const url = getFeedbackFieldsApiUrl(projectId, channelId);
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
await handleApiError("피드백 필드 정보를 불러오는 데 실패했습니다.", response);
|
||||||
|
}
|
||||||
|
const apiFields = await response.json();
|
||||||
|
if (!Array.isArray(apiFields)) {
|
||||||
|
console.error("Error: Fields API response is not an array.", apiFields);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return apiFields
|
||||||
|
.filter((field) => field.status === "ACTIVE")
|
||||||
|
.map((field) => ({
|
||||||
|
id: field.key,
|
||||||
|
name: field.name,
|
||||||
|
type: field.format,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 특정 채널에 새로운 피드백을 생성합니다.
|
||||||
|
*/
|
||||||
|
export const createFeedback = async (projectId, channelId, feedbackData) => {
|
||||||
|
const url = `/api/projects/${projectId}/channels/${channelId}/feedbacks`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(feedbackData),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
await handleApiError("피드백 생성에 실패했습니다.", response);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 프로젝트의 이슈를 검색합니다.
|
||||||
|
*/
|
||||||
|
export const searchIssues = async (projectId, query) => {
|
||||||
|
const url = getIssuesApiUrl(projectId);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: { name: query },
|
||||||
|
limit: 10,
|
||||||
|
page: 1,
|
||||||
|
sort: { createdAt: "ASC" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
await handleApiError("이슈 검색에 실패했습니다.", response);
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
return result.items || [];
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 특정 ID의 피드백 상세 정보를 조회합니다.
|
||||||
|
*/
|
||||||
|
export const getFeedbackById = async (projectId, channelId, feedbackId) => {
|
||||||
|
const url = `/api/projects/${projectId}/channels/${channelId}/feedbacks/${feedbackId}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
await handleApiError("피드백 상세 정보를 불러오는 데 실패했습니다.", response);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 특정 피드백을 수정합니다.
|
||||||
|
*/
|
||||||
|
export const updateFeedback = async (projectId, channelId, feedbackId, feedbackData) => {
|
||||||
|
const url = `/api/projects/${projectId}/channels/${channelId}/feedbacks/${feedbackId}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(feedbackData),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
await handleApiError("피드백 수정에 실패했습니다.", response);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
17
viewer/src/services/issue.d.ts
vendored
Normal file
17
viewer/src/services/issue.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export interface Issue {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
feedbackCount: number;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
category: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 특정 프로젝트의 모든 이슈를 검색합니다.
|
||||||
|
* @param projectId 프로젝트 ID
|
||||||
|
* @returns 이슈 목록 Promise
|
||||||
|
*/
|
||||||
|
export declare const getIssues: (projectId: string) => Promise<Issue[]>;
|
||||||
24
viewer/src/services/issue.js
Normal file
24
viewer/src/services/issue.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// src/services/issue.ts
|
||||||
|
import { handleApiError } from "./error";
|
||||||
|
/**
|
||||||
|
* 특정 프로젝트의 모든 이슈를 검색합니다.
|
||||||
|
* @param projectId 프로젝트 ID
|
||||||
|
* @returns 이슈 목록 Promise
|
||||||
|
*/
|
||||||
|
export const getIssues = async (projectId) => {
|
||||||
|
const url = `/api/projects/${projectId}/issues/search`;
|
||||||
|
// body를 비워서 보내면 모든 이슈를 가져오는 것으로 가정합니다.
|
||||||
|
// 실제 API 명세에 따라 수정이 필요할 수 있습니다.
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
await handleApiError("이슈 목록을 불러오는 데 실패했습니다.", response);
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
return result.items || [];
|
||||||
|
};
|
||||||
@@ -38,4 +38,24 @@ export const getIssues = async (projectId: string): Promise<Issue[]> => {
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
return result.items || [];
|
return result.items || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 프로젝트의 단일 이슈 상세 정보를 가져옵니다.
|
||||||
|
* @param projectId 프로젝트 ID
|
||||||
|
* @param issueId 이슈 ID
|
||||||
|
* @returns 이슈 상세 정보 Promise
|
||||||
|
*/
|
||||||
|
export const getIssue = async (
|
||||||
|
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();
|
||||||
};
|
};
|
||||||
22
viewer/src/services/project.d.ts
vendored
Normal file
22
viewer/src/services/project.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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>;
|
||||||
38
viewer/src/services/project.js
Normal file
38
viewer/src/services/project.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const API_BASE_URL = "/api";
|
||||||
|
/**
|
||||||
|
* 모든 접근 가능한 프로젝트 목록을 가져옵니다.
|
||||||
|
* 현재는 ID가 1인 프로젝트 하나만 가져오도록 구현되어 있습니다.
|
||||||
|
*/
|
||||||
|
export const getProjects = async () => {
|
||||||
|
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) => {
|
||||||
|
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 = 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
68
viewer/src/services/project.ts
Normal file
68
viewer/src/services/project.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
export interface Project {
|
||||||
|
id: string; // 애플리케이션 내에서는 string 타입으로 일관성 유지
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
timezone: {
|
||||||
|
countryCode: string;
|
||||||
|
name: string;
|
||||||
|
offset: string;
|
||||||
|
};
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답의 id는 number 타입이므로, 파싱을 위한 별도 인터페이스 정의
|
||||||
|
interface ProjectApiResponse {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
timezone: {
|
||||||
|
countryCode: string;
|
||||||
|
name: string;
|
||||||
|
offset: string;
|
||||||
|
};
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_URL = "/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 접근 가능한 프로젝트 목록을 가져옵니다.
|
||||||
|
* 현재는 ID가 1인 프로젝트 하나만 가져오도록 구현되어 있습니다.
|
||||||
|
*/
|
||||||
|
export const getProjects = async (): Promise<Project[]> => {
|
||||||
|
try {
|
||||||
|
const project = await getProjectById("1");
|
||||||
|
return project ? [project] : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch projects:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 ID를 가진 프로젝트의 상세 정보를 가져옵니다.
|
||||||
|
* @param id - 조회할 프로젝트의 ID
|
||||||
|
*/
|
||||||
|
export const getProjectById = async (
|
||||||
|
id: string,
|
||||||
|
): Promise<Project | undefined> => {
|
||||||
|
try {
|
||||||
|
// 'project' -> 'projects'로 수정
|
||||||
|
const response = await fetch(`${API_BASE_URL}/projects/${id}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API call failed with status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data: ProjectApiResponse = await response.json();
|
||||||
|
|
||||||
|
// API 응답(id: number)을 내부 모델(id: string)로 변환
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
id: data.id.toString(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch project with id ${id}:`, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
19
viewer/src/store/useSettingsStore.d.ts
vendored
Normal file
19
viewer/src/store/useSettingsStore.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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>>;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
export {};
|
||||||
10
viewer/src/store/useSettingsStore.js
Normal file
10
viewer/src/store/useSettingsStore.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
export const useSettingsStore = create()(persist((set) => ({
|
||||||
|
projectId: "1", // 기본 프로젝트 ID를 1로 설정
|
||||||
|
theme: "system",
|
||||||
|
setProjectId: (projectId) => set({ projectId }),
|
||||||
|
setTheme: (theme) => set({ theme }),
|
||||||
|
}), {
|
||||||
|
name: "settings-storage", // localStorage에 저장될 이름
|
||||||
|
}));
|
||||||
29
viewer/src/store/useSettingsStore.ts
Normal file
29
viewer/src/store/useSettingsStore.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
interface SettingsState {
|
||||||
|
projectId: string | null;
|
||||||
|
channelId: string | null;
|
||||||
|
theme: Theme;
|
||||||
|
setProjectId: (projectId: string) => void;
|
||||||
|
setChannelId: (channelId: string) => void;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsStore = create<SettingsState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
projectId: import.meta.env.VITE_DEFAULT_PROJECT_ID,
|
||||||
|
channelId: import.meta.env.VITE_DEFAULT_CHANNEL_ID,
|
||||||
|
theme: "light",
|
||||||
|
setProjectId: (projectId) => set({ projectId }),
|
||||||
|
setChannelId: (channelId) => set({ channelId }),
|
||||||
|
setTheme: (theme) => set({ theme }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "settings-storage", // localStorage에 저장될 이름
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -77,4 +77,4 @@ const config: Config = {
|
|||||||
plugins: [tailwindcssAnimate],
|
plugins: [tailwindcssAnimate],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -27,6 +27,5 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"]
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
"target": "ES2023",
|
"target": "ES2023",
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2023"],
|
||||||
|
|||||||
1
viewer/tsconfig.tsbuildinfo
Normal file
1
viewer/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/DynamicForm.tsx","./src/components/DynamicTable.tsx","./src/components/ErrorDisplay.tsx","./src/components/Header.tsx","./src/components/LanguageSelectBox.tsx","./src/components/MainLayout.tsx","./src/components/ProjectSelectBox.tsx","./src/components/ThemeSelectBox.tsx","./src/components/UserProfileBox.tsx","./src/components/providers/ThemeProvider.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/table.tsx","./src/components/ui/textarea.tsx","./src/lib/utils.ts","./src/pages/FeedbackCreatePage.tsx","./src/pages/FeedbackDetailPage.tsx","./src/pages/FeedbackListPage.tsx","./src/pages/IssueViewerPage.tsx","./src/services/error.ts","./src/services/feedback.ts","./src/services/issue.ts","./src/services/project.ts","./src/store/useSettingsStore.ts"],"version":"5.8.3"}
|
||||||
2
viewer/vite.config.d.ts
vendored
Normal file
2
viewer/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfigFnObject;
|
||||||
|
export default _default;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user