From 2095001ff3d98f22e3292b6e16a45b4057f8c00b Mon Sep 17 00:00:00 2001 From: jaeml06 Date: Tue, 18 Nov 2025 14:12:05 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=ED=83=80=EC=9D=B4=EB=A8=B8=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=8B=9C=200=EB=B6=84=200=EC=B4=88=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#399)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/page/TimerPage/FeedbackTimerPage.tsx | 10 ---------- src/page/TimerPage/hooks/useFeedbackTimer.ts | 10 +++++----- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/page/TimerPage/FeedbackTimerPage.tsx b/src/page/TimerPage/FeedbackTimerPage.tsx index cbb607bf..1bf02ccd 100644 --- a/src/page/TimerPage/FeedbackTimerPage.tsx +++ b/src/page/TimerPage/FeedbackTimerPage.tsx @@ -1,20 +1,10 @@ -import { useEffect } from 'react'; import { useFeedbackTimer } from './hooks/useFeedbackTimer'; import FeedbackTimer from './components/FeedbackTimer'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import GoToHomeButton from '../../components/GoToHomeButton/GoToHomeButton'; -const INITIAL_TIME = 0; - export default function FeedbackTimerPage() { const feedbackTimerInstance = useFeedbackTimer(); - const { setTimer, setDefaultTimer } = feedbackTimerInstance; - - useEffect(() => { - // 페이지가 로드될 때 타이머의 초기 시간을 설정 - setTimer(INITIAL_TIME); - setDefaultTimer(INITIAL_TIME); - }, [setTimer, setDefaultTimer]); return ( diff --git a/src/page/TimerPage/hooks/useFeedbackTimer.ts b/src/page/TimerPage/hooks/useFeedbackTimer.ts index d3df0cc3..1740fcb2 100644 --- a/src/page/TimerPage/hooks/useFeedbackTimer.ts +++ b/src/page/TimerPage/hooks/useFeedbackTimer.ts @@ -6,15 +6,15 @@ import { useRef, useState, } from 'react'; - +const INITIAL_TIME = 0; export function useFeedbackTimer(): FeedbackTimerLogics { // 타이머에 표시되는 현재 남은 시간 - const [timer, setTimer] = useState(null); + const [timer, setTimer] = useState(INITIAL_TIME); // 타이머 요청에 대한 ID를 저장 const intervalRef = useRef(null); // 타이머의 총 시간 - const [defaultTimer, setDefaultTimer] = useState(0); + const [defaultTimer, setDefaultTimer] = useState(INITIAL_TIME); // 타이머가 현재 동작중인지 여부 const [isRunning, setIsRunning] = useState(false); @@ -72,9 +72,9 @@ export function useFeedbackTimer(): FeedbackTimerLogics { clearInterval(intervalRef.current); intervalRef.current = null; } - setTimer(defaultTimer); + setTimer(INITIAL_TIME); setIsRunning(false); - }, [defaultTimer]); + }, []); /** * 타이머 시간을 조정 (타이머가 멈춰있을 때만) From f2c222e8d30d02bd0dd3486d2938cc0ad3a94250 Mon Sep 17 00:00:00 2001 From: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com> Date: Mon, 24 Nov 2025 21:13:55 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[FEAT]=20=ED=86=A0=EB=A1=A0=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=ED=99=94=EB=A9=B4=20=EB=8F=99=EC=9E=91=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20(#401)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 시간표로 돌아가기 버튼 구현 * feat: 토론 종료 화면에 시간으로 돌아가기 버튼 추가 * feat: 토론 종료 화면으로 돌아가기 버튼 구현 * feat: 피드백 타이머에 돌아가기 버튼 추가 * feat: 투표 URL이 테이블 ID도 표기하도록 수정 * feat: URL 변경 사항 관련 페이지에 반영 * refactor: 버튼들이 `className` 받을 수 있게 개선 * refactor: 버튼 변경 사항 반영 * fix: 불필요한 배경색 삭제 * chore: 더 이상 사용되지 않는 파일 삭제 * design: 써니 의견에 따라 아이콘 변경 * fix: 라우팅 테이블과 실제 페이지 간 경로 다른 문제 수정 * refactor: 테이블 ID 검증 로직 보강 * fix: 쿼리 실행 조건 수정 * fix: 매개변수 이름 수정 * fix: VoteDetailResult 모달에서 버튼이 제대로 눌리지 않는 문제 수정 * fix: 핸들링 함수 중첩되어 사용된 문제 수정 * design: 뒤로 가기 버튼 화살표 방향 수정 * feat: 투표 결과 화면에 토론 종료 화면으로 돌아가기 버튼 추가 * design: 버튼 위치 시안에 맞게 변경 * feat: 투표 세부 결과 페이지에 확인 모달 추가 * refactor: 투표 결과 화면 스타일 태그 일부 수정 * fix: `tableId` 잘못 기입되어 있던 문제 수정 * feat: 투표 결과 화면 뒤로 가기 버튼이 뒤로 가도록 변경 * fix: 불필요한 참조 제거 --- .../GoToDebateEndButton.tsx | 31 ++++++++ .../GoToHomeButton/GoToHomeButton.tsx | 20 ----- src/page/DebateEndPage/DebateEndPage.tsx | 21 ++--- .../components/GoToOverviewButton.tsx | 33 ++++++++ src/page/DebateVotePage/DebateVotePage.tsx | 36 ++++----- .../DebateVoteResultPage.tsx | 76 +++++++++++++------ .../components/VoteDetailResult.tsx | 14 ++-- src/page/TimerPage/FeedbackTimerPage.tsx | 12 ++- src/routes/routes.tsx | 4 +- 9 files changed, 165 insertions(+), 82 deletions(-) create mode 100644 src/components/GoToDebateEndButton/GoToDebateEndButton.tsx delete mode 100644 src/components/GoToHomeButton/GoToHomeButton.tsx create mode 100644 src/page/DebateEndPage/components/GoToOverviewButton.tsx diff --git a/src/components/GoToDebateEndButton/GoToDebateEndButton.tsx b/src/components/GoToDebateEndButton/GoToDebateEndButton.tsx new file mode 100644 index 00000000..a94bbc4f --- /dev/null +++ b/src/components/GoToDebateEndButton/GoToDebateEndButton.tsx @@ -0,0 +1,31 @@ +import clsx from 'clsx'; +import { useNavigate } from 'react-router-dom'; + +interface GoToDebateEndButtonProps { + tableId: number; + className?: string; +} + +export default function GoToDebateEndButton({ + tableId, + className = '', +}: GoToDebateEndButtonProps) { + const navigate = useNavigate(); + const handleClick = (tableId: number) => { + navigate(`/table/customize/${tableId}/end`); + }; + + return ( + + ); +} diff --git a/src/components/GoToHomeButton/GoToHomeButton.tsx b/src/components/GoToHomeButton/GoToHomeButton.tsx deleted file mode 100644 index 5d80226b..00000000 --- a/src/components/GoToHomeButton/GoToHomeButton.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useNavigate } from 'react-router-dom'; - -export default function GoToHomeButton() { - const navigate = useNavigate(); - - const handleClick = () => { - navigate('/'); - }; - - return ( - - ); -} diff --git a/src/page/DebateEndPage/DebateEndPage.tsx b/src/page/DebateEndPage/DebateEndPage.tsx index bb535cf2..c695f400 100644 --- a/src/page/DebateEndPage/DebateEndPage.tsx +++ b/src/page/DebateEndPage/DebateEndPage.tsx @@ -3,27 +3,33 @@ import { useNavigate, useParams } from 'react-router-dom'; import clapImage from '../../assets/debateEnd/clap.png'; import feedbackTimerImage from '../../assets/debateEnd/feedback_timer.png'; import voteStampImage from '../../assets/debateEnd/vote_stamp.png'; -import GoToHomeButton from '../../components/GoToHomeButton/GoToHomeButton'; import usePostPoll from '../../hooks/mutations/useCreatePoll'; import MenuCard from './components/MenuCard'; +import GoToOverviewButton from './components/GoToOverviewButton'; export default function DebateEndPage() { - const { id: tableId } = useParams(); + const { id } = useParams(); + const tableId = Number(id); const navigate = useNavigate(); const handleFeedbackClick = () => { navigate(`/table/customize/${tableId}/end/feedback`); }; - const handleVoteClick = (pollId: number) => { - navigate(`/table/customize/${pollId}/end/vote`); + navigate(`/table/customize/${tableId}/end/vote/${pollId}`); }; const { mutate } = usePostPoll(handleVoteClick); + const backgroundStyle = { background: 'radial-gradient(50% 50% at 50% 50%, #fecd4c21 0%, #ffffff42 100%)', }; + // 테이블 ID 검증 + if (!id || isNaN(tableId)) { + throw new Error('테이블 ID가 올바르지 않습니다.'); + } + return (
{ - if (!tableId) return; // NaN 방지 - mutate(Number(tableId)); - }} + onClick={() => mutate(tableId)} ariaLabel="승패투표 생성 및 진행" />
- +
); diff --git a/src/page/DebateEndPage/components/GoToOverviewButton.tsx b/src/page/DebateEndPage/components/GoToOverviewButton.tsx new file mode 100644 index 00000000..6300edf7 --- /dev/null +++ b/src/page/DebateEndPage/components/GoToOverviewButton.tsx @@ -0,0 +1,33 @@ +import clsx from 'clsx'; +import { RiCalendarScheduleLine } from 'react-icons/ri'; +import { useNavigate } from 'react-router-dom'; + +interface GoToOverviewButtonProps { + tableId: number; + className?: string; +} + +export default function GoToOverviewButton({ + tableId, + className = '', +}: GoToOverviewButtonProps) { + const navigate = useNavigate(); + const handleClick = (tableId: number) => { + navigate(`/overview/customize/${tableId}`); + }; + + return ( + + ); +} diff --git a/src/page/DebateVotePage/DebateVotePage.tsx b/src/page/DebateVotePage/DebateVotePage.tsx index dd08bcb1..70061bdc 100644 --- a/src/page/DebateVotePage/DebateVotePage.tsx +++ b/src/page/DebateVotePage/DebateVotePage.tsx @@ -5,26 +5,30 @@ import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import { useGetPollInfo } from '../../hooks/query/useGetPollInfo'; import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; import useFetchEndPoll from '../../hooks/mutations/useFetchEndPoll'; +import GoToDebateEndButton from '../../components/GoToDebateEndButton/GoToDebateEndButton'; export default function DebateVotePage() { - const { id: pollIdParam } = useParams(); - const pollId = pollIdParam ? Number(pollIdParam) : NaN; - const isValidPollId = !!pollIdParam && !Number.isNaN(pollId); const navigate = useNavigate(); const baseUrl = import.meta.env.MODE !== 'production' ? undefined : import.meta.env.VITE_SHARE_BASE_URL; + + // 매개변수 검증 + const { pollId: rawPollId, tableId: rawTableId } = useParams(); + const pollId = rawPollId ? Number(rawPollId) : NaN; + const isPollIdValid = !!rawPollId && !Number.isNaN(pollId); + const tableId = rawTableId ? Number(rawTableId) : NaN; + const isTableIdValid = !!rawTableId && !Number.isNaN(tableId); + const isArgsValid = isPollIdValid && isTableIdValid; + const voteUrl = useMemo(() => { return `${baseUrl}/vote/${pollId}`; }, [baseUrl, pollId]); const handleGoToResult = () => { - navigate(`/table/customize/${pollId}/end/vote/result`); + navigate(`/table/customize/${tableId}/end/vote/${pollId}/result`); }; - const handleGoHome = () => { - navigate('/'); - }; const { data, isLoading: isFetching, @@ -32,7 +36,7 @@ export default function DebateVotePage() { isRefetching, refetch, isRefetchError, - } = useGetPollInfo(pollId, { refetchInterval: 5000, enabled: isValidPollId }); + } = useGetPollInfo(pollId, { refetchInterval: 5000, enabled: isArgsValid }); const { mutate } = useFetchEndPoll(handleGoToResult); const participants = data?.voterNames; @@ -48,7 +52,8 @@ export default function DebateVotePage() {
); } - if (!isValidPollId) { + + if (!isArgsValid) { return ( @@ -59,6 +64,7 @@ export default function DebateVotePage() { ); } + return ( @@ -108,21 +114,15 @@ export default function DebateVotePage() { -
+
+ -
diff --git a/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx b/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx index 69c19682..4c6cac66 100644 --- a/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx +++ b/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx @@ -7,11 +7,18 @@ import VoteDetailResult from './components/VoteDetailResult'; import { useGetPollInfo } from '../../hooks/query/useGetPollInfo'; import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; import { TeamKey } from '../../type/type'; +import { useState } from 'react'; +import DialogModal from '../../components/DialogModal/DialogModal'; export default function DebateVoteResultPage() { - const { id: pollIdParam } = useParams(); + // 매개변수 검증 + const { pollId: rawPollId, tableId: rawTableId } = useParams(); + const pollId = rawPollId ? Number(rawPollId) : NaN; + const isPollIdValid = !!rawPollId && !Number.isNaN(pollId); + const tableId = rawTableId ? Number(rawTableId) : NaN; + const isTableIdValid = !!rawTableId && !Number.isNaN(tableId); + const isArgsValid = isPollIdValid && isTableIdValid; - const pollId = pollIdParam ? Number(pollIdParam) : NaN; - const isValidPollId = !!pollIdParam && !Number.isNaN(pollId); + const [isConfirmed, setIsConfirmed] = useState(false); const navigate = useNavigate(); const { @@ -21,13 +28,15 @@ export default function DebateVoteResultPage() { isRefetching, refetch, isRefetchError, - } = useGetPollInfo(pollId, { enabled: isValidPollId }); + } = useGetPollInfo(pollId, { enabled: isArgsValid }); const handleGoHome = () => { navigate('/'); }; const isLoading = isFetching || isRefetching; const isError = isFetchError || isRefetchError; - const { openModal, ModalWrapper } = useModal(); + const { openModal, ModalWrapper, closeModal } = useModal({ + onClose: () => setIsConfirmed(false), + }); const getWinner = (result: { prosTeamName: string; @@ -55,7 +64,7 @@ export default function DebateVoteResultPage() { } }; - if (!isValidPollId) { + if (!isArgsValid) { return ( @@ -96,38 +105,57 @@ export default function DebateVoteResultPage() { -
+
- + {isConfirmed ? ( + + ) : ( + closeModal(), + }} + right={{ + text: '네', + onClick: () => setIsConfirmed(true), + isBold: true, + }} + > +
+ 정말로 세부 결과를 공개할까요? +
+
+ )}
); diff --git a/src/page/DebateVoteResultPage/components/VoteDetailResult.tsx b/src/page/DebateVoteResultPage/components/VoteDetailResult.tsx index 64eae086..e22a2bcf 100644 --- a/src/page/DebateVoteResultPage/components/VoteDetailResult.tsx +++ b/src/page/DebateVoteResultPage/components/VoteDetailResult.tsx @@ -44,14 +44,14 @@ export default function VoteDetailResult({ {/* 하단 CTA 바 */} -
- -
+ + ); diff --git a/src/page/TimerPage/FeedbackTimerPage.tsx b/src/page/TimerPage/FeedbackTimerPage.tsx index 1bf02ccd..d1f488cd 100644 --- a/src/page/TimerPage/FeedbackTimerPage.tsx +++ b/src/page/TimerPage/FeedbackTimerPage.tsx @@ -1,10 +1,18 @@ import { useFeedbackTimer } from './hooks/useFeedbackTimer'; import FeedbackTimer from './components/FeedbackTimer'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; -import GoToHomeButton from '../../components/GoToHomeButton/GoToHomeButton'; +import GoToDebateEndButton from '../../components/GoToDebateEndButton/GoToDebateEndButton'; +import { useParams } from 'react-router-dom'; export default function FeedbackTimerPage() { const feedbackTimerInstance = useFeedbackTimer(); + const { id } = useParams(); + const tableId = Number(id); + + // 테이블 ID 검증 로직 + if (!id || isNaN(tableId)) { + throw new Error('테이블 ID가 올바르지 않습니다.'); + } return ( @@ -13,7 +21,7 @@ export default function FeedbackTimerPage() {
- +
diff --git a/src/routes/routes.tsx b/src/routes/routes.tsx index 9bf5a5d5..6310ae14 100644 --- a/src/routes/routes.tsx +++ b/src/routes/routes.tsx @@ -55,12 +55,12 @@ const routesConfig = [ requiresAuth: true, }, { - path: '/table/customize/:id/end/vote', + path: '/table/customize/:tableId/end/vote/:pollId', element: , requiresAuth: true, }, { - path: '/table/customize/:id/end/vote/result', + path: '/table/customize/:tableId/end/vote/:pollId/result', element: , requiresAuth: true, }, From 3ad9bc86f5fa6e7a569c9b5df4ac40e198de5d21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuseon=20Kim=28=EC=8D=AC=EB=8D=B0=EC=9D=B4=29?= <74897720+useon@users.noreply.github.com> Date: Tue, 9 Dec 2025 19:33:54 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[FEAT]=20i18n=20=EC=9E=90=EB=8F=99=ED=99=94?= =?UTF-8?q?=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?(#402)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 국제화 라이브러리 설치 * feat: i18next 초기화 및 언어 감지/HTTP 백엔드 설정 * feat: 국제화 라우팅 래퍼 생성 * feat: 중첩 라우팅 구조로 변경 및 국제화 라우팅 래퍼 적용 * chore: 필요한 바벨 설정 및 스크립트 추가 * feat: 파일 관련 유틸 생성 * feat: ATS 관련 및 국제화 훅 추가, t wrapper 유틸 생성 * feat: 한글 키를 기준으로 각 언어 JSON에 키 추가 유틸 생성 * feat: React 컴포넌트의 한글 텍스트 자동 변환 기능 구현 * fix: 테스트 파일과 스토리북 파일이 포함되는 문제 해결 * refactor: 컴포넌트 판별 로직을 분리하여 여러 형태에 대응할 수 있도록 수정 * feat: 템플릿 리터럴 자동 변환 및 키 추출 기능 구현 * refactor: 컴포넌트 내부에 있는 한글 문자열만 변환되도록 수정 * refactor: 복잡한 확장자 처리를 위한 라이브러리 사용으로 더이상 사용하지 않는 확장자 찾는 함수 삭제 * fix: ts-node 실행 오류 해결 및 tsx 기반으로 스크립트 전환 * refactor: 불필요한 주석 삭제 --- package-lock.json | 1110 ++++++++++++++++++++++++++--- package.json | 16 +- scripts/i18nTransform.ts | 44 ++ scripts/utils/astUtils.ts | 280 ++++++++ scripts/utils/fileUtils.ts | 55 ++ scripts/utils/translationUtils.ts | 45 ++ src/i18n.ts | 31 + src/main.tsx | 1 + src/routes/LanguageWrapper.tsx | 20 + src/routes/routes.tsx | 59 +- 10 files changed, 1523 insertions(+), 138 deletions(-) create mode 100644 scripts/i18nTransform.ts create mode 100644 scripts/utils/astUtils.ts create mode 100644 scripts/utils/fileUtils.ts create mode 100644 scripts/utils/translationUtils.ts create mode 100644 src/i18n.ts create mode 100644 src/routes/LanguageWrapper.tsx diff --git a/package-lock.json b/package-lock.json index 1bbc07d4..7e082515 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,16 +13,24 @@ "axios": "^1.7.9", "clsx": "^2.1.1", "framer-motion": "^12.23.11", + "i18next": "^25.5.2", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "pako": "^2.1.0", "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-ga4": "^2.1.0", + "react-i18next": "^15.7.3", "react-icons": "^5.4.0", "react-router-dom": "^7.1.0", "vite-bundle-visualizer": "^1.2.1" }, "devDependencies": { + "@babel/generator": "^7.28.3", + "@babel/parser": "^7.28.4", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", "@chromatic-com/storybook": "^3.2.2", "@eslint/js": "^9.15.0", "@storybook/addon-essentials": "^8.6.0", @@ -36,7 +44,10 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", + "@types/babel__traverse": "^7.28.0", + "@types/glob": "^8.1.0", "@types/jest": "^29.5.14", + "@types/node": "^24.6.0", "@types/pako": "^2.0.3", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", @@ -54,6 +65,7 @@ "eslint-plugin-react-refresh": "^0.4.14", "eslint-plugin-storybook": "^0.11.1", "eslint-plugin-tailwindcss": "^3.17.5", + "glob": "^11.0.3", "globals": "^15.12.0", "jsdom": "^25.0.1", "msw": "^2.7.0", @@ -66,6 +78,7 @@ "stylelint-config-recommended": "^14.0.1", "stylelint-config-tailwindcss": "^0.0.7", "tailwindcss": "^3.4.16", + "tsx": "^4.21.0", "typescript": "^5.7.2", "typescript-eslint": "^8.15.0", "vite": "^6.0.1", @@ -163,16 +176,16 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", - "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.3", - "@babel/types": "^7.26.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -196,6 +209,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", @@ -283,13 +306,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz", - "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.28.4" }, "bin": { "parser": "bin/babel-parser.js" @@ -331,10 +354,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", - "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", - "dev": true, + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -356,38 +378,28 @@ } }, "node_modules/@babel/traverse": { - "version": "7.26.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", - "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.3", - "@babel/parser": "^7.26.3", - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.3", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", - "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -921,6 +933,23 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", @@ -1314,6 +1343,29 @@ "@types/node": ">=18" } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1397,6 +1449,60 @@ } } }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/magic-string": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", @@ -1410,19 +1516,48 @@ "node": ">=12" } }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -1435,16 +1570,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -1453,9 +1578,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -2856,13 +2981,13 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.20.7" + "@babel/types": "^7.28.2" } }, "node_modules/@types/cookie": { @@ -2885,6 +3010,17 @@ "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", "license": "MIT" }, + "node_modules/@types/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "^5.1.2", + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2978,14 +3114,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { - "version": "22.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz", - "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==", + "version": "24.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz", + "integrity": "sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~7.13.0" } }, "node_modules/@types/pako": { @@ -4405,6 +4548,15 @@ } } }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5683,13 +5835,13 @@ } }, "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -5872,23 +6024,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", "dev": true, "license": "ISC", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" }, + "engines": { + "node": "20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -5905,27 +6073,17 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -6173,6 +6331,15 @@ "node": ">=18" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-tags": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", @@ -6214,6 +6381,55 @@ "node": ">= 14" } }, + "node_modules/i18next": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.5.2.tgz", + "integrity": "sha512-lW8Zeh37i/o0zVr+NoCHfNnfvVw+M6FQbRp36ZZ/NyHDJ3NJVpp2HhAUyU9WafL5AssymNoOjMRB48mmx2P6Hw==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -6820,19 +7036,19 @@ } }, "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, + "engines": { + "node": "20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/jest-diff": { @@ -7624,6 +7840,48 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", @@ -7944,28 +8202,31 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", "dev": true, - "license": "ISC" + "license": "ISC", + "engines": { + "node": "20 || >=22" + } }, "node_modules/path-to-regexp": { "version": "6.3.0", @@ -8587,6 +8848,32 @@ "integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==", "license": "MIT" }, + "node_modules/react-i18next": { + "version": "15.7.3", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.3.tgz", + "integrity": "sha512-AANws4tOE+QSq/IeMF/ncoHlMNZaVLxpa5uUGW1wjike68elVYr0018L9xYoqBr1OFO7G7boDPrbn0HpMCJxTw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 25.4.1", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-icons": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz", @@ -8813,6 +9100,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -9818,6 +10115,93 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/sucrase/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -10261,6 +10645,493 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, "node_modules/tween-functions": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/tween-functions/-/tween-functions-1.2.0.tgz", @@ -10431,9 +11302,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", + "integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==", "dev": true, "license": "MIT" }, @@ -11803,6 +12674,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -12082,9 +12962,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index e8c35368..417f1bf5 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "test": "vitest", - "chromatic": "npx chromatic --project-token=chpt_a6dc39eba6488b2" + "chromatic": "npx chromatic --project-token=chpt_a6dc39eba6488b2", + "i18n:transform": "tsx scripts/i18nTransform.ts" }, "dependencies": { "@tanstack/eslint-plugin-query": "^5.62.9", @@ -23,16 +24,24 @@ "axios": "^1.7.9", "clsx": "^2.1.1", "framer-motion": "^12.23.11", + "i18next": "^25.5.2", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "pako": "^2.1.0", "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-ga4": "^2.1.0", + "react-i18next": "^15.7.3", "react-icons": "^5.4.0", "react-router-dom": "^7.1.0", "vite-bundle-visualizer": "^1.2.1" }, "devDependencies": { + "@babel/generator": "^7.28.3", + "@babel/parser": "^7.28.4", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", "@chromatic-com/storybook": "^3.2.2", "@eslint/js": "^9.15.0", "@storybook/addon-essentials": "^8.6.0", @@ -46,7 +55,10 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", + "@types/babel__traverse": "^7.28.0", + "@types/glob": "^8.1.0", "@types/jest": "^29.5.14", + "@types/node": "^24.6.0", "@types/pako": "^2.0.3", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", @@ -64,6 +76,7 @@ "eslint-plugin-react-refresh": "^0.4.14", "eslint-plugin-storybook": "^0.11.1", "eslint-plugin-tailwindcss": "^3.17.5", + "glob": "^11.0.3", "globals": "^15.12.0", "jsdom": "^25.0.1", "msw": "^2.7.0", @@ -76,6 +89,7 @@ "stylelint-config-recommended": "^14.0.1", "stylelint-config-tailwindcss": "^0.0.7", "tailwindcss": "^3.4.16", + "tsx": "^4.21.0", "typescript": "^5.7.2", "typescript-eslint": "^8.15.0", "vite": "^6.0.1", diff --git a/scripts/i18nTransform.ts b/scripts/i18nTransform.ts new file mode 100644 index 00000000..54e2e578 --- /dev/null +++ b/scripts/i18nTransform.ts @@ -0,0 +1,44 @@ +import * as fs from 'fs/promises'; +import { glob } from 'glob'; +import { parseCode, transformAST, generateCode } from './utils/astUtils.ts'; +import { updateTranslationFiles } from './utils/translationUtils.ts'; + +async function processFile(filePath: string) { + console.log(`\n파일 처리 중: ${filePath}`); + const originalCode = await fs.readFile(filePath, 'utf-8'); + const ast = parseCode(originalCode); + + const koreanKeys = transformAST(ast); + if (koreanKeys.size === 0) { + console.log('한글 텍스트를 찾지 못했습니다.'); + return; + } + + await updateTranslationFiles(koreanKeys); + + const newCode = generateCode(ast); + if (newCode !== originalCode) { + await fs.writeFile(filePath, newCode, 'utf-8'); + console.log(`파일 업데이트 완료: ${filePath}`); + } else { + console.log('변경 사항이 없습니다.'); + } +} + +async function main() { + const files = await glob('src/**/*.tsx', { + ignore: ['src/**/*.test.tsx', 'src/**/*.stories.tsx'], + }); + if (files.length === 0) { + console.log('.tsx 파일을 찾지 못했습니다.'); + return; + } + + for (const file of files) { + await processFile(file); + } + + console.log('\ni18n 변환 작업이 완료되었습니다.'); +} + +main().catch(console.error); diff --git a/scripts/utils/astUtils.ts b/scripts/utils/astUtils.ts new file mode 100644 index 00000000..719ca2ac --- /dev/null +++ b/scripts/utils/astUtils.ts @@ -0,0 +1,280 @@ +import * as parser from '@babel/parser'; +import type { NodePath } from '@babel/traverse'; +import _traverse from '@babel/traverse'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const traverse = (_traverse as any).default; +import _generate from '@babel/generator'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const generate = (_generate as any).default; +import * as t from '@babel/types'; + +const KOREAN_REGEX = /[가-힣]/; + +/** + * 코드 문자열을 파싱하여 AST로 변환 + */ +export function parseCode(code: string) { + return parser.parse(code, { + sourceType: 'module', + plugins: ['jsx', 'typescript'], + }); +} + +/** + * 리액트 컴포넌트 함수인지 판별 + */ +function isReactComponentFunction(path: NodePath): boolean { + // 함수 선언문 + if (path.isFunctionDeclaration()) { + return path.node.id?.name?.[0] === path.node.id?.name?.[0]?.toUpperCase(); + } + + // 화살표 함수 표현식 또는 함수 표현식 + if (path.isArrowFunctionExpression() || path.isFunctionExpression()) { + const parent = path.parentPath; + + // 변수 선언문 + if (parent?.isVariableDeclarator()) { + const varName = (parent.node.id as t.Identifier)?.name; + return /^[A-Z]/.test(varName); + } + + // 합성 컴포넌트 + if (parent?.isAssignmentExpression()) { + const left = parent.get('left'); + if (left.isMemberExpression()) { + const property = left.get('property'); + if (property.isIdentifier()) { + return /^[A-Z]/.test(property.node.name); + } + } + } + } + + return false; +} + +/** + * AST에서 한글 문자열 탐색 및 변환 + */ +export function transformAST(ast: t.File) { + const koreanKeys = new Set(); + const componentsToModify = new Set(); + let hasUseTranslationImport = false; + const simpleStringsToTransform: NodePath[] = []; + const templateLiteralsToTransform: { + path: NodePath; + i18nKey: string; + objectProperties: t.ObjectProperty[]; + }[] = []; + + // 1️. 한글 문자열 탐색 및 변환 대상 수집 + traverse(ast, { + JSXText(path) { + const value = path.node.value.trim(); + if (value && KOREAN_REGEX.test(value)) { + const component = path.findParent((p) => isReactComponentFunction(p)); + if (component) { + const parentT = path.findParent( + (p) => + p.isCallExpression() && + p.get('callee').isIdentifier({ name: 't' }), + ); + if (parentT) return; + + simpleStringsToTransform.push(path); + koreanKeys.add(value); + componentsToModify.add(component); + } + } + }, + StringLiteral(path) { + const value = path.node.value.trim(); + if ( + value && + KOREAN_REGEX.test(value) && + path.parent.type !== 'ImportDeclaration' && + path.parent.type !== 'ExportNamedDeclaration' && + !( + path.parent.type === 'ObjectProperty' && path.parent.key === path.node + ) + ) { + const component = path.findParent((p) => isReactComponentFunction(p)); + if (component) { + const parentT = path.findParent( + (p) => + p.isCallExpression() && + p.get('callee').isIdentifier({ name: 't' }), + ); + if (parentT) return; + + simpleStringsToTransform.push(path); + koreanKeys.add(value); + componentsToModify.add(component); + } + } + }, + TemplateLiteral(path) { + const { quasis, expressions } = path.node; + const hasKorean = quasis.some((q) => KOREAN_REGEX.test(q.value.raw)); + if (!hasKorean) return; + + if ( + path.parent.type === 'CallExpression' && + t.isIdentifier(path.parent.callee) && + path.parent.callee.name === 't' + ) { + return; + } + + const component = path.findParent((p) => isReactComponentFunction(p)); + if (!component) return; + + let i18nKey = ''; + const objectProperties: t.ObjectProperty[] = []; + + for (let i = 0; i < quasis.length; i++) { + i18nKey += quasis[i].value.raw; + if (i < expressions.length) { + const expr = expressions[i]; + let placeholderName: string; + + if (t.isIdentifier(expr)) { + placeholderName = expr.name; + } else if ( + t.isMemberExpression(expr) && + t.isIdentifier(expr.property) + ) { + placeholderName = expr.property.name; + } else { + placeholderName = `val${i}`; + } + + let finalName = placeholderName; + let count = 1; + while ( + objectProperties.some( + (p) => t.isIdentifier(p.key) && p.key.name === finalName, + ) + ) { + finalName = `${placeholderName}${count++}`; + } + + i18nKey += `{{${finalName}}}`; + objectProperties.push( + t.objectProperty( + t.identifier(finalName), + expr, + false, + t.isIdentifier(expr) && finalName === expr.name, + ), + ); + } + } + + koreanKeys.add(i18nKey); + componentsToModify.add(component); + templateLiteralsToTransform.push({ path, i18nKey, objectProperties }); + }, + ImportDeclaration(path) { + if (path.node.source.value === 'react-i18next') { + hasUseTranslationImport = true; + } + }, + }); + + // 2️. useTranslation import 추가 + if (koreanKeys.size > 0 && !hasUseTranslationImport) { + const importDecl = t.importDeclaration( + [ + t.importSpecifier( + t.identifier('useTranslation'), + t.identifier('useTranslation'), + ), + ], + t.stringLiteral('react-i18next'), + ); + ast.program.body.unshift(importDecl); + } + + // 3️. 각 컴포넌트에 const { t } = useTranslation() 추가 + componentsToModify.forEach((componentPath) => { + const bodyPath = componentPath.get('body'); + if (Array.isArray(bodyPath) || !bodyPath.isBlockStatement()) return; + + let hasHook = false; + bodyPath.get('body').forEach((stmt) => { + if (stmt.isVariableDeclaration()) { + const declaration = stmt.node.declarations[0]; + if ( + declaration?.init?.type === 'CallExpression' && + t.isIdentifier(declaration.init.callee) && + declaration.init.callee.name === 'useTranslation' + ) { + hasHook = true; + } + } + }); + + if (!hasHook) { + const hookDecl = t.variableDeclaration('const', [ + t.variableDeclarator( + t.objectPattern([ + t.objectProperty(t.identifier('t'), t.identifier('t'), false, true), + ]), + t.callExpression(t.identifier('useTranslation'), []), + ), + ]); + bodyPath.unshiftContainer('body', hookDecl); + } + }); + + // 4️. 템플릿 리터럴 변환 + templateLiteralsToTransform.forEach(({ path, i18nKey, objectProperties }) => { + const keyLiteral = t.stringLiteral(i18nKey); + if (objectProperties.length > 0) { + const interpolationObject = t.objectExpression(objectProperties); + const tCall = t.callExpression(t.identifier('t'), [ + keyLiteral, + interpolationObject, + ]); + path.replaceWith(tCall); + } else { + const tCall = t.callExpression(t.identifier('t'), [keyLiteral]); + path.replaceWith(tCall); + } + }); + + // 5️. 컴포넌트 내부 한글 텍스트 t()로 감싸기 + simpleStringsToTransform.forEach((path) => { + const value = + path.node.type === 'JSXText' + ? path.node.value.trim() + : (path.node as t.StringLiteral).value; + + const tCall = t.callExpression(t.identifier('t'), [t.stringLiteral(value)]); + + if (path.isJSXText()) { + path.replaceWith(t.jsxExpressionContainer(tCall)); + } else if (path.isStringLiteral()) { + if (path.parent.type === 'JSXAttribute') { + path.replaceWith(t.jsxExpressionContainer(tCall)); + } else { + path.replaceWith(tCall); + } + } + }); + + return koreanKeys; +} + +/** + * AST를 코드 문자열로 다시 변환 + */ +export function generateCode(ast: t.File) { + const { code } = generate(ast, { + retainLines: true, + jsescOption: { minimal: true }, + }); + return code; +} diff --git a/scripts/utils/fileUtils.ts b/scripts/utils/fileUtils.ts new file mode 100644 index 00000000..6782f748 --- /dev/null +++ b/scripts/utils/fileUtils.ts @@ -0,0 +1,55 @@ +import * as fsp from 'fs/promises'; +import * as path from 'path'; + +/** 파일을 읽어서 문자열로 반환 */ +export function readFile(filePath: string): Promise { + return fsp.readFile(filePath, 'utf8'); +} + +/** 파일을 JSON으로 파싱 */ +export async function readJSON(filePath: string): Promise { + const content = await readFile(filePath); + return JSON.parse(content) as T; +} + +/** 디렉토리가 없으면 생성하고, 파일이 없으면 빈 파일 생성 (ensureFile) */ +export async function ensureFile(filePath: string): Promise { + const dir = path.dirname(filePath); + + // 1. 디렉토리 구조 보장 + try { + // 디렉토리가 존재하는지 확인 + await fsp.access(dir); + } catch { + // 디렉토리가 없으면 생성 + await fsp.mkdir(dir, { recursive: true }); + } + // 2. 파일이 존재하는지 확인하고, 없다면 빈 파일 생성 + try { + await fsp.access(filePath); + } catch { + // 파일이 없으면 catch 블록으로 진입, 빈 JSON 파일 생성 + await fsp.writeFile(filePath, '{}', 'utf8'); + } +} + +/** 문자열을 파일로 저장 */ +export async function writeFile(filePath: string, data: string): Promise { + console.log(`[writeFile] 파일 쓰기 시작: ${filePath}`); + const dir = path.dirname(filePath); + + await fsp.mkdir(dir, { recursive: true }).catch(() => {}); + + await fsp.writeFile(filePath, data, 'utf8'); + console.log(`[writeFile] 파일 쓰기 완료: ${filePath}`); +} + +/** JSON 데이터를 파일로 저장 */ +export async function writeJSON( + filePath: string, + data: unknown, +): Promise { + console.log(`[writeJSON] JSON 데이터 저장 시작: ${filePath}`); + await writeFile(filePath, JSON.stringify(data, null, 2)); + console.log(`[writeJSON] JSON 데이터 저장 완료: ${filePath}`); +} diff --git a/scripts/utils/translationUtils.ts b/scripts/utils/translationUtils.ts new file mode 100644 index 00000000..fd0c61a4 --- /dev/null +++ b/scripts/utils/translationUtils.ts @@ -0,0 +1,45 @@ +import * as path from 'path'; +import { ensureFile, readJSON, writeJSON } from './fileUtils.ts'; + +interface TranslationUpdateOptions { + languages?: string[]; + baseDir?: string; +} + +/** + * 한글 키를 기준으로 각 언어 JSON에 키 추가 + * - 중복 키는 건너뜀 + * - en은 빈 문자열, ko는 원문 그대로 + */ +export async function updateTranslationFiles( + keys: Set, + { + languages = ['ko', 'en'], + baseDir = 'public/locales', + }: TranslationUpdateOptions = {}, +) { + if (keys.size === 0) return; + + console.log('번역 파일을 업데이트합니다...'); + + for (const lang of languages) { + const filePath = path.join(baseDir, lang, 'translation.json'); + await ensureFile(filePath); + const translations = await readJSON>(filePath); + + let updated = false; + for (const key of keys) { + if (!(key in translations)) { + translations[key] = lang === 'ko' ? key : ''; + console.log(`키 추가: '${key}' (${lang}/translation.json)`); + updated = true; + } + } + + if (updated) { + await writeJSON(filePath, translations); + } + } + + console.log('번역 파일 업데이트가 완료되었습니다.\n'); +} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 00000000..a7da32f7 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,31 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import HttpApi from 'i18next-http-backend'; + +i18n + .use(HttpApi) // 서버에서 번역 파일을 불러오기 + .use(LanguageDetector) // 사용자의 브라우저 언어 감지 + .use(initReactI18next) // i18n 인스턴스를 react-i18next에 전달 + .init({ + supportedLngs: ['ko', 'en'], // 지원할 언어 목록 + fallbackLng: 'ko', // 감지된 언어를 사용할 수 없을 때 사용할 기본 언어 + + // 언어를 감지하는 순서와 방법 + detection: { + order: ['path', 'localStorage', 'navigator'], + caches: ['localStorage'], + }, + + // 번역 파일을 불러올 위치 + backend: { + loadPath: '/locales/{{lng}}/translation.json', + }, + + // React와 함께 사용할 때의 옵션 + react: { + useSuspense: true, // 비동기 번역 파일 로딩을 위해 필요 + }, + }); + +export default i18n; diff --git a/src/main.tsx b/src/main.tsx index a263a75f..3cee194a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,7 @@ import { GlobalPortal } from './util/GlobalPortal'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import router from './routes/routes.tsx'; import './index.css'; +import './i18n'; import { setupGoogleAnalytics } from './util/setupGoogleAnalytics.tsx'; // Functions that calls msw mocking worker diff --git a/src/routes/LanguageWrapper.tsx b/src/routes/LanguageWrapper.tsx new file mode 100644 index 00000000..cf7dc113 --- /dev/null +++ b/src/routes/LanguageWrapper.tsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import { Outlet, useParams } from 'react-router-dom'; +import i18n from '../i18n'; + +const supportedLangs = ['ko', 'en']; + +export default function LanguageWrapper() { + const { lang } = useParams(); + + useEffect(() => { + // URL에 lang 파라미터가 없으면 'ko'를 기본값으로 사용 + const currentLang = lang || 'ko'; + + if (supportedLangs.includes(currentLang) && i18n.language !== currentLang) { + i18n.changeLanguage(currentLang); + } + }, [lang]); + + return ; +} diff --git a/src/routes/routes.tsx b/src/routes/routes.tsx index 6310ae14..e19ae4ee 100644 --- a/src/routes/routes.tsx +++ b/src/routes/routes.tsx @@ -17,70 +17,71 @@ import DebateVotePage from '../page/DebateVotePage/DebateVotePage'; import VoteParticipationPage from '../page/VoteParticipationPage/VoteParticipationPage'; import VoteCompletePage from '../page/VoteCompletePage/VoteCompletePage'; import DebateVoteResultPage from '../page/DebateVoteResultPage/DebateVoteResultPage'; +import LanguageWrapper from './LanguageWrapper'; -const routesConfig = [ +const appRoutes = [ { - path: '/home', + path: 'home', element: , requiresAuth: false, }, { - path: '/', + path: '', element: , requiresAuth: true, }, { - path: '/composition', + path: 'composition', element: , requiresAuth: false, }, { - path: '/overview/:type/:id', + path: 'overview/:type/:id', element: , requiresAuth: false, }, { - path: '/table/customize/:id', + path: 'table/customize/:id', element: , requiresAuth: false, }, { - path: '/table/customize/:id/end', + path: 'table/customize/:id/end', element: , requiresAuth: true, }, { - path: '/table/customize/:id/end/feedback', + path: 'table/customize/:id/end/feedback', element: , requiresAuth: true, }, { - path: '/table/customize/:tableId/end/vote/:pollId', + path: 'table/customize/:tableId/end/vote/:pollId', element: , requiresAuth: true, }, { - path: '/table/customize/:tableId/end/vote/:pollId/result', + path: 'table/customize/:tableId/end/vote/:pollId/result', element: , requiresAuth: true, }, { - path: '/vote/:id', + path: 'vote/:id', element: , requiresAuth: false, }, { - path: '/vote/end', + path: 'vote/end', element: , requiresAuth: false, }, { - path: '/oauth', + path: 'oauth', element: , requiresAuth: false, }, { - path: '/share', + path: 'share', element: , requiresAuth: false, }, @@ -91,6 +92,16 @@ const routesConfig = [ }, ]; +// 인증 보호 로직을 적용한 라우트 +const protectedAppRoutes = appRoutes.map((route) => ({ + ...route, + element: route.requiresAuth ? ( + {route.element} + ) : ( + route.element + ), +})); + const router = createBrowserRouter([ { element: ( @@ -99,14 +110,18 @@ const router = createBrowserRouter([ ), - children: routesConfig.map((route) => ({ - ...route, - element: route.requiresAuth ? ( - {route.element} - ) : ( - route.element - ), - })), + children: [ + { + path: '/', + element: , + children: protectedAppRoutes, // 기본 언어(ko) 라우트 + }, + { + path: ':lang', // 다른 언어 라우트 + element: , + children: protectedAppRoutes, + }, + ], }, ]); From 4d7a267d6638020b91720889ef1a3eb628209dde Mon Sep 17 00:00:00 2001 From: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com> Date: Tue, 30 Dec 2025 17:29:14 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[FEAT]=20=EC=A2=85=EC=86=8C=EB=A6=AC=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=20=EC=A1=B0=EC=A0=88=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?(#403)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * design: 볼륨 아이콘 추가 * feat: 기존 훅에 벨 소리 볼륨 관련 로직 추가 * refactor: 헤더에 z-index 부여 * feat: 슬라이더 구현 * feat: 볼륨 바 구현 * feat: 타이머 페이지에 볼륨 바 적용 * test: 슬라이더 Storybook 추가 * refactor: 최소, 최대 볼륨 및 간격 상수화 * feat: 음소거 버튼 로직 구현 * feat: 볼륨 설정이 로컬 저장소에 저장되도록 구현 * fix: CodeRabbit 리뷰 반영 오류 위주 * refactor: 접근성 옵션 추가 * refactor: 외부에서 볼륨 변경 시 동기화하는 로직 추가 * chore: 주석 수정 * fix: z-index 일부 수정 * refactor: 볼륨 값을 안전하게 갱신하는 함수 추가 * refactor: 볼륨 값 초기화 함수 분리 * chore: 이해를 위한 주석 추가 * refactor: 코드 리뷰 반영 * feat: 볼륨 바 바깥 클릭 시 닫히도록 개선 * fix: 버그 수정 및 볼륨 기본값 상수로 분리 * chore: 사소한 변경 사항 --- .../CustomRangeSlider.stories.tsx | 24 +++ .../CustomRangeSlider/CustomRangeSlider.tsx | 72 +++++++++ .../VolumeBar/VolumeBar.stories.tsx | 19 +++ src/components/VolumeBar/VolumeBar.tsx | 143 ++++++++++++++++++ src/components/icons/Icon.stories.tsx | 13 ++ src/components/icons/Volume.tsx | 22 +++ src/hooks/useModal.tsx | 2 +- .../header/StickyTriSectionHeader.tsx | 2 +- src/page/TimerPage/TimerPage.tsx | 27 ++++ src/page/TimerPage/hooks/useBellSound.ts | 80 +++++++++- src/page/TimerPage/hooks/useTimerPageState.ts | 55 ++++++- 11 files changed, 451 insertions(+), 8 deletions(-) create mode 100644 src/components/CustomRangeSlider/CustomRangeSlider.stories.tsx create mode 100644 src/components/CustomRangeSlider/CustomRangeSlider.tsx create mode 100644 src/components/VolumeBar/VolumeBar.stories.tsx create mode 100644 src/components/VolumeBar/VolumeBar.tsx create mode 100644 src/components/icons/Volume.tsx diff --git a/src/components/CustomRangeSlider/CustomRangeSlider.stories.tsx b/src/components/CustomRangeSlider/CustomRangeSlider.stories.tsx new file mode 100644 index 00000000..9df65729 --- /dev/null +++ b/src/components/CustomRangeSlider/CustomRangeSlider.stories.tsx @@ -0,0 +1,24 @@ +// Storybook 코드 +import { Meta, StoryObj } from '@storybook/react'; +import CustomRangeSlider from './CustomRangeSlider'; + +const meta: Meta = { + title: 'Components/CustomRangeSlider', + component: CustomRangeSlider, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + value: 5, + max: 10, + min: 0, + onValueChange: (value: number) => { + console.log(value); + }, + }, +}; diff --git a/src/components/CustomRangeSlider/CustomRangeSlider.tsx b/src/components/CustomRangeSlider/CustomRangeSlider.tsx new file mode 100644 index 00000000..2fe91682 --- /dev/null +++ b/src/components/CustomRangeSlider/CustomRangeSlider.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; + +interface CustomRangeSliderProps { + // Controlled props + value: number; + onValueChange: (value: number) => void; + + // Configuration + min?: number; + max?: number; + step?: number; +} + +export default function CustomRangeSlider({ + value, + onValueChange, + min = 0, + max = 100, + step = 1, +}: CustomRangeSliderProps) { + const [showTooltip, setShowTooltip] = useState(false); + const range = max - min; + const percentage = + range <= 0 ? 0 : Math.min(100, Math.max(0, ((value - min) / range) * 100)); + + return ( +
+ {/* 스타일링 레이어 */} + {/* 1. 전체 트랙 (배경) */} +
+ + {/* 2. 현재 진행도 트랙 */} +
+ + {/* 3. Thumb (원형 드래그 핸들) */} +
+ {/* 볼륨 수치로 나타내는 말풍선 */} + {showTooltip && ( +
+
+ {value} + {/* 말풍선 꼬리 */} +
+
+
+ )} +
+ + {/* 실제 상호작용 레이어 */} + onValueChange(Number(e.target.value))} + onMouseEnter={() => setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + onFocus={() => setShowTooltip(true)} + onBlur={() => setShowTooltip(false)} + className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" + /> +
+ ); +} diff --git a/src/components/VolumeBar/VolumeBar.stories.tsx b/src/components/VolumeBar/VolumeBar.stories.tsx new file mode 100644 index 00000000..7ebd5ce3 --- /dev/null +++ b/src/components/VolumeBar/VolumeBar.stories.tsx @@ -0,0 +1,19 @@ +import { Meta, StoryObj } from '@storybook/react'; +import VolumeBar from './VolumeBar'; + +const meta: Meta = { + title: 'Components/VolumeBar', + component: VolumeBar, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + volume: 0, + onVolumeChange: (volume: number) => console.log(volume), + }, +}; diff --git a/src/components/VolumeBar/VolumeBar.tsx b/src/components/VolumeBar/VolumeBar.tsx new file mode 100644 index 00000000..6c9a076c --- /dev/null +++ b/src/components/VolumeBar/VolumeBar.tsx @@ -0,0 +1,143 @@ +// Integer 0-10, step = 1 +// Mute button available +import { useEffect, useState } from 'react'; +import CustomRangeSlider from '../CustomRangeSlider/CustomRangeSlider'; +import DTVolume from '../icons/Volume'; +import clsx from 'clsx'; + +interface VolumeBarProps { + volume: number; + onVolumeChange: (volume: number) => void; + className?: string; +} + +const MIN_VOLUME = 0; +const MAX_VOLUME = 10; +const STEP_VOLUME = 1; + +export default function VolumeBar({ + volume, + onVolumeChange, + className = '', +}: VolumeBarProps) { + // 음소거 해제 시 가장 마지막의 볼륨 값을 복원하기 위함 + const [lastVolume, setLastVolume] = useState(volume > 0 ? volume : 5); + + // 음소거 로직 + const handleMute = () => { + if (volume === 0) { + onVolumeChange(lastVolume === 0 ? 1 : lastVolume); + } else { + setLastVolume(volume); + onVolumeChange(0); + } + }; + + // 음소거 버튼은 오직 볼륨이 0일 때에만 흐리게 강조됨 + const isNotMute = volume > 0; + + // 외부에서 볼륨이 변경될 경우, 값을 관측하여 동기화 + useEffect(() => { + if (volume > 0) { + setLastVolume(volume); + } + }, [volume]); + + return ( +
+ {/* SVG Layer */} + + + + + + + + + + + + + + + + + + + + + + + {/* Content Layer */} +
+
+ + { + onVolumeChange(value); + + // 마지막 볼륨이 0으로 저장되면, 음소거를 해제해도 음소거가 유지되는 버그를 피하기 위함 + if (value > 0) { + setLastVolume(value); + } + }} + min={MIN_VOLUME} + max={MAX_VOLUME} + step={STEP_VOLUME} + /> +
+
+
+ ); +} diff --git a/src/components/icons/Icon.stories.tsx b/src/components/icons/Icon.stories.tsx index ddfa56bf..aa1a5668 100644 --- a/src/components/icons/Icon.stories.tsx +++ b/src/components/icons/Icon.stories.tsx @@ -17,6 +17,7 @@ import DTReset from './Reset'; import DTShare from './Share'; import DTExchange from './Exchange'; import DTBell from './Bell'; +import DTVolume from './Volume'; const meta: Meta = { title: 'Design System/Icons', @@ -256,3 +257,15 @@ export const OnBell: Story = {
), }; + +export const OnVolume: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

볼륨 조절

+
+ ), +}; diff --git a/src/components/icons/Volume.tsx b/src/components/icons/Volume.tsx new file mode 100644 index 00000000..b9a86ec7 --- /dev/null +++ b/src/components/icons/Volume.tsx @@ -0,0 +1,22 @@ +import { IconProps } from './IconProps'; + +export default function DTVolume({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + ); +} diff --git a/src/hooks/useModal.tsx b/src/hooks/useModal.tsx index dcdb1beb..b2d0e73c 100644 --- a/src/hooks/useModal.tsx +++ b/src/hooks/useModal.tsx @@ -62,7 +62,7 @@ export function useModal(options: UseModalOptions = {}) { return (
diff --git a/src/layout/components/header/StickyTriSectionHeader.tsx b/src/layout/components/header/StickyTriSectionHeader.tsx index 8ef669a3..caf3ece9 100644 --- a/src/layout/components/header/StickyTriSectionHeader.tsx +++ b/src/layout/components/header/StickyTriSectionHeader.tsx @@ -20,7 +20,7 @@ function StickyTriSectionHeader(props: PropsWithChildren) { const { children } = props; return ( -
+
{children}
diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index 86fe87f2..c5efdfa5 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -15,6 +15,8 @@ import clsx from 'clsx'; import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; import LoadingIndicator from '../../components/LoadingIndicator/LoadingIndicator'; import { RiFullscreenFill, RiFullscreenExitFill } from 'react-icons/ri'; +import DTVolume from '../../components/icons/Volume'; +import VolumeBar from '../../components/VolumeBar/VolumeBar'; export default function TimerPage() { const pathParams = useParams(); @@ -39,9 +41,14 @@ export default function TimerPage() { isLoading, isError, refetch, + isVolumeBarOpen, + toggleVolumeBar, + volume, + setVolume, isFullscreen, setFullscreen, toggleFullscreen, + volumeRef, } = state; // If error, print error message and let user be able to retry @@ -103,6 +110,26 @@ export default function TimerPage() { )} + +
+ + + {isVolumeBarOpen && ( +
+ setVolume(value)} + /> +
+ )} +
diff --git a/src/page/TimerPage/hooks/useBellSound.ts b/src/page/TimerPage/hooks/useBellSound.ts index a9e96588..b9c35011 100644 --- a/src/page/TimerPage/hooks/useBellSound.ts +++ b/src/page/TimerPage/hooks/useBellSound.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { NormalTimerLogics } from './useNormalTimer'; import { BellConfig } from '../../../type/type'; @@ -7,14 +7,84 @@ interface UseBellSoundProps { bells?: BellConfig[] | null; } +const STORAGE_KEY = 'timer-volume'; +const DEFAULT_VOLUME = 0.5; + export function useBellSound({ normalTimer, bells }: UseBellSoundProps) { + /** 볼륨 값 검증 함수 + * @param value 검증하고자 하는 볼륨 값 + * @returns 검증 성공 여부 (`boolean`) + */ + const isValidVolume = (value: number): boolean => { + if ( + value === null || + Number.isNaN(value) || + !Number.isFinite(value) || + value < 0.0 || + value > 1.0 + ) { + return false; + } + return true; + }; + + // 볼륨 초기화 함수 + const getAndInitVolume = () => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem(STORAGE_KEY); + // 로컬 저장소 값 자체가 null일 경우 + if (saved === null) { + return DEFAULT_VOLUME; + } + + // 볼륨 값 검증 + if (isValidVolume(Number(saved))) { + return Number(saved); + } else { + return DEFAULT_VOLUME; + } + } + return DEFAULT_VOLUME; + }; + + const [volume, setVolume] = useState(() => getAndInitVolume()); + const volumeRef = useRef(volume); + + /** 안전하게 볼륨 값을 갱신하는 함수. + * 값이 정상적이지 않을 경우, 일괄 0.5로 초기화합니다. + * @param value 갱신할 볼륨 값 (실수 0.0부터 1.0 사이) + */ + const updateVolume = (value: number) => { + if (isValidVolume(value)) { + setVolume(value); + } else { + setVolume(DEFAULT_VOLUME); + } + }; + + // playBell과 같은 콜백 함수에서 최신 volume 값을 참조하기 위해 ref를 동기화 + useEffect(() => { + volumeRef.current = volume; + }, [volume]); + // 종소리 여러 번 - 새로운 Audio로 재생 - function playBell(count: number) { + const playBell = useCallback((count: number) => { const audio = new Audio(`/sounds/bell-${count}.mp3`); + audio.volume = volumeRef.current; audio.play().catch((err) => { console.warn('audio.play() 실패:', err); }); - } + }, []); + + // 볼륨 변경 시 최신 값을 로컬 저장소에 저장 + // 500 ms 디바운싱 적용하여 성능 문제 예방 + useEffect(() => { + const timerId = setTimeout(() => { + localStorage.setItem(STORAGE_KEY, volume.toString()); + }, 500); + + return () => clearTimeout(timerId); + }, [volume]); useEffect(() => { const timerVal = normalTimer.timer; @@ -39,5 +109,7 @@ export function useBellSound({ normalTimer, bells }: UseBellSoundProps) { playBell(bell.count); } }); - }, [normalTimer.timer, bells, normalTimer.defaultTimer]); + }, [normalTimer.timer, bells, normalTimer.defaultTimer, playBell]); + + return { volume, updateVolume }; } diff --git a/src/page/TimerPage/hooks/useTimerPageState.ts b/src/page/TimerPage/hooks/useTimerPageState.ts index 813150d7..c94d55ab 100644 --- a/src/page/TimerPage/hooks/useTimerPageState.ts +++ b/src/page/TimerPage/hooks/useTimerPageState.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, } from 'react'; import { useGetDebateTableData } from '../../../hooks/query/useGetDebateTableData'; @@ -18,6 +19,8 @@ import { import { useTimerBackground } from './useTimerBackground'; import useFullscreen from '../../../hooks/useFullscreen'; +const VOLUME_SCALE = 10; + /** * 타이머 페이지의 상태(타이머, 라운드, 벨 등) 전반을 관리하는 커스텀 훅 */ @@ -55,11 +58,30 @@ export function useTimerPageState(tableId: number): TimerPageLogics { useState('PROS'); // 벨 사운드 관련 훅 - useBellSound({ + const { volume: rawVolume, updateVolume: updateRawVolume } = useBellSound({ normalTimer, - bells: data?.table[index].bell, + bells: data?.table[index]?.bell, }); + // 볼륨 값과 조절 함수 + // - React 내부적으로는 0.0 ~ 1.0 사이 값 사용 + // - 아래 값과 함수를 통해 사용자에게는 0 ~ 10 사이 값으로 인식되게 값을 변형 + const volume = Math.round(rawVolume * VOLUME_SCALE); + const updateVolume = (value: number) => { + if (value < 0 || value > VOLUME_SCALE) { + return; + } + + // UI 상의 0 ~ 10 볼륨을 React 내부 로직의 0.0 ~ 1.0으로 바꾸어서 갱신 + updateRawVolume(value / VOLUME_SCALE); + }; + + // 벨 볼륨 관련 + const [isVolumeBarOpen, setIsVolumeBarOpen] = useState(false); + const toggleVolumeBar = () => { + setIsVolumeBarOpen((prev) => !prev); + }; + const { bg, setBg } = useTimerBackground({ timer1, timer2, @@ -149,6 +171,25 @@ export function useTimerPageState(tableId: number): TimerPageLogics { [prosConsSelected, switchCamp, timer1, timer2], ); + // 볼륨 바 바깥 영역에서의 클릭을 처리하기 위한 레퍼런스와 함수 + const volumeRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + volumeRef.current && + !volumeRef.current.contains(event.target as Node) && + isVolumeBarOpen + ) { + toggleVolumeBar(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isVolumeBarOpen, toggleVolumeBar]); + /** * 라운드 이동/초기 진입 시 타이머 상태 초기화 및 셋업 */ @@ -262,6 +303,11 @@ export function useTimerPageState(tableId: number): TimerPageLogics { isFullscreen, toggleFullscreen, setFullscreen, + volume, + setVolume: updateVolume, + isVolumeBarOpen, + toggleVolumeBar, + volumeRef, }; } @@ -287,4 +333,9 @@ export interface TimerPageLogics { isFullscreen: boolean; toggleFullscreen: () => void; setFullscreen: (value: boolean) => void; + volume: number; + setVolume: (value: number) => void; + toggleVolumeBar: () => void; + isVolumeBarOpen: boolean; + volumeRef: React.RefObject; } From cc8e0f9bef164fea7c6307b98c0f8b6c5949a6b1 Mon Sep 17 00:00:00 2001 From: JAEMIN LEE Date: Tue, 30 Dec 2025 18:42:53 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[FIX]=20=EB=B0=9C=EC=96=B8=EC=9E=90=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=20=EC=82=AC=EC=A0=84=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20(#405)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 발언자 글자수 검증이 CUSTOM 유형에서만 동작하던 문제 수정 * fix: input에 발언자 입력 값을 제한하도록 변경 * refactor: 발언자 길이 제한 숫자를 상수화 * fix: 입력 5자까지만 들어가도록 하는 동작 수정 --- .../TimerCreationContent/TimerCreationContent.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx index 2a2363a7..1557f2b1 100644 --- a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx +++ b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx @@ -95,6 +95,7 @@ interface BellInputConfig { count: number; } +const MAX_SPEAKER_LEN = 5; export default function TimerCreationContent({ beforeData, initData, @@ -307,14 +308,15 @@ export default function TimerCreationContent({ // SpeechType에 맞게 문자열 매핑 let speechTypeToSend: string; let stanceToSend: Stance; + if (speaker.trim().length > MAX_SPEAKER_LEN) { + errors.push(`발언자는 최대 ${MAX_SPEAKER_LEN}자까지 입력할 수 있습니다.`); + } + if (currentSpeechType === 'CUSTOM') { // 텍스트 길이 유효성 검사 if (speechTypeTextValue.length > 10) { errors.push('발언 유형은 최대 10자까지 입력할 수 있습니다.'); } - if (speaker.length > 5) { - errors.push('발언자는 최대 5자까지 입력할 수 있습니다.'); - } // 발언시간 유효성 검사 if ( @@ -596,9 +598,12 @@ export default function TimerCreationContent({ setSpeaker(e.target.value)} + onChange={(e) => + setSpeaker(e.target.value.slice(0, MAX_SPEAKER_LEN)) + } onClear={() => setSpeaker('')} - placeholder="N번 토론자" + maxLength={MAX_SPEAKER_LEN} + placeholder="N번" disabled={ stance === 'NEUTRAL' || currentSpeechType === 'TIMEOUT' } From ce3a9eb683cc0a67a2738b406269c6de6fc05396 Mon Sep 17 00:00:00 2001 From: JAEMIN LEE Date: Wed, 31 Dec 2025 16:22:23 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[FEAT]=20=ED=88=AC=ED=91=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20+=20=EC=A2=85=EB=A3=8C=20?= =?UTF-8?q?=EC=8B=9C=20=EB=AA=A8=EB=8B=AC=EC=B0=BD=20(#407)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 하나의 버튼으로도 대응되도록 DialogModal 수정 * feat: 투표 종료 시 한번 더 물어보기 모달 추가 * feat: 투표결과화면에서 뒤로가기 금지 * refactor: 에러 문구를 더 정확하게 표시하도록 변경 * feat: 모달 문구 수정 * refactor: 불필요한 함수 랩핑 제거 * refactor: 투표종료 에러처리 추가 Co-authored-by: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com> --------- Co-authored-by: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com> --- src/apis/primitives.ts | 11 ++- src/components/DialogModal/DialogModal.tsx | 89 ++++++++++--------- src/page/DebateVotePage/DebateVotePage.tsx | 37 +++++++- .../DebateVoteResultPage.tsx | 17 +++- 4 files changed, 102 insertions(+), 52 deletions(-) diff --git a/src/apis/primitives.ts b/src/apis/primitives.ts index 8a3858b7..e4bf1c13 100644 --- a/src/apis/primitives.ts +++ b/src/apis/primitives.ts @@ -42,10 +42,17 @@ export async function request( if (axios.isAxiosError(error)) { // If error is raised during API request, // pass it as an APIError + const responseData = error.response?.data; + const message = + typeof responseData === 'string' + ? responseData + : typeof responseData === 'object' && responseData !== null + ? JSON.stringify(responseData) + : error.message; const apiError = new APIError( - error.response?.data || error.message, + message, error.response?.status || 500, - error.response?.data, + responseData, ); throw apiError; } diff --git a/src/components/DialogModal/DialogModal.tsx b/src/components/DialogModal/DialogModal.tsx index eecb3175..f042d9ad 100644 --- a/src/components/DialogModal/DialogModal.tsx +++ b/src/components/DialogModal/DialogModal.tsx @@ -1,16 +1,14 @@ import { PropsWithChildren } from 'react'; +interface DialogButton { + text: string; + onClick: () => void; + isBold?: boolean; +} + interface DialogModalProps extends PropsWithChildren { - left: { - text: string; - onClick: () => void; - isBold?: boolean; - }; - right: { - text: string; - onClick: () => void; - isBold?: boolean; - }; + left?: DialogButton; + right?: DialogButton; } export default function DialogModal({ @@ -18,12 +16,11 @@ export default function DialogModal({ left, right, }: DialogModalProps) { - if (left.isBold === undefined || null) { - left.isBold = false; - } - if (right.isBold === undefined || null) { - right.isBold = false; - } + const leftIsBold = left?.isBold ?? false; + const rightIsBold = right?.isBold ?? false; + const hasButtons = Boolean(left || right); + const isSingleButton = Boolean(left && !right) || Boolean(right && !left); + const buttonWidthClass = isSingleButton ? 'w-full' : 'w-1/2'; return (
-
- {/** Left button */} - + {hasButtons && ( + <> +
+
+ {left && ( + + )} - {/** Right button */} - -
+ {right && ( + + )} +
+ + )}
); } diff --git a/src/page/DebateVotePage/DebateVotePage.tsx b/src/page/DebateVotePage/DebateVotePage.tsx index 70061bdc..90b27349 100644 --- a/src/page/DebateVotePage/DebateVotePage.tsx +++ b/src/page/DebateVotePage/DebateVotePage.tsx @@ -5,7 +5,8 @@ import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import { useGetPollInfo } from '../../hooks/query/useGetPollInfo'; import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; import useFetchEndPoll from '../../hooks/mutations/useFetchEndPoll'; -import GoToDebateEndButton from '../../components/GoToDebateEndButton/GoToDebateEndButton'; +import { useModal } from '../../hooks/useModal'; +import DialogModal from '../../components/DialogModal/DialogModal'; export default function DebateVotePage() { const navigate = useNavigate(); const baseUrl = @@ -37,7 +38,20 @@ export default function DebateVotePage() { refetch, isRefetchError, } = useGetPollInfo(pollId, { refetchInterval: 5000, enabled: isArgsValid }); + const { openModal, closeModal, ModalWrapper } = useModal(); const { mutate } = useFetchEndPoll(handleGoToResult); + const handleConfirmEnd = () => { + if (!isArgsValid) return; + mutate(pollId, { + onSuccess: () => { + closeModal(); + }, + onError: () => { + closeModal(); + alert('투표 종료에 실패했습니다.'); + }, + }); + }; const participants = data?.voterNames; const isLoading = isFetching || isRefetching; @@ -114,11 +128,10 @@ export default function DebateVotePage() { -
- +
+ + +
+

투표를 마감하시겠습니까?

+

+ 투표를 마감하면 더이상 표를 받을 수 없습니다! +

+
+
+
); } diff --git a/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx b/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx index 4c6cac66..3cf9110a 100644 --- a/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx +++ b/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx @@ -7,7 +7,7 @@ import VoteDetailResult from './components/VoteDetailResult'; import { useGetPollInfo } from '../../hooks/query/useGetPollInfo'; import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; import { TeamKey } from '../../type/type'; -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import DialogModal from '../../components/DialogModal/DialogModal'; export default function DebateVoteResultPage() { // 매개변수 검증 @@ -32,6 +32,17 @@ export default function DebateVoteResultPage() { const handleGoHome = () => { navigate('/'); }; + const handleGoToEndPage = useCallback(() => { + navigate(`/table/customize/${tableId}/end`, { replace: true }); + }, [navigate, tableId]); + + useEffect(() => { + if (!isArgsValid) return; + + window.addEventListener('popstate', handleGoToEndPage); + return () => window.removeEventListener('popstate', handleGoToEndPage); + }, [handleGoToEndPage, isArgsValid]); + const isLoading = isFetching || isRefetching; const isError = isFetchError || isRefetchError; const { openModal, ModalWrapper, closeModal } = useModal({ @@ -108,11 +119,11 @@ export default function DebateVoteResultPage() {