From afad13eb83f94c516d01f47a9bcec0f15340654d Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 16 Sep 2025 16:47:27 +0900 Subject: [PATCH 01/51] =?UTF-8?q?chore:=20=EA=B5=AD=EC=A0=9C=ED=99=94=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=84=A4?= =?UTF-8?q?=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 155 ++++++++++++++++++++++++++++++++++++++++++++-- package.json | 4 ++ 2 files changed, 155 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1bbc07d4..91ea5576 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,15 @@ "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" @@ -331,10 +335,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" @@ -4405,6 +4408,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", @@ -6173,6 +6185,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 +6235,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", @@ -7624,6 +7694,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", @@ -8587,6 +8699,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", @@ -11803,6 +11941,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", diff --git a/package.json b/package.json index e8c35368..bc5dd6e3 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,15 @@ "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" From c8cfc46726c58001ea0d06f631875a8cfaeec5c2 Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 16 Sep 2025 17:51:33 +0900 Subject: [PATCH 02/51] =?UTF-8?q?feat:=20i18next=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EC=96=B8=EC=96=B4=20=EA=B0=90=EC=A7=80?= =?UTF-8?q?/HTTP=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/i18n.ts | 31 +++++++++++++++++++++++++++++++ src/main.tsx | 1 + 2 files changed, 32 insertions(+) create mode 100644 src/i18n.ts 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 From 9accb6fd57a20d8235658f7a7b3a59905323a352 Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 16 Sep 2025 17:54:20 +0900 Subject: [PATCH 03/51] =?UTF-8?q?feat:=20=EA=B5=AD=EC=A0=9C=ED=99=94=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EB=9E=98=ED=8D=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/LanguageWrapper.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/routes/LanguageWrapper.tsx 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 ; +} From f3007035d531ca2a5c5fd0e0b623705ade4c6bbb Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 16 Sep 2025 17:59:07 +0900 Subject: [PATCH 04/51] =?UTF-8?q?feat:=20=EC=A4=91=EC=B2=A9=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8C=85=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=8F=20=EA=B5=AD=EC=A0=9C=ED=99=94=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8C=85=20=EB=9E=98=ED=8D=BC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/routes.tsx | 51 ++++++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/src/routes/routes.tsx b/src/routes/routes.tsx index 87e3f1dd..c60f99d0 100644 --- a/src/routes/routes.tsx +++ b/src/routes/routes.tsx @@ -13,50 +13,51 @@ import FeedbackTimerPage from '../page/TimerPage/FeedbackTimerPage'; import LandingPage from '../page/LandingPage/LandingPage'; import TableSharingPage from '../page/TableSharingPage/TableSharingPage'; import DebateEndPage from '../page/DebateEndPage/DebateEndPage'; +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: '/oauth', + path: 'oauth', element: , requiresAuth: false, }, { - path: '/share', + path: 'share', element: , requiresAuth: false, }, @@ -67,6 +68,16 @@ const routesConfig = [ }, ]; +// 인증 보호 로직을 적용한 라우트 +const protectedAppRoutes = appRoutes.map((route) => ({ + ...route, + element: route.requiresAuth ? ( + {route.element} + ) : ( + route.element + ), +})); + const router = createBrowserRouter([ { element: ( @@ -75,14 +86,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 595e717aa64647029f9809e195d6c3af66caaba1 Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 7 Oct 2025 15:46:16 +0900 Subject: [PATCH 05/51] =?UTF-8?q?chore:=20=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=EB=B0=94=EB=B2=A8=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 585 +++++++++++++++++++++++++++++++++++++--------- package.json | 12 +- 2 files changed, 485 insertions(+), 112 deletions(-) diff --git a/package-lock.json b/package-lock.json index 91ea5576..24de3346 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,10 @@ "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", @@ -40,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", @@ -58,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", @@ -70,6 +78,7 @@ "stylelint-config-recommended": "^14.0.1", "stylelint-config-tailwindcss": "^0.0.7", "tailwindcss": "^3.4.16", + "ts-node": "^10.9.2", "typescript": "^5.7.2", "typescript-eslint": "^8.15.0", "vite": "^6.0.1", @@ -167,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": { @@ -200,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", @@ -287,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" @@ -359,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": { @@ -489,6 +498,30 @@ "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz", @@ -1317,6 +1350,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", @@ -1400,6 +1456,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", @@ -1413,19 +1523,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": { @@ -1438,16 +1577,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", @@ -1456,9 +1585,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": { @@ -2816,6 +2945,34 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2859,13 +3016,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": { @@ -2888,6 +3045,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", @@ -2981,14 +3149,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": { @@ -3481,6 +3656,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.2.tgz", @@ -4408,6 +4596,13 @@ } } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-fetch": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", @@ -4681,6 +4876,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -5695,13 +5900,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": { @@ -5885,22 +6090,25 @@ } }, "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" } @@ -5917,27 +6125,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" @@ -6890,19 +7088,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": { @@ -7413,6 +7611,13 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/map-or-similar": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz", @@ -8056,28 +8261,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", @@ -9956,6 +10164,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", @@ -10367,6 +10662,57 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -10569,9 +10915,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" }, @@ -10685,6 +11031,13 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", @@ -12229,9 +12582,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": { @@ -12391,6 +12744,16 @@ "node": ">=8" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index bc5dd6e3..a1f3403d 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": "ts-node scripts/i18nTransform.ts" }, "dependencies": { "@tanstack/eslint-plugin-query": "^5.62.9", @@ -37,6 +38,10 @@ "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", @@ -50,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", @@ -68,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", @@ -80,6 +89,7 @@ "stylelint-config-recommended": "^14.0.1", "stylelint-config-tailwindcss": "^0.0.7", "tailwindcss": "^3.4.16", + "ts-node": "^10.9.2", "typescript": "^5.7.2", "typescript-eslint": "^8.15.0", "vite": "^6.0.1", From 788275996d8dcdb6fdcff1671e53b4d18f5cbf45 Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 7 Oct 2025 15:56:48 +0900 Subject: [PATCH 06/51] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=9C=A0=ED=8B=B8=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/utils/fileUtils.ts | 88 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 scripts/utils/fileUtils.ts diff --git a/scripts/utils/fileUtils.ts b/scripts/utils/fileUtils.ts new file mode 100644 index 00000000..0ef8933c --- /dev/null +++ b/scripts/utils/fileUtils.ts @@ -0,0 +1,88 @@ +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. 디렉토리 구조 보장 (writeFile에서 mkdir을 처리하므로, 여기서는 fsp.access를 사용) + 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}`); +} + +/** 특정 확장자 파일들을 재귀적으로 찾기 */ +export async function getAllFiles( + dir: string, + ext: string, + fileList: string[] = [], +): Promise { + try { + // 비동기적으로 디렉토리 내용 읽기 + const entries = await fsp.readdir(dir, { withFileTypes: true }); + + // 모든 재귀 호출을 Promise.all로 병렬 처리 + const promises = entries.map(async (entry) => { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + // 디렉토리인 경우, 비동기 재귀 호출을 하고 결과 병합 + const nestedFiles = await getAllFiles(fullPath, ext); + fileList.push(...nestedFiles); + } else if (entry.isFile() && entry.name.endsWith(ext)) { + // 파일인 경우, 리스트에 추가 + fileList.push(fullPath); + } + }); + + // 모든 비동기 작업(하위 디렉토리 순회)이 완료될 때까지 기다림 + await Promise.all(promises); + } catch (err) { + console.error(`에러 발생 디렉토리 ${dir}:`, err); + } + + return fileList; +} From 8a896370fdea0e662c1aab3ac21b0cb61942005e Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 7 Oct 2025 15:58:50 +0900 Subject: [PATCH 07/51] =?UTF-8?q?feat:=20ATS=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B5=AD=EC=A0=9C=ED=99=94=20=ED=9B=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20t=20wrapper=20=EC=9C=A0=ED=8B=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/utils/astUtils.ts | 184 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 scripts/utils/astUtils.ts diff --git a/scripts/utils/astUtils.ts b/scripts/utils/astUtils.ts new file mode 100644 index 00000000..ddd8c4f9 --- /dev/null +++ b/scripts/utils/astUtils.ts @@ -0,0 +1,184 @@ +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'], + }); +} + +/** + * AST에서 한글 문자열 탐색 및 변환 + */ +export function transformAST(ast: t.File) { + const koreanKeys = new Set(); + const componentsToModify = new Set(); + let hasUseTranslationImport = false; + + // 1️. 한글 문자열 탐색 + traverse(ast, { + JSXText(path) { + const value = path.node.value.trim(); + if (value && KOREAN_REGEX.test(value)) { + koreanKeys.add(value); + const component = path.findParent( + (p) => + p.isFunctionDeclaration() || + p.isArrowFunctionExpression() || + p.isFunctionExpression(), + ); + if (component) 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 + ) + ) { + koreanKeys.add(value); + const component = path.findParent( + (p) => + p.isFunctionDeclaration() || + p.isArrowFunctionExpression() || + p.isFunctionExpression(), + ); + if (component) componentsToModify.add(component); + } + }, + 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️. 한글 텍스트를 t('...')로 감싸기 + traverse(ast, { + JSXText(path) { + const value = path.node.value.trim(); + if (value && koreanKeys.has(value)) { + const parent = path.findParent( + (p) => + p.isCallExpression() && + t.isIdentifier(p.node.callee) && + p.node.callee.name === 't', + ); + if (parent) return; + + const tCall = t.callExpression(t.identifier('t'), [ + t.stringLiteral(value), + ]); + path.replaceWith(t.jsxExpressionContainer(tCall)); + } + }, + StringLiteral(path) { + const value = path.node.value.trim(); + if ( + path.parent.type === 'CallExpression' && + t.isIdentifier(path.parent.callee) && + path.parent.callee.name === 't' + ) { + return; + } + + if ( + koreanKeys.has(value) && + path.parent.type !== 'ImportDeclaration' && + path.parent.type !== 'ExportNamedDeclaration' + ) { + if (path.parent.type === 'JSXAttribute') { + const tCall = t.callExpression(t.identifier('t'), [ + t.stringLiteral(value), + ]); + path.replaceWith(t.jsxExpressionContainer(tCall)); + } else { + const tCall = t.callExpression(t.identifier('t'), [ + t.stringLiteral(value), + ]); + path.replaceWith(tCall); + } + } + }, + }); + + return koreanKeys; +} + +/** + * AST를 코드 문자열로 다시 변환 + */ +export function generateCode(ast: t.File) { + const { code } = generate(ast, { + retainLines: true, + jsescOption: { minimal: true }, + }); + return code; +} From b859bc9c9950acc2b6ad244eb8e34788555384d1 Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 7 Oct 2025 15:59:32 +0900 Subject: [PATCH 08/51] =?UTF-8?q?feat:=20=ED=95=9C=EA=B8=80=20=ED=82=A4?= =?UTF-8?q?=EB=A5=BC=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EA=B0=81=20?= =?UTF-8?q?=EC=96=B8=EC=96=B4=20JSON=EC=97=90=20=ED=82=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=9C=A0=ED=8B=B8=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/utils/translationUtils.ts | 45 +++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 scripts/utils/translationUtils.ts 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'); +} From 4bc752562177df669442bc010eb3bf76c787964d Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 7 Oct 2025 16:00:49 +0900 Subject: [PATCH 09/51] =?UTF-8?q?feat:=20React=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EC=9D=98=20=ED=95=9C=EA=B8=80=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=90=EB=8F=99=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/i18nTransform.ts | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 scripts/i18nTransform.ts diff --git a/scripts/i18nTransform.ts b/scripts/i18nTransform.ts new file mode 100644 index 00000000..46bfccd0 --- /dev/null +++ b/scripts/i18nTransform.ts @@ -0,0 +1,42 @@ +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'); + if (files.length === 0) { + console.log('.tsx 파일을 찾지 못했습니다.'); + return; + } + + for (const file of files) { + await processFile(file); + } + + console.log('\ni18n 변환 작업이 완료되었습니다.'); +} + +main().catch(console.error); From 5f02869a79049ae7f939db014d11b48610023c03 Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 7 Oct 2025 16:20:51 +0900 Subject: [PATCH 10/51] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EA=B3=BC=20=EC=8A=A4=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EB=B6=81=20=ED=8C=8C=EC=9D=BC=EC=9D=B4=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/i18nTransform.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/i18nTransform.ts b/scripts/i18nTransform.ts index 46bfccd0..54e2e578 100644 --- a/scripts/i18nTransform.ts +++ b/scripts/i18nTransform.ts @@ -26,7 +26,9 @@ async function processFile(filePath: string) { } async function main() { - const files = await glob('src/**/*.tsx'); + const files = await glob('src/**/*.tsx', { + ignore: ['src/**/*.test.tsx', 'src/**/*.stories.tsx'], + }); if (files.length === 0) { console.log('.tsx 파일을 찾지 못했습니다.'); return; From c0eb304df0678fce890ea67381de6fb4bf275619 Mon Sep 17 00:00:00 2001 From: useon Date: Wed, 8 Oct 2025 16:45:06 +0900 Subject: [PATCH 11/51] =?UTF-8?q?refactor:=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=90=EB=B3=84=20=EB=A1=9C=EC=A7=81=EC=9D=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=ED=95=98=EC=97=AC=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=ED=98=95=ED=83=9C=EC=97=90=20=EB=8C=80=EC=9D=91=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/utils/astUtils.ts | 53 +++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/scripts/utils/astUtils.ts b/scripts/utils/astUtils.ts index ddd8c4f9..345afe2c 100644 --- a/scripts/utils/astUtils.ts +++ b/scripts/utils/astUtils.ts @@ -20,6 +20,40 @@ export function parseCode(code: string) { }); } +/** + * 리액트 컴포넌트 함수인지 판별 + */ +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에서 한글 문자열 탐색 및 변환 */ @@ -34,12 +68,7 @@ export function transformAST(ast: t.File) { const value = path.node.value.trim(); if (value && KOREAN_REGEX.test(value)) { koreanKeys.add(value); - const component = path.findParent( - (p) => - p.isFunctionDeclaration() || - p.isArrowFunctionExpression() || - p.isFunctionExpression(), - ); + const component = path.findParent((p) => isReactComponentFunction(p)); if (component) componentsToModify.add(component); } }, @@ -55,12 +84,7 @@ export function transformAST(ast: t.File) { ) ) { koreanKeys.add(value); - const component = path.findParent( - (p) => - p.isFunctionDeclaration() || - p.isArrowFunctionExpression() || - p.isFunctionExpression(), - ); + const component = path.findParent((p) => isReactComponentFunction(p)); if (component) componentsToModify.add(component); } }, @@ -88,10 +112,7 @@ export function transformAST(ast: t.File) { // 3️. 각 컴포넌트에 const { t } = useTranslation() 추가 componentsToModify.forEach((componentPath) => { const bodyPath = componentPath.get('body'); - - if (Array.isArray(bodyPath) || !bodyPath.isBlockStatement()) { - return; - } + if (Array.isArray(bodyPath) || !bodyPath.isBlockStatement()) return; let hasHook = false; bodyPath.get('body').forEach((stmt) => { From 87e9a1cfb98d1df48fcb4ea6be7609309de288a8 Mon Sep 17 00:00:00 2001 From: useon Date: Thu, 20 Nov 2025 00:01:50 +0900 Subject: [PATCH 12/51] =?UTF-8?q?feat:=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EB=A6=AC=ED=84=B0=EB=9F=B4=20=EC=9E=90=EB=8F=99=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=EB=B0=8F=20=ED=82=A4=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/utils/astUtils.ts | 86 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/scripts/utils/astUtils.ts b/scripts/utils/astUtils.ts index 345afe2c..1819366b 100644 --- a/scripts/utils/astUtils.ts +++ b/scripts/utils/astUtils.ts @@ -61,6 +61,11 @@ export function transformAST(ast: t.File) { const koreanKeys = new Set(); const componentsToModify = new Set(); let hasUseTranslationImport = false; + const templateLiteralsToTransform: { + path: NodePath; + i18nKey: string; + objectProperties: t.ObjectProperty[]; + }[] = []; // 1️. 한글 문자열 탐색 traverse(ast, { @@ -88,6 +93,69 @@ export function transformAST(ast: t.File) { if (component) 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; + } + + 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); + const component = path.findParent((p) => isReactComponentFunction(p)); + if (component) { + componentsToModify.add(component); + } + + templateLiteralsToTransform.push({ path, i18nKey, objectProperties }); + }, ImportDeclaration(path) { if (path.node.source.value === 'react-i18next') { hasUseTranslationImport = true; @@ -141,7 +209,23 @@ export function transformAST(ast: t.File) { } }); - // 4️. 한글 텍스트를 t('...')로 감싸기 + // 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('...')로 감싸기 traverse(ast, { JSXText(path) { const value = path.node.value.trim(); From 52e762201cd0b6ee158be330a996c67753a0d564 Mon Sep 17 00:00:00 2001 From: useon Date: Sat, 22 Nov 2025 00:06:48 +0900 Subject: [PATCH 13/51] =?UTF-8?q?refactor:=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=82=B4=EB=B6=80=EC=97=90=20=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=ED=95=9C=EA=B8=80=20=EB=AC=B8=EC=9E=90=EC=97=B4=EB=A7=8C=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/utils/astUtils.ts | 99 ++++++++++++++++++--------------------- 1 file changed, 45 insertions(+), 54 deletions(-) diff --git a/scripts/utils/astUtils.ts b/scripts/utils/astUtils.ts index 1819366b..719ca2ac 100644 --- a/scripts/utils/astUtils.ts +++ b/scripts/utils/astUtils.ts @@ -61,20 +61,31 @@ 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️. 한글 문자열 탐색 + // 1️. 한글 문자열 탐색 및 변환 대상 수집 traverse(ast, { JSXText(path) { const value = path.node.value.trim(); if (value && KOREAN_REGEX.test(value)) { - koreanKeys.add(value); const component = path.findParent((p) => isReactComponentFunction(p)); - if (component) componentsToModify.add(component); + 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) { @@ -88,9 +99,19 @@ export function transformAST(ast: t.File) { path.parent.type === 'ObjectProperty' && path.parent.key === path.node ) ) { - koreanKeys.add(value); const component = path.findParent((p) => isReactComponentFunction(p)); - if (component) componentsToModify.add(component); + 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) { @@ -106,6 +127,9 @@ export function transformAST(ast: t.File) { return; } + const component = path.findParent((p) => isReactComponentFunction(p)); + if (!component) return; + let i18nKey = ''; const objectProperties: t.ObjectProperty[] = []; @@ -149,11 +173,7 @@ export function transformAST(ast: t.File) { } koreanKeys.add(i18nKey); - const component = path.findParent((p) => isReactComponentFunction(p)); - if (component) { - componentsToModify.add(component); - } - + componentsToModify.add(component); templateLiteralsToTransform.push({ path, i18nKey, objectProperties }); }, ImportDeclaration(path) { @@ -225,53 +245,24 @@ export function transformAST(ast: t.File) { } }); - // 5️. 한글 텍스트를 t('...')로 감싸기 - traverse(ast, { - JSXText(path) { - const value = path.node.value.trim(); - if (value && koreanKeys.has(value)) { - const parent = path.findParent( - (p) => - p.isCallExpression() && - t.isIdentifier(p.node.callee) && - p.node.callee.name === 't', - ); - if (parent) return; + // 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), - ]); - path.replaceWith(t.jsxExpressionContainer(tCall)); - } - }, - StringLiteral(path) { - const value = path.node.value.trim(); - if ( - path.parent.type === 'CallExpression' && - t.isIdentifier(path.parent.callee) && - path.parent.callee.name === 't' - ) { - return; - } + const tCall = t.callExpression(t.identifier('t'), [t.stringLiteral(value)]); - if ( - koreanKeys.has(value) && - path.parent.type !== 'ImportDeclaration' && - path.parent.type !== 'ExportNamedDeclaration' - ) { - if (path.parent.type === 'JSXAttribute') { - const tCall = t.callExpression(t.identifier('t'), [ - t.stringLiteral(value), - ]); - path.replaceWith(t.jsxExpressionContainer(tCall)); - } else { - const tCall = t.callExpression(t.identifier('t'), [ - t.stringLiteral(value), - ]); - path.replaceWith(tCall); - } + 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; From 21ee43c83142b2826ea2c1c6377ac7710e060f0e Mon Sep 17 00:00:00 2001 From: useon Date: Sat, 22 Nov 2025 03:11:19 +0900 Subject: [PATCH 14/51] =?UTF-8?q?refactor:=20=EB=B3=B5=EC=9E=A1=ED=95=9C?= =?UTF-8?q?=20=ED=99=95=EC=9E=A5=EC=9E=90=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC=20=EC=82=AC=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EB=8D=94?= =?UTF-8?q?=EC=9D=B4=EC=83=81=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=ED=99=95=EC=9E=A5=EC=9E=90=20=EC=B0=BE?= =?UTF-8?q?=EB=8A=94=20=ED=95=A8=EC=88=98=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/utils/fileUtils.ts | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/scripts/utils/fileUtils.ts b/scripts/utils/fileUtils.ts index 0ef8933c..2f8c0be8 100644 --- a/scripts/utils/fileUtils.ts +++ b/scripts/utils/fileUtils.ts @@ -53,36 +53,3 @@ export async function writeJSON( await writeFile(filePath, JSON.stringify(data, null, 2)); console.log(`[writeJSON] JSON 데이터 저장 완료: ${filePath}`); } - -/** 특정 확장자 파일들을 재귀적으로 찾기 */ -export async function getAllFiles( - dir: string, - ext: string, - fileList: string[] = [], -): Promise { - try { - // 비동기적으로 디렉토리 내용 읽기 - const entries = await fsp.readdir(dir, { withFileTypes: true }); - - // 모든 재귀 호출을 Promise.all로 병렬 처리 - const promises = entries.map(async (entry) => { - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - // 디렉토리인 경우, 비동기 재귀 호출을 하고 결과 병합 - const nestedFiles = await getAllFiles(fullPath, ext); - fileList.push(...nestedFiles); - } else if (entry.isFile() && entry.name.endsWith(ext)) { - // 파일인 경우, 리스트에 추가 - fileList.push(fullPath); - } - }); - - // 모든 비동기 작업(하위 디렉토리 순회)이 완료될 때까지 기다림 - await Promise.all(promises); - } catch (err) { - console.error(`에러 발생 디렉토리 ${dir}:`, err); - } - - return fileList; -} From 8ff5e40deee13c03db7c7d647f09d5eae19070fe Mon Sep 17 00:00:00 2001 From: useon Date: Mon, 1 Dec 2025 21:18:41 +0900 Subject: [PATCH 15/51] =?UTF-8?q?fix:=20ts-node=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20tsx=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 686 +++++++++++++++++++++++++++++++++++----------- package.json | 4 +- 2 files changed, 530 insertions(+), 160 deletions(-) diff --git a/package-lock.json b/package-lock.json index 24de3346..7e082515 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,7 +78,7 @@ "stylelint-config-recommended": "^14.0.1", "stylelint-config-tailwindcss": "^0.0.7", "tailwindcss": "^3.4.16", - "ts-node": "^10.9.2", + "tsx": "^4.21.0", "typescript": "^5.7.2", "typescript-eslint": "^8.15.0", "vite": "^6.0.1", @@ -498,30 +498,6 @@ "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz", @@ -957,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", @@ -2945,34 +2938,6 @@ "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -3656,19 +3621,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.2.tgz", @@ -4596,13 +4548,6 @@ } } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, "node_modules/cross-fetch": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", @@ -4876,16 +4821,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -6089,6 +6024,19 @@ "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": "11.0.3", "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", @@ -7611,13 +7559,6 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, "node_modules/map-or-similar": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz", @@ -9159,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", @@ -10662,57 +10613,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/ts-node/node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -10745,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", @@ -11031,13 +11418,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", @@ -12744,16 +13124,6 @@ "node": ">=8" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index a1f3403d..417f1bf5 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "build-storybook": "storybook build", "test": "vitest", "chromatic": "npx chromatic --project-token=chpt_a6dc39eba6488b2", - "i18n:transform": "ts-node scripts/i18nTransform.ts" + "i18n:transform": "tsx scripts/i18nTransform.ts" }, "dependencies": { "@tanstack/eslint-plugin-query": "^5.62.9", @@ -89,7 +89,7 @@ "stylelint-config-recommended": "^14.0.1", "stylelint-config-tailwindcss": "^0.0.7", "tailwindcss": "^3.4.16", - "ts-node": "^10.9.2", + "tsx": "^4.21.0", "typescript": "^5.7.2", "typescript-eslint": "^8.15.0", "vite": "^6.0.1", From 99144022e997cb84d7ce54d870da7ef226f274f2 Mon Sep 17 00:00:00 2001 From: useon Date: Mon, 8 Dec 2025 15:44:47 +0900 Subject: [PATCH 16/51] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/utils/fileUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/utils/fileUtils.ts b/scripts/utils/fileUtils.ts index 2f8c0be8..6782f748 100644 --- a/scripts/utils/fileUtils.ts +++ b/scripts/utils/fileUtils.ts @@ -16,7 +16,7 @@ export async function readJSON(filePath: string): Promise { export async function ensureFile(filePath: string): Promise { const dir = path.dirname(filePath); - // 1. 디렉토리 구조 보장 (writeFile에서 mkdir을 처리하므로, 여기서는 fsp.access를 사용) + // 1. 디렉토리 구조 보장 try { // 디렉토리가 존재하는지 확인 await fsp.access(dir); From dd3fa5080405a2830784e853abde54bb097f3b25 Mon Sep 17 00:00:00 2001 From: useon Date: Fri, 26 Dec 2025 23:40:05 +0900 Subject: [PATCH 17/51] =?UTF-8?q?feat:=20=EC=96=B8=EC=96=B4=EC=85=80?= =?UTF-8?q?=EB=A0=89=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/header/LanguageSelector.tsx | 139 ++++++++++++++++++ .../header/StickyTriSectionHeader.tsx | 24 +-- src/page/LandingPage/components/Header.tsx | 18 ++- 3 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 src/layout/components/header/LanguageSelector.tsx diff --git a/src/layout/components/header/LanguageSelector.tsx b/src/layout/components/header/LanguageSelector.tsx new file mode 100644 index 00000000..60d07423 --- /dev/null +++ b/src/layout/components/header/LanguageSelector.tsx @@ -0,0 +1,139 @@ +import { useState, useRef, useEffect } from 'react'; +import { useNavigate, useParams, useLocation } from 'react-router-dom'; +import clsx from 'clsx'; +import { DropdownMenuItem } from '../../../components/DropdownMenu/DropdownMenu'; // DropdownMenuItem 타입 재사용 +import DTExpand from '../../../components/icons/Expand'; + +export default function LanguageSelector() { + const navigate = useNavigate(); + const location = useLocation(); + const { lang: currentLangParam } = useParams(); + + const LANG_OPTIONS: DropdownMenuItem[] = [ + { value: 'ko', label: 'KR' }, + { value: 'en', label: 'EN' }, + ]; + + // URL 파라미터를 기반으로 현재 선택된 언어 결정 + // 유효한 언어 파라미터가 없으면 'ko'를 기본값으로 사용 + const selectedLangValue = + LANG_OPTIONS.find((option) => option.value === currentLangParam)?.value || + 'ko'; + const selectedLangLabel = + LANG_OPTIONS.find((option) => option.value === selectedLangValue)?.label || + 'KR'; + + const handleLanguageChange = (newLang: string) => { + const currentPathname = location.pathname; + const pathSegments = currentPathname.split('/'); // 현재 경로를 '/' 기준으로 분리합니다. + const hasLangSegment = + pathSegments.length > 1 && + pathSegments[1] && + LANG_OPTIONS.some((opt) => opt.value === pathSegments[1]); + + let newPathname = currentPathname; + if (newLang === 'ko') { + if (hasLangSegment) { + pathSegments.splice(1, 1); + newPathname = pathSegments.join('/') || '/'; + } + navigate(newPathname); + return; + } + + // 현재 경로에 언어 세그먼트가 있는지 확인하고 대체하거나 추가합니다. + if (hasLangSegment) { + // 현재 경로에 언어 세그먼트가 있다면, 새 언어로 대체합니다. + pathSegments[1] = newLang; + newPathname = pathSegments.join('/'); + } else { + // 현재 경로에 언어 세그먼트가 없다면 (예: '/', '/home'), 새 언어를 경로 앞에 추가합니다. + newPathname = `/${newLang}${currentPathname === '/' ? '' : currentPathname}`; + } + + navigate(newPathname); + }; + + const [isMenuOpen, setIsMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsMenuOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [menuRef]); + + const triggerButtonClasses = clsx( + 'flex h-full cursor-pointer items-center justify-center gap-[8px] px-[4px] font-semibold leading-none text-default-black', + ); + + const menuPanelClasses = clsx( + 'absolute right-0 top-full z-10 mt-[16px] flex w-[68px] origin-top transform flex-col overflow-hidden border border-default-disabled/hover bg-default-white shadow-[0_3px_5px_rgba(0,0,0,0.2)] transition-opacity transition-transform duration-200 ease-out', + { + 'opacity-100 scale-y-100 pointer-events-auto': isMenuOpen, + 'opacity-0 scale-y-95 pointer-events-none': !isMenuOpen, + }, + ); + + const menuItemClasses = (value: string) => + clsx( + 'flex cursor-pointer items-center justify-center p-[10px] text-center text-body-raw font-semibold transition-colors duration-150 last:border-b-0 md:text-subtitle-raw', + { + 'text-default-black': value === selectedLangValue, + 'text-default-neutral': value !== selectedLangValue, + }, + ); + + return ( +
+ {/* 언어 선택 트리거 버튼 */} + + + {/* 언어 선택 메뉴 패널 */} +
+ {LANG_OPTIONS.map((option) => ( + + ))} +
+
+ ); +} diff --git a/src/layout/components/header/StickyTriSectionHeader.tsx b/src/layout/components/header/StickyTriSectionHeader.tsx index 8ef669a3..95d52c6b 100644 --- a/src/layout/components/header/StickyTriSectionHeader.tsx +++ b/src/layout/components/header/StickyTriSectionHeader.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { PropsWithChildren } from 'react'; import { useNavigate } from 'react-router-dom'; import useLogout from '../../../hooks/mutations/useLogout'; @@ -12,8 +13,8 @@ import DialogModal from '../../../components/DialogModal/DialogModal'; import DTHome from '../../../components/icons/Home'; import DTLogin from '../../../components/icons/Login'; import useFullscreen from '../../../hooks/useFullscreen'; +import LanguageSelector from './LanguageSelector'; -// The type of header icons will be declared here. type HeaderIcons = 'home' | 'auth'; function StickyTriSectionHeader(props: PropsWithChildren) { @@ -64,21 +65,18 @@ StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { <>
{isGuestFlow() && ( - <> - {/* Guest mode indicator */} -
- 비회원 모드 -
- - {/* Vertical divider */} -
- +
+ 비회원 모드 +
)} - {/* Buttons given as an argument */} + + +
+ + {/* props으로 들어오는 버튼들 */} {buttons} - {/* Normal buttons */} {defaultIcons.map((iconName, index) => { switch (iconName) { case 'home': @@ -103,6 +101,7 @@ StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { ); + case 'auth': return ( ); + default: return null; } diff --git a/src/page/LandingPage/components/Header.tsx b/src/page/LandingPage/components/Header.tsx index df4de7d9..1591cec0 100644 --- a/src/page/LandingPage/components/Header.tsx +++ b/src/page/LandingPage/components/Header.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { isLoggedIn } from '../../../util/accessToken'; +import LanguageSelector from '../../../layout/components/header/LanguageSelector'; interface HeaderProps { onLoginButtonClicked: () => void; @@ -24,15 +25,18 @@ export default function Header({ onLoginButtonClicked }: HeaderProps) { }`} >
-
+

Debate Timer +

+
+ +
-
); From 197e4415af91c7ac3aa9dfdc193faf59141c5eef Mon Sep 17 00:00:00 2001 From: useon Date: Mon, 29 Dec 2025 19:05:29 +0900 Subject: [PATCH 18/51] =?UTF-8?q?refactor:=20=EC=96=B8=EC=96=B4=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EB=A1=9C=EC=A7=81=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=9C=A0=ED=8B=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/header/LanguageSelector.tsx | 41 +++++-------- src/routes/LanguageWrapper.tsx | 37 +++++++++--- src/util/languageRouting.ts | 57 +++++++++++++++++++ 3 files changed, 100 insertions(+), 35 deletions(-) create mode 100644 src/util/languageRouting.ts diff --git a/src/layout/components/header/LanguageSelector.tsx b/src/layout/components/header/LanguageSelector.tsx index 60d07423..8e55239f 100644 --- a/src/layout/components/header/LanguageSelector.tsx +++ b/src/layout/components/header/LanguageSelector.tsx @@ -3,6 +3,12 @@ import { useNavigate, useParams, useLocation } from 'react-router-dom'; import clsx from 'clsx'; import { DropdownMenuItem } from '../../../components/DropdownMenu/DropdownMenu'; // DropdownMenuItem 타입 재사용 import DTExpand from '../../../components/icons/Expand'; +import { + buildLangPath, + getSelectedLangFromRoute, + isSupportedLang, +} from '../../../util/languageRouting'; +import i18n from '../../../i18n'; export default function LanguageSelector() { const navigate = useNavigate(); @@ -16,41 +22,22 @@ export default function LanguageSelector() { // URL 파라미터를 기반으로 현재 선택된 언어 결정 // 유효한 언어 파라미터가 없으면 'ko'를 기본값으로 사용 - const selectedLangValue = - LANG_OPTIONS.find((option) => option.value === currentLangParam)?.value || - 'ko'; + const selectedLangValue = getSelectedLangFromRoute( + currentLangParam, + location.pathname, + ); const selectedLangLabel = LANG_OPTIONS.find((option) => option.value === selectedLangValue)?.label || 'KR'; const handleLanguageChange = (newLang: string) => { - const currentPathname = location.pathname; - const pathSegments = currentPathname.split('/'); // 현재 경로를 '/' 기준으로 분리합니다. - const hasLangSegment = - pathSegments.length > 1 && - pathSegments[1] && - LANG_OPTIONS.some((opt) => opt.value === pathSegments[1]); - - let newPathname = currentPathname; - if (newLang === 'ko') { - if (hasLangSegment) { - pathSegments.splice(1, 1); - newPathname = pathSegments.join('/') || '/'; - } - navigate(newPathname); + if (!isSupportedLang(newLang)) { return; } - - // 현재 경로에 언어 세그먼트가 있는지 확인하고 대체하거나 추가합니다. - if (hasLangSegment) { - // 현재 경로에 언어 세그먼트가 있다면, 새 언어로 대체합니다. - pathSegments[1] = newLang; - newPathname = pathSegments.join('/'); - } else { - // 현재 경로에 언어 세그먼트가 없다면 (예: '/', '/home'), 새 언어를 경로 앞에 추가합니다. - newPathname = `/${newLang}${currentPathname === '/' ? '' : currentPathname}`; + const newPathname = buildLangPath(location.pathname, newLang); + if (i18n.language !== newLang) { + i18n.changeLanguage(newLang); } - navigate(newPathname); }; diff --git a/src/routes/LanguageWrapper.tsx b/src/routes/LanguageWrapper.tsx index cf7dc113..35feaf8d 100644 --- a/src/routes/LanguageWrapper.tsx +++ b/src/routes/LanguageWrapper.tsx @@ -1,20 +1,41 @@ import { useEffect } from 'react'; -import { Outlet, useParams } from 'react-router-dom'; +import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'; import i18n from '../i18n'; - -const supportedLangs = ['ko', 'en']; +import { + DEFAULT_LANG, + buildLangPath, + getSelectedLangFromRoute, + isSupportedLang, + stripDefaultLangFromPath, +} from '../util/languageRouting'; export default function LanguageWrapper() { const { lang } = useParams(); + const location = useLocation(); + const navigate = useNavigate(); useEffect(() => { - // URL에 lang 파라미터가 없으면 'ko'를 기본값으로 사용 - const currentLang = lang || 'ko'; + const selectedLang = getSelectedLangFromRoute(lang, location.pathname); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + + if (lang === DEFAULT_LANG) { + const nextPath = stripDefaultLangFromPath(location.pathname); + navigate(nextPath || '/', { replace: true }); + return; + } + + if (!lang && isSupportedLang(currentLang) && currentLang !== DEFAULT_LANG) { + const nextPath = buildLangPath(location.pathname, currentLang); + if (nextPath !== location.pathname) { + navigate(nextPath, { replace: true }); + return; + } + } - if (supportedLangs.includes(currentLang) && i18n.language !== currentLang) { - i18n.changeLanguage(currentLang); + if (isSupportedLang(selectedLang) && i18n.language !== selectedLang) { + i18n.changeLanguage(selectedLang); } - }, [lang]); + }, [lang, location.pathname, navigate]); return ; } diff --git a/src/util/languageRouting.ts b/src/util/languageRouting.ts new file mode 100644 index 00000000..901ce4e0 --- /dev/null +++ b/src/util/languageRouting.ts @@ -0,0 +1,57 @@ +const SUPPORTED_LANGS = ['ko', 'en'] as const; +const DEFAULT_LANG = 'ko'; + +type SupportedLang = (typeof SUPPORTED_LANGS)[number]; + +const isSupportedLang = (value?: string): value is SupportedLang => + !!value && SUPPORTED_LANGS.includes(value as SupportedLang); + +const getLangFromPath = (pathname: string): SupportedLang | undefined => { + const pathSegments = pathname.split('/'); + return isSupportedLang(pathSegments[1]) ? pathSegments[1] : undefined; +}; + +const getSelectedLang = (langParam?: string): SupportedLang => + isSupportedLang(langParam) ? langParam : DEFAULT_LANG; + +const getSelectedLangFromRoute = ( + langParam: string | undefined, + pathname: string, +): SupportedLang => getSelectedLang(langParam ?? getLangFromPath(pathname)); + +const stripDefaultLangFromPath = (pathname: string): string => { + const updated = pathname.replace(new RegExp(`^/${DEFAULT_LANG}(?=/|$)`), '/'); + return updated === '/' ? updated : updated.replace(/\/+$/, ''); +}; + +const buildLangPath = (pathname: string, lang: SupportedLang): string => { + const pathSegments = pathname.split('/'); + const hasLangSegment = + pathSegments.length > 1 && isSupportedLang(pathSegments[1]); + + if (lang === DEFAULT_LANG) { + if (hasLangSegment) { + pathSegments.splice(1, 1); + return pathSegments.join('/') || '/'; + } + return pathname; + } + + if (hasLangSegment) { + pathSegments[1] = lang; + return pathSegments.join('/'); + } + + return `/${lang}${pathname === '/' ? '' : pathname}`; +}; + +export { + SUPPORTED_LANGS, + DEFAULT_LANG, + isSupportedLang, + getLangFromPath, + getSelectedLang, + getSelectedLangFromRoute, + stripDefaultLangFromPath, + buildLangPath, +}; From 887458f048d5fa8fff5e853255599e630c21c35b Mon Sep 17 00:00:00 2001 From: useon Date: Mon, 29 Dec 2025 21:28:19 +0900 Subject: [PATCH 19/51] =?UTF-8?q?refactor:=20prop=20default=20=EA=B0=92?= =?UTF-8?q?=EC=9D=98=20=ED=95=9C=EA=B8=80=EC=9D=B4=20=EB=B2=88=EC=97=AD?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EC=95=84=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/DropdownMenu/DropdownMenu.tsx | 4 ++-- src/components/ErrorIndicator/ErrorIndicator.tsx | 14 +++++--------- .../LoadingIndicator/LoadingIndicator.tsx | 6 ++---- .../ProsAndConsTitle/PropsAndConsTitle.tsx | 10 ++++++---- 4 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/components/DropdownMenu/DropdownMenu.tsx b/src/components/DropdownMenu/DropdownMenu.tsx index 827674e6..122a18e8 100644 --- a/src/components/DropdownMenu/DropdownMenu.tsx +++ b/src/components/DropdownMenu/DropdownMenu.tsx @@ -20,7 +20,7 @@ export default function DropdownMenu({ options, selectedValue, onSelect, - placeholder = '선택', + placeholder, disabled, className = '', }: DropdownMenuProps) { @@ -29,7 +29,7 @@ export default function DropdownMenu({ const selectedOptionLabel = options.find((option) => option.value === selectedValue)?.label || - placeholder; + (placeholder ?? '선택'); // 드롭다운 외부 클릭 시 닫히도록 처리 useEffect(() => { diff --git a/src/components/ErrorIndicator/ErrorIndicator.tsx b/src/components/ErrorIndicator/ErrorIndicator.tsx index 9f0b8b59..6af222c7 100644 --- a/src/components/ErrorIndicator/ErrorIndicator.tsx +++ b/src/components/ErrorIndicator/ErrorIndicator.tsx @@ -6,23 +6,19 @@ interface ErrorIndicatorProps extends PropsWithChildren { } export default function ErrorIndicator({ - children = ( - <> - 데이터를 불러오지 못했어요. -
- 다시 시도할까요? - - ), + children, onClickRetry, }: ErrorIndicatorProps) { return (
-

{children}

+

+ {children ?? '데이터를 불러오지 못했어요.\n다시 시도할까요?'} +

{onClickRetry && (
section302 diff --git a/src/page/LandingPage/components/TimerSection.tsx b/src/page/LandingPage/components/TimerSection.tsx index bbcc1d01..bdb1ac86 100644 --- a/src/page/LandingPage/components/TimerSection.tsx +++ b/src/page/LandingPage/components/TimerSection.tsx @@ -3,6 +3,7 @@ import timerOperationTime from '../../../assets/landing/timer_operation_time.png import timerTimeBased from '../../../assets/landing/timer_timebased.png'; import keyInfo from '../../../assets/landing/key_info.png'; import timeoutButton from '../../../assets/landing/timeout_button.png'; + export default function TimerSection() { return (
타이머 화면
-

- 원하는 때에
- 작전 시간 사용하기 +

+ {'원하는 때에\n작전 시간 사용하기'}

section301

+ {/* TODO: Trans를 활용해서 키 관리하기 */} 토론자가 작전 시간을
요청하면{' '} @@ -39,9 +40,8 @@ export default function TimerSection() {

-

- 작전 시간이 나타나면 -
원하는 시간을 입력하세요! +

+ {'작전 시간이 나타나면\n원하는 시간을 입력하세요!'}

section302 @@ -49,7 +49,7 @@ export default function TimerSection() {

- 키보드 방향키로
더 편리한 조작 + {'키보드 방향키로\n더 편리한 조작'}

diff --git a/src/page/TableComposition/TableCompositionPage.tsx b/src/page/TableComposition/TableCompositionPage.tsx index f528cced..3ac88d01 100644 --- a/src/page/TableComposition/TableCompositionPage.tsx +++ b/src/page/TableComposition/TableCompositionPage.tsx @@ -97,7 +97,7 @@ export default function TableCompositionPage() { refetch()}> - 시간표 정보를 불러오지 못했어요...

다시 시도할까요? + {'시간표 정보를 불러오지 못했어요.\n다시 시도할까요?'}
diff --git a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContentMenuItem.tsx b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContentMenuItem.tsx index b935ba27..76f604b6 100644 --- a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContentMenuItem.tsx +++ b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContentMenuItem.tsx @@ -18,7 +18,9 @@ export default function TimerCreationContentItem({ className, )} > -

{title}

+

+ {title} +

{children}
); diff --git a/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx b/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx index 7d89a5cb..490040b3 100644 --- a/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx +++ b/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx @@ -104,8 +104,8 @@ export default function TeamSelectionModal({
{coinState === 'initial' && (
-

- 팀별로
동전의 앞 / 뒷면 중
하나를 선택해 주세요. +

+ {'팀별로\n동전의 앞 / 뒷면 중\n하나를 선택해 주세요.'}

)} diff --git a/src/page/TimerPage/components/LoginAndStoreModal.tsx b/src/page/TimerPage/components/LoginAndStoreModal.tsx index 71316dde..ac83e110 100644 --- a/src/page/TimerPage/components/LoginAndStoreModal.tsx +++ b/src/page/TimerPage/components/LoginAndStoreModal.tsx @@ -36,9 +36,8 @@ export function LoginAndStoreModal({ isBold: true, }} > -
- 토론을 끝내셨군요!
- 지금까지의 시간표를 로그인하고 저장할까요? +
+ {'토론을 끝내셨군요!\n지금까지의 시간표를 로그인하고 저장할까요?'}
From fcfe9d443703507e3a45efffa2e97107839063ae Mon Sep 17 00:00:00 2001 From: useon Date: Mon, 29 Dec 2025 21:32:23 +0900 Subject: [PATCH 21/51] =?UTF-8?q?refactor:=20=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=99=80=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/GoogleButton.tsx | 46 --------------------------------- src/util/validateUserAgent.ts | 8 ------ 2 files changed, 54 deletions(-) delete mode 100644 src/components/GoogleButton.tsx delete mode 100644 src/util/validateUserAgent.ts diff --git a/src/components/GoogleButton.tsx b/src/components/GoogleButton.tsx deleted file mode 100644 index dec5eb9c..00000000 --- a/src/components/GoogleButton.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { ButtonHTMLAttributes } from 'react'; -import { FcGoogle } from 'react-icons/fc'; -import { isEmbeddedWebView } from '../util/validateUserAgent'; -import { MdOutlineErrorOutline } from 'react-icons/md'; -export default function GoogleButton( - props: ButtonHTMLAttributes, -) { - // Check whether user-agent is acceptable and set background color - const isDisabled = isEmbeddedWebView(); - const bgColor = isDisabled ? 'bg-gray-300' : 'bg-slate-100'; - const hoverBgColor = isDisabled ? '' : 'hover:bg-slate-200'; - console.log(isDisabled); - - return ( -
- {/* Google login button */} - - - {/* Error message */} - {isDisabled && ( -
- -

- 이 브라우저에서는 로그인이 불가능해요. 다른 웹 브라우저로 - 접속해주세요. -

-
- )} -
- ); -} diff --git a/src/util/validateUserAgent.ts b/src/util/validateUserAgent.ts deleted file mode 100644 index e0d44b38..00000000 --- a/src/util/validateUserAgent.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const isEmbeddedWebView = (): boolean => { - const userAgent = navigator.userAgent.toLowerCase(); - // console.log(userAgent); - - return /fban|fbav|instagram|kakaotalk|line|wechat|snapchat|twitter/i.test( - userAgent, - ); -}; From cbfb9b86afbe21e6d472cf3fc4bf2463095bd9e7 Mon Sep 17 00:00:00 2001 From: useon Date: Mon, 29 Dec 2025 22:49:09 +0900 Subject: [PATCH 22/51] =?UTF-8?q?fix:=20prettier=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=B4=20=EC=A4=84=EB=B0=94=EA=BF=88=EC=9D=B4=20=EB=90=98?= =?UTF-8?q?=EC=96=B4=20=EB=B2=88=EC=97=AD=20=ED=82=A4=EC=97=90=20=EA=B3=B5?= =?UTF-8?q?=EB=B0=B1=EC=9D=B4=20=EB=93=A4=EC=96=B4=EA=B0=80=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/page/TimerPage/components/FirstUseToolTip.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/page/TimerPage/components/FirstUseToolTip.tsx b/src/page/TimerPage/components/FirstUseToolTip.tsx index 98d5c3ba..8646edaf 100644 --- a/src/page/TimerPage/components/FirstUseToolTip.tsx +++ b/src/page/TimerPage/components/FirstUseToolTip.tsx @@ -30,8 +30,9 @@ export default function FirstUseToolTip({ onClose }: FirstUseToolTipProps) { 초기화 버튼을 눌러 타이머를 원래 시간으로 초기화 마우스를 사용하여 타이머를 클릭 시, 진영 변경 - 타이머 동작 중 진영이 변경될 경우, 상대 진영의 타이머로 전환과 - 동시에 시작 + { + '타이머 동작 중 진영이 변경될 경우, 상대 진영의 타이머로 전환과 동시에 시작' + }
From fef0a0ffe8c463bb9e95e98b382f80da0692f3fd Mon Sep 17 00:00:00 2001 From: useon Date: Mon, 29 Dec 2025 22:53:20 +0900 Subject: [PATCH 23/51] =?UTF-8?q?fix:=20=EC=96=B8=EC=96=B4=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8C=85=20=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89?= =?UTF-8?q?=ED=8A=B8=20=EC=8B=9C=20=EC=BF=BC=EB=A6=AC=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A7=81=20=EC=9C=A0=EC=A7=80=EA=B0=80=20=EC=95=88=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/LanguageWrapper.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/routes/LanguageWrapper.tsx b/src/routes/LanguageWrapper.tsx index 35feaf8d..b81ddd16 100644 --- a/src/routes/LanguageWrapper.tsx +++ b/src/routes/LanguageWrapper.tsx @@ -20,14 +20,16 @@ export default function LanguageWrapper() { if (lang === DEFAULT_LANG) { const nextPath = stripDefaultLangFromPath(location.pathname); - navigate(nextPath || '/', { replace: true }); + const nextUrl = `${nextPath || '/'}${location.search}${location.hash}`; + navigate(nextUrl, { replace: true }); return; } if (!lang && isSupportedLang(currentLang) && currentLang !== DEFAULT_LANG) { const nextPath = buildLangPath(location.pathname, currentLang); if (nextPath !== location.pathname) { - navigate(nextPath, { replace: true }); + const nextUrl = `${nextPath}${location.search}${location.hash}`; + navigate(nextUrl, { replace: true }); return; } } From 6e51d9d50d41c8193fb1be1b83b9a5be312ad3c6 Mon Sep 17 00:00:00 2001 From: useon Date: Mon, 29 Dec 2025 22:59:46 +0900 Subject: [PATCH 24/51] =?UTF-8?q?fix:=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EB=A6=AC=ED=84=B0=EB=9F=B4=EC=97=90=EC=84=9C=20escape=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=EA=B0=80=20=ED=8F=AC=ED=95=A8=EB=90=9C=20?= =?UTF-8?q?=ED=95=9C=EA=B8=80=20=EB=AC=B8=EC=9E=90=EC=97=B4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/utils/astUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/utils/astUtils.ts b/scripts/utils/astUtils.ts index 719ca2ac..57fcdfb1 100644 --- a/scripts/utils/astUtils.ts +++ b/scripts/utils/astUtils.ts @@ -116,7 +116,7 @@ export function transformAST(ast: t.File) { }, TemplateLiteral(path) { const { quasis, expressions } = path.node; - const hasKorean = quasis.some((q) => KOREAN_REGEX.test(q.value.raw)); + const hasKorean = quasis.some((q) => KOREAN_REGEX.test(q.value.cooked)); if (!hasKorean) return; if ( @@ -134,7 +134,7 @@ export function transformAST(ast: t.File) { const objectProperties: t.ObjectProperty[] = []; for (let i = 0; i < quasis.length; i++) { - i18nKey += quasis[i].value.raw; + i18nKey += quasis[i].value.cooked; if (i < expressions.length) { const expr = expressions[i]; let placeholderName: string; From 8ca75cec69cd95765a1424d4f9cab6084d11885c Mon Sep 17 00:00:00 2001 From: useon Date: Wed, 28 Jan 2026 00:44:52 +0900 Subject: [PATCH 25/51] =?UTF-8?q?refactor:=20i18n=EC=9E=90=EB=8F=99?= =?UTF-8?q?=ED=99=94=EB=A5=BC=20=EC=9D=B4=EC=9A=A9=ED=95=B4=EC=84=9C=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B3=80=ED=99=98,=20im?= =?UTF-8?q?port=20=EB=B0=8F=20hook=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/DropdownMenu/DropdownMenu.tsx | 4 +- src/components/ErrorBoundary/ErrorPage.tsx | 15 ++- src/components/ErrorBoundary/NotFoundPage.tsx | 20 ++- .../ErrorIndicator/ErrorIndicator.tsx | 6 +- .../GoToDebateEndButton.tsx | 6 +- .../HeaderTableInfo/HeaderTableInfo.tsx | 4 +- src/components/HeaderTitle/HeaderTitle.tsx | 4 +- .../LoadingIndicator/LoadingIndicator.tsx | 6 +- .../NotificationBadge/NotificationBadge.tsx | 4 +- .../ProsAndConsTitle/PropsAndConsTitle.tsx | 6 +- .../RoundControlButton/RoundControlButton.tsx | 8 +- src/components/ShareModal/ShareModal.tsx | 8 +- src/hooks/useModal.tsx | 4 +- .../components/header/LanguageSelector.tsx | 4 +- .../header/StickyTriSectionHeader.tsx | 22 ++-- src/page/DebateEndPage/DebateEndPage.tsx | 24 ++-- .../components/GoToOverviewButton.tsx | 6 +- src/page/DebateVotePage/DebateVotePage.tsx | 17 ++- .../DebateVoteResultPage.tsx | 27 ++-- .../components/VoteBar.tsx | 7 +- .../components/VoteDetailResult.tsx | 8 +- .../components/WinnerCard.tsx | 10 +- src/page/LandingPage/components/Header.tsx | 4 +- .../LandingPage/components/MainSection.tsx | 8 +- .../LandingPage/components/ReportSection.tsx | 14 +- .../LandingPage/components/ReviewSection.tsx | 11 +- .../LandingPage/components/ScrollHint.tsx | 4 +- .../LandingPage/components/TableSection.tsx | 24 ++-- .../components/TemplateApplicationSection.tsx | 8 +- .../LandingPage/components/TemplateCard.tsx | 8 +- .../components/TemplateSelection.tsx | 4 +- .../components/TimeTableSection.tsx | 18 +-- .../LandingPage/components/TimerSection.tsx | 38 +++--- .../TableComposition/TableCompositionPage.tsx | 15 ++- .../TableNameAndType/TableNameAndType.tsx | 28 ++-- .../components/TimeBox/TimeBox.tsx | 17 ++- .../TimeBoxManageButtons.tsx | 14 +- .../components/TimeBoxStep/TimeBoxStep.tsx | 10 +- .../TimerCreationContent/TimeInputGroup.tsx | 8 +- .../TimerCreationContent.tsx | 123 ++++++++++-------- src/page/TableListPage/TableListPage.tsx | 4 +- src/page/TableListPage/components/Table.tsx | 20 ++- .../TableOverviewPage/TableOverviewPage.tsx | 9 +- .../TeamSelectionModal/TeamSelectionModal.tsx | 18 +-- .../TableSharingPage/TableSharingPage.tsx | 17 ++- .../components/LoggedInStoreDBModal.tsx | 8 +- src/page/TimerPage/FeedbackTimerPage.tsx | 4 +- src/page/TimerPage/TimerPage.tsx | 12 +- .../components/CompactTimeoutTimer.tsx | 12 +- .../TimerPage/components/FeedbackTimer.tsx | 20 +-- .../TimerPage/components/FirstUseToolTip.tsx | 74 +++++++---- .../components/LoginAndStoreModal.tsx | 8 +- src/page/TimerPage/components/NormalTimer.tsx | 8 +- .../TimerPage/components/TimeBasedTimer.tsx | 8 +- .../TimerPage/components/TimerController.tsx | 6 +- .../VoteCompletePage/VoteCompletePage.tsx | 5 +- .../VoteParticipationPage.tsx | 27 ++-- 57 files changed, 516 insertions(+), 320 deletions(-) diff --git a/src/components/DropdownMenu/DropdownMenu.tsx b/src/components/DropdownMenu/DropdownMenu.tsx index 122a18e8..6b2d9e18 100644 --- a/src/components/DropdownMenu/DropdownMenu.tsx +++ b/src/components/DropdownMenu/DropdownMenu.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useState, useRef, useEffect } from 'react'; import DTExpand from '../icons/Expand'; import clsx from 'clsx'; @@ -24,12 +25,13 @@ export default function DropdownMenu({ disabled, className = '', }: DropdownMenuProps) { + const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); const selectedOptionLabel = options.find((option) => option.value === selectedValue)?.label || - (placeholder ?? '선택'); + (placeholder ?? t('선택')); // 드롭다운 외부 클릭 시 닫히도록 처리 useEffect(() => { diff --git a/src/components/ErrorBoundary/ErrorPage.tsx b/src/components/ErrorBoundary/ErrorPage.tsx index 51c69ccc..402df3c6 100644 --- a/src/components/ErrorBoundary/ErrorPage.tsx +++ b/src/components/ErrorBoundary/ErrorPage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { IoHome } from 'react-icons/io5'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import { useNavigate } from 'react-router-dom'; @@ -11,6 +12,7 @@ interface ErrorPageProps { } export default function ErrorPage({ error, stack, onReset }: ErrorPageProps) { + const { t } = useTranslation(); const navigate = useNavigate(); const goToHome = () => { onReset(); @@ -21,8 +23,9 @@ export default function ErrorPage({ error, stack, onReset }: ErrorPageProps) { // to let user know exact reason of error. const title = error instanceof APIError - ? ERROR_STATUS_TABLE[error.status] || `${error.status} 오류` - : '오류가 발생했어요...'; + ? ERROR_STATUS_TABLE[error.status] || + t('{{status}} 오류', { status: error.status }) + : t('오류가 발생했어요...'); return ( @@ -40,12 +43,12 @@ export default function ErrorPage({ error, stack, onReset }: ErrorPageProps) {
-

오류 내용

+

{t('오류 내용')}

{error.message}

-

스택

+

{t('스택')}

{stack}

@@ -56,7 +59,9 @@ export default function ErrorPage({ error, stack, onReset }: ErrorPageProps) { >
-

홈으로 돌아가기

+

+ {t('홈으로 돌아가기')} +

diff --git a/src/components/ErrorBoundary/NotFoundPage.tsx b/src/components/ErrorBoundary/NotFoundPage.tsx index a1fe810d..7b1ffeab 100644 --- a/src/components/ErrorBoundary/NotFoundPage.tsx +++ b/src/components/ErrorBoundary/NotFoundPage.tsx @@ -1,8 +1,10 @@ +import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import { IoHome } from 'react-icons/io5'; export default function NotFoundPage() { + const { t } = useTranslation(); const navigate = useNavigate(); return ( @@ -17,22 +19,24 @@ export default function NotFoundPage() {

🤔

-

페이지를 찾을 수 없어요...

+

+ {t('페이지를 찾을 수 없어요...')} +

-

요청 URL

+

{t('요청 URL')}

{decodeURIComponent(window.location.href)}

-

오류 내용

+

{t('오류 내용')}

- { - '요청하신 페이지를 찾을 수 없어요.\n홈 화면으로 돌아가 처음부터 다시 시도해주세요.' - } + {t( + '요청하신 페이지를 찾을 수 없어요.\n홈 화면으로 돌아가 처음부터 다시 시도해주세요.', + )}

@@ -43,7 +47,9 @@ export default function NotFoundPage() { >
-

홈으로 돌아가기

+

+ {t('홈으로 돌아가기')} +

diff --git a/src/components/ErrorIndicator/ErrorIndicator.tsx b/src/components/ErrorIndicator/ErrorIndicator.tsx index 6af222c7..8e7312ff 100644 --- a/src/components/ErrorIndicator/ErrorIndicator.tsx +++ b/src/components/ErrorIndicator/ErrorIndicator.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { PropsWithChildren } from 'react'; import { MdErrorOutline } from 'react-icons/md'; @@ -9,11 +10,12 @@ export default function ErrorIndicator({ children, onClickRetry, }: ErrorIndicatorProps) { + const { t } = useTranslation(); return (

- {children ?? '데이터를 불러오지 못했어요.\n다시 시도할까요?'} + {children ?? t('데이터를 불러오지 못했어요.\n다시 시도할까요?')}

{onClickRetry && ( @@ -21,7 +23,7 @@ export default function ErrorIndicator({ onClick={onClickRetry} className="small-button enabled px-8 py-1" > - 다시 시도하기 + {t('다시 시도하기')} )}
diff --git a/src/components/GoToDebateEndButton/GoToDebateEndButton.tsx b/src/components/GoToDebateEndButton/GoToDebateEndButton.tsx index a94bbc4f..81ba6715 100644 --- a/src/components/GoToDebateEndButton/GoToDebateEndButton.tsx +++ b/src/components/GoToDebateEndButton/GoToDebateEndButton.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; import { useNavigate } from 'react-router-dom'; @@ -10,6 +11,7 @@ export default function GoToDebateEndButton({ tableId, className = '', }: GoToDebateEndButtonProps) { + const { t } = useTranslation(); const navigate = useNavigate(); const handleClick = (tableId: number) => { navigate(`/table/customize/${tableId}/end`); @@ -18,14 +20,14 @@ export default function GoToDebateEndButton({ return ( ); } diff --git a/src/components/HeaderTableInfo/HeaderTableInfo.tsx b/src/components/HeaderTableInfo/HeaderTableInfo.tsx index fcd70cae..2136e1be 100644 --- a/src/components/HeaderTableInfo/HeaderTableInfo.tsx +++ b/src/components/HeaderTableInfo/HeaderTableInfo.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import Skeleton from '../Skeleton/Skeleton'; interface HeaderTitleProps { @@ -6,8 +7,9 @@ interface HeaderTitleProps { } export default function HeaderTableInfo(props: HeaderTitleProps) { + const { t } = useTranslation(); const { name, skeletonEnabled: isLoading = false } = props; - const displayName = !name?.trim() ? '테이블 이름 없음' : name.trim(); + const displayName = !name?.trim() ? t('테이블 이름 없음') : name.trim(); return ( <> diff --git a/src/components/HeaderTitle/HeaderTitle.tsx b/src/components/HeaderTitle/HeaderTitle.tsx index d3bebe59..c871daf0 100644 --- a/src/components/HeaderTitle/HeaderTitle.tsx +++ b/src/components/HeaderTitle/HeaderTitle.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import Skeleton from '../Skeleton/Skeleton'; interface HeaderTitleProps { @@ -6,8 +7,9 @@ interface HeaderTitleProps { } export default function HeaderTitle(props: HeaderTitleProps) { + const { t } = useTranslation(); const { title, skeletonEnabled: isLoading = false } = props; - const displayTitle = !title?.trim() ? '주제 없음' : title.trim(); + const displayTitle = !title?.trim() ? t('주제 없음') : title.trim(); return ( <> diff --git a/src/components/LoadingIndicator/LoadingIndicator.tsx b/src/components/LoadingIndicator/LoadingIndicator.tsx index f2c10794..e9635336 100644 --- a/src/components/LoadingIndicator/LoadingIndicator.tsx +++ b/src/components/LoadingIndicator/LoadingIndicator.tsx @@ -1,11 +1,15 @@ +import { useTranslation } from 'react-i18next'; import { PropsWithChildren } from 'react'; import LoadingSpinner from '../LoadingSpinner'; export default function LoadingIndicator({ children }: PropsWithChildren) { + const { t } = useTranslation(); return (
-

{children ?? '데이터를 불러오고 있습니다...'}

+

+ {children ?? t('데이터를 불러오고 있습니다...')} +

); } diff --git a/src/components/NotificationBadge/NotificationBadge.tsx b/src/components/NotificationBadge/NotificationBadge.tsx index 3e2307fb..c15eb747 100644 --- a/src/components/NotificationBadge/NotificationBadge.tsx +++ b/src/components/NotificationBadge/NotificationBadge.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; interface NotificationBadgeProps { @@ -9,6 +10,7 @@ export default function NotificationBadge({ count, className = '', }: NotificationBadgeProps) { + const { t } = useTranslation(); // 음수, NaN 등 의도하지 않은 값 확인 const safeCount = Number.isFinite(count) ? Math.max(0, count) : 0; if (safeCount === 0) { @@ -20,7 +22,7 @@ export default function NotificationBadge({ return (
- {prosTeamName ?? '찬성'} + {prosTeamName ?? t('찬성')}
- {consTeamName ?? '반대'} + {consTeamName ?? t('반대')}
diff --git a/src/components/RoundControlButton/RoundControlButton.tsx b/src/components/RoundControlButton/RoundControlButton.tsx index 69fd0052..8877f0ea 100644 --- a/src/components/RoundControlButton/RoundControlButton.tsx +++ b/src/components/RoundControlButton/RoundControlButton.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import DTLeftArrow from '../icons/LeftArrow'; import DTRightArrow from '../icons/RightArrow'; @@ -12,6 +13,7 @@ export default function RoundControlButton({ type, onClick, }: RoundControlButtonProps) { + const { t } = useTranslation(); return ( diff --git a/src/components/ShareModal/ShareModal.tsx b/src/components/ShareModal/ShareModal.tsx index 20442b4a..663155b7 100644 --- a/src/components/ShareModal/ShareModal.tsx +++ b/src/components/ShareModal/ShareModal.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { QRCodeSVG } from 'qrcode.react'; import { IoLinkOutline, IoShareOutline } from 'react-icons/io5'; import LoadingSpinner from '../LoadingSpinner'; @@ -21,12 +22,13 @@ export default function ShareModal({ onRefetch, onCopyClicked, }: ShareModalProps) { + const { t } = useTranslation(); // If error, print error message and let user be able to retry if (isError) { return (
onRefetch()}> - {'QR 코드를 불러오지 못했어요.\n다시 시도하시겠어요?'} + {t('QR 코드를 불러오지 못했어요.\n다시 시도하시겠어요?')}
); @@ -44,7 +46,7 @@ export default function ShareModal({

- 링크가 클립보드에 복사됨 + {t('링크가 클립보드에 복사됨')}

@@ -89,7 +91,7 @@ export default function ShareModal({ }} > -

{isLoading ? '링크 준비 중' : '공유 링크 복사'}

+

{isLoading ? t('링크 준비 중') : t('공유 링크 복사')}

); diff --git a/src/hooks/useModal.tsx b/src/hooks/useModal.tsx index dcdb1beb..d5a457a4 100644 --- a/src/hooks/useModal.tsx +++ b/src/hooks/useModal.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { ReactNode, useState, useCallback, useEffect } from 'react'; import { GlobalPortal } from '../util/GlobalPortal'; import DTClose from '../components/icons/Close'; @@ -57,6 +58,7 @@ export function useModal(options: UseModalOptions = {}) { children: ReactNode; closeButtonColor?: string; }) => { + const { t } = useTranslation(); if (!isOpen) return null; return ( @@ -72,7 +74,7 @@ export function useModal(options: UseModalOptions = {}) { type="button" onClick={closeModal} className={`absolute right-4 top-4 text-3xl ${closeButtonColor}`} - aria-label="모달 닫기" + aria-label={t('모달 닫기')} > diff --git a/src/layout/components/header/LanguageSelector.tsx b/src/layout/components/header/LanguageSelector.tsx index 8e55239f..b9cf4a0f 100644 --- a/src/layout/components/header/LanguageSelector.tsx +++ b/src/layout/components/header/LanguageSelector.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useState, useRef, useEffect } from 'react'; import { useNavigate, useParams, useLocation } from 'react-router-dom'; import clsx from 'clsx'; @@ -11,6 +12,7 @@ import { import i18n from '../../../i18n'; export default function LanguageSelector() { + const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const { lang: currentLangParam } = useParams(); @@ -90,7 +92,7 @@ export default function LanguageSelector() { onClick={() => setIsMenuOpen((prev) => !prev)} aria-haspopup="listbox" aria-expanded={isMenuOpen} - aria-label="언어 선택" + aria-label={t('언어 선택')} > {/* 현재 선택된 언어 표시 */} diff --git a/src/layout/components/header/StickyTriSectionHeader.tsx b/src/layout/components/header/StickyTriSectionHeader.tsx index 95d52c6b..59b321ac 100644 --- a/src/layout/components/header/StickyTriSectionHeader.tsx +++ b/src/layout/components/header/StickyTriSectionHeader.tsx @@ -48,6 +48,7 @@ StickyTriSectionHeader.Center = function Center(props: PropsWithChildren) { }; StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { + const { t } = useTranslation(); const { children: buttons } = props; const navigate = useNavigate(); const { mutate: logoutMutate } = useLogout(() => navigate('/home')); @@ -66,7 +67,7 @@ StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) {
{isGuestFlow() && (
- 비회원 모드 + {t('비회원 모드')}
)} @@ -83,8 +84,8 @@ StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { return ( ); } diff --git a/src/page/DebateVotePage/DebateVotePage.tsx b/src/page/DebateVotePage/DebateVotePage.tsx index 70061bdc..13a41728 100644 --- a/src/page/DebateVotePage/DebateVotePage.tsx +++ b/src/page/DebateVotePage/DebateVotePage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { QRCodeSVG } from 'qrcode.react'; @@ -7,6 +8,7 @@ import ErrorIndicator from '../../components/ErrorIndicator/ErrorIndicator'; import useFetchEndPoll from '../../hooks/mutations/useFetchEndPoll'; import GoToDebateEndButton from '../../components/GoToDebateEndButton/GoToDebateEndButton'; export default function DebateVotePage() { + const { t } = useTranslation(); const navigate = useNavigate(); const baseUrl = import.meta.env.MODE !== 'production' @@ -58,7 +60,7 @@ export default function DebateVotePage() { navigate('/')}> - 유효하지 않은 투표 링크입니다. + {t('유효하지 않은 투표 링크입니다.')} @@ -71,13 +73,13 @@ export default function DebateVotePage() {

- 승패투표 + {t('승패투표')}

-

스캔해 주세요!

+

{t('스캔해 주세요!')}

@@ -87,7 +89,8 @@ export default function DebateVotePage() {

- 참여자 + {t('참여자')} + ({participants?.length ?? 0}) @@ -95,7 +98,9 @@ export default function DebateVotePage() {

{!isLoading && participants && participants.length === 0 && ( -

등록된 토론자가 없어요.

+

+ {t('등록된 토론자가 없어요.')} +

)} {!isLoading && participants && participants.length > 0 && (
    @@ -121,7 +126,7 @@ export default function DebateVotePage() { onClick={() => mutate(pollId)} className="button enabled brand flex flex-1 flex-row rounded-full p-[24px]" > - 투표 결과 보기 + {t('투표 결과 보기')}
diff --git a/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx b/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx index 4c6cac66..c2d2a499 100644 --- a/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx +++ b/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; @@ -9,7 +10,9 @@ 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 { t } = useTranslation(); // 매개변수 검증 const { pollId: rawPollId, tableId: rawTableId } = useParams(); const pollId = rawPollId ? Number(rawPollId) : NaN; @@ -59,7 +62,7 @@ export default function DebateVoteResultPage() { } else { return { teamKey: null, - teamName: '무승부', + teamName: t('무승부'), }; } }; @@ -69,7 +72,7 @@ export default function DebateVoteResultPage() { navigate('/')}> - 유효하지 않은 투표 결과 링크입니다. + {t('유효하지 않은 투표 결과 링크입니다.')} @@ -85,8 +88,8 @@ export default function DebateVoteResultPage() { ); } const { teamKey, teamName } = getWinner({ - prosTeamName: data?.prosTeamName || '찬성팀', - consTeamName: data?.consTeamName || '반대팀', + prosTeamName: data?.prosTeamName || t('찬성팀'), + consTeamName: data?.consTeamName || t('반대팀'), prosCount: data?.prosCount || 0, consCount: data?.consCount || 0, }); @@ -96,7 +99,7 @@ export default function DebateVoteResultPage() {

- 승패투표 + {t('승패투표')}

@@ -112,7 +115,7 @@ export default function DebateVoteResultPage() { className="button enabled neutral flex w-full flex-1 rounded-full p-[24px]" disabled={isLoading} > - 뒤로 가기 ← + {t('뒤로 가기 ←')}
@@ -130,11 +133,11 @@ export default function DebateVoteResultPage() { {isConfirmed ? ( closeModal(), }} right={{ - text: '네', + text: t('네'), onClick: () => setIsConfirmed(true), isBold: true, }} >
- 정말로 세부 결과를 공개할까요? + {t('정말로 세부 결과를 공개할까요?')}
)} diff --git a/src/page/DebateVoteResultPage/components/VoteBar.tsx b/src/page/DebateVoteResultPage/components/VoteBar.tsx index 2a6c6439..fcb84cf1 100644 --- a/src/page/DebateVoteResultPage/components/VoteBar.tsx +++ b/src/page/DebateVoteResultPage/components/VoteBar.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; import AnimatedCounter from './AnimatedCounter'; import { TEAM_STYLE, TeamKey } from '../../../type/type'; @@ -17,9 +18,10 @@ export default function VoteBar({ total, heightClass = 'h-20', }: VoteBarProps) { + const { t } = useTranslation(); const style = TEAM_STYLE[teamKey]; const percentage = total > 0 ? (count / total) * 100 : 0; - const sideLabel = teamKey === 'PROS' ? '찬성팀' : '반대팀'; + const sideLabel = teamKey === 'PROS' ? t('찬성팀') : t('반대팀'); // 배경 바 색상은 좀 더 투명하게 const barTone = @@ -52,7 +54,8 @@ export default function VoteBar({
- 명 + + {t('명')}
diff --git a/src/page/DebateVoteResultPage/components/VoteDetailResult.tsx b/src/page/DebateVoteResultPage/components/VoteDetailResult.tsx index e22a2bcf..8198fba6 100644 --- a/src/page/DebateVoteResultPage/components/VoteDetailResult.tsx +++ b/src/page/DebateVoteResultPage/components/VoteDetailResult.tsx @@ -1,4 +1,4 @@ -// pages/VoteDetailResult.tsx +import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; import VoteBar from './VoteBar'; @@ -13,6 +13,7 @@ export default function VoteDetailResult({ pros, cons, }: VoteDetailResultProps) { + const { t } = useTranslation(); return (

- 투표 세부 결과 + {t('투표 세부 결과')}

@@ -34,6 +35,7 @@ export default function VoteDetailResult({ count={pros.count} total={pros.count + cons.count} /> +
- 홈으로 돌아가기 + {t('홈으로 돌아가기')}
diff --git a/src/page/DebateVoteResultPage/components/WinnerCard.tsx b/src/page/DebateVoteResultPage/components/WinnerCard.tsx index 61c72528..eb013bcc 100644 --- a/src/page/DebateVoteResultPage/components/WinnerCard.tsx +++ b/src/page/DebateVoteResultPage/components/WinnerCard.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import crown from '../../../assets/debateEnd/crown.svg'; import { TEAM_STYLE, TeamKey } from '../../../type/type'; import clsx from 'clsx'; @@ -8,9 +9,14 @@ interface WinnerCardProps { } export default function WinnerCard({ teamkey, teamName }: WinnerCardProps) { + const { t } = useTranslation(); const style = teamkey ? TEAM_STYLE[teamkey] : null; const sideLabel = - teamkey === 'PROS' ? '찬성팀' : teamkey === 'CONS' ? '반대팀' : '무승부'; + teamkey === 'PROS' + ? t('찬성팀') + : teamkey === 'CONS' + ? t('반대팀') + : t('무승부'); return (
@@ -46,7 +52,7 @@ export default function WinnerCard({ teamkey, teamName }: WinnerCardProps) { {/* 왕관 — 무승부일 때는 표시 안 함 */} {teamkey && (
- 왕관 + {t('왕관')}
)}
diff --git a/src/page/LandingPage/components/Header.tsx b/src/page/LandingPage/components/Header.tsx index 1591cec0..0d8df66f 100644 --- a/src/page/LandingPage/components/Header.tsx +++ b/src/page/LandingPage/components/Header.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useState, useEffect } from 'react'; import { isLoggedIn } from '../../../util/accessToken'; import LanguageSelector from '../../../layout/components/header/LanguageSelector'; @@ -7,6 +8,7 @@ interface HeaderProps { } export default function Header({ onLoginButtonClicked }: HeaderProps) { + const { t } = useTranslation(); const [isScrolled, setIsScrolled] = useState(false); useEffect(() => { @@ -34,7 +36,7 @@ export default function Header({ onLoginButtonClicked }: HeaderProps) { className="text-body-raw font-semibold md:text-subtitle-raw" onClick={onLoginButtonClicked} > - {!isLoggedIn() ? '3초 로그인' : '로그아웃'} + {!isLoggedIn() ? t('3초 로그인') : t('로그아웃')}
diff --git a/src/page/LandingPage/components/MainSection.tsx b/src/page/LandingPage/components/MainSection.tsx index bd86ad77..ddd70fa5 100644 --- a/src/page/LandingPage/components/MainSection.tsx +++ b/src/page/LandingPage/components/MainSection.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import preview from '../../../assets/landing/preview.webm'; import { isLoggedIn } from '../../../util/accessToken'; @@ -10,22 +11,23 @@ export default function MainSection({ onStartWithoutLogin, onDashboardButtonClicked, }: MainSectionProps) { + const { t } = useTranslation(); return (

- 토론 진행을 더 쉽고 빠르게 + {t('토론 진행을 더 쉽고 빠르게')}

); diff --git a/src/page/LandingPage/components/ReportSection.tsx b/src/page/LandingPage/components/ReportSection.tsx index 57a20ea6..86365e78 100644 --- a/src/page/LandingPage/components/ReportSection.tsx +++ b/src/page/LandingPage/components/ReportSection.tsx @@ -1,16 +1,18 @@ +import { useTranslation } from 'react-i18next'; import section501 from '../../../assets/landing/section5-1.png'; import { LANDING_URLS } from '../../../constants/urls'; export default function ReportSection() { + const { t } = useTranslation(); return (

- 버그 및 불편사항 제보 + {t('버그 및 불편사항 제보')}

- 디베이트 타이머 사용 중 불편함을 느끼셨나요? + {t('디베이트 타이머 사용 중 불편함을 느끼셨나요?')}

section501

- 디베이트 타이머 + {t('디베이트 타이머')}

| @@ -57,7 +59,7 @@ export default function ReportSection() { } className="text-[min(max(0.75rem,1vw),1rem)] text-neutral-500 transition-colors hover:text-neutral-700" > - 서비스 이용약관 + {t('서비스 이용약관')}
diff --git a/src/page/LandingPage/components/ReviewSection.tsx b/src/page/LandingPage/components/ReviewSection.tsx index 81a0b754..d63a245d 100644 --- a/src/page/LandingPage/components/ReviewSection.tsx +++ b/src/page/LandingPage/components/ReviewSection.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import ReviewCard from './ReviewCard'; import { REVIEWS } from '../../../constants/reviews'; @@ -8,14 +9,18 @@ interface ReviewSectionProps { export default function ReviewSection({ onStartWithoutLogin, }: ReviewSectionProps) { + const { t } = useTranslation(); return (
-

이미 많은 사람들이 디베이트 타이머로

-

더 나은 토론환경을 만들고 있어요.

+

+ {t( + '이미 많은 사람들이 디베이트 타이머로\n더 나은 토론환경을 만들고 있어요.', + )} +

{REVIEWS.map((review) => ( @@ -27,7 +32,7 @@ export default function ReviewSection({ className="rounded-full border border-neutral-300 bg-brand px-20 py-2 text-[min(max(0.875rem,1.25vw),1.2rem)] font-medium text-default-black transition-all duration-100 hover:bg-semantic-table hover:text-default-white" onClick={onStartWithoutLogin} > - 비회원으로 시작하기 + {t('비회원으로 시작하기')}
diff --git a/src/page/LandingPage/components/ScrollHint.tsx b/src/page/LandingPage/components/ScrollHint.tsx index 648ce25a..0bb216b1 100644 --- a/src/page/LandingPage/components/ScrollHint.tsx +++ b/src/page/LandingPage/components/ScrollHint.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useEffect, useState } from 'react'; import arrowDown from '../../../assets/landing/bottom_arrow.png'; type ScrollHintProps = { @@ -5,6 +6,7 @@ type ScrollHintProps = { }; export default function ScrollHint({ topThreshold = 10 }: ScrollHintProps) { + const { t } = useTranslation(); const [visible, setVisible] = useState(true); useEffect(() => { @@ -39,7 +41,7 @@ export default function ScrollHint({ topThreshold = 10 }: ScrollHintProps) { > 아래로 스크롤
diff --git a/src/page/LandingPage/components/TableSection.tsx b/src/page/LandingPage/components/TableSection.tsx index 3cab8a18..aa5aef38 100644 --- a/src/page/LandingPage/components/TableSection.tsx +++ b/src/page/LandingPage/components/TableSection.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import section301 from '../../../assets/landing/debate_info_setting.png'; import section302 from '../../../assets/landing/table_list.png'; @@ -6,25 +7,25 @@ interface TableSectionProps { } export default function TableSection({ onLogin }: TableSectionProps) { + const { t } = useTranslation(); return (
- 홈 | 설정 + {t('홈 | 설정')}
-

- 토론 정보
- 관리 및 기록 +

+ {t('토론 정보\n관리 및 기록')}

- 토론 기본 정보 설정 + {t('토론 기본 정보 설정')}

- 시간표 이름부터 주제까지! + {t('시간표 이름부터 주제까지!')}

section301 @@ -33,22 +34,23 @@ export default function TableSection({ onLogin }: TableSectionProps) { section302

- 시간표 목록 + {t('시간표 목록')}

- 내가 만든 시간표를 저장하고 싶나요? + {t('내가 만든 시간표를 저장하고 싶나요?')}

-

시간표를 저장하려면,

-

디베이트 타이머에 로그인해 보세요!

+

+ {t('시간표를 저장하려면,\n디베이트 타이머에 로그인해 보세요!')} +

diff --git a/src/page/LandingPage/components/TemplateApplicationSection.tsx b/src/page/LandingPage/components/TemplateApplicationSection.tsx index e9fb0747..43578023 100644 --- a/src/page/LandingPage/components/TemplateApplicationSection.tsx +++ b/src/page/LandingPage/components/TemplateApplicationSection.tsx @@ -1,15 +1,17 @@ +import { useTranslation } from 'react-i18next'; import section501 from '../../../assets/landing/section5-1.png'; import { LANDING_URLS } from '../../../constants/urls'; export default function TemplateApplicationSection() { + const { t } = useTranslation(); return (

- 템플릿 신청하기 + {t('템플릿 신청하기')}

- 새로운 템플릿도 신청해 볼까요? + {t('새로운 템플릿도 신청해 볼까요?')}

section501 diff --git a/src/page/LandingPage/components/TemplateCard.tsx b/src/page/LandingPage/components/TemplateCard.tsx index 0bc90367..f0ee93dd 100644 --- a/src/page/LandingPage/components/TemplateCard.tsx +++ b/src/page/LandingPage/components/TemplateCard.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { DebateTemplate } from '../../../type/type'; import clsx from 'clsx'; @@ -8,6 +9,7 @@ export default function TemplateCard({ actions, className, }: DebateTemplate) { + const { t } = useTranslation(); return (
diff --git a/src/page/LandingPage/components/TemplateSelection.tsx b/src/page/LandingPage/components/TemplateSelection.tsx index d7afa6ee..a238875f 100644 --- a/src/page/LandingPage/components/TemplateSelection.tsx +++ b/src/page/LandingPage/components/TemplateSelection.tsx @@ -1,13 +1,15 @@ +import { useTranslation } from 'react-i18next'; import { DEBATE_TEMPLATE } from '../../../constants/debate_template'; import TemplateApplicationSection from './TemplateApplicationSection'; import TemplateList from './TemplateList'; export default function TemplateSelection() { + const { t } = useTranslation(); return (

- 다양한 토론 템플릿을 원클릭으로 만나보세요! + {t('다양한 토론 템플릿을 원클릭으로 만나보세요!')}

diff --git a/src/page/LandingPage/components/TimeTableSection.tsx b/src/page/LandingPage/components/TimeTableSection.tsx index 7e44f22b..b165bbbf 100644 --- a/src/page/LandingPage/components/TimeTableSection.tsx +++ b/src/page/LandingPage/components/TimeTableSection.tsx @@ -1,18 +1,20 @@ +import { useTranslation } from 'react-i18next'; import timeboxStep from '../../../assets/landing/timebox_step.png'; import timeboxButtons from '../../../assets/landing/timebox_step_button.png'; import bellSetting from '../../../assets/landing/bell_setting.png'; import twoTimer from '../../../assets/landing/two_timer.png'; import timeboxAddButton from '../../../assets/landing/timebox_add_button.png'; export default function TimeTableSection() { + const { t } = useTranslation(); return (
- 시간표 설정화면 + {t('시간표 설정화면')}

- 간편한 시간표 구성 + {t('간편한 시간표 구성')}

@@ -21,11 +23,11 @@ export default function TimeTableSection() {

- 시간표 추가 + {t('시간표 추가')}

시간표 추가 버튼
@@ -33,20 +35,20 @@ export default function TimeTableSection() { section302

- 두가지 타이머 + {t('두가지 타이머')}

- {'일반형과 자유토론형 타이머로\n다양한 토론 방식을 지원해요.'} + {t('일반형과 자유토론형 타이머로\n다양한 토론 방식을 지원해요.')}

- 종소리 설정 + {t('종소리 설정')}

- {'시간에 따른 종소리를 내마음대로\n커스터마이징 할 수 있어요.'} + {t('시간에 따른 종소리를 내마음대로\n커스터마이징 할 수 있어요.')}

section302 diff --git a/src/page/LandingPage/components/TimerSection.tsx b/src/page/LandingPage/components/TimerSection.tsx index bdb1ac86..608f3b46 100644 --- a/src/page/LandingPage/components/TimerSection.tsx +++ b/src/page/LandingPage/components/TimerSection.tsx @@ -1,3 +1,4 @@ +import { Trans, useTranslation } from 'react-i18next'; import timer from '../../../assets/landing/timer.png'; import timerOperationTime from '../../../assets/landing/timer_operation_time.png'; import timerTimeBased from '../../../assets/landing/timer_timebased.png'; @@ -5,6 +6,7 @@ import keyInfo from '../../../assets/landing/key_info.png'; import timeoutButton from '../../../assets/landing/timeout_button.png'; export default function TimerSection() { + const { t } = useTranslation(); return (
- 타이머 화면 + {t('타이머 화면')}

- {'원하는 때에\n작전 시간 사용하기'} + {t('원하는 때에\n작전 시간 사용하기')}

section301
-

- {/* TODO: Trans를 활용해서 키 관리하기 */} - 토론자가 작전 시간을 -
- 요청하면{' '} - 작전 시간 사용{' '} -
- 버튼을 눌러 시간을 사용해요 +

+ \n버튼을 눌러 시간을 사용해요' + } + components={[ + {t('작전, + ]} + />

- {'작전 시간이 나타나면\n원하는 시간을 입력하세요!'} + {t('작전 시간이 나타나면\n원하는 시간을 입력하세요!')}

section302 @@ -49,7 +53,7 @@ export default function TimerSection() {

- {'키보드 방향키로\n더 편리한 조작'} + {t('키보드 방향키로\n더 편리한 조작')}

diff --git a/src/page/TableComposition/TableCompositionPage.tsx b/src/page/TableComposition/TableCompositionPage.tsx index 3ac88d01..858c07f4 100644 --- a/src/page/TableComposition/TableCompositionPage.tsx +++ b/src/page/TableComposition/TableCompositionPage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import TableNameAndType from './components/TableNameAndType/TableNameAndType'; import useFunnel from '../../hooks/useFunnel'; @@ -14,13 +15,14 @@ export type TableCompositionStep = 'NameAndType' | 'TimeBox'; type Mode = 'edit' | 'add'; export default function TableCompositionPage() { + const { t } = useTranslation(); // URL 등으로부터 "editMode"와 "tableId"를 추출 const [searchParams] = useSearchParams(); const rawMode = searchParams.get('mode'); const rawTableId = searchParams.get('tableId'); if (rawMode !== 'edit' && rawMode !== 'add') { - throw new Error('테이블 모드가 올바르지 않습니다.'); + throw new Error(t('테이블 모드가 올바르지 않습니다.')); } const mode = rawMode as Mode; @@ -29,7 +31,7 @@ export default function TableCompositionPage() { mode === 'edit' && (rawTableId === null || isNaN(Number(rawTableId))) ) { - throw new Error('테이블 ID가 올바르지 않습니다.'); + throw new Error(t('테이블 ID가 올바르지 않습니다.')); } const tableId = rawTableId ? Number(rawTableId) : 0; @@ -78,9 +80,9 @@ export default function TableCompositionPage() { const handleButtonClick = () => { const patchedInfo = { ...formData.info, - name: formData.info.name ?? '시간표 1', - prosTeamName: formData.info.prosTeamName ?? '찬성', - consTeamName: formData.info.consTeamName ?? '반대', + name: formData.info.name ?? t('시간표 1'), + prosTeamName: formData.info.prosTeamName ?? t('찬성'), + consTeamName: formData.info.consTeamName ?? t('반대'), }; updateInfo(patchedInfo); @@ -97,7 +99,7 @@ export default function TableCompositionPage() { refetch()}> - {'시간표 정보를 불러오지 못했어요.\n다시 시도할까요?'} + {t('시간표 정보를 불러오지 못했어요.\n다시 시도할까요?')} @@ -119,6 +121,7 @@ export default function TableCompositionPage() { onButtonClick={() => goToStep('TimeBox')} /> ), + TimeBox: ( @@ -59,29 +63,30 @@ export default function TableNameAndType(props: TableNameAndTypeProps) {
handleFieldChange('name', e.target.value)} onClear={() => clearField('name')} - placeholder="시간표 1" + placeholder={t('시간표 1')} disabled={isLoading} /> handleFieldChange('agenda', e.target.value)} onClear={() => clearField('agenda')} - placeholder="토론 주제를 입력해주세요" + placeholder={t('토론 주제를 입력해주세요')} disabled={isLoading} /> + <>
+ vs. 8 || cons.length > 8; if (isTooLong) { - alert('팀명은 최대 8자까지 입력할 수 있습니다.'); + alert(t('팀명은 최대 8자까지 입력할 수 있습니다.')); return; } const updatedInfo = { ...info, - name: info.name || '시간표 1', - prosTeamName: info.prosTeamName || '찬성', - consTeamName: info.consTeamName || '반대', + name: info.name || t('시간표 1'), + prosTeamName: info.prosTeamName || t('찬성'), + consTeamName: info.consTeamName || t('반대'), }; onInfoChange(updatedInfo); @@ -141,7 +147,7 @@ export default function TableNameAndType(props: TableNameAndTypeProps) { }} className="button enabled brand w-full rounded-full" > - 다음 + {t('다음')}
diff --git a/src/page/TableComposition/components/TimeBox/TimeBox.tsx b/src/page/TableComposition/components/TimeBox/TimeBox.tsx index d6a2074b..cb0f35d1 100644 --- a/src/page/TableComposition/components/TimeBox/TimeBox.tsx +++ b/src/page/TableComposition/components/TimeBox/TimeBox.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { HTMLAttributes } from 'react'; import TimeBoxManageButtons from '../TimeBoxManageButtons/TimeBoxManageButtons'; import { TimeBoxInfo } from '../../../../type/type'; @@ -20,6 +21,7 @@ interface TimeBoxProps extends HTMLAttributes { } export default function TimeBox(props: TimeBoxProps) { + const { t } = useTranslation(); const { stance, speechType, @@ -40,18 +42,21 @@ export default function TimeBox(props: TimeBoxProps) { if (boxType === 'NORMAL') { const { minutes, seconds } = Formatting.formatSecondsToMinutes(time!); - timeStr = `${minutes}분 ${seconds}초`; + timeStr = t('{{minutes}}분 {{seconds}}초', { minutes, seconds }); } else { const { minutes, seconds } = Formatting.formatSecondsToMinutes( timePerTeam!, ); - timeStr = `팀당 ${minutes}분 ${seconds}초`; + timeStr = t('팀당 {{minutes}}분 {{seconds}}초', { minutes, seconds }); } if (timePerSpeaking !== null) { const { minutes, seconds } = Formatting.formatSecondsToMinutes(timePerSpeaking); - timePerSpeakingStr = `발언당 ${minutes}분 ${seconds}초`; + timePerSpeakingStr = t('발언당 {{minutes}}분 {{seconds}}초', { + minutes, + seconds, + }); } const fullTimeStr = timePerSpeakingStr ? `${timeStr} | ${timePerSpeakingStr}` @@ -75,7 +80,7 @@ export default function TimeBox(props: TimeBoxProps) { ${isPros ? 'right-[10px]' : 'left-[10px]'} `} onMouseDown={onMouseDown} - title="위/아래로 드래그" + title={t('위/아래로 드래그')} > @@ -128,7 +133,9 @@ export default function TimeBox(props: TimeBoxProps) {

{speechType} {speaker && ( - {` | ${speaker} 토론자`} + + {t(' | {{speaker}} 토론자', { speaker })} + )}

diff --git a/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.tsx b/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.tsx index eccbfcb3..09070d90 100644 --- a/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.tsx +++ b/src/page/TableComposition/components/TimeBoxManageButtons/TimeBoxManageButtons.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { TimeBoxInfo } from '../../../../type/type'; import { useModal } from '../../../../hooks/useModal'; import TimerCreationContent from '../TimerCreationContent/TimerCreationContent'; @@ -19,6 +20,7 @@ interface TimeBoxManageButtonsProps { } export default function TimeBoxManageButtons(props: TimeBoxManageButtonsProps) { + const { t } = useTranslation(); const { openModal: openEditModal, closeModal: closeEditModal, @@ -38,21 +40,21 @@ export default function TimeBoxManageButtons(props: TimeBoxManageButtonsProps) { <>
{onSubmitEdit && ( - )} {onSubmitDelete && ( - )} {onSubmitCopy && ( -
@@ -287,7 +291,7 @@ export default function TimeBoxStep(props: TimeBoxStepProps) { disabled={isLoading} > - 토론 정보 수정하기 + {t('토론 정보 수정하기')}
diff --git a/src/page/TableComposition/components/TimerCreationContent/TimeInputGroup.tsx b/src/page/TableComposition/components/TimerCreationContent/TimeInputGroup.tsx index 160362b0..e962fbb2 100644 --- a/src/page/TableComposition/components/TimerCreationContent/TimeInputGroup.tsx +++ b/src/page/TableComposition/components/TimerCreationContent/TimeInputGroup.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import ClearableInput from '../../../../components/ClearableInput/ClearableInput'; import TimerCreationContentItem from './TimerCreationContentMenuItem'; @@ -16,6 +17,7 @@ export default function TimeInputGroup({ onMinutesChange, onSecondsChange, }: TimeInputGroupProps) { + const { t } = useTranslation(); const validateTime = (value: string) => value === '' ? 0 : Math.max(0, Math.min(59, Number(value))); @@ -29,7 +31,8 @@ export default function TimeInputGroup({ onChange={(e) => onMinutesChange(validateTime(e.target.value))} onClear={() => onMinutesChange(0)} /> -

+ +

{t('분')}

@@ -39,7 +42,8 @@ export default function TimeInputGroup({ onChange={(e) => onSecondsChange(validateTime(e.target.value))} onClear={() => onSecondsChange(0)} /> -

+ +

{t('초')}

diff --git a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx index 2a2363a7..f1c4cb98 100644 --- a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx +++ b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useCallback, useMemo, useState } from 'react'; import { TimeBoxInfo, @@ -103,6 +104,7 @@ export default function TimerCreationContent({ onSubmit, onClose, }: TimerCreationContentProps) { + const { t } = useTranslation(); const [stance, setStance] = useState( beforeData?.stance ? beforeData?.stance === 'NEUTRAL' @@ -119,18 +121,18 @@ export default function TimerCreationContent({ // 발언 유형 초기화 const getSpeechTypeFromString = (value: string): SpeechType => { switch (value.trim()) { - case '입론': + case t('입론'): return 'OPENING'; - case '반론': + case t('반론'): return 'REBUTTAL'; - case '최종발언': - case '최종 발언': + case t('최종발언'): + case t('최종 발언'): return 'CLOSING'; - case '작전시간': - case '작전 시간': + case t('작전시간'): + case t('작전 시간'): return 'TIMEOUT'; - case '교차조사': - case '교차 조사': + case t('교차조사'): + case t('교차 조사'): return 'CROSS_EXAM'; default: return 'CUSTOM'; @@ -147,7 +149,7 @@ export default function TimerCreationContent({ }, []); const initSpeechType = - beforeData?.speechType ?? initData?.speechType ?? '입론'; + beforeData?.speechType ?? initData?.speechType ?? t('입론'); const [currentSpeechType, setCurrentSpeechType] = useState( getSpeechTypeFromString(initSpeechType), ); @@ -203,6 +205,7 @@ export default function TimerCreationContent({ { type: 'BEFORE_END', min: 0, sec: 30, count: 1 }, { type: 'BEFORE_END', min: 0, sec: 0, count: 2 }, ]; + const savedBellOptions: BellInputConfig[] = rawBellConfigData === null ? defaultBellConfig @@ -255,6 +258,7 @@ export default function TimerCreationContent({ { value: 'CONS', label: consTeamName }, { value: 'NEUTRAL', label: STANCE_RECORD['NEUTRAL'] }, ], + [prosTeamName, consTeamName], ); @@ -264,6 +268,7 @@ export default function TimerCreationContent({ { value: 'AFTER_END', label: BellTypeToString['AFTER_END'] }, { value: 'AFTER_START', label: BellTypeToString['AFTER_START'] }, ], + [], ); @@ -279,7 +284,7 @@ export default function TimerCreationContent({ if (timerType === 'NORMAL') { if (totalTime <= 0) { - errors.push('발언 시간은 1초 이상이어야 해요.'); + errors.push(t('발언 시간은 1초 이상이어야 해요.')); } // 타종 옵션 유효성 검사 @@ -288,7 +293,7 @@ export default function TimerCreationContent({ const bellTime = item.min * 60 + item.sec; if (bellTime > totalTime) { - errors.push('종료 전 타종은 발언 시간보다 길 수 없어요.'); + errors.push(t('종료 전 타종은 발언 시간보다 길 수 없어요.')); } } }); @@ -296,11 +301,11 @@ export default function TimerCreationContent({ if (timerType === 'TIME_BASED') { if (totalTimePerTeam <= 0) { - errors.push('팀당 발언 시간은 1초 이상이어야 해요.'); + errors.push(t('팀당 발언 시간은 1초 이상이어야 해요.')); } if (totalTimePerSpeaking > totalTimePerTeam) { - errors.push('1회당 발언 시간은 팀당 발언 시간을 초과할 수 없어요.'); + errors.push(t('1회당 발언 시간은 팀당 발언 시간을 초과할 수 없어요.')); } } @@ -310,10 +315,10 @@ export default function TimerCreationContent({ if (currentSpeechType === 'CUSTOM') { // 텍스트 길이 유효성 검사 if (speechTypeTextValue.length > 10) { - errors.push('발언 유형은 최대 10자까지 입력할 수 있습니다.'); + errors.push(t('발언 유형은 최대 10자까지 입력할 수 있습니다.')); } if (speaker.length > 5) { - errors.push('발언자는 최대 5자까지 입력할 수 있습니다.'); + errors.push(t('발언자는 최대 5자까지 입력할 수 있습니다.')); } // 발언시간 유효성 검사 @@ -321,12 +326,14 @@ export default function TimerCreationContent({ timerType === 'TIME_BASED' && totalTimePerSpeaking > totalTimePerTeam ) { - errors.push('1회당 발언 시간은 팀당 총 발언 시간보다 클 수 없습니다.'); + errors.push( + t('1회당 발언 시간은 팀당 총 발언 시간보다 클 수 없습니다.'), + ); } // 커스텀 타이머 발언유형 유효성 검사 if (timerType === 'NORMAL' && speechTypeTextValue.trim() === '') { - errors.push('발언 유형을 입력해주세요.'); + errors.push(t('발언 유형을 입력해주세요.')); } } @@ -360,7 +367,7 @@ export default function TimerCreationContent({ onSubmit({ stance: stanceToSend, speechType: - speechTypeToSend.trim() === '' ? '자유토론' : speechTypeToSend, + speechTypeToSend.trim() === '' ? t('자유토론') : speechTypeToSend, boxType: timerType, time: null, timePerTeam: totalTimePerTeam, @@ -387,6 +394,7 @@ export default function TimerCreationContent({ teamSeconds, stance, speechTypeTextValue, + t, timerType, ]); @@ -436,7 +444,7 @@ export default function TimerCreationContent({ if (selectedValue === 'NEUTRAL') { if (currentSpeechType !== 'CUSTOM') { alert( - "중립은 발언 유형이 '직접 입력'일 경우에만 선택할 수 있습니다.", + t("중립은 발언 유형이 '직접 입력'일 경우에만 선택할 수 있습니다."), ); return; } @@ -444,7 +452,7 @@ export default function TimerCreationContent({ setStance(selectedValue); }, - [currentSpeechType], + [currentSpeechType, t], ); const handleBellExpandButtonClick = useCallback(() => { @@ -502,18 +510,14 @@ export default function TimerCreationContent({ {/* 제목 */}

- {timerType === 'NORMAL' ? '일반 타이머' : '자유토론 타이머'} + {timerType === 'NORMAL' ? t('일반 타이머') : t('자유토론 타이머')}

-

- {timerType === 'NORMAL' ? ( - '한 팀의 발언 시간이 세팅된 일반적인 타이머' - ) : ( - <> - {'팀별 발언 시간과 1회당 발언 시간이 세팅된 타이머'} -
- {'1회당 발언 시간이 지나면, 상대 팀으로 발언권이 넘어감'} - - )} +

+ {timerType === 'NORMAL' + ? t('한 팀의 발언 시간이 세팅된 일반적인 타이머') + : t( + '팀별 발언 시간과 1회당 발언 시간이 세팅된 타이머\n1회당 발언 시간이 지나면, 상대 팀으로 발언권이 넘어감', + )}

@@ -562,7 +566,7 @@ export default function TimerCreationContent({ case 'TIMER_TYPE': return ( @@ -570,15 +574,16 @@ export default function TimerCreationContent({ id="timer-type-normal" name="timer-type" value="NORMAL" - label="일반 타이머" + label={t('일반 타이머')} checked={isNormalTimer} onChange={handleTimerChange} /> + @@ -590,7 +595,7 @@ export default function TimerCreationContent({ case 'SPEAKER': return ( setSpeaker(e.target.value)} onClear={() => setSpeaker('')} - placeholder="N번 토론자" + placeholder={t('N번 토론자')} disabled={ stance === 'NEUTRAL' || currentSpeechType === 'TIMEOUT' } @@ -610,7 +615,7 @@ export default function TimerCreationContent({ case 'TIME_NORMAL': return ( setSpeechTypeTextValue(e.target.value)} onClear={() => setSpeechTypeTextValue('')} - placeholder="주도권 토론 등" + placeholder={t('주도권 토론 등')} /> ); @@ -666,7 +671,7 @@ export default function TimerCreationContent({ case 'SPEECH_TYPE_NORMAL': return ( @@ -677,7 +682,7 @@ export default function TimerCreationContent({ options={speechTypeOptions} selectedValue={currentSpeechType} onSelect={handleSpeechTypeChange} - placeholder="선택" + placeholder={t('선택')} /> {currentSpeechType === 'CUSTOM' && ( @@ -688,7 +693,7 @@ export default function TimerCreationContent({ setSpeechTypeTextValue(e.target.value) } onClear={() => setSpeechTypeTextValue('')} - placeholder="입론, 반론, 작전 시간 등" + placeholder={t('입론, 반론, 작전 시간 등')} /> )} @@ -699,7 +704,7 @@ export default function TimerCreationContent({ case 'TEAM': return (

- 종소리 설정 + {t('종소리 설정')}

@@ -769,6 +774,7 @@ export default function TimerCreationContent({ })); }} /> + {/* 분, 초, 타종 횟수 */} @@ -791,9 +797,10 @@ export default function TimerCreationContent({ min: getValidateTimeValue(safeValue), })); }} - placeholder="분" + placeholder={t('분')} /> - + + {t('분')} - + + {t('초')} @@ -827,8 +835,9 @@ export default function TimerCreationContent({ className="w-[60px] rounded-[4px] border border-default-border p-[8px]" value={bellInput.count} onChange={handleBellCountChange} - placeholder="횟수" + placeholder={t('횟수')} /> +
); + default: return null; } @@ -884,7 +897,7 @@ export default function TimerCreationContent({ {/* 제출 버튼 */}
); diff --git a/src/page/TableListPage/TableListPage.tsx b/src/page/TableListPage/TableListPage.tsx index 6dc2bb18..98bb030f 100644 --- a/src/page/TableListPage/TableListPage.tsx +++ b/src/page/TableListPage/TableListPage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import HeaderTitle from '../../components/HeaderTitle/HeaderTitle'; import { Suspense } from 'react'; @@ -5,12 +6,13 @@ import LoadingIndicator from '../../components/LoadingIndicator/LoadingIndicator import TableListPageContent from './components/TableListPageContent'; export default function TableListPage() { + const { t } = useTranslation(); return ( - + diff --git a/src/page/TableListPage/components/Table.tsx b/src/page/TableListPage/components/Table.tsx index e50a858e..21b38386 100644 --- a/src/page/TableListPage/components/Table.tsx +++ b/src/page/TableListPage/components/Table.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useState } from 'react'; import { DebateTable } from '../../../type/type'; import { IoArrowForward } from 'react-icons/io5'; @@ -23,6 +24,7 @@ export default function Table({ onEdit, onClick, }: TableProps) { + const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); const { openShareModal, TableShareModal } = useTableShare(id); const { openModal, closeModal, ModalWrapper } = useModal({ @@ -57,7 +59,7 @@ export default function Table({ e.stopPropagation(); onEdit(); }} - aria-label="수정하기" + aria-label={t('수정하기')} > @@ -68,7 +70,7 @@ export default function Table({ e.stopPropagation(); openModal(); }} - aria-label="삭제하기" + aria-label={t('삭제하기')} > @@ -79,7 +81,7 @@ export default function Table({ e.stopPropagation(); openShareModal(); }} - aria-label="공유하기" + aria-label={t('공유하기')} > @@ -107,16 +109,18 @@ export default function Table({

- 주제 | {agenda} + {t('주제 |')} + + {agenda}

closeModal() }} + left={{ text: t('취소'), onClick: () => closeModal() }} right={{ - text: '삭제', + text: t('삭제'), isBold: true, onClick: () => { onDelete(); @@ -125,7 +129,9 @@ export default function Table({ }} >
-

테이블을 삭제하시겠습니까?

+

+ {t('테이블을 삭제하시겠습니까?')} +

{name}

diff --git a/src/page/TableOverviewPage/TableOverviewPage.tsx b/src/page/TableOverviewPage/TableOverviewPage.tsx index 577ed899..1640e3e7 100644 --- a/src/page/TableOverviewPage/TableOverviewPage.tsx +++ b/src/page/TableOverviewPage/TableOverviewPage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import PropsAndConsTitle from '../../components/ProsAndConsTitle/PropsAndConsTitle'; import { useNavigate, useParams } from 'react-router-dom'; @@ -21,6 +22,7 @@ import clsx from 'clsx'; import { useState, useCallback } from 'react'; export default function TableOverviewPage() { + const { t } = useTranslation(); const { id } = useParams(); const tableId = Number(id); const navigate = useNavigate(); @@ -135,11 +137,12 @@ export default function TableOverviewPage() { > 팀 선정하기 +
- 팀 선정하기 + {t('팀 선정하기')}
)} @@ -190,7 +193,7 @@ export default function TableOverviewPage() { onClick={handleStartDebate} > - 토론하기 + {t('토론하기')} diff --git a/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx b/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx index 490040b3..c680b949 100644 --- a/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx +++ b/src/page/TableOverviewPage/components/TeamSelectionModal/TeamSelectionModal.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import Cointoss from '../../../../assets/teamSelection/cointoss.png'; import CoinFront from '../../../../assets/teamSelection/coinfront.png'; @@ -19,6 +20,7 @@ export default function TeamSelectionModal({ initialCoinState, onCoinStateChange, }: TeamSelectionModalProps) { + const { t } = useTranslation(); const [coinState, setCoinState] = useState(initialCoinState); const hasResultSoundPlayedRef = useRef(false); @@ -105,7 +107,7 @@ export default function TeamSelectionModal({ {coinState === 'initial' && (

- {'팀별로\n동전의 앞 / 뒷면 중\n하나를 선택해 주세요.'} + {t('팀별로\n동전의 앞 / 뒷면 중\n하나를 선택해 주세요.')}

)} @@ -116,14 +118,14 @@ export default function TeamSelectionModal({
동전
- 동전 던지는 중... + {t('동전 던지는 중...')}
@@ -135,12 +137,12 @@ export default function TeamSelectionModal({
동전
- {coinState === 'front' ? '앞' : '뒤'} + {coinState === 'front' ? t('앞') : t('뒤')}
@@ -154,7 +156,7 @@ export default function TeamSelectionModal({ className="sm:text-lg sm:py-4 w-full bg-brand py-3 text-[22px] font-semibold hover:bg-brand-hover md:py-5 md:text-xl lg:py-[21px] lg:text-[22px]" onClick={() => updateCoinState('tossing')} > - 동전 던지기 + {t('동전 던지기')} )} {(coinState === 'front' || coinState === 'back') && ( @@ -163,13 +165,13 @@ export default function TeamSelectionModal({ className="sm:text-lg sm:py-4 w-full border-[2px] border-default-disabled/hover bg-default-white py-3 text-lg font-semibold hover:bg-default-disabled/hover md:py-5 md:text-xl lg:py-[21px] lg:text-[22px]" onClick={handleEdit} > - 토론 정보 수정하기 + {t('토론 정보 수정하기')} )} diff --git a/src/page/TableSharingPage/TableSharingPage.tsx b/src/page/TableSharingPage/TableSharingPage.tsx index 1ebb7c7e..34bdc797 100644 --- a/src/page/TableSharingPage/TableSharingPage.tsx +++ b/src/page/TableSharingPage/TableSharingPage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useModal } from '../../hooks/useModal'; @@ -44,6 +45,7 @@ function getDecodedDataOrNull( * - 로그인 상태가 아닐 경우, 비회원 플로우 실행 */ export default function TableSharingPage() { + const { t } = useTranslation(); const navigate = useNavigate(); const { openModal, closeModal, ModalWrapper } = useModal({ isCloseButtonExist: false, @@ -71,14 +73,14 @@ export default function TableSharingPage() { // 저장 실패 시 () => { closeModal(); - throw new Error('공유받은 테이블을 저장하지 못했어요.'); + throw new Error(t('공유받은 테이블을 저장하지 못했어요.')); }, ); }, () => { // 세션 저장소에서 테이블을 불러오지 못할 때 closeModal(); - throw new Error('테이블 데이터를 확인할 수 없어요.'); + throw new Error(t('테이블 데이터를 확인할 수 없어요.')); }, ); } else { @@ -90,7 +92,7 @@ export default function TableSharingPage() { } else { // On this case, getRepository() will automatically decide what data source to use if (!decodedData) { - throw new Error('공유된 데이터가 비어 있어요.'); + throw new Error(t('공유된 데이터가 비어 있어요.')); } sessionDebateTableRepository.deleteTable(); @@ -101,7 +103,7 @@ export default function TableSharingPage() { }, () => { // Handling error - throw new Error('공유된 토론 테이블을 DB에 저장하지 못했어요.'); + throw new Error(t('공유된 토론 테이블을 DB에 저장하지 못했어요.')); }, ); } @@ -115,7 +117,8 @@ export default function TableSharingPage() { size={'size-24'} color={'text-brand-main'} /> -

데이터를 처리하고 있습니다...

+ +

{t('데이터를 처리하고 있습니다...')}

@@ -131,7 +134,7 @@ export default function TableSharingPage() { }, () => { closeModal(); - throw new Error('공유받은 테이블을 저장하지 못했어요.'); + throw new Error(t('공유받은 테이블을 저장하지 못했어요.')); }, ); }} @@ -143,7 +146,7 @@ export default function TableSharingPage() { }, () => { closeModal(); - throw new Error('공유받은 데이터 처리에 실패했어요.'); + throw new Error(t('공유받은 데이터 처리에 실패했어요.')); }, ); }} diff --git a/src/page/TableSharingPage/components/LoggedInStoreDBModal.tsx b/src/page/TableSharingPage/components/LoggedInStoreDBModal.tsx index d87250a8..68259f08 100644 --- a/src/page/TableSharingPage/components/LoggedInStoreDBModal.tsx +++ b/src/page/TableSharingPage/components/LoggedInStoreDBModal.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import DialogModal from '../../../components/DialogModal/DialogModal'; /** @@ -20,17 +21,18 @@ export default function LoggedInStoreDBModal({ onSave, onContinue, }: LoggedInStoreDBModalProps) { + const { t } = useTranslation(); return ( onContinue() }} + left={{ text: t('비회원 상태로 토론하기'), onClick: () => onContinue() }} right={{ - text: '저장하기', + text: t('저장하기'), onClick: () => onSave(), isBold: true, }} >

- 공유받은 토론 시간표를 내 시간표 목록에 저장하시겠어요? + {t('공유받은 토론 시간표를 내 시간표 목록에 저장하시겠어요?')}

); diff --git a/src/page/TimerPage/FeedbackTimerPage.tsx b/src/page/TimerPage/FeedbackTimerPage.tsx index d1f488cd..9caf9dda 100644 --- a/src/page/TimerPage/FeedbackTimerPage.tsx +++ b/src/page/TimerPage/FeedbackTimerPage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useFeedbackTimer } from './hooks/useFeedbackTimer'; import FeedbackTimer from './components/FeedbackTimer'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; @@ -5,13 +6,14 @@ import GoToDebateEndButton from '../../components/GoToDebateEndButton/GoToDebate import { useParams } from 'react-router-dom'; export default function FeedbackTimerPage() { + const { t } = useTranslation(); const feedbackTimerInstance = useFeedbackTimer(); const { id } = useParams(); const tableId = Number(id); // 테이블 ID 검증 로직 if (!id || isNaN(tableId)) { - throw new Error('테이블 ID가 올바르지 않습니다.'); + throw new Error(t('테이블 ID가 올바르지 않습니다.')); } return ( diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index 86fe87f2..eb6b44c1 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import HeaderTableInfo from '../../components/HeaderTableInfo/HeaderTableInfo'; @@ -17,6 +18,7 @@ import LoadingIndicator from '../../components/LoadingIndicator/LoadingIndicator import { RiFullscreenFill, RiFullscreenExitFill } from 'react-icons/ri'; export default function TimerPage() { + const { t } = useTranslation(); const pathParams = useParams(); const tableId = Number(pathParams.id); const { @@ -65,7 +67,7 @@ export default function TimerPage() { @@ -76,7 +78,7 @@ export default function TimerPage() { @@ -85,8 +87,8 @@ export default function TimerPage() { @@ -88,7 +90,7 @@ export default function CompactTimeoutTimer({ } }} > - -1분 + {t('-1분')} { @@ -97,7 +99,7 @@ export default function CompactTimeoutTimer({ } }} > - -30초 + {t('-30초')} {/* 재생 및 일시정지 버튼 */} @@ -122,12 +124,12 @@ export default function CompactTimeoutTimer({ state.setTimer((state.timer ?? 0) + 30)} > - +30초 + {t('+30초')} state.setTimer((state.timer ?? 0) + 60)} > - +1분 + {t('+1분')} diff --git a/src/page/TimerPage/components/FeedbackTimer.tsx b/src/page/TimerPage/components/FeedbackTimer.tsx index 21981ff0..f033d1ab 100644 --- a/src/page/TimerPage/components/FeedbackTimer.tsx +++ b/src/page/TimerPage/components/FeedbackTimer.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import TimerController from './TimerController'; import { Formatting } from '../../../util/formatting'; import CircularTimer from './CircularTimer'; @@ -10,16 +11,10 @@ interface FeedbackTimerProps { feedbackTimerInstance: FeedbackTimerLogics; } -const timeAdjustments = [ - { label: '-5분', value: -300 }, - { label: '-1분', value: -60 }, - { label: '+1분', value: 60 }, - { label: '+5분', value: 300 }, -]; - export default function FeedbackTimer({ feedbackTimerInstance, }: FeedbackTimerProps) { + const { t } = useTranslation(); const { timer, isRunning, @@ -30,6 +25,13 @@ export default function FeedbackTimer({ defaultTimer, } = feedbackTimerInstance; + const timeAdjustments = [ + { label: t('-5분'), value: -300 }, + { label: t('-1분'), value: -60 }, + { label: t('+1분'), value: 60 }, + { label: t('+5분'), value: 300 }, + ]; + const totalTime = timer ?? 0; const { minutes, seconds } = Formatting.formatSecondsToMinutes(totalTime); const minute = Formatting.formatTwoDigits(minutes); @@ -55,7 +57,9 @@ export default function FeedbackTimer({ {/* 좌측 영역 */}
{/* 제목 */} -

피드백 타이머

+

+ {t('피드백 타이머')} +

{/* 시간 조절 버튼 */}
diff --git a/src/page/TimerPage/components/FirstUseToolTip.tsx b/src/page/TimerPage/components/FirstUseToolTip.tsx index 8646edaf..ae830a66 100644 --- a/src/page/TimerPage/components/FirstUseToolTip.tsx +++ b/src/page/TimerPage/components/FirstUseToolTip.tsx @@ -1,3 +1,4 @@ +import { Trans, useTranslation } from 'react-i18next'; import { PropsWithChildren } from 'react'; import { LuKeyboard } from 'react-icons/lu'; import { MdOutlineTimer } from 'react-icons/md'; @@ -11,6 +12,7 @@ interface FirstUseToolTipProps { } export default function FirstUseToolTip({ onClose }: FirstUseToolTipProps) { + const { t } = useTranslation(); return (
-

자유토론 타이머 조작

+

{t('자유토론 타이머 조작')}

- 재생 버튼을 눌러 타이머를 시작 + {t('재생 버튼을 눌러 타이머를 시작')} - 타이머가 동작 중일 때, 일시정지 버튼을 눌러 타이머를 일시정지 + {t('타이머가 동작 중일 때, 일시정지 버튼을 눌러 타이머를 일시정지')} - 초기화 버튼을 눌러 타이머를 원래 시간으로 초기화 - 마우스를 사용하여 타이머를 클릭 시, 진영 변경 - { - '타이머 동작 중 진영이 변경될 경우, 상대 진영의 타이머로 전환과 동시에 시작' - } + {t('초기화 버튼을 눌러 타이머를 원래 시간으로 초기화')} + + + {t('마우스를 사용하여 타이머를 클릭 시, 진영 변경')} + + + {t( + '타이머 동작 중 진영이 변경될 경우, 상대 진영의 타이머로 전환과 동시에 시작', + )}
@@ -40,17 +46,19 @@ export default function FirstUseToolTip({ onClose }: FirstUseToolTipProps) {
-

일반 토론 타이머 조작

+

{t('일반 토론 타이머 조작')}

- 재생 버튼을 눌러 타이머를 시작 + {t('재생 버튼을 눌러 타이머를 시작')} + + {t('타이머가 동작 중일 때, 일시정지 버튼을 눌러 타이머를 일시정지')} + - 타이머가 동작 중일 때, 일시정지 버튼을 눌러 타이머를 일시정지 + {t('초기화 버튼을 눌러 타이머를 원래 시간으로 초기화')} - 초기화 버튼을 눌러 타이머를 원래 시간으로 초기화 - 작전 시간 사용 버튼을 눌러 별도의 작전 시간 타이머 사용 가능 + {t('작전 시간 사용 버튼을 눌러 별도의 작전 시간 타이머 사용 가능')}
@@ -58,34 +66,46 @@ export default function FirstUseToolTip({ onClose }: FirstUseToolTipProps) {
-

키보드 조작

+

{t('키보드 조작')}

- 스페이스 바로 타이머를 시작 및 일시정지 - R 키로 타이머 초기화 - 좌우 방향키로 이전/다음 차례로 이동 - A/L 키로 토론 진영 변경 - Enter 키로 상대 진영으로 변경 + {t('스페이스 바로 타이머를 시작 및 일시정지')} + {t('R 키로 타이머 초기화')} + {t('좌우 방향키로 이전/다음 차례로 이동')} + {t('A/L 키로 토론 진영 변경')} + {t('Enter 키로 상대 진영으로 변경')}
-

전체 화면

+

{t('전체 화면')}

- 화면 우측 상단 헤더의 전체 화면 버튼 - - 으로 활성화 + , + ]} + /> - 화면 우측 상단 헤더의 전체 화면 닫기 버튼 - - 또는 ESC 키를 눌러 전체 화면 비활성화 + , + ]} + />
@@ -96,7 +116,7 @@ export default function FirstUseToolTip({ onClose }: FirstUseToolTipProps) { className="w-fit justify-end rounded-2xl bg-neutral-50 px-6 py-2 font-bold text-neutral-900 hover:bg-neutral-300" onClick={() => onClose()} > - 닫기 + {t('닫기')}
diff --git a/src/page/TimerPage/components/LoginAndStoreModal.tsx b/src/page/TimerPage/components/LoginAndStoreModal.tsx index ac83e110..1bc3a397 100644 --- a/src/page/TimerPage/components/LoginAndStoreModal.tsx +++ b/src/page/TimerPage/components/LoginAndStoreModal.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { ComponentType, ReactNode } from 'react'; import DialogModal from '../../../components/DialogModal/DialogModal'; import { oAuthLogin } from '../../../util/googleAuth'; @@ -15,20 +16,21 @@ export function LoginAndStoreModal({ Wrapper, onClose, }: LoginAndStoreModalProps) { + const { t } = useTranslation(); const navigate = useNavigate(); return ( { onClose(); navigate('/overview/customize/guest'); }, }} right={{ - text: '네', + text: t('네'), onClick: () => { onClose(); oAuthLogin(); @@ -37,7 +39,7 @@ export function LoginAndStoreModal({ }} >
- {'토론을 끝내셨군요!\n지금까지의 시간표를 로그인하고 저장할까요?'} + {t('토론을 끝내셨군요!\n지금까지의 시간표를 로그인하고 저장할까요?')}
diff --git a/src/page/TimerPage/components/NormalTimer.tsx b/src/page/TimerPage/components/NormalTimer.tsx index c74af4f2..f86319a9 100644 --- a/src/page/TimerPage/components/NormalTimer.tsx +++ b/src/page/TimerPage/components/NormalTimer.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { TimeBoxInfo } from '../../../type/type'; import TimerController from './TimerController'; import { Formatting } from '../../../util/formatting'; @@ -33,6 +34,7 @@ export default function NormalTimer({ item, teamName, }: NormalTimerProps) { + const { t } = useTranslation(); const { timer, isAdditionalTimerOn, @@ -76,9 +78,9 @@ export default function NormalTimer({

- {teamName && teamName + ' 팀'} + {teamName && teamName + t(' 팀')} {teamName && item.speaker && ' | '} - {item.speaker && item.speaker + ' 토론자'} + {item.speaker && item.speaker + t(' 토론자')}

)} @@ -108,7 +110,7 @@ export default function NormalTimer({ }, )} > - 작전 시간 사용 + {t('작전 시간 사용')} )} diff --git a/src/page/TimerPage/components/TimeBasedTimer.tsx b/src/page/TimerPage/components/TimeBasedTimer.tsx index f72ae9dd..7384a574 100644 --- a/src/page/TimerPage/components/TimeBasedTimer.tsx +++ b/src/page/TimerPage/components/TimeBasedTimer.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import TimerController from './TimerController'; import { Formatting } from '../../../util/formatting'; import KeyboardKeyA from '../../../assets/keyboard/keyboard_key_A.png'; @@ -33,6 +34,7 @@ export default function TimeBasedTimer({ teamName, item, }: TimeBasedTimerProps) { + const { t } = useTranslation(); const { totalTimer, speakingTimer, @@ -113,7 +115,7 @@ export default function TimeBasedTimer({ {prosCons
@@ -138,7 +140,7 @@ export default function TimeBasedTimer({ {speakingTimer !== null && (

- 전체 시간 + {t('전체 시간')}

@@ -159,7 +161,7 @@ export default function TimeBasedTimer({ { 'bg-camp-red': prosCons === 'CONS' }, )} > - 현재 시간 + {t('현재 시간')}

diff --git a/src/page/TimerPage/components/TimerController.tsx b/src/page/TimerPage/components/TimerController.tsx index 87765264..429b6c5e 100644 --- a/src/page/TimerPage/components/TimerController.tsx +++ b/src/page/TimerPage/components/TimerController.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { GiPauseButton } from 'react-icons/gi'; import DTReset from '../../../components/icons/Reset'; import DTPlay from '../../../components/icons/Play'; @@ -21,6 +22,7 @@ export default function TimerController({ stance, boxType, }: TimerControllerProps) { + const { t } = useTranslation(); const bgClass = boxType === 'FEEDBACK' ? 'bg-brand' @@ -34,7 +36,7 @@ export default function TimerController({ {/* 초기화 버튼 */} closeModal(), isBold: true, }} right={{ - text: '제출하기', + text: t('제출하기'), onClick: () => { handleSubmit(); closeModal(); @@ -163,8 +166,12 @@ export default function VoteParticipationPage() { }} >

-

투표를 제출하시겠습니까?

-

(제출 후에는 변경이 불가능 합니다.)

+

+ {t('투표를 제출하시겠습니까?')} +

+

+ {t('(제출 후에는 변경이 불가능 합니다.)')} +

From 4358d8ed07d4c718123f44f7edae2ca1ab9c3c5d Mon Sep 17 00:00:00 2001 From: useon Date: Wed, 28 Jan 2026 00:45:39 +0900 Subject: [PATCH 26/51] =?UTF-8?q?feat:=20i18n=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=ED=99=94=EB=A5=BC=20=EC=A0=81=EC=9A=A9=ED=95=9C=20=EC=96=B8?= =?UTF-8?q?=EC=96=B4=EB=B3=84=20key,=20value=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/en/translation.json | 231 +++++++++++++++++++++++++++++ public/locales/ko/translation.json | 231 +++++++++++++++++++++++++++++ 2 files changed, 462 insertions(+) create mode 100644 public/locales/en/translation.json create mode 100644 public/locales/ko/translation.json diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json new file mode 100644 index 00000000..e6549f26 --- /dev/null +++ b/public/locales/en/translation.json @@ -0,0 +1,231 @@ +{ + "모달 닫기": "Close modal", + "유효하지 않은 투표 링크입니다.": "Invalid vote link.", + "승패투표": "Win/Loss Vote", + "참여자 :": "Participants:", + "찬성팀": "Affirmative Team", + "반대팀": "Negative Team", + "투표완료": "Vote submitted", + "다시 투표하기": "Vote again", + "제출하기": "Submit", + "투표를 제출하시겠습니까?": "Submit your vote?", + "(제출 후에는 변경이 불가능 합니다.)": "(You cannot change it after submitting.)", + "투표가 완료되었습니다.": "Your vote has been submitted.", + "테이블 이름 없음": "Unnamed table", + "주제 없음": "No Topic", + "도움말": "Help", + "전체 화면": "Fullscreen", + "테이블 ID가 올바르지 않습니다.": "Invalid table ID.", + "공유받은 테이블을 저장하지 못했어요.": "Couldn't save the shared table.", + "테이블 데이터를 확인할 수 없어요.": "Can't find the table data.", + "공유된 데이터가 비어 있어요.": "Shared data is empty.", + "공유된 토론 테이블을 DB에 저장하지 못했어요.": "Couldn't save the shared debate table to the database.", + "데이터를 처리하고 있습니다...": "Processing data...", + "공유받은 데이터 처리에 실패했어요.": "Failed to process the shared data.", + "팀 선정하기": "Select teams", + "토론하기": "Start debate", + "토론 시간표를 선택해주세요": "Please select a debate schedule", + "테이블 모드가 올바르지 않습니다.": "Invalid table mode.", + "시간표 1": "Schedule 1", + "찬성": "Affirmative", + "반대": "Negative", + "시간표 정보를 불러오지 못했어요.\n다시 시도할까요?": "Couldn't load the schedule.\nTry again?", + "무승부": "Tie", + "유효하지 않은 투표 결과 링크입니다.": "Invalid vote result link.", + "뒤로 가기 ←": "Back ←", + "세부 결과 확인하기": "View detailed results", + "아니오": "No", + "네": "Yes", + "정말로 세부 결과를 공개할까요?": "Show detailed results?", + "토론을 모두 마치셨습니다": "You have finished the debate", + "박수": "Applause", + "피드백 타이머": "Feedback Timer", + "심사평 및 Q&A용 타이머 →": "Timer for feedback & Q&A →", + "피드백 타이머로 이동": "Go to Feedback Timer", + "승패투표 진행하기": "Start Win/Loss Vote", + "QR 코드를 통해 투표 페이지로 이동해요.": "Go to the voting page via QR code.", + "승패투표 생성 및 진행": "Create and run Win/Loss Vote", + "스캔해 주세요!": "Please scan!", + "참여자": "Participants", + "등록된 토론자가 없어요.": "No debaters registered.", + "투표 결과 보기": "View vote results", + "QR 코드를 불러오지 못했어요.\n다시 시도하시겠어요?": "Couldn't load the QR code.\nTry again?", + "링크가 클립보드에 복사됨": "Link copied to clipboard", + "링크 준비 중": "Preparing link", + "공유 링크 복사": "Copy share link", + "이전 차례": "Previous turn", + "다음 차례": "Next turn", + "토론 종료": "End debate", + "알림 {{displayCount}}개": "{{displayCount}} notifications", + "데이터를 불러오고 있습니다...": "Loading data...", + "토론 종료 화면으로 돌아가기": "Back to debate end screen", + "데이터를 불러오지 못했어요.\n다시 시도할까요?": "Couldn't load the data.\nTry again?", + "다시 시도하기": "Try again", + "페이지를 찾을 수 없어요...": "Page not found...", + "요청 URL": "Requested URL", + "오류 내용": "Error details", + "요청하신 페이지를 찾을 수 없어요.\n홈 화면으로 돌아가 처음부터 다시 시도해주세요.": "Couldn't find the page you requested.\nPlease return to Home and try again.", + "홈으로 돌아가기": "Go to Home", + "{{status}} 오류": "{{status}} Error", + "오류가 발생했어요...": "Something went wrong...", + "스택": "Stack", + "선택": "Select", + "타이머 초기화": "Reset timer", + "일시정지": "Pause", + "재생": "Play", + "A키": "A key", + "L키": "L key", + "전체 시간": "Total time", + "현재 시간": "Current time", + "팀": "Team", + "토론자": "Debater", + "작전 시간 사용": "Use Prep Time", + "토론을 끝내셨군요!\n지금까지의 시간표를 로그인하고 저장할까요?": "You've finished the debate!\nLog in to save your schedule so far?", + "자유토론 타이머 조작": "Open Debate Timer Controls", + "재생 버튼을 눌러 타이머를 시작": "Press Play to start the timer", + "타이머가 동작 중일 때, 일시정지 버튼을 눌러 타이머를 일시정지": "When the timer is running, press Pause to pause it", + "초기화 버튼을 눌러 타이머를 원래 시간으로 초기화": "Press Reset to restore the timer to its original time", + "마우스를 사용하여 타이머를 클릭 시, 진영 변경": "Click the timer with the mouse to switch Stance", + "타이머 동작 중 진영이 변경될 경우, 상대 진영의 타이머로 전환과 동시에 시작": "When Stance changes while running, switch to the other side's timer and start immediately", + "일반 토론 타이머 조작": "Standard Debate Timer Controls", + "작전 시간 사용 버튼을 눌러 별도의 작전 시간 타이머 사용 가능": "Press Use Prep Time to use a separate Prep Time timer", + "키보드 조작": "Keyboard controls", + "스페이스 바로 타이머를 시작 및 일시정지": "Use the Spacebar to start/pause the timer", + "R 키로 타이머 초기화": "Use R to reset the timer", + "좌우 방향키로 이전/다음 차례로 이동": "Use Left/Right arrows to go to the previous/next turn", + "A/L 키로 토론 진영 변경": "Use A/L to switch Stance", + "Enter 키로 상대 진영으로 변경": "Use Enter to switch to the opposing Stance", + "화면 우측 상단 헤더의 전체 화면 버튼 <0/> 으로 활성화": "Activate fullscreen with the header button <0/> in the top right", + "화면 우측 상단 헤더의 전체 화면 닫기 버튼 <0/> 또는 ESC 키를 눌러 전체 화면 비활성화": "Exit fullscreen with the header close button <0/> or press ESC", + "닫기": "Close", + "작전 시간": "Prep Time", + "-1분": "-1 min", + "-30초": "-30 sec", + "+30초": "+30 sec", + "+1분": "+1 min", + "-5분": "-5 min", + "+5분": "+5 min", + "비회원 상태로 토론하기": "Debate as guest", + "저장하기": "Save", + "공유받은 토론 시간표를 내 시간표 목록에 저장하시겠어요?": "Save the shared debate schedule to my schedules?", + "수정하기": "Edit", + "삭제하기": "Delete", + "공유하기": "Share", + "주제 |": "Topic |", + "취소": "Cancel", + "삭제": "Delete", + "테이블을 삭제하시겠습니까?": "Delete this table?", + "타이머 화면": "Timer screen", + "원하는 때에\n작전 시간 사용하기": "Use Prep Time\nwhenever you need it", + "토론자가 작전 시간을\n요청하면 <0/>\n버튼을 눌러 시간을 사용해요": "If a debater requests Prep Time\npress <0/>\nto use time", + "작전 시간이 나타나면\n원하는 시간을 입력하세요!": "When Prep Time appears,\nenter the time you want!", + "키보드 방향키로\n더 편리한 조작": "More convenient control\nwith arrow keys", + "시간표 설정화면": "Schedule setup screen", + "간편한 시간표 구성": "Simple schedule setup", + "시간표 추가": "Add schedule", + "시간표 추가 버튼": "Add schedule button", + "두가지 타이머": "Two timers", + "일반형과 자유토론형 타이머로\n다양한 토론 방식을 지원해요.": "Support various debate formats\nwith Standard and Open Debate timers.", + "종소리 설정": "bell settings", + "시간에 따른 종소리를 내마음대로\n커스터마이징 할 수 있어요.": "Customize the bell by time\nhowever you like.", + "다양한 토론 템플릿을 원클릭으로 만나보세요!": "Explore various debate templates with one click!", + "{{title}} 로고": "{{title}} logo", + "{{label}} 토론하기": "Debate {{label}}", + "템플릿 신청하기": "Request a template", + "새로운 템플릿도 신청해 볼까요?": "Want to request a new template too?", + "신청하기": "Apply", + "홈 | 설정": "Home | Settings", + "토론 정보\n관리 및 기록": "Debate info\nManagement and records", + "토론 기본 정보 설정": "Set debate basics", + "시간표 이름부터 주제까지!": "From schedule name to Topic!", + "시간표 목록": "Schedule list", + "내가 만든 시간표를 저장하고 싶나요?": "Want to save the schedules you made?", + "시간표를 저장하려면,\n디베이트 타이머에 로그인해 보세요!": "To save your schedule,\nplease log in to Debate Timer!", + "3초 로그인 하기": "3-second login", + "아래로 스크롤": "Scroll down", + "이미 많은 사람들이 디베이트 타이머로\n더 나은 토론환경을 만들고 있어요.": "Many people already use Debate Timer\nto create a better debate environment.", + "비회원으로 시작하기": "Start as guest", + "버그 및 불편사항 제보": "Report bugs or issues", + "디베이트 타이머 사용 중 불편함을 느끼셨나요?": "Did you run into issues while using Debate Timer?", + "접수하기": "Submit", + "디베이트 타이머": "Debate Timer", + "개인정보처리방침": "Privacy Policy", + "서비스 이용약관": "Terms of Service", + "브라우저에서 비디오를 지원하지 않습니다.": "Your browser does not support video.", + "토론 진행을 더 쉽고 빠르게": "Run debates easier and faster", + "대시보드로 이동": "Go to Dashboard", + "3초 로그인": "3-second login", + "로그아웃": "Log out", + "왕관": "Crown", + "투표 세부 결과": "Detailed vote results", + "명": "people", + "시간표로 돌아가기": "Back to schedule", + "비회원 모드": "Guest mode", + "홈으로 이동": "Go to Home", + "로그인": "Log in", + "비회원으로 사용하던 시간표가 있습니다.\n로그인 후에도 이 시간표를 계속 사용하시겠습니까?": "You have schedules created in guest mode.\nDo you want to keep using them after you log in?", + "언어 선택": "Select language", + "팀별로\n동전의 앞 / 뒷면 중\n하나를 선택해 주세요.": "For each team,\nchoose heads or tails\non the coin.", + "동전": "Coin", + "동전 던지는 중...": "Flipping coin...", + "앞": "Heads", + "뒤": "Tails", + "동전 던지기": "Flip coin", + "토론 정보 수정하기": "Edit debate info", + "토론 바로 시작하기": "Start debate now", + "입론": "Constructive", + "반론": "Rebuttal", + "최종발언": "Final Focus", + "최종 발언": "Final Focus", + "작전시간": "Prep Time", + "교차조사": "Cross Fire", + "교차 조사": "Cross Fire", + "발언 시간은 1초 이상이어야 해요.": "Speaking Time must be at least 1 second.", + "종료 전 타종은 발언 시간보다 길 수 없어요.": "The bell before the end cannot be longer than the Speaking Time.", + "팀당 발언 시간은 1초 이상이어야 해요.": "Speaking Time per team must be at least 1 second.", + "1회당 발언 시간은 팀당 발언 시간을 초과할 수 없어요.": "Speaking Time per turn cannot exceed the team's total Speaking Time.", + "발언 유형은 최대 10자까지 입력할 수 있습니다.": "Speech Type can be up to 10 characters.", + "발언자는 최대 5자까지 입력할 수 있습니다.": "Speaker can be up to 5 characters.", + "1회당 발언 시간은 팀당 총 발언 시간보다 클 수 없습니다.": "Per-turn Speaking Time cannot exceed the total team Speaking Time.", + "발언 유형을 입력해주세요.": "Please enter a Speech Type.", + "자유토론": "Open Debate", + "중립은 발언 유형이 '직접 입력'일 경우에만 선택할 수 있습니다.": "Netural can only be selected when Speech Type is 'Custom'.", + "일반 타이머": "Standard timer", + "자유토론 타이머": "Open Debate timer", + "한 팀의 발언 시간이 세팅된 일반적인 타이머": "A standard timer with a single team's Speaking Time set", + "팀별 발언 시간과 1회당 발언 시간이 세팅된 타이머\n1회당 발언 시간이 지나면, 상대 팀으로 발언권이 넘어감": "A timer with team time and per-turn time set\nWhen a per-turn time ends, the turn passes to the other team", + "종류": "Type", + "발언자": "Speaker", + "N번 토론자": "Speaker N", + "발언 시간": "Speaking Time", + "1회당\n발언 시간": "Per-turn\nSpeaking Time", + "팀당\n발언 시간": "Per-team\nSpeaking Time", + "발언 유형": "Speech Type", + "주도권 토론 등": "e.g. Lead debate", + "입론, 반론, 작전 시간 등": "e.g. Constructive, Rebuttal, Prep Time", + "종소리 설정 접기": "Collapse bell settings", + "종소리 설정 펼치기": "Expand bell settings", + "분": "min", + "초": "sec", + "횟수": "Count", + "설정 완료": "Save settings", + "타이머 추가": "Add timer", + "수정 완료": "Save changes", + "추가하기": "Add", + "복사하기": "Copy", + "이 타이머를 삭제하시겠습니까?": "Delete this timer?", + "{{minutes}}분 {{seconds}}초": "{{minutes}} min {{seconds}} sec", + "팀당 {{minutes}}분 {{seconds}}초": "{{minutes}} min {{seconds}} sec per team", + "발언당 {{minutes}}분 {{seconds}}초": "{{minutes}} min {{seconds}} sec per speech", + "위/아래로 드래그": "Drag up/down", + " | {{speaker}} 토론자": " | {{speaker}} Debater", + "토론 정보를 {{val0}}해주세요": "Please {{val0}} the debate info", + "수정": "Edit", + "설정": "Settings", + "토론 시간표 이름": "Debate schedule name", + "토론 주제": "Debate Topic", + "토론 주제를 입력해주세요": "Please enter a debate Topic", + "팀명": "Team name", + "팀명은 최대 8자까지 입력할 수 있습니다.": "Team name can be up to 8 characters.", + "다음": "Next" +} diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json new file mode 100644 index 00000000..1133b2fe --- /dev/null +++ b/public/locales/ko/translation.json @@ -0,0 +1,231 @@ +{ + "모달 닫기": "모달 닫기", + "유효하지 않은 투표 링크입니다.": "유효하지 않은 투표 링크입니다.", + "승패투표": "승패투표", + "참여자 :": "참여자 :", + "찬성팀": "찬성팀", + "반대팀": "반대팀", + "투표완료": "투표완료", + "다시 투표하기": "다시 투표하기", + "제출하기": "제출하기", + "투표를 제출하시겠습니까?": "투표를 제출하시겠습니까?", + "(제출 후에는 변경이 불가능 합니다.)": "(제출 후에는 변경이 불가능 합니다.)", + "투표가 완료되었습니다.": "투표가 완료되었습니다.", + "테이블 이름 없음": "테이블 이름 없음", + "주제 없음": "주제 없음", + "도움말": "도움말", + "전체 화면": "전체 화면", + "테이블 ID가 올바르지 않습니다.": "테이블 ID가 올바르지 않습니다.", + "공유받은 테이블을 저장하지 못했어요.": "공유받은 테이블을 저장하지 못했어요.", + "테이블 데이터를 확인할 수 없어요.": "테이블 데이터를 확인할 수 없어요.", + "공유된 데이터가 비어 있어요.": "공유된 데이터가 비어 있어요.", + "공유된 토론 테이블을 DB에 저장하지 못했어요.": "공유된 토론 테이블을 DB에 저장하지 못했어요.", + "데이터를 처리하고 있습니다...": "데이터를 처리하고 있습니다...", + "공유받은 데이터 처리에 실패했어요.": "공유받은 데이터 처리에 실패했어요.", + "팀 선정하기": "팀 선정하기", + "토론하기": "토론하기", + "토론 시간표를 선택해주세요": "토론 시간표를 선택해주세요", + "테이블 모드가 올바르지 않습니다.": "테이블 모드가 올바르지 않습니다.", + "시간표 1": "시간표 1", + "찬성": "찬성", + "반대": "반대", + "시간표 정보를 불러오지 못했어요.\n다시 시도할까요?": "시간표 정보를 불러오지 못했어요.\n다시 시도할까요?", + "무승부": "무승부", + "유효하지 않은 투표 결과 링크입니다.": "유효하지 않은 투표 결과 링크입니다.", + "뒤로 가기 ←": "뒤로 가기 ←", + "세부 결과 확인하기": "세부 결과 확인하기", + "아니오": "아니오", + "네": "네", + "정말로 세부 결과를 공개할까요?": "정말로 세부 결과를 공개할까요?", + "토론을 모두 마치셨습니다": "토론을 모두 마치셨습니다", + "박수": "박수", + "피드백 타이머": "피드백 타이머", + "심사평 및 Q&A용 타이머 →": "심사평 및 Q&A용 타이머 →", + "피드백 타이머로 이동": "피드백 타이머로 이동", + "승패투표 진행하기": "승패투표 진행하기", + "QR 코드를 통해 투표 페이지로 이동해요.": "QR 코드를 통해 투표 페이지로 이동해요.", + "승패투표 생성 및 진행": "승패투표 생성 및 진행", + "스캔해 주세요!": "스캔해 주세요!", + "참여자": "참여자", + "등록된 토론자가 없어요.": "등록된 토론자가 없어요.", + "투표 결과 보기": "투표 결과 보기", + "QR 코드를 불러오지 못했어요.\n다시 시도하시겠어요?": "QR 코드를 불러오지 못했어요.\n다시 시도하시겠어요?", + "링크가 클립보드에 복사됨": "링크가 클립보드에 복사됨", + "링크 준비 중": "링크 준비 중", + "공유 링크 복사": "공유 링크 복사", + "이전 차례": "이전 차례", + "다음 차례": "다음 차례", + "토론 종료": "토론 종료", + "알림 {{displayCount}}개": "알림 {{displayCount}}개", + "데이터를 불러오고 있습니다...": "데이터를 불러오고 있습니다...", + "토론 종료 화면으로 돌아가기": "토론 종료 화면으로 돌아가기", + "데이터를 불러오지 못했어요.\n다시 시도할까요?": "데이터를 불러오지 못했어요.\n다시 시도할까요?", + "다시 시도하기": "다시 시도하기", + "페이지를 찾을 수 없어요...": "페이지를 찾을 수 없어요...", + "요청 URL": "요청 URL", + "오류 내용": "오류 내용", + "요청하신 페이지를 찾을 수 없어요.\n홈 화면으로 돌아가 처음부터 다시 시도해주세요.": "요청하신 페이지를 찾을 수 없어요.\n홈 화면으로 돌아가 처음부터 다시 시도해주세요.", + "홈으로 돌아가기": "홈으로 돌아가기", + "{{status}} 오류": "{{status}} 오류", + "오류가 발생했어요...": "오류가 발생했어요...", + "스택": "스택", + "선택": "선택", + "타이머 초기화": "타이머 초기화", + "일시정지": "일시정지", + "재생": "재생", + "A키": "A키", + "L키": "L키", + "전체 시간": "전체 시간", + "현재 시간": "현재 시간", + "팀": "팀", + "토론자": "토론자", + "작전 시간 사용": "작전 시간 사용", + "토론을 끝내셨군요!\n지금까지의 시간표를 로그인하고 저장할까요?": "토론을 끝내셨군요!\n지금까지의 시간표를 로그인하고 저장할까요?", + "자유토론 타이머 조작": "자유토론 타이머 조작", + "재생 버튼을 눌러 타이머를 시작": "재생 버튼을 눌러 타이머를 시작", + "타이머가 동작 중일 때, 일시정지 버튼을 눌러 타이머를 일시정지": "타이머가 동작 중일 때, 일시정지 버튼을 눌러 타이머를 일시정지", + "초기화 버튼을 눌러 타이머를 원래 시간으로 초기화": "초기화 버튼을 눌러 타이머를 원래 시간으로 초기화", + "마우스를 사용하여 타이머를 클릭 시, 진영 변경": "마우스를 사용하여 타이머를 클릭 시, 진영 변경", + "타이머 동작 중 진영이 변경될 경우, 상대 진영의 타이머로 전환과 동시에 시작": "타이머 동작 중 진영이 변경될 경우, 상대 진영의 타이머로 전환과 동시에 시작", + "일반 토론 타이머 조작": "일반 토론 타이머 조작", + "작전 시간 사용 버튼을 눌러 별도의 작전 시간 타이머 사용 가능": "작전 시간 사용 버튼을 눌러 별도의 작전 시간 타이머 사용 가능", + "키보드 조작": "키보드 조작", + "스페이스 바로 타이머를 시작 및 일시정지": "스페이스 바로 타이머를 시작 및 일시정지", + "R 키로 타이머 초기화": "R 키로 타이머 초기화", + "좌우 방향키로 이전/다음 차례로 이동": "좌우 방향키로 이전/다음 차례로 이동", + "A/L 키로 토론 진영 변경": "A/L 키로 토론 진영 변경", + "Enter 키로 상대 진영으로 변경": "Enter 키로 상대 진영으로 변경", + "화면 우측 상단 헤더의 전체 화면 버튼 <0/> 으로 활성화": "화면 우측 상단 헤더의 전체 화면 버튼 <0/> 으로 활성화", + "화면 우측 상단 헤더의 전체 화면 닫기 버튼 <0/> 또는 ESC 키를 눌러 전체 화면 비활성화": "화면 우측 상단 헤더의 전체 화면 닫기 버튼 <0/> 또는 ESC 키를 눌러 전체 화면 비활성화", + "닫기": "닫기", + "작전 시간": "작전 시간", + "-1분": "-1분", + "-30초": "-30초", + "+30초": "+30초", + "+1분": "+1분", + "-5분": "-5분", + "+5분": "+5분", + "비회원 상태로 토론하기": "비회원 상태로 토론하기", + "저장하기": "저장하기", + "공유받은 토론 시간표를 내 시간표 목록에 저장하시겠어요?": "공유받은 토론 시간표를 내 시간표 목록에 저장하시겠어요?", + "수정하기": "수정하기", + "삭제하기": "삭제하기", + "공유하기": "공유하기", + "주제 |": "주제 |", + "취소": "취소", + "삭제": "삭제", + "테이블을 삭제하시겠습니까?": "테이블을 삭제하시겠습니까?", + "타이머 화면": "타이머 화면", + "원하는 때에\n작전 시간 사용하기": "원하는 때에\n작전 시간 사용하기", + "토론자가 작전 시간을\n요청하면 <0/>\n버튼을 눌러 시간을 사용해요": "토론자가 작전 시간을\n요청하면 <0/>\n버튼을 눌러 시간을 사용해요", + "작전 시간이 나타나면\n원하는 시간을 입력하세요!": "작전 시간이 나타나면\n원하는 시간을 입력하세요!", + "키보드 방향키로\n더 편리한 조작": "키보드 방향키로\n더 편리한 조작", + "시간표 설정화면": "시간표 설정화면", + "간편한 시간표 구성": "간편한 시간표 구성", + "시간표 추가": "시간표 추가", + "시간표 추가 버튼": "시간표 추가 버튼", + "두가지 타이머": "두가지 타이머", + "일반형과 자유토론형 타이머로\n다양한 토론 방식을 지원해요.": "일반형과 자유토론형 타이머로\n다양한 토론 방식을 지원해요.", + "종소리 설정": "종소리 설정", + "시간에 따른 종소리를 내마음대로\n커스터마이징 할 수 있어요.": "시간에 따른 종소리를 내마음대로\n커스터마이징 할 수 있어요.", + "다양한 토론 템플릿을 원클릭으로 만나보세요!": "다양한 토론 템플릿을 원클릭으로 만나보세요!", + "{{title}} 로고": "{{title}} 로고", + "{{label}} 토론하기": "{{label}} 토론하기", + "템플릿 신청하기": "템플릿 신청하기", + "새로운 템플릿도 신청해 볼까요?": "새로운 템플릿도 신청해 볼까요?", + "신청하기": "신청하기", + "홈 | 설정": "홈 | 설정", + "토론 정보\n관리 및 기록": "토론 정보\n관리 및 기록", + "토론 기본 정보 설정": "토론 기본 정보 설정", + "시간표 이름부터 주제까지!": "시간표 이름부터 주제까지!", + "시간표 목록": "시간표 목록", + "내가 만든 시간표를 저장하고 싶나요?": "내가 만든 시간표를 저장하고 싶나요?", + "시간표를 저장하려면,\n디베이트 타이머에 로그인해 보세요!": "시간표를 저장하려면,\n디베이트 타이머에 로그인해 보세요!", + "3초 로그인 하기": "3초 로그인 하기", + "아래로 스크롤": "아래로 스크롤", + "이미 많은 사람들이 디베이트 타이머로\n더 나은 토론환경을 만들고 있어요.": "이미 많은 사람들이 디베이트 타이머로\n더 나은 토론환경을 만들고 있어요.", + "비회원으로 시작하기": "비회원으로 시작하기", + "버그 및 불편사항 제보": "버그 및 불편사항 제보", + "디베이트 타이머 사용 중 불편함을 느끼셨나요?": "디베이트 타이머 사용 중 불편함을 느끼셨나요?", + "접수하기": "접수하기", + "디베이트 타이머": "디베이트 타이머", + "개인정보처리방침": "개인정보처리방침", + "서비스 이용약관": "서비스 이용약관", + "브라우저에서 비디오를 지원하지 않습니다.": "브라우저에서 비디오를 지원하지 않습니다.", + "토론 진행을 더 쉽고 빠르게": "토론 진행을 더 쉽고 빠르게", + "대시보드로 이동": "대시보드로 이동", + "3초 로그인": "3초 로그인", + "로그아웃": "로그아웃", + "왕관": "왕관", + "투표 세부 결과": "투표 세부 결과", + "명": "명", + "시간표로 돌아가기": "시간표로 돌아가기", + "비회원 모드": "비회원 모드", + "홈으로 이동": "홈으로 이동", + "로그인": "로그인", + "비회원으로 사용하던 시간표가 있습니다.\n로그인 후에도 이 시간표를 계속 사용하시겠습니까?": "비회원으로 사용하던 시간표가 있습니다.\n로그인 후에도 이 시간표를 계속 사용하시겠습니까?", + "언어 선택": "언어 선택", + "팀별로\n동전의 앞 / 뒷면 중\n하나를 선택해 주세요.": "팀별로\n동전의 앞 / 뒷면 중\n하나를 선택해 주세요.", + "동전": "동전", + "동전 던지는 중...": "동전 던지는 중...", + "앞": "앞", + "뒤": "뒤", + "동전 던지기": "동전 던지기", + "토론 정보 수정하기": "토론 정보 수정하기", + "토론 바로 시작하기": "토론 바로 시작하기", + "입론": "입론", + "반론": "반론", + "최종발언": "최종발언", + "최종 발언": "최종 발언", + "작전시간": "작전시간", + "교차조사": "교차조사", + "교차 조사": "교차 조사", + "발언 시간은 1초 이상이어야 해요.": "발언 시간은 1초 이상이어야 해요.", + "종료 전 타종은 발언 시간보다 길 수 없어요.": "종료 전 타종은 발언 시간보다 길 수 없어요.", + "팀당 발언 시간은 1초 이상이어야 해요.": "팀당 발언 시간은 1초 이상이어야 해요.", + "1회당 발언 시간은 팀당 발언 시간을 초과할 수 없어요.": "1회당 발언 시간은 팀당 발언 시간을 초과할 수 없어요.", + "발언 유형은 최대 10자까지 입력할 수 있습니다.": "발언 유형은 최대 10자까지 입력할 수 있습니다.", + "발언자는 최대 5자까지 입력할 수 있습니다.": "발언자는 최대 5자까지 입력할 수 있습니다.", + "1회당 발언 시간은 팀당 총 발언 시간보다 클 수 없습니다.": "1회당 발언 시간은 팀당 총 발언 시간보다 클 수 없습니다.", + "발언 유형을 입력해주세요.": "발언 유형을 입력해주세요.", + "자유토론": "자유토론", + "중립은 발언 유형이 '직접 입력'일 경우에만 선택할 수 있습니다.": "중립은 발언 유형이 '직접 입력'일 경우에만 선택할 수 있습니다.", + "일반 타이머": "일반 타이머", + "자유토론 타이머": "자유토론 타이머", + "한 팀의 발언 시간이 세팅된 일반적인 타이머": "한 팀의 발언 시간이 세팅된 일반적인 타이머", + "팀별 발언 시간과 1회당 발언 시간이 세팅된 타이머\n1회당 발언 시간이 지나면, 상대 팀으로 발언권이 넘어감": "팀별 발언 시간과 1회당 발언 시간이 세팅된 타이머\n1회당 발언 시간이 지나면, 상대 팀으로 발언권이 넘어감", + "종류": "종류", + "발언자": "발언자", + "N번 토론자": "N번 토론자", + "발언 시간": "발언 시간", + "1회당\n발언 시간": "1회당\n발언 시간", + "팀당\n발언 시간": "팀당\n발언 시간", + "발언 유형": "발언 유형", + "주도권 토론 등": "주도권 토론 등", + "입론, 반론, 작전 시간 등": "입론, 반론, 작전 시간 등", + "종소리 설정 접기": "종소리 설정 접기", + "종소리 설정 펼치기": "종소리 설정 펼치기", + "분": "분", + "초": "초", + "횟수": "횟수", + "설정 완료": "설정 완료", + "타이머 추가": "타이머 추가", + "수정 완료": "수정 완료", + "추가하기": "추가하기", + "복사하기": "복사하기", + "이 타이머를 삭제하시겠습니까?": "이 타이머를 삭제하시겠습니까?", + "{{minutes}}분 {{seconds}}초": "{{minutes}}분 {{seconds}}초", + "팀당 {{minutes}}분 {{seconds}}초": "팀당 {{minutes}}분 {{seconds}}초", + "발언당 {{minutes}}분 {{seconds}}초": "발언당 {{minutes}}분 {{seconds}}초", + "위/아래로 드래그": "위/아래로 드래그", + " | {{speaker}} 토론자": " | {{speaker}} 토론자", + "토론 정보를 {{val0}}해주세요": "토론 정보를 {{val0}}해주세요", + "수정": "수정", + "설정": "설정", + "토론 시간표 이름": "토론 시간표 이름", + "토론 주제": "토론 주제", + "토론 주제를 입력해주세요": "토론 주제를 입력해주세요", + "팀명": "팀명", + "팀명은 최대 8자까지 입력할 수 있습니다.": "팀명은 최대 8자까지 입력할 수 있습니다.", + "다음": "다음" +} From c8beb3f412cbe4579a3f528ab2773f6edc6baa43 Mon Sep 17 00:00:00 2001 From: useon Date: Wed, 28 Jan 2026 04:48:57 +0900 Subject: [PATCH 27/51] =?UTF-8?q?refactor:=20=EC=96=B8=EC=96=B4=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EC=BF=BC=EB=A6=AC=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=20=EB=B0=8F=20=EA=B2=BD=EB=A1=9C=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/header/LanguageSelector.tsx | 3 +- .../DebateVoteResultPage.tsx | 17 +++++++-- .../components/TableListPageContent.tsx | 20 ++++++++-- .../TableOverviewPage/TableOverviewPage.tsx | 37 ++++++++++++++----- src/routes/LanguageWrapper.tsx | 2 +- 5 files changed, 62 insertions(+), 17 deletions(-) diff --git a/src/layout/components/header/LanguageSelector.tsx b/src/layout/components/header/LanguageSelector.tsx index b9cf4a0f..439d6607 100644 --- a/src/layout/components/header/LanguageSelector.tsx +++ b/src/layout/components/header/LanguageSelector.tsx @@ -37,10 +37,11 @@ export default function LanguageSelector() { return; } const newPathname = buildLangPath(location.pathname, newLang); + const nextUrl = `${newPathname}${location.search}${location.hash}`; if (i18n.language !== newLang) { i18n.changeLanguage(newLang); } - navigate(newPathname); + navigate(nextUrl); }; const [isMenuOpen, setIsMenuOpen] = useState(false); diff --git a/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx b/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx index c2d2a499..5e86705d 100644 --- a/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx +++ b/src/page/DebateVoteResultPage/DebateVoteResultPage.tsx @@ -8,7 +8,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() { @@ -35,6 +35,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({ @@ -111,11 +122,11 @@ export default function DebateVoteResultPage() {

- {speechType} + {getSpeechTypeLabel(speechType)}

{timeStr}

@@ -185,7 +191,7 @@ export default function TimeBox(props: TimeBoxProps) { )} - {speechType} + {getSpeechTypeLabel(speechType)} {fullTimeStr} diff --git a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx index f1c4cb98..28efa077 100644 --- a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx +++ b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx @@ -9,6 +9,10 @@ import { BellConfig, } from '../../../../type/type'; import { Formatting } from '../../../../util/formatting'; +import { + SPEECH_TYPE_RECORD, + type SpeechTypeKey, +} from '../../../../util/speechType'; import normalTimerProsImage from '../../../../assets/timer/normal_timer_pros.jpg'; import normalTimerConsImage from '../../../../assets/timer/normal_timer_cons.jpg'; import normalTimerNeutralImage from '../../../../assets/timer/normal_timer_neutral.jpg'; @@ -39,22 +43,7 @@ type TimerCreationOption = | 'TIME_NORMAL' | 'BELL'; -type SpeechType = - | 'OPENING' - | 'REBUTTAL' - | 'TIMEOUT' - | 'CLOSING' - | 'CROSS_EXAM' - | 'CUSTOM'; - -const SPEECH_TYPE_RECORD: Record = { - OPENING: '입론', - CLOSING: '최종 발언', - CUSTOM: '직접 입력', - REBUTTAL: '반론', - CROSS_EXAM: '교차 조사', - TIMEOUT: '작전 시간', -} as const; +type SpeechType = SpeechTypeKey; const STANCE_RECORD: Record = { PROS: '찬성', @@ -96,6 +85,7 @@ interface BellInputConfig { count: number; } +const MAX_SPEAKER_LEN = 5; export default function TimerCreationContent({ beforeData, initData, @@ -120,20 +110,21 @@ export default function TimerCreationContent({ // 발언 유형 초기화 const getSpeechTypeFromString = (value: string): SpeechType => { - switch (value.trim()) { - case t('입론'): + const normalize = (val: string) => val.replace(/\s+/g, '').trim(); + const normalized = normalize(value); + switch (normalized) { + case normalize(SPEECH_TYPE_RECORD.OPENING): return 'OPENING'; - case t('반론'): + case normalize(SPEECH_TYPE_RECORD.REBUTTAL): return 'REBUTTAL'; - case t('최종발언'): - case t('최종 발언'): + case normalize(SPEECH_TYPE_RECORD.CLOSING): return 'CLOSING'; - case t('작전시간'): - case t('작전 시간'): + case normalize(SPEECH_TYPE_RECORD.TIMEOUT): return 'TIMEOUT'; - case t('교차조사'): - case t('교차 조사'): + case normalize(SPEECH_TYPE_RECORD.CROSS_EXAM): return 'CROSS_EXAM'; + case normalize(SPEECH_TYPE_RECORD.OPEN_DEBATE): + return 'OPEN_DEBATE'; default: return 'CUSTOM'; } @@ -149,14 +140,16 @@ export default function TimerCreationContent({ }, []); const initSpeechType = - beforeData?.speechType ?? initData?.speechType ?? t('입론'); + beforeData?.speechType ?? + initData?.speechType ?? + SPEECH_TYPE_RECORD.OPENING; const [currentSpeechType, setCurrentSpeechType] = useState( getSpeechTypeFromString(initSpeechType), ); const [speechTypeTextValue, setSpeechTypeTextValue] = useState( currentSpeechType === 'CUSTOM' ? (initData?.speechType ?? '') - : SPEECH_TYPE_RECORD[currentSpeechType], + : t(SPEECH_TYPE_RECORD[currentSpeechType]), ); // 종소리 영역 확장 여부 @@ -244,12 +237,12 @@ export default function TimerCreationContent({ const isNormalTimer = timerType === 'NORMAL'; const speechTypeOptions: DropdownMenuItem[] = [ - { value: 'OPENING', label: SPEECH_TYPE_RECORD['OPENING'] }, - { value: 'REBUTTAL', label: SPEECH_TYPE_RECORD['REBUTTAL'] }, - { value: 'TIMEOUT', label: SPEECH_TYPE_RECORD['TIMEOUT'] }, - { value: 'CROSS_EXAM', label: SPEECH_TYPE_RECORD['CROSS_EXAM'] }, - { value: 'CLOSING', label: SPEECH_TYPE_RECORD['CLOSING'] }, - { value: 'CUSTOM', label: SPEECH_TYPE_RECORD['CUSTOM'] }, + { value: 'OPENING', label: t(SPEECH_TYPE_RECORD['OPENING']) }, + { value: 'REBUTTAL', label: t(SPEECH_TYPE_RECORD['REBUTTAL']) }, + { value: 'TIMEOUT', label: t(SPEECH_TYPE_RECORD['TIMEOUT']) }, + { value: 'CROSS_EXAM', label: t(SPEECH_TYPE_RECORD['CROSS_EXAM']) }, + { value: 'CLOSING', label: t(SPEECH_TYPE_RECORD['CLOSING']) }, + { value: 'CUSTOM', label: t(SPEECH_TYPE_RECORD['CUSTOM']) }, ] as const; const stanceOptions: DropdownMenuItem[] = useMemo( @@ -312,6 +305,14 @@ export default function TimerCreationContent({ // SpeechType에 맞게 문자열 매핑 let speechTypeToSend: string; let stanceToSend: Stance; + if (speaker.trim().length > MAX_SPEAKER_LEN) { + errors.push( + t('발언자는 최대 {{MAX_SPEAKER_LEN}}자까지 입력할 수 있습니다.', { + MAX_SPEAKER_LEN, + }), + ); + } + if (currentSpeechType === 'CUSTOM') { // 텍스트 길이 유효성 검사 if (speechTypeTextValue.length > 10) { @@ -367,7 +368,9 @@ export default function TimerCreationContent({ onSubmit({ stance: stanceToSend, speechType: - speechTypeToSend.trim() === '' ? t('자유토론') : speechTypeToSend, + speechTypeToSend.trim() === '' + ? SPEECH_TYPE_RECORD.OPEN_DEBATE + : speechTypeToSend, boxType: timerType, time: null, timePerTeam: totalTimePerTeam, @@ -601,9 +604,12 @@ export default function TimerCreationContent({ setSpeaker(e.target.value)} + onChange={(e) => + setSpeaker(e.target.value.slice(0, MAX_SPEAKER_LEN)) + } onClear={() => setSpeaker('')} placeholder={t('N번 토론자')} + maxLength={MAX_SPEAKER_LEN} disabled={ stance === 'NEUTRAL' || currentSpeechType === 'TIMEOUT' } @@ -729,7 +735,6 @@ export default function TimerCreationContent({

{t('종소리 설정')}

- { + const normalized = normalizeSpeechTypeKey(value); + return normalized ? t(normalized) : value; + }; const { timer, isAdditionalTimerOn, @@ -50,7 +55,7 @@ export default function NormalTimer({ Math.floor(Math.abs(totalTime) / 60), ); const second = Formatting.formatTwoDigits(Math.abs(totalTime % 60)); - const titleText = item.speechType; + const titleText = getSpeechTypeLabel(item.speechType); const rawProgress = timer !== null && item.time ? ((item.time - timer) / item.time) * 100 : 0; const progressMotionValue = useCircularTimerAnimation(rawProgress, isRunning); @@ -78,9 +83,9 @@ export default function NormalTimer({

- {teamName && teamName + t(' 팀')} - {teamName && item.speaker && ' | '} - {item.speaker && item.speaker + t(' 토론자')} + {teamName && t('{{team}} 팀', { team: teamName })} + {item.speaker && + t(' | {{speaker}} 토론자', { speaker: item.speaker })}

)} diff --git a/src/util/speechType.ts b/src/util/speechType.ts new file mode 100644 index 00000000..5f78f1a0 --- /dev/null +++ b/src/util/speechType.ts @@ -0,0 +1,29 @@ +export type SpeechTypeKey = + | 'OPENING' + | 'REBUTTAL' + | 'TIMEOUT' + | 'CLOSING' + | 'CROSS_EXAM' + | 'CUSTOM' + | 'OPEN_DEBATE'; + +export const SPEECH_TYPE_RECORD: Record = { + OPENING: '입론', + CLOSING: '최종발언', + CUSTOM: '직접 입력', + REBUTTAL: '반론', + CROSS_EXAM: '교차조사', + TIMEOUT: '작전시간', + OPEN_DEBATE: '자유토론', +} as const; + +const normalize = (value: string) => value.replace(/\s+/g, '').trim(); + +const SPEECH_TYPE_LABEL_BY_NORMALIZED = new Map( + Object.values(SPEECH_TYPE_RECORD).map((label) => [normalize(label), label]), +); + +export const normalizeSpeechTypeKey = (value: string): string | null => { + const compact = normalize(value); + return SPEECH_TYPE_LABEL_BY_NORMALIZED.get(compact) ?? null; +}; From 372fa18ab30ef54cfad0247dc6e367ddb06628ed Mon Sep 17 00:00:00 2001 From: useon Date: Wed, 28 Jan 2026 05:26:22 +0900 Subject: [PATCH 29/51] =?UTF-8?q?feat:=20develop=20=EB=B3=91=ED=95=A9?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EC=9A=B4=20=ED=82=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/en/translation.json | 15 +++++++++++---- public/locales/ko/translation.json | 15 +++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index e6549f26..d73557c5 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -32,7 +32,6 @@ "시간표 정보를 불러오지 못했어요.\n다시 시도할까요?": "Couldn't load the schedule.\nTry again?", "무승부": "Tie", "유효하지 않은 투표 결과 링크입니다.": "Invalid vote result link.", - "뒤로 가기 ←": "Back ←", "세부 결과 확인하기": "View detailed results", "아니오": "No", "네": "Yes", @@ -57,6 +56,7 @@ "다음 차례": "Next turn", "토론 종료": "End debate", "알림 {{displayCount}}개": "{{displayCount}} notifications", + "{{team}} 팀": "{{team}} team", "데이터를 불러오고 있습니다...": "Loading data...", "토론 종료 화면으로 돌아가기": "Back to debate end screen", "데이터를 불러오지 못했어요.\n다시 시도할까요?": "Couldn't load the data.\nTry again?", @@ -176,10 +176,9 @@ "입론": "Constructive", "반론": "Rebuttal", "최종발언": "Final Focus", - "최종 발언": "Final Focus", "작전시간": "Prep Time", "교차조사": "Cross Fire", - "교차 조사": "Cross Fire", + "직접입력": "Custom", "발언 시간은 1초 이상이어야 해요.": "Speaking Time must be at least 1 second.", "종료 전 타종은 발언 시간보다 길 수 없어요.": "The bell before the end cannot be longer than the Speaking Time.", "팀당 발언 시간은 1초 이상이어야 해요.": "Speaking Time per team must be at least 1 second.", @@ -227,5 +226,13 @@ "토론 주제를 입력해주세요": "Please enter a debate Topic", "팀명": "Team name", "팀명은 최대 8자까지 입력할 수 있습니다.": "Team name can be up to 8 characters.", - "다음": "Next" + "다음": "Next", + "볼륨 조절": "Volume control", + "투표 종료에 실패했습니다.": "Failed to end the vote.", + "마감하기": "Close voting", + "투표를 마감하시겠습니까?": "Do you want to close the vote?", + "투표를 마감하면 더이상 표를 받을 수 없습니다!": "Once you close the vote, you can no longer receive votes!", + "음소거": "Mute", + "음소거 해제": "Unmute", + "발언자는 최대 {{MAX_SPEAKER_LEN}}자까지 입력할 수 있습니다.": "Speaker can be up to {{MAX_SPEAKER_LEN}} characters." } diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 1133b2fe..50546255 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -32,7 +32,6 @@ "시간표 정보를 불러오지 못했어요.\n다시 시도할까요?": "시간표 정보를 불러오지 못했어요.\n다시 시도할까요?", "무승부": "무승부", "유효하지 않은 투표 결과 링크입니다.": "유효하지 않은 투표 결과 링크입니다.", - "뒤로 가기 ←": "뒤로 가기 ←", "세부 결과 확인하기": "세부 결과 확인하기", "아니오": "아니오", "네": "네", @@ -57,6 +56,7 @@ "다음 차례": "다음 차례", "토론 종료": "토론 종료", "알림 {{displayCount}}개": "알림 {{displayCount}}개", + "{{team}} 팀": "{{team}} 팀", "데이터를 불러오고 있습니다...": "데이터를 불러오고 있습니다...", "토론 종료 화면으로 돌아가기": "토론 종료 화면으로 돌아가기", "데이터를 불러오지 못했어요.\n다시 시도할까요?": "데이터를 불러오지 못했어요.\n다시 시도할까요?", @@ -176,10 +176,9 @@ "입론": "입론", "반론": "반론", "최종발언": "최종발언", - "최종 발언": "최종 발언", "작전시간": "작전시간", "교차조사": "교차조사", - "교차 조사": "교차 조사", + "직접입력": "직접입력", "발언 시간은 1초 이상이어야 해요.": "발언 시간은 1초 이상이어야 해요.", "종료 전 타종은 발언 시간보다 길 수 없어요.": "종료 전 타종은 발언 시간보다 길 수 없어요.", "팀당 발언 시간은 1초 이상이어야 해요.": "팀당 발언 시간은 1초 이상이어야 해요.", @@ -227,5 +226,13 @@ "토론 주제를 입력해주세요": "토론 주제를 입력해주세요", "팀명": "팀명", "팀명은 최대 8자까지 입력할 수 있습니다.": "팀명은 최대 8자까지 입력할 수 있습니다.", - "다음": "다음" + "다음": "다음", + "볼륨 조절": "볼륨 조절", + "투표 종료에 실패했습니다.": "투표 종료에 실패했습니다.", + "마감하기": "마감하기", + "투표를 마감하시겠습니까?": "투표를 마감하시겠습니까?", + "투표를 마감하면 더이상 표를 받을 수 없습니다!": "투표를 마감하면 더이상 표를 받을 수 없습니다!", + "음소거": "음소거", + "음소거 해제": "음소거 해제", + "발언자는 최대 {{MAX_SPEAKER_LEN}}자까지 입력할 수 있습니다.": "발언자는 최대 {{MAX_SPEAKER_LEN}}자까지 입력할 수 있습니다." } From eb99216754319a135063c7ea3208b8ac3a4956b2 Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 3 Feb 2026 16:23:49 +0900 Subject: [PATCH 30/51] =?UTF-8?q?refactor:=20Neutral=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/en/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index d73557c5..4ab946e2 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -188,7 +188,7 @@ "1회당 발언 시간은 팀당 총 발언 시간보다 클 수 없습니다.": "Per-turn Speaking Time cannot exceed the total team Speaking Time.", "발언 유형을 입력해주세요.": "Please enter a Speech Type.", "자유토론": "Open Debate", - "중립은 발언 유형이 '직접 입력'일 경우에만 선택할 수 있습니다.": "Netural can only be selected when Speech Type is 'Custom'.", + "중립은 발언 유형이 '직접 입력'일 경우에만 선택할 수 있습니다.": "Neutral can only be selected when Speech Type is 'Custom'.", "일반 타이머": "Standard timer", "자유토론 타이머": "Open Debate timer", "한 팀의 발언 시간이 세팅된 일반적인 타이머": "A standard timer with a single team's Speaking Time set", From 302f617c80eb82c13da225fe6967553372905f11 Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 3 Feb 2026 16:38:55 +0900 Subject: [PATCH 31/51] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=EB=A9=94?= =?UTF-8?q?=EC=84=B8=EC=A7=80=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=8F=99?= =?UTF-8?q?=EC=82=AC=ED=98=95=EC=9C=BC=EB=A1=9C=20=ED=88=AC=ED=91=9C?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EB=B2=88=EC=97=AD=20=EB=82=B4=EC=9A=A9=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/en/translation.json | 12 ++++++++++-- public/locales/ko/translation.json | 10 +++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 4ab946e2..2a343508 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -5,7 +5,7 @@ "참여자 :": "Participants:", "찬성팀": "Affirmative Team", "반대팀": "Negative Team", - "투표완료": "Vote submitted", + "투표완료": "Submit vote", "다시 투표하기": "Vote again", "제출하기": "Submit", "투표를 제출하시겠습니까?": "Submit your vote?", @@ -234,5 +234,13 @@ "투표를 마감하면 더이상 표를 받을 수 없습니다!": "Once you close the vote, you can no longer receive votes!", "음소거": "Mute", "음소거 해제": "Unmute", - "발언자는 최대 {{MAX_SPEAKER_LEN}}자까지 입력할 수 있습니다.": "Speaker can be up to {{MAX_SPEAKER_LEN}} characters." + "발언자는 최대 {{MAX_SPEAKER_LEN}}자까지 입력할 수 있습니다.": "Speaker can be up to {{MAX_SPEAKER_LEN}} characters.", + "400 잘못된 요청": "400 Bad Request", + "401 권한 없음": "401 Unauthorized", + "403 거부됨": "403 Forbidden", + "404 찾을 수 없음": "404 Not Found", + "500 내부 서버 오류": "500 Internal Server Error", + "502 게이트웨이 불량": "502 Bad Gateway", + "503 서비스가 일시적으로 중단됨": "503 Service Unavailable", + "504 게이트웨이 시간 초과": "504 Gateway Timeout" } diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 50546255..532cfc13 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -234,5 +234,13 @@ "투표를 마감하면 더이상 표를 받을 수 없습니다!": "투표를 마감하면 더이상 표를 받을 수 없습니다!", "음소거": "음소거", "음소거 해제": "음소거 해제", - "발언자는 최대 {{MAX_SPEAKER_LEN}}자까지 입력할 수 있습니다.": "발언자는 최대 {{MAX_SPEAKER_LEN}}자까지 입력할 수 있습니다." + "발언자는 최대 {{MAX_SPEAKER_LEN}}자까지 입력할 수 있습니다.": "발언자는 최대 {{MAX_SPEAKER_LEN}}자까지 입력할 수 있습니다.", + "400 잘못된 요청": "400 잘못된 요청", + "401 권한 없음": "401 권한 없음", + "403 거부됨": "403 거부됨", + "404 찾을 수 없음": "404 찾을 수 없음", + "500 내부 서버 오류": "500 내부 서버 오류", + "502 게이트웨이 불량": "502 게이트웨이 불량", + "503 서비스가 일시적으로 중단됨": "503 서비스가 일시적으로 중단됨", + "504 게이트웨이 시간 초과": "504 게이트웨이 시간 초과" } From f73ade7543244246f066869adc4fa3f02946d098 Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 3 Feb 2026 16:39:58 +0900 Subject: [PATCH 32/51] =?UTF-8?q?fix:=20=ED=95=9C=EA=B8=80=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=A9=94=EC=84=B8=EC=A7=80=EA=B0=80=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EB=93=A4=EC=96=B4=EC=98=A4=EB=8A=94=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20=EB=B2=88=EC=97=AD=20=EC=95=88=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ErrorBoundary/ErrorPage.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/ErrorBoundary/ErrorPage.tsx b/src/components/ErrorBoundary/ErrorPage.tsx index 402df3c6..9d2b549b 100644 --- a/src/components/ErrorBoundary/ErrorPage.tsx +++ b/src/components/ErrorBoundary/ErrorPage.tsx @@ -21,11 +21,16 @@ export default function ErrorPage({ error, stack, onReset }: ErrorPageProps) { // If error is from API request, print status code // to let user know exact reason of error. - const title = - error instanceof APIError - ? ERROR_STATUS_TABLE[error.status] || - t('{{status}} 오류', { status: error.status }) - : t('오류가 발생했어요...'); + const title = (() => { + if (!(error instanceof APIError)) { + return t('오류가 발생했어요...'); + } + + const statusKey = ERROR_STATUS_TABLE[error.status]; + return statusKey + ? t(statusKey) + : t('{{status}} 오류', { status: error.status }); + })(); return ( From 50b96a8f4be0235dc443b6029ec13204cf6119fc Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 3 Feb 2026 17:19:12 +0900 Subject: [PATCH 33/51] =?UTF-8?q?refactor:=20=EB=8B=A8=EC=88=98,=20?= =?UTF-8?q?=EB=B3=B5=EC=88=98=20=EA=B5=AC=EB=B6=84=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20i18n=20Plurals=20=EA=B7=9C=EC=B9=99=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/en/translation.json | 6 ++++-- public/locales/ko/translation.json | 6 ++++-- src/components/NotificationBadge/NotificationBadge.tsx | 7 ++++++- src/page/DebateVoteResultPage/components/VoteBar.tsx | 2 +- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 2a343508..8d5572a2 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -55,7 +55,8 @@ "이전 차례": "Previous turn", "다음 차례": "Next turn", "토론 종료": "End debate", - "알림 {{displayCount}}개": "{{displayCount}} notifications", + "알림 개수_one": "{{displayCount}} notification", + "알림 개수_other": "{{displayCount}} notifications", "{{team}} 팀": "{{team}} team", "데이터를 불러오고 있습니다...": "Loading data...", "토론 종료 화면으로 돌아가기": "Back to debate end screen", @@ -158,7 +159,8 @@ "로그아웃": "Log out", "왕관": "Crown", "투표 세부 결과": "Detailed vote results", - "명": "people", + "명_one": "{{count}} person", + "명_other": "{{count}} people", "시간표로 돌아가기": "Back to schedule", "비회원 모드": "Guest mode", "홈으로 이동": "Go to Home", diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 532cfc13..bbb45310 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -55,7 +55,8 @@ "이전 차례": "이전 차례", "다음 차례": "다음 차례", "토론 종료": "토론 종료", - "알림 {{displayCount}}개": "알림 {{displayCount}}개", + "알림 개수_one": "알림 {{displayCount}}개", + "알림 개수_other": "알림 {{displayCount}}개", "{{team}} 팀": "{{team}} 팀", "데이터를 불러오고 있습니다...": "데이터를 불러오고 있습니다...", "토론 종료 화면으로 돌아가기": "토론 종료 화면으로 돌아가기", @@ -158,7 +159,8 @@ "로그아웃": "로그아웃", "왕관": "왕관", "투표 세부 결과": "투표 세부 결과", - "명": "명", + "명_one": "{{count}}명", + "명_other": "{{count}}명", "시간표로 돌아가기": "시간표로 돌아가기", "비회원 모드": "비회원 모드", "홈으로 이동": "홈으로 이동", diff --git a/src/components/NotificationBadge/NotificationBadge.tsx b/src/components/NotificationBadge/NotificationBadge.tsx index c15eb747..657b182b 100644 --- a/src/components/NotificationBadge/NotificationBadge.tsx +++ b/src/components/NotificationBadge/NotificationBadge.tsx @@ -19,10 +19,15 @@ export default function NotificationBadge({ const displayCount = safeCount > 99 ? '99+' : safeCount; + const ariaLabel = t('알림 개수', { + count: safeCount, + displayCount, + }); + return ( - {t('명')} + {t('명', { count })} From 69b0ae783bacaed60dd4751264b87de3cd7016b2 Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 3 Feb 2026 18:04:09 +0900 Subject: [PATCH 34/51] =?UTF-8?q?refactor:=20=ED=88=AC=ED=91=9C=20?= =?UTF-8?q?=EC=9D=B8=EC=9B=90=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=EC=99=80=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=EC=82=AC=EC=9D=B4=EC=97=90=20=EA=B3=B5?= =?UTF-8?q?=EB=B0=B1=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=88=AC=ED=91=9C?= =?UTF-8?q?=20=EC=9D=B8=EC=9B=90=EC=9D=B4=20=EC=A4=91=EB=B3=B5=EB=8F=BC?= =?UTF-8?q?=EC=84=9C=20=EB=82=98=EC=98=A4=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/en/translation.json | 4 ++-- public/locales/ko/translation.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 8d5572a2..a7282f8b 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -159,8 +159,8 @@ "로그아웃": "Log out", "왕관": "Crown", "투표 세부 결과": "Detailed vote results", - "명_one": "{{count}} person", - "명_other": "{{count}} people", + "명_one": " person", + "명_other": " people", "시간표로 돌아가기": "Back to schedule", "비회원 모드": "Guest mode", "홈으로 이동": "Go to Home", diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index bbb45310..7ccf6264 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -159,8 +159,8 @@ "로그아웃": "로그아웃", "왕관": "왕관", "투표 세부 결과": "투표 세부 결과", - "명_one": "{{count}}명", - "명_other": "{{count}}명", + "명_one": " 명", + "명_other": " 명", "시간표로 돌아가기": "시간표로 돌아가기", "비회원 모드": "비회원 모드", "홈으로 이동": "홈으로 이동", From 486a5740c4693e74af0a39d5f4e79b6085170d84 Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 3 Feb 2026 18:17:15 +0900 Subject: [PATCH 35/51] =?UTF-8?q?refactor:=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=EC=9D=84=20=EC=96=B8?= =?UTF-8?q?=EC=96=B4=20=EA=B2=BD=EB=A1=9C=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20?= =?UTF-8?q?buildLangPath=20=EC=9D=B4=EC=9A=A9=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 언어 경로가 깨지거나 리다이렉트가 발생하는 문제 해결 --- src/apis/axiosInstance.ts | 10 ++++++++- src/components/BackActionHandler.tsx | 17 ++++++++++++--- src/components/ErrorBoundary/ErrorPage.tsx | 12 +++++++++-- src/components/ErrorBoundary/NotFoundPage.tsx | 12 +++++++++-- .../GoToDebateEndButton.tsx | 11 ++++++++-- .../header/StickyTriSectionHeader.tsx | 14 ++++++++++--- src/page/DebateEndPage/DebateEndPage.tsx | 15 ++++++++++--- .../components/GoToOverviewButton.tsx | 11 ++++++++-- src/page/DebateVotePage/DebateVotePage.tsx | 18 +++++++++++++--- .../DebateVoteResultPage.tsx | 20 +++++++++++++----- .../hooks/useLandingPageHandlers.ts | 21 ++++++++++++++----- src/page/OAuthPage/OAuth.tsx | 13 ++++++++++-- .../TableComposition/hook/useTableFrom.tsx | 15 ++++++++++--- .../TableSharingPage/TableSharingPage.tsx | 19 +++++++++++------ .../components/LoginAndStoreModal.tsx | 11 ++++++++-- src/page/TimerPage/hooks/useTimerPageModal.ts | 11 +++++++++- .../VoteParticipationPage.tsx | 15 ++++++++++--- src/routes/ProtectedRoute.tsx | 13 ++++++++++-- 18 files changed, 208 insertions(+), 50 deletions(-) diff --git a/src/apis/axiosInstance.ts b/src/apis/axiosInstance.ts index 89e9bede..d21d39ac 100644 --- a/src/apis/axiosInstance.ts +++ b/src/apis/axiosInstance.ts @@ -4,6 +4,12 @@ import { removeAccessToken, setAccessToken, } from '../util/accessToken'; +import i18n from '../i18n'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../util/languageRouting'; // Get current mode (DEV, PROD or TEST) const currentMode = import.meta.env.MODE; @@ -66,7 +72,9 @@ axiosInstance.interceptors.response.use( } catch (refreshError) { console.error('Refresh Token is invalid or expired', refreshError); // 재발급도 실패하면 -> 로그인 페이지 이동 - window.location.href = '/home'; + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + window.location.href = buildLangPath('/home', lang); removeAccessToken(); return Promise.reject(refreshError); } diff --git a/src/components/BackActionHandler.tsx b/src/components/BackActionHandler.tsx index e75bd923..5b128133 100644 --- a/src/components/BackActionHandler.tsx +++ b/src/components/BackActionHandler.tsx @@ -1,15 +1,26 @@ import { useCallback, useEffect } from 'react'; import { getAccessToken } from '../util/accessToken'; import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../util/languageRouting'; export default function BackActionHandler() { + const { i18n } = useTranslation(); const navigate = useNavigate(); const handleBackAction = useCallback(() => { - if (getAccessToken() !== null && window.location.pathname === '/') { + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + const rootPath = buildLangPath('/', lang); + + if (getAccessToken() !== null && window.location.pathname === rootPath) { // Push the current state again to prevent going back - navigate('/'); + navigate(rootPath); } - }, [navigate]); + }, [i18n.language, i18n.resolvedLanguage, navigate]); useEffect(() => { const onPopState = () => { diff --git a/src/components/ErrorBoundary/ErrorPage.tsx b/src/components/ErrorBoundary/ErrorPage.tsx index 9d2b549b..bc30de76 100644 --- a/src/components/ErrorBoundary/ErrorPage.tsx +++ b/src/components/ErrorBoundary/ErrorPage.tsx @@ -4,6 +4,11 @@ import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import { useNavigate } from 'react-router-dom'; import { APIError } from '../../apis/primitives'; import { ERROR_STATUS_TABLE } from '../../constants/errors'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../util/languageRouting'; interface ErrorPageProps { error: Error; @@ -12,11 +17,14 @@ interface ErrorPageProps { } export default function ErrorPage({ error, stack, onReset }: ErrorPageProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const navigate = useNavigate(); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + const homePath = buildLangPath('/home', lang); const goToHome = () => { onReset(); - navigate('/home', { replace: true }); + navigate(homePath, { replace: true }); }; // If error is from API request, print status code diff --git a/src/components/ErrorBoundary/NotFoundPage.tsx b/src/components/ErrorBoundary/NotFoundPage.tsx index 7b1ffeab..b72208ce 100644 --- a/src/components/ErrorBoundary/NotFoundPage.tsx +++ b/src/components/ErrorBoundary/NotFoundPage.tsx @@ -2,10 +2,18 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import { IoHome } from 'react-icons/io5'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../util/languageRouting'; export default function NotFoundPage() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const navigate = useNavigate(); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + const rootPath = buildLangPath('/', lang); return ( @@ -43,7 +51,7 @@ export default function NotFoundPage() { {/* 언어 선택 메뉴 패널 */} From 39de0dd0c8311a468bd7de8924a62d430619615a Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 3 Feb 2026 18:41:32 +0900 Subject: [PATCH 43/51] =?UTF-8?q?refactor:=20=EC=96=B8=EC=96=B4=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=ED=95=B8=EB=93=A4=EB=9F=AC=EC=97=90=20?= =?UTF-8?q?=EC=85=80=EB=A0=89=ED=84=B0=20=EB=8B=AB=EB=8A=94=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=8F=84=20=ED=95=A8=EA=BB=98=20=EB=84=A3=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/layout/components/header/LanguageSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layout/components/header/LanguageSelector.tsx b/src/layout/components/header/LanguageSelector.tsx index 97fc1f87..ac370f13 100644 --- a/src/layout/components/header/LanguageSelector.tsx +++ b/src/layout/components/header/LanguageSelector.tsx @@ -42,6 +42,7 @@ export default function LanguageSelector() { i18n.changeLanguage(newLang); } navigate(nextUrl); + setIsMenuOpen(false); }; const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -115,7 +116,6 @@ export default function LanguageSelector() { className={menuItemClasses(option.value)} onClick={() => { handleLanguageChange(option.value); - setIsMenuOpen(false); }} role="option" aria-selected={option.value === selectedLangValue} From b89265ec835cf93cc37a109c3cd2cd88b27c3458 Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 3 Feb 2026 19:16:35 +0900 Subject: [PATCH 44/51] =?UTF-8?q?refactor:=20=EC=A2=85=EC=86=8C=EB=A6=AC?= =?UTF-8?q?=20=EB=9D=BC=EB=B2=A8=20=EB=B2=88=EC=97=AD=20=EB=B0=8F=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=ED=91=9C=EA=B8=B0=20=EB=9D=84=EC=96=B4?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENT.md | 124 ------------------ public/locales/en/translation.json | 3 + public/locales/ko/translation.json | 3 + .../TimerCreationContent.tsx | 31 ++--- 4 files changed, 22 insertions(+), 139 deletions(-) delete mode 100644 AGENT.md diff --git a/AGENT.md b/AGENT.md deleted file mode 100644 index 6ff10ae1..00000000 --- a/AGENT.md +++ /dev/null @@ -1,124 +0,0 @@ -# 언어 인식 네비게이션 중앙화 구현 계획 - -## 목표 -23개 이상 파일에서 반복되는 언어 추출 + 경로 빌드 로직을 중앙화하여 DRY 원칙 준수 - -## 현재 반복 패턴 (제거 대상) -```typescript -const { i18n } = useTranslation(); -const currentLang = i18n.resolvedLanguage ?? i18n.language; -const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; -navigate(buildLangPath('/path', lang)); -``` - ---- - -## 구현 계획 - -### 1. Core Utility 확장 (`src/util/languageRouting.ts`) - -`SupportedLang` 타입과 `getCurrentLang` 함수 추가: - -```typescript -// 새로 추가 -export type { SupportedLang }; - -export const getCurrentLang = (i18n: { resolvedLanguage?: string; language: string }): SupportedLang => { - const currentLang = i18n.resolvedLanguage ?? i18n.language; - return isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; -}; -``` - -### 2. React Hooks 생성 (`src/hooks/useLangNavigate.ts`) - -```typescript -// useCurrentLang - 현재 언어 반환 -// useLangNavigate - navigate() 대체 -// useLangPath - 경로만 필요할 때 -``` - -### 3. React 외부 유틸 (`src/util/langNavigation.ts`) - -```typescript -// navigateWithLang(path) - axios interceptor 등에서 사용 -// i18n 싱글톤 직접 참조 -``` - ---- - -## 수정 대상 파일 - -### A. 신규 생성 -1. `src/hooks/useLangNavigate.ts` - 훅 정의 - -### B. 수정 -1. `src/util/languageRouting.ts` - getCurrentLang, SupportedLang export 추가 -2. `src/apis/axiosInstance.ts` - navigateWithLang 사용 - -### C. 마이그레이션 (useLangNavigate 적용) -- `src/components/BackActionHandler.tsx` -- `src/components/ErrorBoundary/ErrorPage.tsx` -- `src/components/ErrorBoundary/NotFoundPage.tsx` -- `src/components/GoToDebateEndButton/GoToDebateEndButton.tsx` -- `src/layout/components/header/StickyTriSectionHeader.tsx` -- `src/page/DebateEndPage/DebateEndPage.tsx` -- `src/page/DebateEndPage/components/GoToOverviewButton.tsx` -- `src/page/DebateVotePage/DebateVotePage.tsx` -- `src/page/DebateVoteResultPage/DebateVoteResultPage.tsx` -- `src/page/LandingPage/hooks/useLandingPageHandlers.ts` -- `src/page/OAuthPage/OAuth.tsx` -- `src/page/TableComposition/hook/useTableFrom.tsx` -- `src/page/TableSharingPage/TableSharingPage.tsx` -- `src/page/TimerPage/components/LoginAndStoreModal.tsx` -- `src/page/TimerPage/hooks/useTimerPageModal.ts` -- `src/page/VoteParticipationPage/VoteParticipationPage.tsx` -- `src/routes/ProtectedRoute.tsx` - ---- - -## 변경 전후 비교 - -### Before (각 컴포넌트) -```typescript -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { buildLangPath, DEFAULT_LANG, isSupportedLang } from '../../util/languageRouting'; - -const { i18n } = useTranslation(); -const navigate = useNavigate(); -const currentLang = i18n.resolvedLanguage ?? i18n.language; -const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; -navigate(buildLangPath('/path', lang)); -``` - -### After (각 컴포넌트) -```typescript -import { useLangNavigate } from '../../hooks/useLangNavigate'; - -const navigate = useLangNavigate(); -navigate('/path'); -``` - -### axios interceptor Before -```typescript -const currentLang = i18n.resolvedLanguage ?? i18n.language; -const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; -window.location.href = buildLangPath('/home', lang); -``` - -### axios interceptor After -```typescript -import { navigateWithLang } from '../util/languageRouting'; -navigateWithLang('/home'); -``` - ---- - -## 검증 방법 - -1. **타입 체크**: `npm run type-check` 또는 `tsc --noEmit` -2. **빌드 테스트**: `npm run build` -3. **수동 테스트**: - - 한국어 모드에서 페이지 이동 확인 (URL에 `/ko` 없음) - - 영어 모드에서 페이지 이동 확인 (URL에 `/en` 있음) - - 401 에러 시 홈으로 리다이렉트 확인 diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 06647422..ae480487 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -206,6 +206,9 @@ "입론, 반론, 작전 시간 등": "e.g. Constructive, Rebuttal, Prep Time", "종소리 설정 접기": "Collapse bell settings", "종소리 설정 펼치기": "Expand bell settings", + "종료 전": "Before end", + "종료 후": "After end", + "시작 후": "After start", "분": "min", "초": "sec", "횟수": "Count", diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 26e9963f..93a359cc 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -206,6 +206,9 @@ "입론, 반론, 작전 시간 등": "입론, 반론, 작전 시간 등", "종소리 설정 접기": "종소리 설정 접기", "종소리 설정 펼치기": "종소리 설정 펼치기", + "종료 전": "종료 전", + "종료 후": "종료 후", + "시작 후": "시작 후", "분": "분", "초": "초", "횟수": "횟수", diff --git a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx index 5b3f3741..96657841 100644 --- a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx +++ b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx @@ -5,7 +5,6 @@ import { Stance, TimeBoxType, BellType, - BellTypeToString, BellConfig, } from '../../../../type/type'; import { Formatting } from '../../../../util/formatting'; @@ -51,6 +50,12 @@ const STANCE_RECORD: Record = { NEUTRAL: '중립', } as const; +const BELL_TYPE_LABEL_KEYS: Record = { + BEFORE_END: '종료 전', + AFTER_END: '종료 후', + AFTER_START: '시작 후', +} as const; + const NORMAL_OPTIONS: TimerCreationOption[] = [ 'TIMER_TYPE', 'SPEECH_TYPE_NORMAL', @@ -255,15 +260,11 @@ export default function TimerCreationContent({ [prosTeamName, consTeamName], ); - const bellOptions: DropdownMenuItem[] = useMemo( - () => [ - { value: 'BEFORE_END', label: BellTypeToString['BEFORE_END'] }, - { value: 'AFTER_END', label: BellTypeToString['AFTER_END'] }, - { value: 'AFTER_START', label: BellTypeToString['AFTER_START'] }, - ], - - [], - ); + const bellOptions: DropdownMenuItem[] = [ + { value: 'BEFORE_END', label: t(BELL_TYPE_LABEL_KEYS['BEFORE_END']) }, + { value: 'AFTER_END', label: t(BELL_TYPE_LABEL_KEYS['AFTER_END']) }, + { value: 'AFTER_START', label: t(BELL_TYPE_LABEL_KEYS['AFTER_START']) }, + ]; const options = isNormalTimer ? NORMAL_OPTIONS : TIME_BASED_OPTIONS; @@ -861,13 +862,13 @@ export default function TimerCreationContent({ >

- {BellTypeToString[bell.type]} + {t(BELL_TYPE_LABEL_KEYS[bell.type])}

- {bell.min} - {t('분')} - {bell.sec} - {t('초')} + {t('{{minutes}}분 {{seconds}}초', { + minutes: bell.min, + seconds: bell.sec, + })}

From b16f6755ec0b8792ca33f87abcd596e13343dda4 Mon Sep 17 00:00:00 2001 From: useon Date: Tue, 3 Feb 2026 19:22:11 +0900 Subject: [PATCH 45/51] =?UTF-8?q?refactor:=20tailwind=20=EC=98=A4=ED=83=80?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/page/VoteParticipationPage/VoteParticipationPage.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/page/VoteParticipationPage/VoteParticipationPage.tsx b/src/page/VoteParticipationPage/VoteParticipationPage.tsx index 6a895d5f..5341ecde 100644 --- a/src/page/VoteParticipationPage/VoteParticipationPage.tsx +++ b/src/page/VoteParticipationPage/VoteParticipationPage.tsx @@ -77,7 +77,9 @@ export default function VoteParticipationPage() { return ( - navigate(buildLangPath('/', lang))}> + navigate(buildLangPath('/', lang))} + > {t('유효하지 않은 투표 링크입니다.')} @@ -173,7 +175,7 @@ export default function VoteParticipationPage() {

{t('투표를 제출하시겠습니까?')}

-

+

{t('(제출 후에는 변경이 불가능 합니다.)')}

From 05e359a1e0dc625af4bc127e933c97c03146b19f Mon Sep 17 00:00:00 2001 From: useon Date: Thu, 5 Feb 2026 23:11:54 +0900 Subject: [PATCH 46/51] =?UTF-8?q?refactor:=20=EB=A3=A8=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B9=84=EA=B5=90=20=EC=8B=9C=20trailing?= =?UTF-8?q?=20slash=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=8A=94=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=ED=99=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/BackActionHandler.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/BackActionHandler.tsx b/src/components/BackActionHandler.tsx index 5b128133..0b3bbfe5 100644 --- a/src/components/BackActionHandler.tsx +++ b/src/components/BackActionHandler.tsx @@ -6,6 +6,7 @@ import { buildLangPath, DEFAULT_LANG, isSupportedLang, + stripDefaultLangFromPath, } from '../util/languageRouting'; export default function BackActionHandler() { @@ -15,8 +16,11 @@ export default function BackActionHandler() { const currentLang = i18n.resolvedLanguage ?? i18n.language; const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; const rootPath = buildLangPath('/', lang); + const normalizedPathname = stripDefaultLangFromPath( + window.location.pathname, + ); - if (getAccessToken() !== null && window.location.pathname === rootPath) { + if (getAccessToken() !== null && normalizedPathname === rootPath) { // Push the current state again to prevent going back navigate(rootPath); } From 630430623ee417e2b5ba68f18ec8f429098e6780 Mon Sep 17 00:00:00 2001 From: useon Date: Fri, 6 Feb 2026 02:13:21 +0900 Subject: [PATCH 47/51] =?UTF-8?q?refactor:=20=EC=9E=90=EC=9C=A0=ED=86=A0?= =?UTF-8?q?=EB=A1=A0=20=ED=8E=B8=EC=A7=91=20=EC=8B=9C=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=EB=AC=B4=EC=8B=9C=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=EB=A5=BC=20TIME=5FBASED=EB=A5=BC=20=EC=9E=85=EB=A0=A5=EA=B0=92?= =?UTF-8?q?=20=EC=9A=B0=EC=84=A0=EC=9C=BC=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TimerCreationContent.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx index 96657841..11b48fc5 100644 --- a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx +++ b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx @@ -148,13 +148,17 @@ export default function TimerCreationContent({ beforeData?.speechType ?? initData?.speechType ?? SPEECH_TYPE_RECORD.OPENING; + const isTimeBasedInit = + (beforeData?.boxType ?? initData?.boxType ?? 'NORMAL') === 'TIME_BASED'; const [currentSpeechType, setCurrentSpeechType] = useState( - getSpeechTypeFromString(initSpeechType), + isTimeBasedInit ? 'CUSTOM' : getSpeechTypeFromString(initSpeechType), ); const [speechTypeTextValue, setSpeechTypeTextValue] = useState( - currentSpeechType === 'CUSTOM' - ? (initData?.speechType ?? '') - : t(SPEECH_TYPE_RECORD[currentSpeechType]), + isTimeBasedInit + ? initSpeechType + : currentSpeechType === 'CUSTOM' + ? (initData?.speechType ?? '') + : t(SPEECH_TYPE_RECORD[currentSpeechType]), ); // 종소리 영역 확장 여부 @@ -341,9 +345,12 @@ export default function TimerCreationContent({ alert(errors.join('\n')); return; } else { - if (currentSpeechType === 'CUSTOM') { + if (timerType === 'TIME_BASED') { speechTypeToSend = speechTypeTextValue; - stanceToSend = timerType === 'TIME_BASED' ? 'NEUTRAL' : stance; + stanceToSend = 'NEUTRAL'; + } else if (currentSpeechType === 'CUSTOM') { + speechTypeToSend = speechTypeTextValue; + stanceToSend = stance; } else { speechTypeToSend = SPEECH_TYPE_RECORD[currentSpeechType]; stanceToSend = currentSpeechType === 'TIMEOUT' ? 'NEUTRAL' : stance; From f1bd2d8046a521c1fe742db05b394a983af566e9 Mon Sep 17 00:00:00 2001 From: useon Date: Fri, 6 Feb 2026 02:18:56 +0900 Subject: [PATCH 48/51] =?UTF-8?q?refactor:=20=EC=A4=91=EB=A6=BD=20?= =?UTF-8?q?=EB=9D=BC=EB=B2=A8=20=EB=B2=88=EC=97=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/en/translation.json | 1 + public/locales/ko/translation.json | 1 + .../components/TimerCreationContent/TimerCreationContent.tsx | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index ae480487..e91dd6aa 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -191,6 +191,7 @@ "발언 유형을 입력해주세요.": "Please enter a Speech Type.", "자유토론": "Open Debate", "중립은 발언 유형이 '직접 입력'일 경우에만 선택할 수 있습니다.": "Neutral can only be selected when Speech Type is 'Custom'.", + "중립": "Neutral", "일반 타이머": "Standard timer", "자유토론 타이머": "Open Debate timer", "한 팀의 발언 시간이 세팅된 일반적인 타이머": "A standard timer with a single team's Speaking Time set", diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 93a359cc..940eaf84 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -29,6 +29,7 @@ "시간표 1": "시간표 1", "찬성": "찬성", "반대": "반대", + "중립": "중립", "시간표 정보를 불러오지 못했어요.\n다시 시도할까요?": "시간표 정보를 불러오지 못했어요.\n다시 시도할까요?", "무승부": "무승부", "유효하지 않은 투표 결과 링크입니다.": "유효하지 않은 투표 결과 링크입니다.", diff --git a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx index 11b48fc5..98974a0d 100644 --- a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx +++ b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx @@ -258,10 +258,10 @@ export default function TimerCreationContent({ () => [ { value: 'PROS', label: prosTeamName }, { value: 'CONS', label: consTeamName }, - { value: 'NEUTRAL', label: STANCE_RECORD['NEUTRAL'] }, + { value: 'NEUTRAL', label: t(STANCE_RECORD['NEUTRAL']) }, ], - [prosTeamName, consTeamName], + [prosTeamName, consTeamName, t], ); const bellOptions: DropdownMenuItem[] = [ From bd742e4e5f1d4cc0b49671826e4a7a8043f5bca1 Mon Sep 17 00:00:00 2001 From: useon Date: Fri, 6 Feb 2026 04:07:20 +0900 Subject: [PATCH 49/51] =?UTF-8?q?fix:=20=EA=B5=AD=EC=A0=9C=ED=99=94=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.ts | 6 ++++++ src/mocks/handlers/global.ts | 2 ++ src/page/TableComposition/TableCompositionPage.test.tsx | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/setup.ts b/setup.ts index ce75593c..08261b45 100644 --- a/setup.ts +++ b/setup.ts @@ -2,6 +2,7 @@ import { cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; import { server } from './src/mocks/server'; import { vi } from 'vitest'; +import i18n from './src/i18n'; // msw 서버 시작 beforeAll(() => { @@ -16,6 +17,11 @@ afterEach(() => server.resetHandlers()); // msw 서버 종료 afterAll(() => server.close()); +i18n.options.react = { + ...(i18n.options.react ?? {}), + useSuspense: false, +}; + // vitest.setup.ts 또는 setupTests.ts // ResizeObserver를 전역적으로 모킹합니다. global.ResizeObserver = class ResizeObserver { diff --git a/src/mocks/handlers/global.ts b/src/mocks/handlers/global.ts index f43ded4d..421a6a22 100644 --- a/src/mocks/handlers/global.ts +++ b/src/mocks/handlers/global.ts @@ -1,8 +1,10 @@ +import { http, HttpResponse } from 'msw'; import { customizeHandlers } from './customize'; import { memberHandlers } from './member'; import { pollHandlers } from './poll'; export const allHandlers = [ + http.get(/\/locales\/[^/]+\/translation\.json$/, () => HttpResponse.json({})), ...memberHandlers, ...customizeHandlers, ...pollHandlers, diff --git a/src/page/TableComposition/TableCompositionPage.test.tsx b/src/page/TableComposition/TableCompositionPage.test.tsx index 07954359..0d40d390 100644 --- a/src/page/TableComposition/TableCompositionPage.test.tsx +++ b/src/page/TableComposition/TableCompositionPage.test.tsx @@ -27,7 +27,7 @@ function TestWrapper({ {/* 실제로 이동하고 싶은 /overview 경로 - 테스트용 컴포넌트 */} Overview Page} /> From 83956a520f125a92180bdaf0b3f735f6999ef67d Mon Sep 17 00:00:00 2001 From: useon Date: Fri, 6 Feb 2026 04:31:10 +0900 Subject: [PATCH 50/51] =?UTF-8?q?test:=20MSW=20=EB=B2=88=EC=97=AD=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=EC=97=90=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=EC=97=B4=20=EC=9D=91=EB=8B=B5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mocks/handlers/global.ts | 39 +++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/mocks/handlers/global.ts b/src/mocks/handlers/global.ts index 421a6a22..675b68a2 100644 --- a/src/mocks/handlers/global.ts +++ b/src/mocks/handlers/global.ts @@ -3,8 +3,45 @@ import { customizeHandlers } from './customize'; import { memberHandlers } from './member'; import { pollHandlers } from './poll'; +const TRANSLATIONS: Record> = { + ko: { + '토론 시간표를 선택해주세요': '토론 시간표를 선택해주세요', + '토론 정보를 설정해주세요': '토론 정보를 설정해주세요', + '토론 정보를 수정해주세요': '토론 정보를 수정해주세요', + 다음: '다음', + 추가하기: '추가하기', + '타이머 추가': '타이머 추가', + '설정 완료': '설정 완료', + '주제 없음': '주제 없음', + '테이블을 삭제하시겠습니까?': '테이블을 삭제하시겠습니까?', + 취소: '취소', + 삭제하기: '삭제하기', + 수정하기: '수정하기', + }, + en: { + '토론 시간표를 선택해주세요': 'Please select a debate timetable', + '토론 정보를 설정해주세요': 'Please set the debate information', + '토론 정보를 수정해주세요': 'Please edit the debate information', + 다음: 'Next', + 추가하기: 'Add', + '타이머 추가': 'Add timer', + '설정 완료': 'Done', + '주제 없음': 'No topic', + '테이블을 삭제하시겠습니까?': 'Do you want to delete the table?', + 취소: 'Cancel', + 삭제하기: 'Delete', + 수정하기: 'Edit', + }, +}; + export const allHandlers = [ - http.get(/\/locales\/[^/]+\/translation\.json$/, () => HttpResponse.json({})), + http.get(/\/locales\/[^/]+\/translation\.json$/, ({ request }) => { + const pathname = new URL(request.url).pathname; + const match = pathname.match(/\/locales\/([^/]+)\/translation\.json$/); + const locale = match?.[1] ?? 'ko'; + const translations = TRANSLATIONS[locale] ?? TRANSLATIONS.ko; + return HttpResponse.json(translations); + }), ...memberHandlers, ...customizeHandlers, ...pollHandlers, From 9f5cd5cf0586f44785692efca2b527d44185b722 Mon Sep 17 00:00:00 2001 From: useon Date: Fri, 6 Feb 2026 04:46:35 +0900 Subject: [PATCH 51/51] =?UTF-8?q?test:=20i18n=20locale=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95,=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.ts | 6 ++++++ src/page/TableComposition/TableCompositionPage.test.tsx | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/setup.ts b/setup.ts index 08261b45..48b1c9df 100644 --- a/setup.ts +++ b/setup.ts @@ -22,6 +22,12 @@ i18n.options.react = { useSuspense: false, }; +// 로컬스토리에 언어 설정 +if (typeof localStorage !== 'undefined') { + localStorage.setItem('i18nextLng', 'ko'); +} +i18n.changeLanguage('ko'); + // vitest.setup.ts 또는 setupTests.ts // ResizeObserver를 전역적으로 모킹합니다. global.ResizeObserver = class ResizeObserver { diff --git a/src/page/TableComposition/TableCompositionPage.test.tsx b/src/page/TableComposition/TableCompositionPage.test.tsx index 0d40d390..e4f076f3 100644 --- a/src/page/TableComposition/TableCompositionPage.test.tsx +++ b/src/page/TableComposition/TableCompositionPage.test.tsx @@ -30,6 +30,10 @@ function TestWrapper({ path="/:lang/overview/customize/1" element={

Overview Page

} /> + Overview Page} + />