diff --git a/TODO.md b/TODO.md index a9c40fb..76ebf27 100644 --- a/TODO.md +++ b/TODO.md @@ -14,9 +14,12 @@ ## 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을 이용한 코드 포맷팅 및 린트 규칙 적용 및 검사 ## Phase 3: 인증 및 배포 @@ -24,4 +27,4 @@ - [ ] OIDC 클라이언트 연동 및 인증 로직 구현 - [ ] 로그인/로그아웃 및 인증 상태 관리 - [ ] 인증이 필요한 라우트 보호 기능 적용 -- [ ] Docker를 이용한 배포 환경 구축 \ No newline at end of file +- [ ] Docker를 이용한 배포 환경 구축 diff --git a/viewer/package.json b/viewer/package.json index dba4eed..157a2b2 100644 --- a/viewer/package.json +++ b/viewer/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc --noEmit && vite build", "format": "biome format --write .", "lint": "biome lint --write .", "preview": "vite preview", @@ -33,6 +33,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.1.2", + "@types/node": "^24.1.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", diff --git a/viewer/pnpm-lock.yaml b/viewer/pnpm-lock.yaml index 3871138..f4f99ef 100644 --- a/viewer/pnpm-lock.yaml +++ b/viewer/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: '@biomejs/biome': specifier: ^2.1.2 version: 2.1.2 + '@types/node': + specifier: ^24.1.0 + version: 24.1.0 '@types/react': specifier: ^19.1.8 version: 19.1.9 @@ -74,7 +77,7 @@ importers: version: 19.1.7(@types/react@19.1.9) '@vitejs/plugin-react': specifier: ^4.6.0 - version: 4.7.0(vite@7.0.6(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0)) + version: 4.7.0(vite@7.0.6(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0)) autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.6) @@ -95,7 +98,7 @@ importers: version: 5.8.3 vite: specifier: ^7.0.4 - version: 7.0.6(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0) + version: 7.0.6(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0) viewer: dependencies: @@ -1064,6 +1067,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@24.1.0': + resolution: {integrity: sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==} + '@types/react-dom@19.1.7': resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} peerDependencies: @@ -2030,6 +2036,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@7.8.0: + resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true @@ -2923,6 +2932,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@24.1.0': + dependencies: + undici-types: 7.8.0 + '@types/react-dom@19.1.7(@types/react@19.1.9)': dependencies: '@types/react': 19.1.9 @@ -3024,7 +3037,7 @@ snapshots: '@typescript-eslint/types': 8.38.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react@4.7.0(vite@7.0.6(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0))': + '@vitejs/plugin-react@4.7.0(vite@7.0.6(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0))': dependencies: '@babel/core': 7.28.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0) @@ -3032,7 +3045,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.0.6(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0) + vite: 7.0.6(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0) transitivePeerDependencies: - supports-color @@ -3898,6 +3911,8 @@ snapshots: typescript@5.8.3: {} + undici-types@7.8.0: {} + update-browserslist-db@1.1.3(browserslist@4.25.1): dependencies: browserslist: 4.25.1 @@ -3936,7 +3951,7 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - vite@7.0.6(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0): + vite@7.0.6(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0): dependencies: esbuild: 0.25.8 fdir: 6.4.6(picomatch@4.0.3) @@ -3945,6 +3960,7 @@ snapshots: rollup: 4.46.1 tinyglobby: 0.2.14 optionalDependencies: + '@types/node': 24.1.0 fsevents: 2.3.3 jiti: 2.5.1 lightningcss: 1.30.1 diff --git a/viewer/src/App.d.ts b/viewer/src/App.d.ts new file mode 100644 index 0000000..1a358c9 --- /dev/null +++ b/viewer/src/App.d.ts @@ -0,0 +1,2 @@ +declare function App(): import("react/jsx-runtime").JSX.Element; +export default App; diff --git a/viewer/src/App.js b/viewer/src/App.js new file mode 100644 index 0000000..6c53d51 --- /dev/null +++ b/viewer/src/App.js @@ -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; diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx index 8110db7..ff3744e 100644 --- a/viewer/src/App.tsx +++ b/viewer/src/App.tsx @@ -1,45 +1,42 @@ // src/App.tsx -import { useEffect } from "react"; import { Routes, Route, Navigate } from "react-router-dom"; import { MainLayout } from "@/components/MainLayout"; import { FeedbackCreatePage } from "@/pages/FeedbackCreatePage"; import { FeedbackListPage } from "@/pages/FeedbackListPage"; import { FeedbackDetailPage } from "@/pages/FeedbackDetailPage"; import { IssueViewerPage } from "@/pages/IssueViewerPage"; +import { ThemeProvider } from "./components/providers/ThemeProvider"; function App() { - useEffect(() => { - const root = window.document.documentElement; - root.classList.add("dark"); - }, []); - return ( - - {/* 기본 경로 리디렉션 */} - } - /> + + + {/* 기본 경로 리디렉션 */} + } + /> - {/* 피드백 관련 페이지 (메인 레이아웃 사용) */} - } - > - } /> - } /> - } /> - + {/* 피드백 관련 페이지 (메인 레이아웃 사용) */} + } + > + } /> + } /> + } /> + - {/* 독립적인 이슈 뷰어 페이지 */} - } - /> + {/* 독립적인 이슈 뷰어 페이지 */} + } + /> - {/* 잘못된 접근을 위한 리디렉션 */} - } /> - + {/* 잘못된 접근을 위한 리디렉션 */} + } /> + + ); } diff --git a/viewer/src/components/DynamicForm.d.ts b/viewer/src/components/DynamicForm.d.ts new file mode 100644 index 0000000..c6a4e60 --- /dev/null +++ b/viewer/src/components/DynamicForm.d.ts @@ -0,0 +1,10 @@ +import type { FeedbackField } from "@/services/feedback"; +interface DynamicFormProps { + fields: FeedbackField[]; + onSubmit: (formData: Record) => Promise; + initialData?: Record; + submitButtonText?: string; +} +export declare function DynamicForm({ fields, onSubmit, initialData, // 기본값으로 상수 사용 +submitButtonText, }: DynamicFormProps): import("react/jsx-runtime").JSX.Element; +export {}; diff --git a/viewer/src/components/DynamicForm.js b/viewer/src/components/DynamicForm.js new file mode 100644 index 0000000..8a0ae60 --- /dev/null +++ b/viewer/src/components/DynamicForm.js @@ -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 })] })); +} diff --git a/viewer/src/components/DynamicForm.tsx b/viewer/src/components/DynamicForm.tsx index bb52097..a9794d2 100644 --- a/viewer/src/components/DynamicForm.tsx +++ b/viewer/src/components/DynamicForm.tsx @@ -7,7 +7,6 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, - SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; diff --git a/viewer/src/components/DynamicTable.d.ts b/viewer/src/components/DynamicTable.d.ts new file mode 100644 index 0000000..b36fe81 --- /dev/null +++ b/viewer/src/components/DynamicTable.d.ts @@ -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; diff --git a/viewer/src/components/DynamicTable.js b/viewer/src/components/DynamicTable.js new file mode 100644 index 0000000..2786be8 --- /dev/null +++ b/viewer/src/components/DynamicTable.js @@ -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; diff --git a/viewer/src/components/DynamicTable.tsx b/viewer/src/components/DynamicTable.tsx index 73ab54b..ac80a08 100644 --- a/viewer/src/components/DynamicTable.tsx +++ b/viewer/src/components/DynamicTable.tsx @@ -23,13 +23,13 @@ import { ChevronsLeft, ChevronsRight, } from "lucide-react"; -import { useMemo, useState } from "react"; -import { DateRange } from "react-day-picker"; +import { useMemo, useState, Fragment } from "react"; +import type { DateRange } from "react-day-picker"; import { Link, useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -242,7 +242,7 @@ export function DynamicTable({ return ( - +
{table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - <> + handleRowClick(row.original.id.toString())} className="cursor-pointer hover:bg-muted/50" @@ -353,7 +352,7 @@ export function DynamicTable({ ))} {row.getIsExpanded() && ( - +

@@ -366,7 +365,7 @@ export function DynamicTable({ )} - + )) ) : ( diff --git a/viewer/src/components/ErrorDisplay.d.ts b/viewer/src/components/ErrorDisplay.d.ts new file mode 100644 index 0000000..78c29e1 --- /dev/null +++ b/viewer/src/components/ErrorDisplay.d.ts @@ -0,0 +1,5 @@ +interface ErrorDisplayProps { + message: string; +} +export declare function ErrorDisplay({ message }: ErrorDisplayProps): import("react/jsx-runtime").JSX.Element; +export {}; diff --git a/viewer/src/components/ErrorDisplay.js b/viewer/src/components/ErrorDisplay.js new file mode 100644 index 0000000..38a21f6 --- /dev/null +++ b/viewer/src/components/ErrorDisplay.js @@ -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 })] })); +} diff --git a/viewer/src/components/ErrorDisplay.tsx b/viewer/src/components/ErrorDisplay.tsx index db12206..74ef3db 100644 --- a/viewer/src/components/ErrorDisplay.tsx +++ b/viewer/src/components/ErrorDisplay.tsx @@ -1,97 +1,15 @@ -// src/components/ErrorDisplay.tsx -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; - interface ErrorDisplayProps { - errorMessage: string | null; + message: string; } -export const ErrorDisplay = ({ errorMessage }: ErrorDisplayProps) => { - if (!errorMessage) return null; - - const prettifyJson = (text: string) => { - try { - const obj = JSON.parse(text); - return JSON.stringify(obj, null, 2); - } catch (e) { - return text; - } - }; - - // For production or simple errors - if (import.meta.env.PROD || !errorMessage.startsWith("[Dev]")) { - return ( - - - 오류가 발생했습니다 - - -
-						{errorMessage}
-					
-
-
- ); - } - - // For dev errors, parse and display detailed information - const parts = errorMessage.split(" | "); - const message = parts[0]?.replace("[Dev] ", ""); - const requestUrl = parts[1]?.replace("URL: ", ""); - const status = parts[2]?.replace("Status: ", ""); - const bodyRaw = parts.slice(3).join(" | ").replace("Body: ", ""); - const body = bodyRaw === "Empty" ? "Empty" : prettifyJson(bodyRaw); - - // Reconstruct the final proxied URL based on environment variables - let proxiedUrl = "프록시 URL을 계산할 수 없습니다."; - if (requestUrl) { - try { - const url = new URL(requestUrl); - const proxyTarget = import.meta.env.VITE_API_PROXY_TARGET; - // This logic MUST match the proxy rewrite in `vite.config.ts` - const finalPath = url.pathname.replace(/^\/api/, "/_api/api"); - proxiedUrl = `${proxyTarget}${finalPath}`; - } catch (e) { - // Ignore if URL parsing fails - } - } - +export function ErrorDisplay({ message }: ErrorDisplayProps) { return ( - - - - {message || "오류가 발생했습니다"} - - -
-

- 요청 상태: - {status} -

-

- Vite 서버 URL: - {requestUrl} -

-

- 최종 API URL: - {proxiedUrl} -

-
-
-
- -

- Response Body: -

-
-					{body}
-				
-
-
+
+

오류 발생

+

{message}

+
); -}; \ No newline at end of file +} diff --git a/viewer/src/components/Header.d.ts b/viewer/src/components/Header.d.ts new file mode 100644 index 0000000..193fa63 --- /dev/null +++ b/viewer/src/components/Header.d.ts @@ -0,0 +1 @@ +export declare function Header(): import("react/jsx-runtime").JSX.Element; diff --git a/viewer/src/components/Header.js b/viewer/src/components/Header.js new file mode 100644 index 0000000..d25be76 --- /dev/null +++ b/viewer/src/components/Header.js @@ -0,0 +1,25 @@ +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: "Landing", path: "/" }, + { name: "Feedback", path: "/feedbacks" }, + { name: "Issue", path: "/issues" }, +]; +export function Header() { + const projectId = useSettingsStore((state) => state.projectId); + const getFullPath = (path) => { + if (path === "/") + return `/projects/${projectId}`; // Landing 페이지 경로 예시 + if (path.startsWith("/")) { + return `/projects/${projectId}/channels/4${path}`; // 채널 ID는 4로 고정 + } + return path; + }; + return (_jsxs("header", { className: "flex h-16 items-center border-b px-4", children: [_jsx(ProjectSelectBox, {}), _jsx(Separator, { orientation: "vertical", className: "mx-4 h-8" }), _jsx("nav", { className: "flex items-center space-x-4 lg:space-x-6 flex-1", 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, {})] })] })); +} diff --git a/viewer/src/components/Header.tsx b/viewer/src/components/Header.tsx new file mode 100644 index 0000000..7d62811 --- /dev/null +++ b/viewer/src/components/Header.tsx @@ -0,0 +1,52 @@ +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: "Landing", path: "/" }, + { name: "Feedback", path: "/feedbacks" }, + { name: "Issue", path: "/issues" }, +]; + +export function Header() { + const projectId = useSettingsStore((state) => state.projectId); + + const getFullPath = (path: string) => { + if (path === "/") return `/projects/${projectId}`; // Landing 페이지 경로 예시 + if (path.startsWith("/")) { + return `/projects/${projectId}/channels/4${path}`; // 채널 ID는 4로 고정 + } + return path; + }; + + return ( +
+ + + +
+ + + +
+
+ ); +} diff --git a/viewer/src/components/LanguageSelectBox.d.ts b/viewer/src/components/LanguageSelectBox.d.ts new file mode 100644 index 0000000..c38f946 --- /dev/null +++ b/viewer/src/components/LanguageSelectBox.d.ts @@ -0,0 +1 @@ +export declare function LanguageSelectBox(): import("react/jsx-runtime").JSX.Element; diff --git a/viewer/src/components/LanguageSelectBox.js b/viewer/src/components/LanguageSelectBox.js new file mode 100644 index 0000000..361b9e7 --- /dev/null +++ b/viewer/src/components/LanguageSelectBox.js @@ -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" })] })); +} diff --git a/viewer/src/components/LanguageSelectBox.tsx b/viewer/src/components/LanguageSelectBox.tsx new file mode 100644 index 0000000..19bbc1c --- /dev/null +++ b/viewer/src/components/LanguageSelectBox.tsx @@ -0,0 +1,11 @@ +import { Languages } from "lucide-react"; +import { Button } from "./ui/button"; + +export function LanguageSelectBox() { + return ( + + ); +} diff --git a/viewer/src/components/MainLayout.d.ts b/viewer/src/components/MainLayout.d.ts new file mode 100644 index 0000000..3e7dfd7 --- /dev/null +++ b/viewer/src/components/MainLayout.d.ts @@ -0,0 +1 @@ +export declare function MainLayout(): import("react/jsx-runtime").JSX.Element; diff --git a/viewer/src/components/MainLayout.js b/viewer/src/components/MainLayout.js new file mode 100644 index 0000000..5583d2d --- /dev/null +++ b/viewer/src/components/MainLayout.js @@ -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, {}) })] })); +} diff --git a/viewer/src/components/MainLayout.tsx b/viewer/src/components/MainLayout.tsx index 2268e6d..f08ba82 100644 --- a/viewer/src/components/MainLayout.tsx +++ b/viewer/src/components/MainLayout.tsx @@ -1,16 +1,12 @@ // src/components/MainLayout.tsx -import { Link, Outlet, useParams } from "react-router-dom"; -import { Button } from "@/components/ui/button"; +import { Outlet } from "react-router-dom"; +import { Header } from "./Header"; export function MainLayout() { - const { projectId, channelId } = useParams(); - return ( -
-
-

피드백 뷰어

-
-
+
+
+
diff --git a/viewer/src/components/ProjectSelectBox.d.ts b/viewer/src/components/ProjectSelectBox.d.ts new file mode 100644 index 0000000..25efdee --- /dev/null +++ b/viewer/src/components/ProjectSelectBox.d.ts @@ -0,0 +1 @@ +export declare function ProjectSelectBox(): import("react/jsx-runtime").JSX.Element; diff --git a/viewer/src/components/ProjectSelectBox.js b/viewer/src/components/ProjectSelectBox.js new file mode 100644 index 0000000..ef0e7e5 --- /dev/null +++ b/viewer/src/components/ProjectSelectBox.js @@ -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))) })] })); +} diff --git a/viewer/src/components/ProjectSelectBox.tsx b/viewer/src/components/ProjectSelectBox.tsx new file mode 100644 index 0000000..c500d9f --- /dev/null +++ b/viewer/src/components/ProjectSelectBox.tsx @@ -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([]); + 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 ( +
+ ); + } + + return ( + + ); +} \ No newline at end of file diff --git a/viewer/src/components/ThemeSelectBox.d.ts b/viewer/src/components/ThemeSelectBox.d.ts new file mode 100644 index 0000000..85bbc3e --- /dev/null +++ b/viewer/src/components/ThemeSelectBox.d.ts @@ -0,0 +1 @@ +export declare function ThemeSelectBox(): import("react/jsx-runtime").JSX.Element; diff --git a/viewer/src/components/ThemeSelectBox.js b/viewer/src/components/ThemeSelectBox.js new file mode 100644 index 0000000..3933ae0 --- /dev/null +++ b/viewer/src/components/ThemeSelectBox.js @@ -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"] })] })] })); +} diff --git a/viewer/src/components/ThemeSelectBox.tsx b/viewer/src/components/ThemeSelectBox.tsx new file mode 100644 index 0000000..351650d --- /dev/null +++ b/viewer/src/components/ThemeSelectBox.tsx @@ -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 ( + + + + + + setTheme("light")}> + + Light + + setTheme("dark")}> + + Dark + + setTheme("system")}> + + System + + + + ); +} diff --git a/viewer/src/components/UserProfileBox.d.ts b/viewer/src/components/UserProfileBox.d.ts new file mode 100644 index 0000000..299c11b --- /dev/null +++ b/viewer/src/components/UserProfileBox.d.ts @@ -0,0 +1 @@ +export declare function UserProfileBox(): import("react/jsx-runtime").JSX.Element; diff --git a/viewer/src/components/UserProfileBox.js b/viewer/src/components/UserProfileBox.js new file mode 100644 index 0000000..f8a36f4 --- /dev/null +++ b/viewer/src/components/UserProfileBox.js @@ -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" })] })); +} diff --git a/viewer/src/components/UserProfileBox.tsx b/viewer/src/components/UserProfileBox.tsx new file mode 100644 index 0000000..01104bc --- /dev/null +++ b/viewer/src/components/UserProfileBox.tsx @@ -0,0 +1,11 @@ +import { CircleUser } from "lucide-react"; +import { Button } from "./ui/button"; + +export function UserProfileBox() { + return ( + + ); +} diff --git a/viewer/src/components/providers/ThemeProvider.d.ts b/viewer/src/components/providers/ThemeProvider.d.ts new file mode 100644 index 0000000..83ecded --- /dev/null +++ b/viewer/src/components/providers/ThemeProvider.d.ts @@ -0,0 +1,5 @@ +interface ThemeProviderProps { + children: React.ReactNode; +} +export declare function ThemeProvider({ children }: ThemeProviderProps): import("react/jsx-runtime").JSX.Element; +export {}; diff --git a/viewer/src/components/providers/ThemeProvider.js b/viewer/src/components/providers/ThemeProvider.js new file mode 100644 index 0000000..1b0c7f0 --- /dev/null +++ b/viewer/src/components/providers/ThemeProvider.js @@ -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 }); +} diff --git a/viewer/src/components/providers/ThemeProvider.tsx b/viewer/src/components/providers/ThemeProvider.tsx new file mode 100644 index 0000000..57a57e7 --- /dev/null +++ b/viewer/src/components/providers/ThemeProvider.tsx @@ -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}; +} diff --git a/viewer/src/components/ui/button.d.ts b/viewer/src/components/ui/button.d.ts new file mode 100644 index 0000000..38cf895 --- /dev/null +++ b/viewer/src/components/ui/button.d.ts @@ -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 & { + asChild?: boolean; +}): import("react/jsx-runtime").JSX.Element; +export { Button, buttonVariants }; diff --git a/viewer/src/components/ui/button.js b/viewer/src/components/ui/button.js new file mode 100644 index 0000000..dc1b063 --- /dev/null +++ b/viewer/src/components/ui/button.js @@ -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 }; diff --git a/viewer/src/components/ui/calendar.d.ts b/viewer/src/components/ui/calendar.d.ts new file mode 100644 index 0000000..676b993 --- /dev/null +++ b/viewer/src/components/ui/calendar.d.ts @@ -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 & { + buttonVariant?: React.ComponentProps["variant"]; +}): import("react/jsx-runtime").JSX.Element; +declare function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps): import("react/jsx-runtime").JSX.Element; +export { Calendar, CalendarDayButton }; diff --git a/viewer/src/components/ui/calendar.js b/viewer/src/components/ui/calendar.js new file mode 100644 index 0000000..18c3cbf --- /dev/null +++ b/viewer/src/components/ui/calendar.js @@ -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 }; diff --git a/viewer/src/components/ui/card.d.ts b/viewer/src/components/ui/card.d.ts new file mode 100644 index 0000000..e18ab9b --- /dev/null +++ b/viewer/src/components/ui/card.d.ts @@ -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, }; diff --git a/viewer/src/components/ui/card.js b/viewer/src/components/ui/card.js new file mode 100644 index 0000000..038a3eb --- /dev/null +++ b/viewer/src/components/ui/card.js @@ -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, }; diff --git a/viewer/src/components/ui/dropdown-menu.d.ts b/viewer/src/components/ui/dropdown-menu.d.ts new file mode 100644 index 0000000..682db8f --- /dev/null +++ b/viewer/src/components/ui/dropdown-menu.d.ts @@ -0,0 +1,27 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +declare const DropdownMenu: React.FC; +declare const DropdownMenuTrigger: React.ForwardRefExoticComponent>; +declare const DropdownMenuGroup: React.ForwardRefExoticComponent>; +declare const DropdownMenuPortal: React.FC; +declare const DropdownMenuSub: React.FC; +declare const DropdownMenuRadioGroup: React.ForwardRefExoticComponent>; +declare const DropdownMenuSubTrigger: React.ForwardRefExoticComponent, "ref"> & { + inset?: boolean; +} & React.RefAttributes>; +declare const DropdownMenuSubContent: React.ForwardRefExoticComponent, "ref"> & React.RefAttributes>; +declare const DropdownMenuContent: React.ForwardRefExoticComponent, "ref"> & React.RefAttributes>; +declare const DropdownMenuItem: React.ForwardRefExoticComponent, "ref"> & { + inset?: boolean; +} & React.RefAttributes>; +declare const DropdownMenuCheckboxItem: React.ForwardRefExoticComponent, "ref"> & React.RefAttributes>; +declare const DropdownMenuRadioItem: React.ForwardRefExoticComponent, "ref"> & React.RefAttributes>; +declare const DropdownMenuLabel: React.ForwardRefExoticComponent, "ref"> & { + inset?: boolean; +} & React.RefAttributes>; +declare const DropdownMenuSeparator: React.ForwardRefExoticComponent, "ref"> & React.RefAttributes>; +declare const DropdownMenuShortcut: { + ({ className, ...props }: React.HTMLAttributes): import("react/jsx-runtime").JSX.Element; + displayName: string; +}; +export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuGroup, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup, }; diff --git a/viewer/src/components/ui/dropdown-menu.js b/viewer/src/components/ui/dropdown-menu.js new file mode 100644 index 0000000..bec1dc7 --- /dev/null +++ b/viewer/src/components/ui/dropdown-menu.js @@ -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, }; diff --git a/viewer/src/components/ui/input.d.ts b/viewer/src/components/ui/input.d.ts new file mode 100644 index 0000000..ab9e938 --- /dev/null +++ b/viewer/src/components/ui/input.d.ts @@ -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 }; diff --git a/viewer/src/components/ui/input.js b/viewer/src/components/ui/input.js new file mode 100644 index 0000000..9675ad4 --- /dev/null +++ b/viewer/src/components/ui/input.js @@ -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 }; diff --git a/viewer/src/components/ui/label.d.ts b/viewer/src/components/ui/label.d.ts new file mode 100644 index 0000000..7ae35df --- /dev/null +++ b/viewer/src/components/ui/label.d.ts @@ -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, "ref"> & VariantProps<(props?: import("class-variance-authority/types").ClassProp | undefined) => string> & React.RefAttributes>; +export { Label }; diff --git a/viewer/src/components/ui/label.js b/viewer/src/components/ui/label.js new file mode 100644 index 0000000..ec2a55d --- /dev/null +++ b/viewer/src/components/ui/label.js @@ -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 }; diff --git a/viewer/src/components/ui/popover.d.ts b/viewer/src/components/ui/popover.d.ts new file mode 100644 index 0000000..7234b16 --- /dev/null +++ b/viewer/src/components/ui/popover.d.ts @@ -0,0 +1,7 @@ +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; +declare const Popover: React.FC; +declare const PopoverTrigger: React.ForwardRefExoticComponent>; +declare const PopoverAnchor: React.ForwardRefExoticComponent>; +declare const PopoverContent: React.ForwardRefExoticComponent, "ref"> & React.RefAttributes>; +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/viewer/src/components/ui/popover.js b/viewer/src/components/ui/popover.js new file mode 100644 index 0000000..af3ac5b --- /dev/null +++ b/viewer/src/components/ui/popover.js @@ -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 }; diff --git a/viewer/src/components/ui/select.d.ts b/viewer/src/components/ui/select.d.ts new file mode 100644 index 0000000..56e04ae --- /dev/null +++ b/viewer/src/components/ui/select.d.ts @@ -0,0 +1,13 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +declare const Select: React.FC; +declare const SelectGroup: React.ForwardRefExoticComponent>; +declare const SelectValue: React.ForwardRefExoticComponent>; +declare const SelectTrigger: React.ForwardRefExoticComponent, "ref"> & React.RefAttributes>; +declare const SelectScrollUpButton: React.ForwardRefExoticComponent, "ref"> & React.RefAttributes>; +declare const SelectScrollDownButton: React.ForwardRefExoticComponent, "ref"> & React.RefAttributes>; +declare const SelectContent: React.ForwardRefExoticComponent, "ref"> & React.RefAttributes>; +declare const SelectLabel: React.ForwardRefExoticComponent, "ref"> & React.RefAttributes>; +declare const SelectItem: React.ForwardRefExoticComponent, "ref"> & React.RefAttributes>; +declare const SelectSeparator: React.ForwardRefExoticComponent, "ref"> & React.RefAttributes>; +export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton, }; diff --git a/viewer/src/components/ui/select.js b/viewer/src/components/ui/select.js new file mode 100644 index 0000000..eba403b --- /dev/null +++ b/viewer/src/components/ui/select.js @@ -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, }; diff --git a/viewer/src/components/ui/separator.d.ts b/viewer/src/components/ui/separator.d.ts new file mode 100644 index 0000000..1c83f73 --- /dev/null +++ b/viewer/src/components/ui/separator.d.ts @@ -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): import("react/jsx-runtime").JSX.Element; +export { Separator }; diff --git a/viewer/src/components/ui/separator.js b/viewer/src/components/ui/separator.js new file mode 100644 index 0000000..f44edcf --- /dev/null +++ b/viewer/src/components/ui/separator.js @@ -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 }; diff --git a/viewer/src/components/ui/table.d.ts b/viewer/src/components/ui/table.d.ts new file mode 100644 index 0000000..543d98f --- /dev/null +++ b/viewer/src/components/ui/table.d.ts @@ -0,0 +1,10 @@ +import * as React from "react"; +declare const Table: React.ForwardRefExoticComponent & React.RefAttributes>; +declare const TableHeader: React.ForwardRefExoticComponent & React.RefAttributes>; +declare const TableBody: React.ForwardRefExoticComponent & React.RefAttributes>; +declare const TableFooter: React.ForwardRefExoticComponent & React.RefAttributes>; +declare const TableRow: React.ForwardRefExoticComponent & React.RefAttributes>; +declare const TableHead: React.ForwardRefExoticComponent & React.RefAttributes>; +declare const TableCell: React.ForwardRefExoticComponent & React.RefAttributes>; +declare const TableCaption: React.ForwardRefExoticComponent & React.RefAttributes>; +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption, }; diff --git a/viewer/src/components/ui/table.js b/viewer/src/components/ui/table.js new file mode 100644 index 0000000..7ce5ea3 --- /dev/null +++ b/viewer/src/components/ui/table.js @@ -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, }; diff --git a/viewer/src/components/ui/textarea.d.ts b/viewer/src/components/ui/textarea.d.ts new file mode 100644 index 0000000..7520a0f --- /dev/null +++ b/viewer/src/components/ui/textarea.d.ts @@ -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 }; diff --git a/viewer/src/components/ui/textarea.js b/viewer/src/components/ui/textarea.js new file mode 100644 index 0000000..f4e8f8e --- /dev/null +++ b/viewer/src/components/ui/textarea.js @@ -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 }; diff --git a/viewer/src/index.css b/viewer/src/index.css index 33b2b0f..d20a915 100644 --- a/viewer/src/index.css +++ b/viewer/src/index.css @@ -1,121 +1,74 @@ -@import "tw-animate-css"; - -@custom-variant dark (&:is(.dark *)); - @tailwind base; @tailwind components; @tailwind utilities; -@theme inline { - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); -} +@layer base { + :root { + --radius: 0.625rem; + /* Laracon Light Theme */ + --background: oklch(0.98 0.01 240); /* Very light gray */ + --foreground: oklch(0.2 0.02 240); /* Dark gray */ + --card: oklch(1 0 0); /* White */ + --card-foreground: oklch(0.2 0.02 240); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.2 0.02 240); + --primary: oklch(0.65 0.22 25); /* Laracon Red-Orange */ + --primary-foreground: oklch(0.98 0.01 240); + --secondary: oklch(0.94 0.02 240); + --secondary-foreground: oklch(0.2 0.02 240); + --muted: oklch(0.94 0.02 240); + --muted-foreground: oklch(0.5 0.02 240); + --accent: oklch(0.96 0.02 240); + --accent-foreground: oklch(0.2 0.02 240); + --destructive: oklch(0.65 0.22 25); + --border: oklch(0.9 0.02 240); + --input: oklch(0.9 0.02 240); + --ring: oklch(0.65 0.22 25); + --sidebar: oklch(0.98 0.01 240); + --sidebar-foreground: oklch(0.2 0.02 240); + --sidebar-primary: oklch(0.65 0.22 25); + --sidebar-primary-foreground: oklch(0.98 0.01 240); + --sidebar-accent: oklch(0.94 0.02 240); + --sidebar-accent-foreground: oklch(0.2 0.02 240); + --sidebar-border: oklch(0.9 0.02 240); + --sidebar-ring: oklch(0.65 0.22 25); + } -:root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); -} - -.dark { - --background: oklch(0.22 0.03 265); /* Dracula Background */ - --foreground: oklch(0.97 0.01 265); /* Dracula Foreground */ - --card: oklch(0.31 0.04 265); /* Dracula Current Line */ - --card-foreground: oklch(0.97 0.01 265); /* Dracula Foreground */ - --popover: oklch(0.31 0.04 265); /* Dracula Current Line - Opaque */ - --popover-foreground: oklch(0.97 0.01 265); /* Dracula Foreground */ - --primary: oklch(0.7 0.15 290); /* Dracula Purple */ - --primary-foreground: oklch(0.22 0.03 265); /* Dracula Background for contrast */ - --secondary: oklch(0.45 0.1 265); /* Dracula Comment */ - --secondary-foreground: oklch(0.97 0.01 265); /* Dracula Foreground */ - --muted: oklch(0.31 0.04 265); - --muted-foreground: oklch(0.65 0.05 265); - --accent: oklch(0.8 0.15 320); /* Dracula Pink */ - --accent-foreground: oklch(0.22 0.03 265); - --destructive: oklch(0.65 0.2 15); /* Dracula Red */ - --border: oklch(0.45 0.1 265 / 0.5); /* Dracula Comment with transparency */ - --input: oklch(0.45 0.1 265 / 0.5); /* Dracula Comment with transparency */ - --ring: oklch(0.7 0.15 290); /* Dracula Purple */ - --chart-1: oklch(0.85 0.15 180); /* Dracula Cyan */ - --chart-2: oklch(0.8 0.2 130); /* Dracula Green */ - --chart-3: oklch(0.8 0.15 80); /* Dracula Orange */ - --chart-4: oklch(0.8 0.15 320); /* Dracula Pink */ - --chart-5: oklch(0.9 0.1 90); /* Dracula Yellow */ - --sidebar: oklch(0.22 0.03 265); - --sidebar-foreground: oklch(0.97 0.01 265); - --sidebar-primary: oklch(0.7 0.15 290); - --sidebar-primary-foreground: oklch(0.22 0.03 265); - --sidebar-accent: oklch(0.31 0.04 265); - --sidebar-accent-foreground: oklch(0.97 0.01 265); - --sidebar-border: oklch(0.45 0.1 265 / 0.5); - --sidebar-ring: oklch(0.7 0.15 290); + .dark { + /* Custom Dark Theme based on user's CSS */ + --background: oklch(0.15 0.02 240); /* Very dark blue-gray */ + --foreground: oklch(0.95 0.01 240); /* Light gray */ + --card: oklch(0.2 0.02 240); + --card-foreground: oklch(0.95 0.01 240); + --popover: oklch(0.2 0.02 240); + --popover-foreground: oklch(0.95 0.01 240); + --primary: oklch(0.6 0.11 220); /* Vivid Blue from user's CSS */ + --primary-foreground: oklch(0.98 0.01 240); + --secondary: oklch(0.3 0.02 240); + --secondary-foreground: oklch(0.95 0.01 240); + --muted: oklch(0.3 0.02 240); + --muted-foreground: oklch(0.6 0.02 240); + --accent: oklch(0.4 0.05 230); + --accent-foreground: oklch(0.95 0.01 240); + --destructive: oklch(0.65 0.2 15); /* Red */ + --border: oklch(0.3 0.02 240); + --input: oklch(0.3 0.02 240); + --ring: oklch(0.6 0.11 220); + --sidebar: oklch(0.15 0.02 240); + --sidebar-foreground: oklch(0.95 0.01 240); + --sidebar-primary: oklch(0.6 0.11 220); + --sidebar-primary-foreground: oklch(0.98 0.01 240); + --sidebar-accent: oklch(0.3 0.02 240); + --sidebar-accent-foreground: oklch(0.95 0.01 240); + --sidebar-border: oklch(0.3 0.02 240); + --sidebar-ring: oklch(0.6 0.11 220); + } } @layer base { * { - @apply border-border outline-ring/50; + @apply border-border; + outline-color: oklch(var(--ring) / 0.5); } body { @apply bg-background text-foreground; diff --git a/viewer/src/lib/utils.d.ts b/viewer/src/lib/utils.d.ts new file mode 100644 index 0000000..d219a42 --- /dev/null +++ b/viewer/src/lib/utils.d.ts @@ -0,0 +1,2 @@ +import { type ClassValue } from "clsx"; +export declare function cn(...inputs: ClassValue[]): string; diff --git a/viewer/src/lib/utils.js b/viewer/src/lib/utils.js new file mode 100644 index 0000000..c3725b1 --- /dev/null +++ b/viewer/src/lib/utils.js @@ -0,0 +1,5 @@ +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +export function cn(...inputs) { + return twMerge(clsx(inputs)); +} diff --git a/viewer/src/main.d.ts b/viewer/src/main.d.ts new file mode 100644 index 0000000..3a29aed --- /dev/null +++ b/viewer/src/main.d.ts @@ -0,0 +1 @@ +import "./index.css"; diff --git a/viewer/src/main.js b/viewer/src/main.js new file mode 100644 index 0000000..da59726 --- /dev/null +++ b/viewer/src/main.js @@ -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, {}) }) })); diff --git a/viewer/src/pages/FeedbackCreatePage.d.ts b/viewer/src/pages/FeedbackCreatePage.d.ts new file mode 100644 index 0000000..62e356a --- /dev/null +++ b/viewer/src/pages/FeedbackCreatePage.d.ts @@ -0,0 +1 @@ +export declare function FeedbackCreatePage(): import("react/jsx-runtime").JSX.Element; diff --git a/viewer/src/pages/FeedbackCreatePage.js b/viewer/src/pages/FeedbackCreatePage.js new file mode 100644 index 0000000..b1dbd94 --- /dev/null +++ b/viewer/src/pages/FeedbackCreatePage.js @@ -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 }))] })] })); +} diff --git a/viewer/src/pages/FeedbackDetailPage.d.ts b/viewer/src/pages/FeedbackDetailPage.d.ts new file mode 100644 index 0000000..f4961f4 --- /dev/null +++ b/viewer/src/pages/FeedbackDetailPage.d.ts @@ -0,0 +1 @@ +export declare function FeedbackDetailPage(): import("react/jsx-runtime").JSX.Element; diff --git a/viewer/src/pages/FeedbackDetailPage.js b/viewer/src/pages/FeedbackDetailPage.js new file mode 100644 index 0000000..a0ee16e --- /dev/null +++ b/viewer/src/pages/FeedbackDetailPage.js @@ -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 }))] })] })); +} diff --git a/viewer/src/pages/FeedbackListPage.d.ts b/viewer/src/pages/FeedbackListPage.d.ts new file mode 100644 index 0000000..2269787 --- /dev/null +++ b/viewer/src/pages/FeedbackListPage.d.ts @@ -0,0 +1 @@ +export declare function FeedbackListPage(): import("react/jsx-runtime").JSX.Element; diff --git a/viewer/src/pages/FeedbackListPage.js b/viewer/src/pages/FeedbackListPage.js new file mode 100644 index 0000000..a3a8583 --- /dev/null +++ b/viewer/src/pages/FeedbackListPage.js @@ -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 })] })); +} diff --git a/viewer/src/pages/IssueViewerPage.d.ts b/viewer/src/pages/IssueViewerPage.d.ts new file mode 100644 index 0000000..5b75486 --- /dev/null +++ b/viewer/src/pages/IssueViewerPage.d.ts @@ -0,0 +1 @@ +export declare function IssueViewerPage(): import("react/jsx-runtime").JSX.Element; diff --git a/viewer/src/pages/IssueViewerPage.js b/viewer/src/pages/IssueViewerPage.js new file mode 100644 index 0000000..425cd5c --- /dev/null +++ b/viewer/src/pages/IssueViewerPage.js @@ -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." }) })) })] }) }))] })] })] })); +} diff --git a/viewer/src/pages/IssueViewerPage.tsx b/viewer/src/pages/IssueViewerPage.tsx index f866a1d..ba06817 100644 --- a/viewer/src/pages/IssueViewerPage.tsx +++ b/viewer/src/pages/IssueViewerPage.tsx @@ -50,7 +50,7 @@ export function IssueViewerPage() {

- {error && } + {error && } diff --git a/viewer/src/services/error.d.ts b/viewer/src/services/error.d.ts new file mode 100644 index 0000000..91ffbbc --- /dev/null +++ b/viewer/src/services/error.d.ts @@ -0,0 +1,6 @@ +/** + * API 요청 실패 시 공통으로 사용할 에러 처리 함수 + * @param message 프로덕션 환경에서 보여줄 기본 에러 메시지 + * @param response fetch API의 응답 객체 + */ +export declare const handleApiError: (message: string, response: Response) => Promise; diff --git a/viewer/src/services/error.js b/viewer/src/services/error.js new file mode 100644 index 0000000..96f6866 --- /dev/null +++ b/viewer/src/services/error.js @@ -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); +}; diff --git a/viewer/src/services/feedback.d.ts b/viewer/src/services/feedback.d.ts new file mode 100644 index 0000000..cd221c8 --- /dev/null +++ b/viewer/src/services/feedback.d.ts @@ -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; +/** + * 특정 채널의 동적 폼 필드 스키마를 조회합니다. + */ +export declare const getFeedbackFields: (projectId: string, channelId: string) => Promise; +/** + * 특정 채널에 새로운 피드백을 생성합니다. + */ +export declare const createFeedback: (projectId: string, channelId: string, feedbackData: CreateFeedbackRequest) => Promise; +/** + * 프로젝트의 이슈를 검색합니다. + */ +export declare const searchIssues: (projectId: string, query: string) => Promise; +/** + * 특정 ID의 피드백 상세 정보를 조회합니다. + */ +export declare const getFeedbackById: (projectId: string, channelId: string, feedbackId: string) => Promise; +/** + * 특정 피드백을 수정합니다. + */ +export declare const updateFeedback: (projectId: string, channelId: string, feedbackId: string, feedbackData: Partial) => Promise; diff --git a/viewer/src/services/feedback.js b/viewer/src/services/feedback.js new file mode 100644 index 0000000..83e671d --- /dev/null +++ b/viewer/src/services/feedback.js @@ -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(); +}; diff --git a/viewer/src/services/issue.d.ts b/viewer/src/services/issue.d.ts new file mode 100644 index 0000000..287661d --- /dev/null +++ b/viewer/src/services/issue.d.ts @@ -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; diff --git a/viewer/src/services/issue.js b/viewer/src/services/issue.js new file mode 100644 index 0000000..d5f6873 --- /dev/null +++ b/viewer/src/services/issue.js @@ -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 || []; +}; diff --git a/viewer/src/services/project.d.ts b/viewer/src/services/project.d.ts new file mode 100644 index 0000000..04a4c40 --- /dev/null +++ b/viewer/src/services/project.d.ts @@ -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; +/** + * 특정 ID를 가진 프로젝트의 상세 정보를 가져옵니다. + * @param id - 조회할 프로젝트의 ID + */ +export declare const getProjectById: (id: string) => Promise; diff --git a/viewer/src/services/project.js b/viewer/src/services/project.js new file mode 100644 index 0000000..3da9fda --- /dev/null +++ b/viewer/src/services/project.js @@ -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; + } +}; diff --git a/viewer/src/services/project.ts b/viewer/src/services/project.ts new file mode 100644 index 0000000..daca395 --- /dev/null +++ b/viewer/src/services/project.ts @@ -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 => { + 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 => { + 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; + } +}; diff --git a/viewer/src/store/useSettingsStore.d.ts b/viewer/src/store/useSettingsStore.d.ts new file mode 100644 index 0000000..b95f41d --- /dev/null +++ b/viewer/src/store/useSettingsStore.d.ts @@ -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, "persist"> & { + persist: { + setOptions: (options: Partial>) => void; + clearStorage: () => void; + rehydrate: () => Promise | void; + hasHydrated: () => boolean; + onHydrate: (fn: (state: SettingsState) => void) => () => void; + onFinishHydration: (fn: (state: SettingsState) => void) => () => void; + getOptions: () => Partial>; + }; +}>; +export {}; diff --git a/viewer/src/store/useSettingsStore.js b/viewer/src/store/useSettingsStore.js new file mode 100644 index 0000000..331e6df --- /dev/null +++ b/viewer/src/store/useSettingsStore.js @@ -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에 저장될 이름 +})); diff --git a/viewer/src/store/useSettingsStore.ts b/viewer/src/store/useSettingsStore.ts new file mode 100644 index 0000000..26bc2c4 --- /dev/null +++ b/viewer/src/store/useSettingsStore.ts @@ -0,0 +1,25 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +type Theme = "light" | "dark" | "system"; + +interface SettingsState { + projectId: string | null; + theme: Theme; + setProjectId: (projectId: string) => void; + setTheme: (theme: Theme) => void; +} + +export const useSettingsStore = create()( + persist( + (set) => ({ + projectId: "1", // 기본 프로젝트 ID를 1로 설정 + theme: "system", + setProjectId: (projectId) => set({ projectId }), + setTheme: (theme) => set({ theme }), + }), + { + name: "settings-storage", // localStorage에 저장될 이름 + }, + ), +); diff --git a/viewer/tailwind.config.ts b/viewer/tailwind.config.ts index 59b32e4..21d744d 100644 --- a/viewer/tailwind.config.ts +++ b/viewer/tailwind.config.ts @@ -19,38 +19,38 @@ const config: Config = { }, extend: { colors: { - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", + border: "oklch(var(--border))", + input: "oklch(var(--input))", + ring: "oklch(var(--ring))", + background: "oklch(var(--background))", + foreground: "oklch(var(--foreground))", primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", + DEFAULT: "oklch(var(--primary))", + foreground: "oklch(var(--primary-foreground))", }, secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", + DEFAULT: "oklch(var(--secondary))", + foreground: "oklch(var(--secondary-foreground))", }, destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", + DEFAULT: "oklch(var(--destructive))", + foreground: "oklch(var(--destructive-foreground))", }, muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", + DEFAULT: "oklch(var(--muted))", + foreground: "oklch(var(--muted-foreground))", }, accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", + DEFAULT: "oklch(var(--accent))", + foreground: "oklch(var(--accent-foreground))", }, popover: { DEFAULT: "oklch(var(--popover))", foreground: "oklch(var(--popover-foreground))", }, card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", + DEFAULT: "oklch(var(--card))", + foreground: "oklch(var(--card-foreground))", }, }, borderRadius: { @@ -77,4 +77,4 @@ const config: Config = { plugins: [tailwindcssAnimate], }; -export default config; \ No newline at end of file +export default config; diff --git a/viewer/tsconfig.json b/viewer/tsconfig.json index ee82e04..ee42bf1 100644 --- a/viewer/tsconfig.json +++ b/viewer/tsconfig.json @@ -27,6 +27,5 @@ ] } }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + "include": ["src"] } \ No newline at end of file diff --git a/viewer/tsconfig.node.json b/viewer/tsconfig.node.json index 1a5ed45..36bbe69 100644 --- a/viewer/tsconfig.node.json +++ b/viewer/tsconfig.node.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "target": "ES2023", "lib": ["ES2023"], diff --git a/viewer/tsconfig.tsbuildinfo b/viewer/tsconfig.tsbuildinfo new file mode 100644 index 0000000..3c350f3 --- /dev/null +++ b/viewer/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file diff --git a/viewer/vite.config.d.ts b/viewer/vite.config.d.ts new file mode 100644 index 0000000..089eeef --- /dev/null +++ b/viewer/vite.config.d.ts @@ -0,0 +1,2 @@ +declare const _default: import("vite").UserConfigFnObject; +export default _default; diff --git a/viewer/vite.config.js b/viewer/vite.config.js new file mode 100644 index 0000000..2b135d8 --- /dev/null +++ b/viewer/vite.config.js @@ -0,0 +1,36 @@ +import path from "path"; +import react from "@vitejs/plugin-react"; +import { defineConfig, loadEnv } from "vite"; +import tailwindcss from "tailwindcss"; +import autoprefixer from "autoprefixer"; +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + return { + plugins: [react()], + css: { + postcss: { + plugins: [tailwindcss, autoprefixer], + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + proxy: { + // 나머지 /api 경로 처리 + "/api": { + target: env.VITE_API_PROXY_TARGET, + changeOrigin: true, + configure: (proxy, _options) => { + proxy.on("proxyReq", (proxyReq, _req, _res) => { + proxyReq.setHeader("X-Api-Key", env.VITE_API_KEY); + proxyReq.removeHeader("cookie"); + }); + } + } + } + } + }; +});