diff --git a/frontend/package.json b/frontend/package.json index 93d13d2..226032b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "dependencies": { "@tanstack/react-query": "^5.101.2", "clsx": "^2.1.1", + "i18next": "^26.3.6", "lucide-react": "^1.24.0", "pretendard": "^1.3.9", "react": "^19.2.7", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index f1fc272..76b8f4b 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + i18next: + specifier: ^26.3.6 + version: 26.3.6(typescript@6.0.3) lucide-react: specifier: ^1.24.0 version: 1.24.0(react@19.2.7) @@ -1029,6 +1032,14 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + i18next@26.3.6: + resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2573,6 +2584,10 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + i18next@26.3.6(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + ignore@5.3.2: {} ignore@7.0.5: {} diff --git a/frontend/src/components/LocaleProvider.tsx b/frontend/src/components/LocaleProvider.tsx new file mode 100644 index 0000000..627458a --- /dev/null +++ b/frontend/src/components/LocaleProvider.tsx @@ -0,0 +1,27 @@ +import { useEffect, useState, type ReactNode } from "react"; + +import { i18n } from "@/locales"; + +/** + * LocaleProvider makes the module-global `L` Proxy reactive without a page + * reload. `L.x.y` reads the current language on every access, but a module + * global can't tell React to re-render — so this boundary subscribes to + * i18next's `languageChanged` event and remounts its subtree (via a changing + * `key`) when the language switches. The tree then re-renders in place — no + * reload, so no flash — and every `L.x.y` re-resolves to the new language. + */ +export default function LocaleProvider({ children }: { children: ReactNode }) { + const [lang, setLang] = useState(i18n.language); + + useEffect(() => { + const onChange = (next: string) => setLang(next); + i18n.on("languageChanged", onChange); + if (i18n.language !== lang) setLang(i18n.language); + return () => { + i18n.off("languageChanged", onChange); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return
{children}
; +} diff --git a/frontend/src/components/auth/OwnerLockedNotice.tsx b/frontend/src/components/auth/OwnerLockedNotice.tsx index aa14caa..8259ce2 100644 --- a/frontend/src/components/auth/OwnerLockedNotice.tsx +++ b/frontend/src/components/auth/OwnerLockedNotice.tsx @@ -6,6 +6,7 @@ import RuneMark from "@/components/elements/RuneMark"; import PublicNavbar from "@/components/navigation/PublicNavbar"; import { postLogout } from "@/api/authAPIs"; import { BTN_TEXT, PATH_LIST, QUERY_KEYS } from "@/constants/commonConstants"; +import { L } from "@/locales"; /** * OwnerLockedNotice is the soft-block screen shown when a signed-in account is @@ -35,18 +36,18 @@ const OwnerLockedNotice = ({ owner }: { owner?: string }) => {
-

사용할 수 없는 계정

+

{L.auth.ownerLockedTitle}

- 이 콘솔은{" "} + {L.auth.ownerLockedPrefix} {owner ? ( {owner} ) : ( - "다른 계정" + L.auth.ownerFallback )} - 이(가) 관리하고 있어요. + {L.auth.ownerLockedSuffix}
- 이 계정으로는 콘솔을 사용할 수 없습니다. + {L.auth.ownerLockedBody}

+ ); +}; + +export default LanguageToggle; diff --git a/frontend/src/components/navigation/Navbar.tsx b/frontend/src/components/navigation/Navbar.tsx index bae937e..cfc8e37 100644 --- a/frontend/src/components/navigation/Navbar.tsx +++ b/frontend/src/components/navigation/Navbar.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import RuneMark from "@/components/elements/RuneMark"; import WorkspaceStatus from "@/components/elements/WorkspaceStatus"; +import LanguageToggle from "@/components/navigation/LanguageToggle"; import ProfileMenu from "@/components/navigation/ProfileMenu"; import WorkspaceModal from "@/components/workspace/WorkspaceModal"; import { useSessionQuery } from "@/hooks/queries/useSessionQuery"; @@ -13,6 +14,7 @@ import { PATH_LIST, QUERY_KEYS, } from "@/constants/commonConstants"; +import { L } from "@/locales"; import { useWorkspaceStore } from "@/stores/workspaceStore"; /** @@ -64,21 +66,23 @@ const Navbar = () => { }`} onClick={workspace ? openModal : undefined} > - 워크스페이스 + + {L.workspace.badgeLabel} + {workspace ? ( workspace.orphaned ? ( /* Reinstall detected: the workspace no longer matches this console. Flag it so the badge doesn't read as healthy; clicking opens the modal's 재생성 prompt. */ - 재생성 필요 + {L.workspace.badgeRecreate} ) : workspace.reconnectRequired ? ( /* Data-plane credential expired: the cloud workspace is fine but the local engine link is stale. Flag it (not the healthy pill); clicking opens the modal's 재연결 prompt. */ - 재연결 필요 + {L.workspace.badgeReconnect} ) : ( @@ -89,10 +93,11 @@ const Navbar = () => { className="text-muted-foreground hover:text-foreground cursor-pointer rounded border px-2 py-1 text-xs" onClick={() => navigate(PATH_LIST.workspace)} > - 워크스페이스 없음 + {L.workspace.badgeNone} ) : null}
+ {me && } diff --git a/frontend/src/components/navigation/ProfileMenu.tsx b/frontend/src/components/navigation/ProfileMenu.tsx index ba66f14..d2455c7 100644 --- a/frontend/src/components/navigation/ProfileMenu.tsx +++ b/frontend/src/components/navigation/ProfileMenu.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import TextButton from "@/components/elements/TextButton"; import { BTN_TEXT } from "@/constants/commonConstants"; +import { L } from "@/locales"; interface ProfileMenuProps { // avatar is optional: the backend omits it when the principal has no picture, @@ -59,7 +60,7 @@ const ProfileMenu = ({ me, onSignOut }: ProfileMenuProps) => { type="button" aria-haspopup="menu" aria-expanded={open} - aria-label="프로필 메뉴" + aria-label={L.profile.menu} className="block cursor-pointer rounded-full" onClick={() => setOpen((v) => !v)} > @@ -68,7 +69,7 @@ const ProfileMenu = ({ me, onSignOut }: ProfileMenuProps) => { ) : ( 프로필 이미지 setImgError(true)} /> @@ -83,7 +84,7 @@ const ProfileMenu = ({ me, onSignOut }: ProfileMenuProps) => {
{me.email}
-
플랜: Free
+
{L.profile.plan}

{ {BRAND_WORDMARK} - {!onLoginPage && ( -